Skip to content

Commit

Permalink
XML resource optimizations (#4559)
Browse files Browse the repository at this point in the history
Users noticed if an app has big a `string.xml` file it affects the app
startup time:
#4537

The problem is slow XML parsing.

Possible ways for optimization:
 1) inject text resources direct to the source code
 2) convert XMLs to an optimized format to read it faster

We selected the second way because texts injected to source code have
several problems:
 - strict limitations on text size
 - increase compilation and analysation time
 - affects a class loader and GC

> Note: android resources do the same and converts xml values to own
`resources.arsc` file

Things was done in the PR:
 1) added support any XML files in the `values` directory
2) **[BREAKING CHANGE]** added `Res.array` accessor for string-array
resources
3) in a final app there won't be original `values*/*.xml` files. There
will be converted `values*/*.cvr` files.
 4) generated code points on string resources as file -> offset+size
5) string resource cache is by item now (it was by the full xml file
before)
 6) implemented random access to read CVR files
7) tasks for syncing ios resources to a final app were seriously
refactored to support generated resources (CVR files)
 8) restriction for 3-party resources plugin were deleted
9) Gradle property `compose.resources.always.generate.accessors` was
deleted. It was for internal needs only.

Fixes #4537
  • Loading branch information
terrakok authored Apr 3, 2024
1 parent 04edeed commit 5d9dfde
Show file tree
Hide file tree
Showing 75 changed files with 1,747 additions and 1,808 deletions.
5 changes: 5 additions & 0 deletions components/resources/demo/shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,8 @@ android {
compose.experimental {
web.application {}
}

//because the dependency on the compose library is a project dependency
compose.resources {
generateResClass = always
}
1 change: 0 additions & 1 deletion components/resources/demo/shared/gradle.properties

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,6 @@ fun StringRes(paddingValues: PaddingValues) {
Column(
modifier = Modifier.padding(paddingValues).verticalScroll(rememberScrollState())
) {
Text(
modifier = Modifier.padding(16.dp),
text = "values/strings.xml",
style = MaterialTheme.typography.titleLarge
)
OutlinedCard(
modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
shape = RoundedCornerShape(4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
var bytes by remember { mutableStateOf(ByteArray(0)) }
LaunchedEffect(Unit) {
bytes = Res.readBytes("values/strings.xml")
}
Text(
modifier = Modifier.padding(8.dp),
text = bytes.decodeToString(),
color = MaterialTheme.colorScheme.onPrimaryContainer,
softWrap = false
)
}
OutlinedTextField(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
value = stringResource(Res.string.app_name),
Expand Down Expand Up @@ -89,9 +68,9 @@ fun StringRes(paddingValues: PaddingValues) {
)
OutlinedTextField(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
value = stringArrayResource(Res.string.str_arr).toString(),
value = stringArrayResource(Res.array.str_arr).toString(),
onValueChange = {},
label = { Text("Text(stringArrayResource(Res.string.str_arr).toString())") },
label = { Text("Text(stringArrayResource(Res.array.str_arr).toString())") },
enabled = false,
colors = TextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
Expand Down
1 change: 1 addition & 0 deletions components/resources/library/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ kotlin {
optIn("kotlin.RequiresOptIn")
optIn("kotlinx.cinterop.ExperimentalForeignApi")
optIn("kotlin.experimental.ExperimentalNativeApi")
optIn("org.jetbrains.compose.resources.InternalResourceApi")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ import androidx.compose.ui.text.font.*
@Composable
actual fun Font(resource: FontResource, weight: FontWeight, style: FontStyle): Font {
val environment = LocalComposeEnvironment.current.rememberEnvironment()
val path = remember(environment) { resource.getPathByEnvironment(environment) }
val path = remember(environment) { resource.getResourceItemByEnvironment(environment).path }
return Font(path, LocalContext.current.assets, weight, style)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import android.content.res.Configuration
import android.content.res.Resources
import java.util.*

@OptIn(InternalResourceApi::class)
internal actual fun getSystemEnvironment(): ResourceEnvironment {
val locale = Locale.getDefault()
val configuration = Resources.getSystem().configuration
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
package org.jetbrains.compose.resources

import java.io.File
import java.io.InputStream

private object AndroidResourceReader
internal actual fun getPlatformResourceReader(): ResourceReader = object : ResourceReader {
override suspend fun read(path: String): ByteArray {
val resource = getResourceAsStream(path)
return resource.readBytes()
}

@OptIn(ExperimentalResourceApi::class)
@InternalResourceApi
actual suspend fun readResourceBytes(path: String): ByteArray {
val classLoader = Thread.currentThread().contextClassLoader ?: AndroidResourceReader.javaClass.classLoader
val resource = classLoader.getResourceAsStream(path) ?: run {
//try to find a font in the android assets
if (File(path).parentFile?.name.orEmpty().startsWith("font")) {
classLoader.getResourceAsStream("assets/$path")
} else null
} ?: throw MissingResourceException(path)
return resource.readBytes()
override suspend fun readPart(path: String, offset: Long, size: Long): ByteArray {
val resource = getResourceAsStream(path)
val result = ByteArray(size.toInt())
resource.use { input ->
input.skip(offset)
input.read(result, 0, size.toInt())
}
return result
}

@OptIn(ExperimentalResourceApi::class)
private fun getResourceAsStream(path: String): InputStream {
val classLoader = Thread.currentThread().contextClassLoader ?: this.javaClass.classLoader
val resource = classLoader.getResourceAsStream(path) ?: run {
//try to find a font in the android assets
if (File(path).parentFile?.name.orEmpty().startsWith("font")) {
classLoader.getResourceAsStream("assets/$path")
} else null
} ?: throw MissingResourceException(path)
return resource
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import androidx.compose.ui.text.font.*
*
* @see Resource
*/
@OptIn(InternalResourceApi::class)
@ExperimentalResourceApi
@Immutable
class FontResource
Expand All @@ -24,11 +23,10 @@ class FontResource
* @param path The path to the font resource file.
* @return A new [FontResource] object.
*/
@OptIn(InternalResourceApi::class)
@ExperimentalResourceApi
fun FontResource(path: String): FontResource = FontResource(
id = "FontResource:$path",
items = setOf(ResourceItem(emptySet(), path))
items = setOf(ResourceItem(emptySet(), path, -1, -1))
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import org.jetbrains.compose.resources.vector.xmldom.Element
* @param id The unique identifier of the drawable resource.
* @param items The set of resource items associated with the image resource.
*/
@OptIn(InternalResourceApi::class)
@ExperimentalResourceApi
@Immutable
class DrawableResource
Expand All @@ -32,11 +31,10 @@ class DrawableResource
* @param path The path of the drawable resource.
* @return An [DrawableResource] object.
*/
@OptIn(InternalResourceApi::class)
@ExperimentalResourceApi
fun DrawableResource(path: String): DrawableResource = DrawableResource(
id = "DrawableResource:$path",
items = setOf(ResourceItem(emptySet(), path))
items = setOf(ResourceItem(emptySet(), path, -1, -1))
)

/**
Expand All @@ -50,7 +48,7 @@ fun DrawableResource(path: String): DrawableResource = DrawableResource(
@Composable
fun painterResource(resource: DrawableResource): Painter {
val environment = LocalComposeEnvironment.current.rememberEnvironment()
val filePath = remember(resource, environment) { resource.getPathByEnvironment(environment) }
val filePath = remember(resource, environment) { resource.getResourceItemByEnvironment(environment).path }
val isXml = filePath.endsWith(".xml", true)
if (isXml) {
return rememberVectorPainter(vectorResource(resource))
Expand All @@ -72,7 +70,7 @@ private val emptyImageBitmap: ImageBitmap by lazy { ImageBitmap(1, 1) }
fun imageResource(resource: DrawableResource): ImageBitmap {
val resourceReader = LocalResourceReader.current
val imageBitmap by rememberResourceState(resource, { emptyImageBitmap }) { env ->
val path = resource.getPathByEnvironment(env)
val path = resource.getResourceItemByEnvironment(env).path
val cached = loadImage(path, resourceReader) {
ImageCache.Bitmap(it.toImageBitmap())
} as ImageCache.Bitmap
Expand All @@ -97,7 +95,7 @@ fun vectorResource(resource: DrawableResource): ImageVector {
val resourceReader = LocalResourceReader.current
val density = LocalDensity.current
val imageVector by rememberResourceState(resource, { emptyImageVector }) { env ->
val path = resource.getPathByEnvironment(env)
val path = resource.getResourceItemByEnvironment(env).path
val cached = loadImage(path, resourceReader) {
ImageCache.Vector(it.toXmlElement().toImageVector(density))
} as ImageCache.Vector
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package org.jetbrains.compose.resources

import androidx.compose.runtime.*
import org.jetbrains.compose.resources.plural.PluralCategory
import org.jetbrains.compose.resources.plural.PluralRuleList

/**
* Represents a quantity string resource in the application.
*
* @param id The unique identifier of the resource.
* @param key The key used to retrieve the string resource.
* @param items The set of resource items associated with the string resource.
*/
@ExperimentalResourceApi
@Immutable
class PluralStringResource
@InternalResourceApi constructor(id: String, val key: String, items: Set<ResourceItem>) : Resource(id, items)

/**
* Retrieves the string for the pluralization for the given quantity using the specified quantity string resource.
*
* @param resource The quantity string resource to be used.
* @param quantity The quantity of the pluralization to use.
* @return The retrieved string resource.
*
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file.
*/
@ExperimentalResourceApi
@Composable
fun pluralStringResource(resource: PluralStringResource, quantity: Int): String {
val resourceReader = LocalResourceReader.current
val pluralStr by rememberResourceState(resource, quantity, { "" }) { env ->
loadPluralString(resource, quantity, resourceReader, env)
}
return pluralStr
}

/**
* Loads a string using the specified string resource.
*
* @param resource The string resource to be used.
* @param quantity The quantity of the pluralization to use.
* @return The loaded string resource.
*
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file.
*/
@ExperimentalResourceApi
suspend fun getPluralString(resource: PluralStringResource, quantity: Int): String =
loadPluralString(resource, quantity, DefaultResourceReader, getResourceEnvironment())

@OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
private suspend fun loadPluralString(
resource: PluralStringResource,
quantity: Int,
resourceReader: ResourceReader,
environment: ResourceEnvironment
): String {
val resourceItem = resource.getResourceItemByEnvironment(environment)
val item = getStringItem(resourceItem, resourceReader) as StringItem.Plurals
val pluralRuleList = PluralRuleList.getInstance(
environment.language,
environment.region,
)
val pluralCategory = pluralRuleList.getCategory(quantity)
val str = item.items[pluralCategory]
?: item.items[PluralCategory.OTHER]
?: error("Quantity string ID=`${resource.key}` does not have the pluralization $pluralCategory for quantity $quantity!")
return str
}

/**
* Retrieves the string for the pluralization for the given quantity using the specified quantity string resource.
*
* @param resource The quantity string resource to be used.
* @param quantity The quantity of the pluralization to use.
* @param formatArgs The arguments to be inserted into the formatted string.
* @return The retrieved string resource.
*
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file.
*/
@ExperimentalResourceApi
@Composable
fun pluralStringResource(resource: PluralStringResource, quantity: Int, vararg formatArgs: Any): String {
val resourceReader = LocalResourceReader.current
val args = formatArgs.map { it.toString() }
val pluralStr by rememberResourceState(resource, quantity, args, { "" }) { env ->
loadPluralString(resource, quantity, args, resourceReader, env)
}
return pluralStr
}

/**
* Loads a string using the specified string resource.
*
* @param resource The string resource to be used.
* @param quantity The quantity of the pluralization to use.
* @param formatArgs The arguments to be inserted into the formatted string.
* @return The loaded string resource.
*
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file.
*/
@ExperimentalResourceApi
suspend fun getPluralString(resource: PluralStringResource, quantity: Int, vararg formatArgs: Any): String =
loadPluralString(
resource, quantity,
formatArgs.map { it.toString() },
DefaultResourceReader,
getResourceEnvironment(),
)

@OptIn(ExperimentalResourceApi::class)
private suspend fun loadPluralString(
resource: PluralStringResource,
quantity: Int,
args: List<String>,
resourceReader: ResourceReader,
environment: ResourceEnvironment
): String {
val str = loadPluralString(resource, quantity, resourceReader, environment)
return str.replaceWithArgs(args)
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,14 @@ sealed class Resource
*
* @property qualifiers The qualifiers of the resource item.
* @property path The path of the resource item.
* @property offset The offset in bytes of the resource in the file. '-1' means the resource is whole file
* @property size The size in bytes of the resource in the file. '-1' means the resource is whole file
*/
@InternalResourceApi
@Immutable
data class ResourceItem(
internal val qualifiers: Set<Qualifier>,
internal val path: String
internal val path: String,
internal val offset: Long,
internal val size: Long,
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.intl.Locale

@OptIn(InternalResourceApi::class)
internal data class ResourceEnvironment(
val language: LanguageQualifier,
val region: RegionQualifier,
Expand All @@ -18,7 +17,6 @@ internal interface ComposeEnvironment {
fun rememberEnvironment(): ResourceEnvironment
}

@OptIn(InternalResourceApi::class)
internal val DefaultComposeEnvironment = object : ComposeEnvironment {
@Composable
override fun rememberEnvironment(): ResourceEnvironment {
Expand Down Expand Up @@ -51,17 +49,17 @@ internal expect fun getSystemEnvironment(): ResourceEnvironment
internal var getResourceEnvironment = ::getSystemEnvironment

@OptIn(InternalResourceApi::class, ExperimentalResourceApi::class)
internal fun Resource.getPathByEnvironment(environment: ResourceEnvironment): String {
internal fun Resource.getResourceItemByEnvironment(environment: ResourceEnvironment): ResourceItem {
//Priority of environments: https://developer.android.com/guide/topics/resources/providing-resources#table2
items.toList()
.filterBy(environment.language)
.also { if (it.size == 1) return it.first().path }
.also { if (it.size == 1) return it.first() }
.filterBy(environment.region)
.also { if (it.size == 1) return it.first().path }
.also { if (it.size == 1) return it.first() }
.filterBy(environment.theme)
.also { if (it.size == 1) return it.first().path }
.also { if (it.size == 1) return it.first() }
.filterBy(environment.density)
.also { if (it.size == 1) return it.first().path }
.also { if (it.size == 1) return it.first() }
.let { items ->
if (items.isEmpty()) {
error("Resource with ID='$id' not found")
Expand All @@ -71,7 +69,6 @@ internal fun Resource.getPathByEnvironment(environment: ResourceEnvironment): St
}
}

@OptIn(InternalResourceApi::class)
private fun List<ResourceItem>.filterBy(qualifier: Qualifier): List<ResourceItem> {
//Android has a slightly different algorithm,
//but it provides the same result: https://developer.android.com/guide/topics/resources/providing-resources#BestMatch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,16 @@ class MissingResourceException(path: String) : Exception("Missing resource with
* @return The content of the file as a byte array.
*/
@InternalResourceApi
expect suspend fun readResourceBytes(path: String): ByteArray
suspend fun readResourceBytes(path: String): ByteArray = DefaultResourceReader.read(path)

internal interface ResourceReader {
suspend fun read(path: String): ByteArray
suspend fun readPart(path: String, offset: Long, size: Long): ByteArray
}

internal val DefaultResourceReader: ResourceReader = object : ResourceReader {
@OptIn(InternalResourceApi::class)
override suspend fun read(path: String): ByteArray = readResourceBytes(path)
}
internal expect fun getPlatformResourceReader(): ResourceReader

internal val DefaultResourceReader = getPlatformResourceReader()

//ResourceReader provider will be overridden for tests
internal val LocalResourceReader = staticCompositionLocalOf { DefaultResourceReader }
Loading

0 comments on commit 5d9dfde

Please sign in to comment.