Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support getting resources by key #4880

Closed
enoler opened this issue May 27, 2024 · 18 comments · Fixed by #5068
Closed

Support getting resources by key #4880

enoler opened this issue May 27, 2024 · 18 comments · Fixed by #5068
Assignees
Labels
enhancement New feature or request resources

Comments

@enoler
Copy link

enoler commented May 27, 2024

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
Copy link

angelix commented Jun 1, 2024

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

@terrakok
Copy link
Collaborator

terrakok commented Jun 2, 2024

#4909

@enoler
Copy link
Author

enoler commented Jun 2, 2024

#4909

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

@terrakok
Copy link
Collaborator

terrakok commented Jun 2, 2024

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
Copy link

angelix commented Jun 2, 2024

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
Copy link
Collaborator

terrakok commented Jun 3, 2024

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
Copy link
Collaborator

terrakok commented Jun 3, 2024

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
Copy link
Author

enoler commented Jun 3, 2024

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
Copy link
Collaborator

terrakok commented Jun 4, 2024

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
Copy link

angelix commented Jun 6, 2024

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
Copy link

+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
Copy link

angelix commented Jun 19, 2024

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
Copy link

@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
Copy link

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

@angelix
Copy link

angelix commented Jun 20, 2024

@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
Copy link

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
Copy link

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
Copy link

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
}

terrakok added a commit that referenced this issue Jul 10, 2024
The PR adds a generation special properties with maps a string ID to the
resource for each type of resources:
```kotlin
val Res.allDrawableResources: Map<String, DrawableResource>
val Res.allStringResources: Map<String, StringResource>
val Res.allStringArrayResources: Map<String, StringArrayResource>
val Res.allPluralStringResources: Map<String, PluralStringResource>
val Res.allFontResources: Map<String, FontResource>
```

<!-- Optional -->
Fixes #4880
Fixes https://youtrack.jetbrains.com/issue/CMP-1607

## Testing
I checked it in the sample project but this should be tested by QA (KMP
and JVM only projects)

## Release Notes
### Features - Resources
- Now the gradle plugin generates resources map to find a resource by a
string ID
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment