Skip to content

Commit

Permalink
Generate initializer functions in the Res file to avoid the MethodToo…
Browse files Browse the repository at this point in the history
…LargeException (#4205)
  • Loading branch information
terrakok authored Jan 31, 2024
1 parent b4881ff commit b1e86ad
Show file tree
Hide file tree
Showing 5 changed files with 290 additions and 178 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package org.jetbrains.compose.resources

import com.squareup.kotlinpoet.*
import java.nio.file.Path
import java.util.SortedMap
import java.util.TreeMap
import kotlin.io.path.invariantSeparatorsPathString

internal enum class ResourceType(val typeName: String) {
Expand All @@ -26,12 +28,14 @@ internal data class ResourceItem(
val path: Path
)

private fun ResourceItem.getClassName(): ClassName = when (type) {
private fun ResourceType.getClassName(): ClassName = when (this) {
ResourceType.DRAWABLE -> ClassName("org.jetbrains.compose.resources", "DrawableResource")
ResourceType.STRING -> ClassName("org.jetbrains.compose.resources", "StringResource")
ResourceType.FONT -> ClassName("org.jetbrains.compose.resources", "FontResource")
}

private val resourceItemClass = ClassName("org.jetbrains.compose.resources", "ResourceItem")

private fun CodeBlock.Builder.addQualifiers(resourceItem: ResourceItem): CodeBlock.Builder {
val languageQualifier = ClassName("org.jetbrains.compose.resources", "LanguageQualifier")
val regionQualifier = ClassName("org.jetbrains.compose.resources", "RegionQualifier")
Expand Down Expand Up @@ -101,85 +105,118 @@ internal fun getResFileSpec(
//type -> id -> items
resources: Map<ResourceType, Map<String, List<ResourceItem>>>,
packageName: String
): FileSpec = FileSpec.builder(packageName, "Res").apply {
addType(TypeSpec.objectBuilder("Res").apply {
addModifiers(KModifier.INTERNAL)
): FileSpec =
FileSpec.builder(packageName, "Res").apply {
addAnnotation(
AnnotationSpec.builder(ClassName("kotlin", "OptIn"))
.addMember("org.jetbrains.compose.resources.InternalResourceApi::class")
.build()
)
addAnnotation(
AnnotationSpec.builder(ClassName("org.jetbrains.compose.resources", "ExperimentalResourceApi"))
.addMember("org.jetbrains.compose.resources.ExperimentalResourceApi::class")
.build()
)

//readFileBytes
val readResourceBytes = MemberName("org.jetbrains.compose.resources", "readResourceBytes")
addFunction(
FunSpec.builder("readBytes")
.addKdoc("""
//we need to sort it to generate the same code on different platforms
val sortedResources = sortResources(resources)

addType(TypeSpec.objectBuilder("Res").apply {
addModifiers(KModifier.INTERNAL)
addAnnotation(
AnnotationSpec.builder(
ClassName("org.jetbrains.compose.resources", "ExperimentalResourceApi")
).build()
)

//readFileBytes
val readResourceBytes = MemberName("org.jetbrains.compose.resources", "readResourceBytes")
addFunction(
FunSpec.builder("readBytes")
.addKdoc(
"""
Reads the content of the resource file at the specified path and returns it as a byte array.
Example: `val bytes = Res.readBytes("files/key.bin")`
@param path The path of the file to read in the compose resource's directory.
@return The content of the file as a byte array.
""".trimIndent())
.addParameter("path", String::class)
.addModifiers(KModifier.SUSPEND)
.returns(ByteArray::class)
.addStatement("return %M(path)", readResourceBytes) //todo: add module ID here
.build()
)
""".trimIndent()
)
.addParameter("path", String::class)
.addModifiers(KModifier.SUSPEND)
.returns(ByteArray::class)
.addStatement("return %M(path)", readResourceBytes) //todo: add module ID here
.build()
)
val types = sortedResources.map { (type, idToResources) ->
getResourceTypeObject(type, idToResources)
}
addTypes(types)
}.build())

val types = resources.map { (type, idToResources) ->
getResourceTypeObject(type, idToResources)
}.sortedBy { it.name }
addTypes(types)
}.build())
}.build()
sortedResources
.flatMap { (type, idToResources) ->
idToResources.map { (name, items) ->
getResourceInitializer(name, type, items)
}
}
.forEach { addFunction(it) }
}.build()

private fun getResourceTypeObject(type: ResourceType, nameToResources: Map<String, List<ResourceItem>>) =
TypeSpec.objectBuilder(type.typeName).apply {
nameToResources.entries
.sortedBy { it.key }
.forEach { (name, items) ->
addResourceProperty(name, items.sortedBy { it.path })
nameToResources.keys
.forEach { name ->
addProperty(
PropertySpec
.builder(name, type.getClassName())
.initializer("get_$name()")
.build()
)
}
}.build()

private fun TypeSpec.Builder.addResourceProperty(name: String, items: List<ResourceItem>) {
val resourceItemClass = ClassName("org.jetbrains.compose.resources", "ResourceItem")

val first = items.first()
val propertyClassName = first.getClassName()
val resourceId = first.let { "${it.type}:${it.name}" }

val initializer = CodeBlock.builder()
.add("%T(\n", propertyClassName).withIndent {
add("\"$resourceId\",\n")
if (first.type == ResourceType.STRING) {
add("\"${first.name}\",\n")
}
add("setOf(\n").withIndent {
items.forEach { item ->
add("%T(\n", resourceItemClass).withIndent {
add("setOf(").addQualifiers(item).add("),\n")
//file separator should be '/' on all platforms
add("\"${item.path.invariantSeparatorsPathString}\"\n") //todo: add module ID here
private fun getResourceInitializer(name: String, type: ResourceType, items: List<ResourceItem>): FunSpec {
val propertyTypeName = type.getClassName()
val resourceId = "${type}:${name}"
return FunSpec.builder("get_$name")
.addModifiers(KModifier.PRIVATE)
.returns(propertyTypeName)
.addStatement(
CodeBlock.builder()
.add("return %T(\n", propertyTypeName).withIndent {
add("\"$resourceId\",")
if (type == ResourceType.STRING) add(" \"$name\",")
withIndent {
add("\nsetOf(\n").withIndent {
items.forEach { item ->
add("%T(", resourceItemClass)
add("setOf(").addQualifiers(item).add("), ")
//file separator should be '/' on all platforms
add("\"${item.path.invariantSeparatorsPathString}\"") //todo: add module ID here
add("),\n")
}
}
add(")\n")
}
add("),\n")
}
}
add(")\n")
}
.add(")")
.add(")")
.build().toString()
)
.build()
}

addProperty(
PropertySpec.builder(name, propertyClassName)
.initializer(initializer)
.build()
)
private fun sortResources(
resources: Map<ResourceType, Map<String, List<ResourceItem>>>
): TreeMap<ResourceType, TreeMap<String, List<ResourceItem>>> {
val result = TreeMap<ResourceType, TreeMap<String, List<ResourceItem>>>()
resources
.entries
.forEach { (type, items) ->
val typeResult = TreeMap<String, List<ResourceItem>>()
items
.entries
.forEach { (name, resItems) ->
typeResult[name] = resItems.sortedBy { it.path }
}
result[type] = typeResult
}
return result
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import kotlin.io.path.Path

class ResourcesTest : GradlePluginTestBase() {
@Test
fun testGeneratedAccessorsAndCopiedFonts(): Unit = with(testProject("misc/commonResources")) {
fun testGeneratedAccessors(): Unit = with(testProject("misc/commonResources")) {
//check generated resource's accessors
gradle("generateComposeResClass").checks {
assertEqualTextFiles(
Expand Down Expand Up @@ -137,4 +137,89 @@ class ResourcesTest : GradlePluginTestBase() {
}
gradle("jar")
}

//https://github.com/JetBrains/compose-multiplatform/issues/4194
@Test
fun testHugeNumberOfStrings(): Unit = with(
//disable cache for the test because the generateStringFiles task doesn't support it
testProject("misc/commonResources", defaultTestEnvironment.copy(useGradleConfigurationCache = false))
) {
file("build.gradle.kts").let { f ->
val originText = f.readText()
f.writeText(
buildString {
appendLine("import java.util.Locale")
append(originText)
appendLine()
append("""
val template = ""${'"'}
<resources>
<string name="app_name">Compose Resources App</string>
<string name="hello">😊 Hello world!</string>
<string name="multi_line">Lorem ipsum dolor sit amet,
consectetur adipiscing elit.
Donec eget turpis ac sem ultricies consequat.</string>
<string name="str_template">Hello, %1${'$'}{"$"}s! You have %2${'$'}{"$"}d new messages.</string>
<string-array name="str_arr">
<item>item 1</item>
<item>item 2</item>
<item>item 3</item>
</string-array>
[ADDITIONAL_STRINGS]
</resources>
""${'"'}.trimIndent()
val generateStringFiles = tasks.register("generateStringFiles") {
val numberOfLanguages = 20
val numberOfStrings = 500
val langs = Locale.getAvailableLocales()
.map { it.language }
.filter { it.count() == 2 }
.sorted()
.distinct()
.take(numberOfLanguages)
.toList()
val resourcesFolder = project.file("src/commonMain/composeResources")
doLast {
// THIS REMOVES THE `values` FOLDER IN `composeResources`
// THIS REMOVES THE `values` FOLDER IN `composeResources`
// Necessary when reducing the number of languages.
resourcesFolder.listFiles()?.filter { it.name.startsWith("values") }?.forEach {
it.deleteRecursively()
}
langs.forEachIndexed { langIndex, lang ->
val additionalStrings =
(0 until numberOfStrings).joinToString(System.lineSeparator()) { index ->
""${'"'}
<string name="string_${'$'}{index.toString().padStart(4, '0')}">String ${'$'}index in lang ${'$'}lang</string>
""${'"'}.trimIndent()
}
val langFile = if (langIndex == 0) {
File(resourcesFolder, "values/strings.xml")
} else {
File(resourcesFolder, "values-${'$'}lang/strings.xml")
}
langFile.parentFile.mkdirs()
langFile.writeText(template.replace("[ADDITIONAL_STRINGS]", additionalStrings))
}
}
}
tasks.named("generateComposeResClass") {
dependsOn(generateStringFiles)
}
""".trimIndent())
}
)
}
gradle("desktopJar").checks {
check.taskSuccessful(":generateStringFiles")
check.taskSuccessful(":generateComposeResClass")
assert(file("src/commonMain/composeResources/values/strings.xml").readLines().size == 513)
}
}
}
Loading

0 comments on commit b1e86ad

Please sign in to comment.