Saltar al contenido
K
CalidadPaso a paso30 min de lectura · 8 min de máquina Intermedio

Dos idiomas en Android: los recursos

Al terminar: Pasar los textos de Android a recursos y traducirlos al inglés.

La interfaz de Android tiene los textos escritos dentro del código (Text("Ajustes")). Hay que sacarlos a recursos, que es donde Android espera los textos de una app, y añadir la traducción al inglés. Son muchos cambios, así que van en dos lecciones: esta crea los recursos, aprende la técnica con las etiquetas de los tipos y la tarjeta de la lista, y escribe los tests que miran la app en los dos idiomas; la siguiente, Dos idiomas en Android: todas las pantallas, recorre el resto de pantallas. Parte de Dos idiomas: el idioma en el núcleo, que dejó el módulo compartido pidiendo a PokeAPI el idioma del usuario. La otra plataforma, la de iPhone, está en Dos idiomas en iOS y no depende de esta.

El cambio va fichero a fichero y compilando cada vez: el proyecto nunca queda en rojo más de un paso.

androidApp
src/main/res/values/strings.xmlse sustituye (paso 1)
src/main/res/values-en/strings.xmlnuevo (paso 1)
src/main/kotlin/com/sebastiannarvaez/pokedex/android
ui/TypeColors.ktetiquetaRes (paso 2)
list/PokemonCard.ktla tarjeta (paso 3)
src/test/resources/robolectric.propertiespaso 4
src/test/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonCardIdiomaTest.ktnuevo (paso 4)
Qué se crea y qué cambia en esta lección (rutas de la vista Project).

El concepto · Un texto que no es un texto

Hasta ahora, cada pantalla escribe sus palabras dentro del código: Text("Ajustes"). Eso funciona con un solo idioma. Con dos, la pantalla no puede decidir qué palabra poner, porque no sabe en qué idioma está el teléfono. La solución es separar qué texto (una clave, como pestana_ajustes) de qué palabras (un fichero por idioma). El código pide la clave y Android entrega las palabras del idioma que toca.

Un ejemplo ajeno al proyecto, con todas las piezas:

Ejemplo ajeno al proyecto: de la clave al texto
// 1. Un fichero por idioma, con la MISMA clave en los dos:
//    res/values/strings.xml      →  <string name="saludo">Hola</string>      (el idioma por defecto)
//    res/values-en/strings.xml   →  <string name="saludo">Hello</string>     (el inglés)
 
// 2. Android genera una clase llamada R, con un número por cada clave.
//    R.string.saludo NO es el texto: es un número que lo identifica.
 
@Composable
fun Saludo() {
    // 3. stringResource cambia ese número por el texto del idioma del teléfono.
    //    Es una función @Composable: lee el «contexto» de la pantalla (los datos del
    //    teléfono que la rodean, entre ellos el idioma), así que solo se puede
    //    llamar desde otro composable.
    Text(stringResource(R.string.saludo))
}

Tres cosas nuevas en ese ejemplo, que verás una y otra vez: la clase R (la genera Android a partir de la carpeta res; en este proyecto se importa de com.sebastiannarvaez.pokedex.android.R), la función stringResource y los huecos de los textos con datos. Un hueco es un trozo del texto que se rellena al pedirlo: %d es un hueco para un número entero y %1$s el hueco del primer texto (%2$s, el del segundo). Con <string name="saludo_a">Hola, %1$s</string>, stringResource(R.string.saludo_a, "Ash") da «Hola, Ash».

Paso 1 · Los textos, en recursos

Los calificadores. Las carpetas de res/ pueden llevar un sufijo que dice cuándo se usan: values-en/ para inglés, values-night/ para el tema oscuro. Android elige la carpeta que mejor encaja con el dispositivo y, si no hay ninguna, cae a la que no tiene sufijo. Aquí values/ (sin sufijo) es el español, el idioma del proyecto, y values-en/ es el inglés.

Los dos ficheros son largos y no enseñan nada nuevo línea a línea: son una entrada por texto de la app, todos los de las dos lecciones. Por eso muchas claves no las usa nada todavía: las irás estrenando en esta lección y en la siguiente. Cópialos:

strings.xml

(Ya existe un strings.xml con app_name: se sustituye entero.) Para el inglés, la carpeta no existe: clic derecho sobre androidApp → New → File y escribe src/main/res/values-en/strings.xml. En la vista Project quedan en dos carpetas contiguas, res/values/ y res/values-en/, con el mismo nombre de fichero.

strings.xml

Fíjate en tres entradas, las que enseñan algo. La primera es el nombre de la app:

androidApp/src/main/res/values/strings.xml: app_name
    <string name="app_name" translatable="false">Pokédex</string>

translatable="false" le dice a Android Studio que este texto no se traduce a propósito: la app se llama Pokédex en todos los idiomas. Por eso app_name está solo en values/ y no en values-en/. Sin esa marca, la revisión automática de Android (lint) la daría por olvidada.

La segunda es el plural:

androidApp/src/main/res/values/strings.xml: el plural
    <plurals name="favoritos_contados">
        <item quantity="one">%d favorito</item>
        <item quantity="other">%d favoritos</item>
    </plurals>

Es un plural de verdad, no un «(s)». El español y el inglés solo tienen one y other, así que parece innecesario; pero el polaco o el ruso tienen más formas (few, many) y el código no cambia el día que se traduzca. En el código se usa pluralStringResource, y hay una trampa:

El número va dos veces: una para elegir la forma y otra para rellenar el %d. Olvidar la segunda deja un «%d favoritos» en pantalla. La usarás en la lección siguiente.

La tercera es la que más se olvida: qué pasa si falta una clave en values-en/. Android no falla, no deja un hueco ni enseña el nombre de la clave: cae a values/ y enseña el texto en español en medio de una interfaz en inglés. Como red de seguridad es excelente (una traducción incompleta da una app mezclada, no una app rota); como control de calidad es malísimo, porque nadie se entera de que falta. Por eso el punto de control de esta lección compara las claves de los dos ficheros, y por eso app_name es la única ausencia deliberada.

Compila para comprobar que los recursos están bien formados (todavía nada los usa):

./gradlew :androidApp:assembleDebug

Termina en BUILD SUCCESSFUL.

Paso 2 · Un texto traducible no es una propiedad normal

stringResource es una función @Composable, y eso tiene una consecuencia. Abre TypeColors.kt (androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/ui/TypeColors.kt): el nombre de cada tipo está en una propiedad normal, PokemonType.etiqueta, que devuelve un String. (Que se escriba PokemonType.etiqueta quiere decir que es una propiedad que se le añade al tipo PokemonType desde fuera, sin tocar su código; se usa como tipo.etiqueta.)

Añade primero, junto a los demás imports de TypeColors.kt, los dos que necesita:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/ui/TypeColors.kt: imports nuevos
import androidx.annotation.StringRes
import com.sebastiannarvaez.pokedex.android.R

Provócalo. Añade al final del fichero esta propiedad de prueba y compila:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/ui/TypeColors.kt: prueba temporal, bórrala después
val PokemonType.prueba: String
    // el nombre largo evita añadir otro import solo para esta prueba
    get() = androidx.compose.ui.res.stringResource(R.string.tipo_fire)

El compilador responde:

e: Functions which invoke @Composable functions must be marked with the @Composable annotation
e: @Composable invocations can only happen from the context of a @Composable function

Borra la propiedad de prueba. La salida no es marcar la propiedad como @Composable. La propiedad devuelve el identificador del recurso (un Int, R.string.tipo_fire) y quien pinta el texto lo resuelve. Añade, al final del fichero, debajo de la propiedad etiqueta que ya existe (se queda por ahora, para que cada pantalla compile mientras la migras), la propiedad nueva. Su get() = when (this) { … } es la receta que se ejecuta cada vez que alguien la lee: según el tipo, devuelve uno u otro identificador.

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/ui/TypeColors.kt: etiquetaRes, debajo de etiqueta
/**
 * El nombre del tipo, como identificador de recurso.
 *
 * Devuelve el identificador y no el texto porque `stringResource` es
 * `@Composable`: quien pinta resuelve, esto solo dice cuál.
 */
@get:StringRes   // el compilador comprueba que el Int sea un recurso de texto
val PokemonType.etiquetaRes: Int
    get() = when (this) {
        PokemonType.NORMAL -> R.string.tipo_normal
        PokemonType.FIGHTING -> R.string.tipo_fighting
        PokemonType.FLYING -> R.string.tipo_flying
        PokemonType.POISON -> R.string.tipo_poison
        PokemonType.GROUND -> R.string.tipo_ground
        PokemonType.ROCK -> R.string.tipo_rock
        PokemonType.BUG -> R.string.tipo_bug
        PokemonType.GHOST -> R.string.tipo_ghost
        PokemonType.STEEL -> R.string.tipo_steel
        PokemonType.FIRE -> R.string.tipo_fire
        PokemonType.WATER -> R.string.tipo_water
        PokemonType.GRASS -> R.string.tipo_grass
        PokemonType.ELECTRIC -> R.string.tipo_electric
        PokemonType.PSYCHIC -> R.string.tipo_psychic
        PokemonType.ICE -> R.string.tipo_ice
        PokemonType.DRAGON -> R.string.tipo_dragon
        PokemonType.DARK -> R.string.tipo_dark
        PokemonType.FAIRY -> R.string.tipo_fairy
    }

Compila (./gradlew :androidApp:assembleDebug). Desde ahora, donde se pintaba tipo.etiqueta se pinta stringResource(tipo.etiquetaRes). El mismo patrón sirve para el nombre de cada estadística y para el tema de Ajustes, en la lección siguiente.

Paso 3 · La tarjeta: una frase no se traduce por trozos

Abre PokemonCard.kt (androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonCard.kt). Imports: añade com.sebastiannarvaez.pokedex.android.R y androidx.compose.ui.res.stringResource, y cambia etiqueta por etiquetaRes en el import de …ui.etiqueta.

El texto que lee TalkBack se armaba concatenando (", tipo ", " y "). Eso no se traduce: en español el tipo va detrás de «tipo» y en inglés va delante de «type». Una frase con huecos numerados sí puede reordenarse. La descripción ya está en los strings.xml que copiaste (%1$s y %2$s son los huecos 1 y 2):

androidApp/src/main/res/values*/strings.xml: la descripción, con huecos (lo que ya copiaste)
<string name="tarjeta_descripcion">%1$s, tipo %2$s</string>          <!-- values -->
<string name="tarjeta_descripcion">%1$s, %2$s type</string>          <!-- values-en -->

En el código se arma antes del Card(…): justo después de la línea val tono = … de PokemonCard, añade esto:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonCard.kt: la descripción, tras val tono
    // Se arma ANTES del bloque `semantics`, que no es composable.
    val nombre = pokemon.name.replaceFirstChar { it.uppercase() }
    val separador = stringResource(R.string.separador_tipos)
    // Los textos se resuelven FUERA de `joinToString`: dentro de su lambda
    // no se puede llamar a nada `@Composable` (el aviso de abajo lo explica).
    val etiquetas = pokemon.types.map { stringResource(it.etiquetaRes) }
    val descripcion = if (etiquetas.isEmpty()) {
        nombre
    } else {
        stringResource(R.string.tarjeta_descripcion, nombre, etiquetas.joinToString(separador))
    }

Y el .semantics { … } de la tarjeta se reduce:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonCard.kt: el modifier de la tarjeta, antes → después
// antes
            .semantics {
                contentDescription = buildString {
                    append(pokemon.name.replaceFirstChar { it.uppercase() })
                    if (pokemon.types.isNotEmpty()) {
                        append(", tipo ")
                        append(pokemon.types.joinToString(" y ") { it.etiqueta })
                    }
                }
            },
 
// después
            .semantics { contentDescription = descripcion },

Quedan tres textos sueltos en el mismo fichero. Cambia estos tres, sin más:

AntesDespués
text = "N.º ${pokemon.id.toString().padStart(4, '0')}"text = stringResource(R.string.numero_pokemon, pokemon.id)
contentDescription = if (esFavorito) "Quitar de favoritos" else "Añadir a favoritos"contentDescription = if (esFavorito) stringResource(R.string.quitar_de_favoritos) else stringResource(R.string.anadir_a_favoritos)
TypeChip(tipo.etiqueta, tipo.color)TypeChip(stringResource(tipo.etiquetaRes), tipo.color)

(El formato del número también es un recurso: N.º %04d en español y No. %04d en inglés; %04d es un número entero con ceros delante hasta llegar a cuatro cifras.) Compila: las demás pantallas siguen con tipo.etiqueta, y compila porque las dos propiedades existen a la vez.

Paso 4 · Los tests dicen en qué idioma miran

Los tests de Compose de Pruebas de interfaz en Android buscan textos («Entrar», «Ajustes»…), y en la lección siguiente esos textos saldrán de los recursos. Robolectric (el programa que simula un móvil dentro de los tests) no mira el idioma de tu ordenador: si no se le dice otra cosa, simula un teléfono en inglés de Estados Unidos. Cuando existe values-en/, los tests buscarían «Entrar» y encontrarían «Sign in». No es que los tests estén mal: es que no dicen en qué idioma miran. Fíjalo ya. Abre robolectric.properties (androidApp/src/test/resources/robolectric.properties, el que creaste en esa lección) y añade al final:

androidApp/src/test/resources/robolectric.properties: el idioma de los tests
# El idioma con el que corren los tests: el del proyecto.
# Para comprobar la traducción, un test concreto lo cambia con
# `@Config(qualifiers = "en")`.
qualifiers=es

(qualifiers=es es el mismo calificador que el sufijo de una carpeta, pero dicho al simulador. No hay values-es/: el español está en values/, y Android cae allí cuando el idioma pedido es el español y no existe una carpeta propia.)

Ahora un test nuevo que mire los dos idiomas con la descripción de la tarjeta. Clic derecho sobre androidApp → New → File (el método de Pruebas de interfaz en Android) y la ruta src/test/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonCardIdiomaTest.kt:

androidApp/src/test/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonCardIdiomaTest.kt: la misma tarjeta, en dos idiomas
package com.sebastiannarvaez.pokedex.android.list
 
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.printToString
import com.sebastiannarvaez.pokedex.domain.Pokemon
import com.sebastiannarvaez.pokedex.domain.PokemonType
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
 
@RunWith(RobolectricTestRunner::class)
class PokemonCardIdiomaTest {
 
    @get:Rule
    val compose = createComposeRule()
 
    private fun pintarBulbasaur() = compose.setContent {
        PokemonCard(
            pokemon = Pokemon(id = 1, name = "bulbasaur", types = listOf(PokemonType.GRASS, PokemonType.POISON)),
            esFavorito = false,
            alPulsar = {},
            alMarcar = {},
        )
    }
 
    // Sin anotación vale `qualifiers=es` del fichero de propiedades.
    @Test
    fun `en espanol el tipo va detras de la palabra tipo`() {
        pintarBulbasaur()
        // Para ver qué hay de verdad en pantalla, imprime el árbol de semántica.
        println(compose.onRoot().printToString())
 
        compose.onNodeWithContentDescription("Bulbasaur, tipo Planta y Veneno").assertExists()
    }
 
    // La anotación manda sobre el fichero, solo para este test.
    @Config(qualifiers = "en")
    @Test
    fun `en ingles el tipo va delante de la palabra type`() {
        pintarBulbasaur()
 
        compose.onNodeWithContentDescription("Bulbasaur, Grass and Poison type").assertExists()
    }
}
./gradlew :androidApp:testDebugUnitTest

Pasan los dos. Ahora, provócalo: el separador de la lista de tipos es " y ", con un espacio a cada lado, y en el fichero que copiaste va entre comillas. Quítaselas en los dos ficheros (values y values-en):

androidApp/src/main/res/values/strings.xml: el separador, sin comillas (prueba temporal)
    <string name="separador_tipos"> y </string>
androidApp/src/main/res/values-en/strings.xml: el separador, sin comillas (prueba temporal)
    <string name="separador_tipos"> and </string>

Vuelve a lanzar ./gradlew :androidApp:testDebugUnitTest. Fallan los dos. El mensaje solo dice que no encuentra ningún nodo con esa descripción, pero el println del primero imprime el árbol (sale en la salida del test: abre el informe androidApp/build/reports/tests/testDebugUnitTest/index.html con doble clic en Finder, entra en la clase del test, y en el test que falla busca «Standard output»; busca ahí «ContentDescription»):

ContentDescription = '[Bulbasaur, tipo PlantayVeneno]'

«PlantayVeneno», sin espacios. Android recorta los espacios del principio y del final del texto de un strings.xml. Para conservarlos, el texto va entre comillas. Devuélveselas a los dos:

androidApp/src/main/res/values/strings.xml: el separador, con comillas
    <string name="separador_tipos">" y "</string>
androidApp/src/main/res/values-en/strings.xml: el separador, con comillas
    <string name="separador_tipos">" and "</string>

Vuelve a lanzar los tests: pasan los dos. Un test que mira los dos idiomas encontró un fallo que a simple vista no se ve, porque en pantalla el separador no se usa: solo lo lee TalkBack. Deja el println: es la forma de ver el árbol la próxima vez que un texto no aparezca.

Con estos dos, androidApp suma 16 tests: los 9 de la pantalla de entrada, los 4 de la lista, el de la fila deslizable de Accesibilidad: el lector de pantalla y estos dos.

Punto de control

  • values/ y values-en/ tienen las mismas claves, salvo app_name, que solo está en el primero. Se comprueba en la Terminal, desde la carpeta del proyecto, con diff <(grep -o 'name="[^"]*"' androidApp/src/main/res/values/strings.xml | sort) <(grep -o 'name="[^"]*"' androidApp/src/main/res/values-en/strings.xml | sort). Cada pieza: grep -o 'name="[^"]*"' saca de un fichero solo los trozos name="…" (uno por clave), | sort los ordena, <( … ) hace que diff trate esa lista como si fuera un fichero, y diff imprime las líneas que no están en las dos listas. Debe salir una sola: < name="app_name" (el < dice que solo está en la primera lista).
  • ./gradlew :androidApp:testDebugUnitTest termina en BUILD SUCCESSFUL. Con todo en verde Gradle no cuenta los tests: ábrelos con doble clic en androidApp/build/reports/tests/testDebugUnitTest/index.html (en Finder) y verás 16, los dos de PokemonCardIdiomaTest incluidos.
  • Las tres tareas del núcleo, sin filtro (:shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test), siguen en 129, 91 y 94 tests: esta lección no toca shared.
  • El separador de los dos ficheros lleva comillas (" y ", " and ").
  • Todavía quedan pantallas con sus textos escritos a mano (los de Favoritos, Ajustes, la ficha…): son el trabajo de la lección siguiente.

Comprueba lo que entendiste

7 preguntas · necesitas 5 para completar la lección

  1. 1.pluralStringResource(R.plurals.favoritos_contados, n, n) lleva el número dos veces. ¿Por qué?

  2. 2.¿Por qué las etiquetas de tipo devuelven un identificador de recurso y no la cadena?

  3. 3.¿Por qué la descripción accesible de la tarjeta se arma con un recurso de dos huecos y no concatenando?

  4. 4.Un stringResource dentro de la lambda de joinToString no compila, pero dentro de la de map sí. ¿Por qué?

  5. 5.Un separador <string name="sep"> y </string> en strings.xml termina pintando «PlantayVeneno». ¿Por qué, y cómo se arregla?

  6. 6.Falta una clave en values-en/strings.xml. ¿Qué enseña la app en inglés?

  7. 7.app_name está en values/strings.xml con translatable="false" y no existe en values-en/. ¿Qué quiere decir esa marca?

Faltan 7 de 7