JetBrains / compose-multiplatform

Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable.
https://jetbrains.com/lp/compose-multiplatform
Apache License 2.0
15.24k stars 1.11k forks source link

Support getting resources by key #4880

Open enoler opened 4 weeks ago

enoler commented 4 weeks ago

Hello!

I would like to be able to get resources given a key name. I think it is quite useful as you can load images or strings dynamically. For example, given a list of countries (which their ISO code) you can have their translated name and their flag just passing their code to the corresponding function. If you want to get it from Spain (ES):

Country(code = "es")
<resources>
    <string name="es" translatable="false">Spain</string>
</resources>

You can access it through getString(Res.string.es) or getString("es").

The same with images, if you have a file es.png, you can access it through getDrawable("es").

Why is it useful? Because if you have a big list of data, you don't need to manually define their images/strings for each element, for example in a list. You can just iterate over your items and get their resource by their code or id.

val countries = arrayListOf(
     Country(code = "es"),
     Country(code = "de"),
     ....
     Country(code = "us")
)

countries.forEach { country ->
     val name = getString(country.code)
     val image = getDrawable(country.code)
}
angelix commented 3 weeks ago

Very useful feature and a bit of blocker for me, especially with the lack of reflection in Native.

terrakok commented 3 weeks ago

https://github.com/JetBrains/compose-multiplatform/pull/4909

enoler commented 3 weeks ago

4909

Wow thank you! Any chance that it will be available the same for strings?

terrakok commented 3 weeks ago

No chance. If you want to read strings by path then just read a text file by a path.

String items don't have a file path

angelix commented 3 weeks ago

No chance. If you want to read strings by path then just read a text file by a path.

What about getting a string by key, eg. getString(key = "en")?

terrakok commented 3 weeks ago

No. How will it work with qualifiers and arguments? There is no a such thing as a full qualified key for the string in a text representation. (as path for other resource types) We need to combine path + key in the original xml. It is not what we want. Use text files instead.

terrakok commented 3 weeks ago

If you notice the API Res.drawable.byPath("drawable/my_icon.xml") doesn't work with qualifiers and environment. It associates a concrete file with a new resource instance. If you have two icons: drawable-night/my_icon.xml and drawable-light/my_icon.xml, you are supposed to select a right icon by your own.

I understand that you would like to have something like a runtime search in your string resources based on a string key and a current environment but it is a huge performance problem. That's why we converted XML files to the internal format and use generated classes instead. To iterate by files in the runtime is not possible.

So, for your case you have to save strings to the regular txt file and read it as string on your side.

enoler commented 3 weeks ago

If you notice the API Res.drawable.byPath("drawable/my_icon.xml") doesn't work with qualifiers and environment. It associates a concrete file with a new resource instance. If you have two icons: drawable-night/my_icon.xml and drawable-light/my_icon.xml, you are supposed to select a right icon by your own.

I understand that you would like to have something like a runtime search in your string resources based on a string key and a current environment but it is a huge performance problem. That's why we converted XML files to the internal format and use generated classes instead. To iterate by files in the runtime is not possible.

So, for your case you have to save strings to the regular txt file and read it as string on your side.

My current implementation consisted in creating a mapper that maps a string (the key) with the Resource. I had to do it like that because I was not able to use reflection. The main problem is that I need to mantain this file manually and it is very susceptible to bugs.

Sorry because of my ignorance because I don't know how the resources are internally working, but it wouldn't be possible to generate this mapping internally so we could access them through it?

terrakok commented 3 weeks ago

It is possible but the case is rare enough to maintain it. It requires more resources to support it than a real profit. And the preferable way is static accessors for strings.

angelix commented 2 weeks ago

I fully agree that the recommended and performant way to access resources, especially string resources, is through static accessors.

Android supports accessing resources by key, it discourages this practice for performance reasons. However, this capability exists for special cases where it might be necessary.

@Discouraged(message = "Use of this function is discouraged because resource reflection makes "
                         + "it harder to perform build optimizations and compile-time "
                         + "verification of code. It is much more efficient to retrieve "
                         + "resources by identifier (e.g. `R.foo.bar`) than by name (e.g. "
                         + "`getIdentifier(\"bar\", \"foo\", null)`).")
public int getIdentifier(String name, String defType, String defPackage)

My proposal is to introduce an opt-in feature that allows accessing resources by key. Should be discouraged, but available for the cases where is needed.

This feature could look something like the following:

private val stringResourcesMap: Map<String, StringResource> = mapOf(
        "key1" to Res.string.resource_with_key_1,
        "key2" to Res.string.resource_with_key_2
)

fun Res.string.byPath(key: String): StringResource? {
   return stringResourcesMap[key]
}
duanemalcolm commented 1 week ago

+1

I use JSON to define a catalog of weather data types which includes the name, name_string_key, description, units, icon id, etc... I used the getIdentifier call in Android to retrieve the icon drawable using the icon id. Now I'm moving to CMP, it would be useful to have this feature. I've used this feature in Android for over 5 years to get R.string and R.drawable resources without problems.

angelix commented 1 week ago

Until this is resolved, i use a bash script to extract keys from xml and create a map<String, StringResouce>.

Example generated class:

object StringResourcesMap {
    val strings: Map<String, StringResource> = mapOf(
        "id_1030_minutes" to Res.string.id_1030_minutes,
        "id_12_confirmations" to Res.string.id_12_confirmations,
        "id_12_months_51840_blocks" to Res.string.id_12_months_51840_blocks,
        "id_12_words" to Res.string.id_12_words
        ....
    }
}

@Composable
fun stringResourceFromId(id: String): String {
    return StringResourcesMap.strings[id]?.let {
        stringResource(it)
    } ?: id
}

suspend fun getStringFromId(id: String): String {
    return StringResourcesMap.strings[id]?.let {
        getString(it)
    } ?: id
}
duanemalcolm commented 6 days ago

@angelix, I am manually creating a map for my drawables but it got me wondering - does this cause hundreds of string and drawable resources to be instantiated? Is this inefficient?

I notice the generated Res code accesses the resources using the by lazy {} construct.

I wonder if using a when loop that puts the resource in a mutable map would be a better approach? I generate some code and post it later today.

duanemalcolm commented 6 days ago

An associated question, it there a way to check if a resource exists?

angelix commented 5 days ago

@angelix, I am manually creating a map for my drawables but it got me wondering - does this cause hundreds of string and drawable resources to be instantiated? Is this inefficient?

You have a point, all StringResources are initialized, but not the actual strings or drawables, only the representation of them. That's why we need an official solution and not a custom one.

It's a custom solution, so yes, you can have a function to check the existence of the key.

duanemalcolm commented 1 day ago

I've got a solution that doesn't initialize references to resources:

object Iconic {

    private val icons = mutableMapOf<String, DrawableResource?>()

    private val fallbackIcon = Res.drawable.ic_fallback_icon

    fun get(id: String): DrawableResource {
        if (id !in icons) {
            icons[id] = getDrawable(id)
        }
        return icons[id] ?: fallbackIcon
    }

    @OptIn(ExperimentalResourceApi::class)
    fun getDrawable(id: String): DrawableResource? {
        if (id !in icons.keys) {
            try {
                Res.getUri("drawable/$id.xml") // throws exception is path does not exist
                icons[id] = initDrawable(id)
            } catch (e: Exception) {
                icons[id] = null
            }
        }
        return icons[id]
    }

    @OptIn(InternalResourceApi::class)
    private fun initDrawable(id: String): DrawableResource =
        DrawableResource(
            "drawable:$id",
            setOf(
                org.jetbrains.compose.resources.ResourceItem(setOf(),
                    "composeResources/my.app.uri.generated.resources/drawable/$id.xml", -1, -1),
            )
        )
}

But this only worked on Android. My iOS build and runs but I was unable to catch the exception. I got an "Uncaught Kotlin exception".

If anyone has a solution to catching the exception on iOS, please let me know.

It would be good if we had a Res.exists("drawable/$id.xml") function. Again, if anyone knows how to do this, please let me know.


My final solution still requires manually adding the resources:

object Iconic {

    private val icons = mutableMapOf<String, DrawableResource?>()

    private val fallbackIcon = Res.drawable.ic_fallback_icon

    fun get(id: String): DrawableResource {
        if (id !in icons) {
            icons[id] = getDrawable(id)
        }
        return icons[id] ?: fallbackIcon
    }

    private fun getDrawable(id: String): DrawableResource? {
        return when (id) {
            "ic_clock" -> Res.drawable.ic_clock
            "ic_fallback_icon" -> Res.drawable.ic_fallback_icon
            "ic_precipitation" -> Res.drawable.ic_precipitation
            "ic_propeller" -> Res.drawable.ic_propeller
            "ic_thermometer" -> Res.drawable.ic_thermometer
            else -> null
        }
    }
}

I wonder if this will cause a concurrent mutation of the icons mutable map??

duanemalcolm commented 1 day ago

The reason the fist solution doesn't work on iOS is that Res.getUri("drawable/$id.xml") does not throw an exception if the resource is missing.

However, if I replace it with the inefficient Res.readBytes("drawable/$id.xml"), then it'll throw an exception and the first solution does work.

It would be great if we can get a Res.exists(path: String): Boolean function.

duanemalcolm commented 1 day ago

Ok, I've got something that works. It's implemented for drawables but should be extendable to strings as well.

object Iconic {

    private val icons = mutableMapOf<String, DrawableResource?>()

    private val fallbackIcon = Res.drawable.ic_fallback_icon

    fun get(id: String): DrawableResource {
        if (id !in icons) {
            icons[id] = getDrawable(id)
        }
        return icons[id] ?: fallbackIcon
    }

    private fun getDrawable(id: String): DrawableResource? {
        if (id !in icons.keys) {
            icons[id] = if (drawableExists(id)) { initDrawable(id) } else { null }
        }
        return icons[id]
    }

    @OptIn(InternalResourceApi::class)
    private fun initDrawable(id: String): DrawableResource =
        DrawableResource(
            "drawable:$id",
            setOf(
                org.jetbrains.compose.resources.ResourceItem(setOf(),
                    "composeResources/my.app.path.generated.resources/drawable/$id.xml", -1, -1),
            )
        )
}

expect fun drawableExists(id: String): Boolean

Iconic.android.kt

@OptIn(ExperimentalResourceApi::class)
actual fun drawableExists(id: String): Boolean {
    try {
        Res.getUri("drawable/$id.xml")
        return true
    } catch (e: Exception) { }
    return false
}

Iconic.ios.kt

import platform.Foundation.NSBundle
import platform.Foundation.NSFileManager

actual fun drawableExists(id: String): Boolean {
    val fileManager = NSFileManager.defaultManager
    var resourceRoot = NSBundle.mainBundle.resourcePath + "/compose-resources/"
    val path = resourceRoot +
            "composeResources/my.app.path.generated.resources/drawable/$id.xml"
    val exists = fileManager.fileExistsAtPath(path)
    return exists
}