Traduciendo texto en JetPack Compose

Compartir esta publicación

Share on facebook
Share on linkedin
Share on twitter
Share on email

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:

  • Definir las claves de los textos a traducir.
  • Proporcionar valores de fallback en caso de 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.

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: para tener acceso a los strings definidos en el archivo de textos.
  2. api: para poder descargar nuevas traducciones.
  3. database: para tener acceso a las traducciones estando sin conexión.
  4. cache: para un acceso más rápido a las traducciones sin necesidad de acceder a la base de datos.

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

  1. Pedir si hay nuevas traducciones del lenguaje de la aplicación.

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

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

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)


banner blog

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

  1. Buscamos el clave en la cache, si falla:
  2. Buscamos la clave en la base de datos:

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.

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.

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
  }
}

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Suscríbete a nuestro boletín de noticias

Recibe actualizaciones de los últimos descubrimientos tecnológicos

Acerca de Apiumhub

Apiumhub reúne a una comunidad de desarrolladores y arquitectos de software para ayudarte a transformar tu idea en un producto potente y escalable. Nuestro Tech Hub se especializa en Arquitectura de Software, Desarrollo Web & Desarrollo de Aplicaciones Móviles. Aquí compartimos con usted consejos de la industria & mejores prácticas, basadas en nuestra experiencia.

Posts populares
Descarga Grow Professionally: Inside Apiumhub's Dev Team

¿Estás orientado a Datos?

Construyamos tu éxito juntos.

Contáctanos

¿Tienes un proyecto desafiante?

Podemos trabajar juntos