Compartir esta publicación

Contexto

A veces hay proyectos en los que el sistema de gestión de textos nativos de Android se nos queda corto. Por ejemplo, si quisiéramos arreglar un error de traducción, ajustar una explicación o añadir un nuevo idioma sin tener que desplegar una nueva versión de la aplicación, el sistema de Android no nos lo permitiría.

En nuestro caso, tenemos una aplicación con este problema. El cliente tiene un portal a partir del cual gestiona las traducciones y puede modificar los textos o añadir nuevos idiomas sin tener que desplegar nuevas versiones de la aplicación.

En este artículo nos centraremos en la parte de la solución que afecta a Android y cómo adaptarla para integrarla con jetpack compose.

Preparación

Aunque la idea es no usar del sistema de Android para realizar las traducciones, si que lo vamos a aprovechar para un par de cosas:

  • Define the text keys to be translated.
  • Provide fallback values in case of error.

Así que crearemos un fichero strings.xml con total normalidad, pero no vamos a crear uno para cada idioma, sino que nos centraremos en uno solo, el que queremos que se muestre en caso de que no se encuentre una traducción. En nuestro caso, por ejemplo, es el inglés:

<resources>
  <string name="app_name">My Application</string>
  <string name="home_title">Home</string>
 ...
</resources>

Los strings que definamos en este archivo deberán tener el mismo nombre que los que se definan en el backend (ya que lo aprovecharemos más tarde) y el texto que se deba mostrar en caso de error al obtener una traducción.

Android Project CTA

Traductor

El traductor es la clase encargada de gestionar las traducciones y facilitar a otras piezas de software el acceso a ellas.

class Translator(
  private val context: Context,
  private val api: TranslationApi,
  private val database: TranslationDao,
  private val cache: TranslationCache
)

El traductor tiene 4 dependencias:

  1. context: to have access to the strings defined in the text file.
  2. api: to be able to download new translations
  3. database: for offline access to translations
  4. cache: for faster access to translations without the need to access the database.

Cuando abrimos la aplicación e iniciamos el traductor este se encargará de ciertas cosas:

  1. Ask if there are new translations of the application language.

1.a) Actualizar la base de datos si las había.

2. Cargar la base de datos en la caché.

  Comparación de librerías de React forms: Formik vs React Hook Form

Por defecto el traductor pedirá las traducciones del idioma en el que esté la aplicación, y a su vez la aplicación se establecerá como el idioma del teléfono. En caso de que no sea uno de los idiomas disponibles el idioma por defecto será el inglés. (Para nuestro caso, otras aplicaciones pueden tener otros requerimientos).

La llamada a backend además de responder con las traducciones nos devolverá el último número de versión. Este mismo valor es el que debemos mandar al backend la siguiente vez que abramos la aplicación para comprobar si hay cambios. En caso de haber cambios el backend nos mandará solo los textos nuevos o modificados, de este modo podemos actualizar la base de datos sin necesidad de descargar todas las traducciones cada vez. En caso de que no poder conectarse a la api de traducciones se usaran las ultimas que en base de datos.

class Translator(
  ...
) {
  suspend fun startup(language: String) {
    ...
  }

  fun translate(@StringRes resourceId: Int, vararg params: String) =
    translate(getKey(resourceId), *params)

  fun translate(key: String, vararg params: String) {
    ...
  }

  private fun getKey(@StringRes resourceId: Int) = context.resources.getResourceEntryName(resourceId)  
}

(aunque no estén especificados en el ejemplo se puede añadir métodos para traducir plurales)

Cuando se pide una traducción el traductor trabajará por defecto con la cache, usando las otras capas como fallback:

  1. We look for the key in the cache, if it fails:
  2. We look for the key in the database:

a. Si la encuentra, actualizamos la caché

b. Si falla:

3. Cogemos la traducción que hay en el archivo strings.xml

Este flujo nos asegura que siempre iremos a la solución menos costosa y veraz, haciendo fallback a soluciones más lentas sólo en caso de fallo y usando la traducción del fichero xml solo en caso de no encontrar esa clave.

Gracias al método getKey podemos obtener el nombre de un texto del fichero de strings.xml usando su identificador. De este modo podemos hacer llamadas como:

translator.translate(R.string.home_title)

Cosa que nos asegura evitar typos a la hora de pedir traducciones así como evitar pedir textos de los cuales no tengamos una estrategia de fallback viable.

Cuando el usuario cambie el idioma de la aplicación, podemos llamar al método startup con el nuevo idioma seleccionado, cosa que reproducirá el ciclo de inicio del traductor, pero para el nuevo idioma. Este flujo asegura que nunca se descargan y guardan en base de datos idiomas que el usuario no va a usar, reduciendo el consumo de datos y memoria de la aplicación.

  Visión de la Arquitectura Mobile: Event-Binder

Los métodos translate tienen un segundo parámetro: params. Params se usa para textos que requieren de información adicional, por ejemplo: “Hello, {0}” el método translate reemplazará todos los {0}, con el primer parámetro pasado a params, {1} con el segundo, etc.

Traduciendo

A la hora de usar el traductor es tan sencillo como conseguir una instancia y llamar al método de traducción adecuado para cada situación. El principal problema está en cómo conseguir esa instancia. Podemos usar nuestro framework de inyección de dependencias favorito para conseguirla en un fragment o activity, pero la cosa se complica cuando queremos traducir texto dentro de un adapter, una custom view o en una función que no esté ligada a una clase. En nuestro caso koin nos permite implementar la interfaz KoinComponent para tener acceso a la inyección de dependencias, pero no siempre es posible o deseable inyectarlo en el punto del código en el que lo vamos a usar. y en este caso no nos queda más remedio que pasar el traductor por parámetro.

...
val translator by inject<Translator>()
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    ...
    binding.homeScreenTitle.text = translator.translate(R.string.home_title)
    ...
}

Traduciendo en Compose

Lo discutido anteriormente es una solución que funciona y realmente en la mayoría de los casos queda bastante bien. Pero Jetpack Compose nos ofrece otra solución a la hora de gestionar la traducción: los CompositionLocals.

No entraremos en mucho detalle en que es un CompositionLocal, sino más bien veremos lo que son a través de su uso.

Primero definiremos el composition local:

val LocalTranslator = staticCompositionLocalOf<Translator> { error("CompositionLocal Translator not present") }

Tenemos dos maneras de crear un composition locals, estáticos y normales. Con los estáticos compose no hace un seguimiento de los lugares en los que es usado y un cambio del valor va a provocar que todo contenido sea repintado y no solo donde es usado. La ventaja que aportan es que si el composition local va a sufrir pocos o ningún cambio en la vida del componente esta falta de seguimiento hace que sea más eficiente. En el caso que nos ocupa el traductor no va a cambiar nunca y esto nos permite hacerlo estático.

La lambda que se le pasa a la función staticCompositionLocalOf es la encargada de proporcionar un valor por defecto. En nuestro caso al tratarse de un componente complejo que requiere de otras instancias no podemos proporcionar uno, así que si se intenta usar sin que haya un valor proporcionado dará un error.

  Proxy / Caché: Un entorno local más rápido

Entonces, ¿cómo le proporcionamos un valor?

CompositionLocalProvider(LocalTranslator provides translator) {
    App()
}

Este método es un composable que nos permite pasarle una lambda con el contenido que queremos que tenga disponible el traductor. Todos los compostables que estén anidados dentro de esta llamada tendrán acceso al traductor proporcionado accediendo a él del siguiente modo:

LocalTranslator.current

En caso de que alguna parte de la aplicación requiera de un traductor distinto (por ejemplo queremos que en la pantalla de selección de idioma funcione de una forma distinta) solo hace falta proporcionar un traductor distinto en la raíz de esa pantalla y todos los compostables llamados dentro tendrán acceso al nuevo traductor.

Finalmente, para simplificar el uso y no tener que ir llamando a LocalTranslator.current en todas partes, podemos crear un composable nuevo que refiera todo menos la traducción al composable de texto de compose:

@Composable
fun TranslatedText(
  id: Int,
  vararg params: String,
  modifier: Modifier = Modifier,
  color: Color = Color.Unspecified,
  fontSize: TextUnit = TextUnit.Unspecified,
  fontStyle: FontStyle? = null,
  fontWeight: FontWeight? = null,
  fontFamily: FontFamily? = null,
  letterSpacing: TextUnit = TextUnit.Unspecified,
  textDecoration: TextDecoration? = null,
  textAlign: TextAlign? = null,
  lineHeight: TextUnit = TextUnit.Unspecified,
  overflow: TextOverflow = TextOverflow.Clip,
  softWrap: Boolean = true,
  maxLines: Int = Int.MAX_VALUE,
  onTextLayout: (TextLayoutResult) -> Unit = {},
  style: TextStyle = LocalTextStyle.current,
) {
  Text(
    LocalTranslator.current.translate(id, *params),
    modifier,
    color,
    fontSize,
    fontStyle,
    fontWeight,
    fontFamily,
    letterSpacing,
    textDecoration,
    textAlign,
    lineHeight,
    overflow,
    softWrap,
    maxLines,
    onTextLayout,
    style,
  )
}

Con esto y un alguna más como para los casos de string anotados, podemos usar este composable para conseguir textos traducidos sin necesidad de preocupaciones o soporte por parte de nuestro framework de inyección de dependencias.

@Composable
fun App() {
  Scaffold(topBar = {
    TranslatedText(R.string.home_title)
  }) {
    //TODO
  }
}

Author

  • Eric Martori

    Software engineer with over 6 years of experience working as an Android Developer. Passionate about clean architecture, clean code, and design patterns, pragmatism is used to find the best technical solution to deliver the best mobile app with maintainable, secure and valuable code and architecture. Able to lead small android teams and helps as Android Tech Lead.

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

Suscríbete a nuestro boletín de noticias

Recibe actualizaciones de los últimos descubrimientos tecnológicos

¿Tienes un proyecto desafiante?

Podemos trabajar juntos

apiumhub software development projects barcelona
Secured By miniOrange