String Catalogs: la traducción como estado, no como archivo
Durante quince años la localización en las plataformas de Apple vivió en archivos de texto plano que había que sincronizar a mano y en una herramienta de extracción que rompía tanto como arreglaba. El String Catalog sustituye ese régimen por una fuente única, versionable y con estado explícito por cadena. Esta lección explica qué compila realmente el formato, cómo funciona la extracción desde el compilador, por qué la elección de la clave es una decisión de arquitectura y qué significa cada estado del catálogo.
Casi todos los equipos descubren la localización como un problema de traducción y la mantienen después como un problema de sincronización. La diferencia entre ambas descripciones es lo que separa una app que se traduce una vez de una app que sigue traducida tres años después. Un texto en la interfaz no es un dato inmóvil: nace en inglés, se deriva a doce idiomas, alguien corrige una coma en el original y en ese instante las doce derivaciones quedan silenciosamente desactualizadas sin que nada en el sistema lo señale. El formato antiguo no tenía manera de expresar esa desactualización, así que no la expresaba: el equipo enviaba una versión con textos que ya no correspondían y se enteraba por una reseña. El String Catalog no traduce mejor; simplemente convierte cada cadena en una entidad con estado, y con eso transforma un problema de disciplina humana en un problema de herramienta.
- Explicar qué compila realmente un
String Catalogy en qué se convierte dentro del paquete final. - Dominar la extracción automática desde
LocalizedStringKey,String(localized:)y el ajuste del compilador. - Decidir con criterio cuándo la clave debe ser el texto de origen y cuándo un identificador simbólico.
- Interpretar y gobernar los estados de cada cadena: nueva, por revisar, traducida y obsoleta.
Qué es realmente un catálogo
Un archivo Localizable.xcstrings es un documento JSON con una entrada por clave, y dentro de cada entrada un diccionario de idiomas con su traducción y su estado. Xcode muestra ese documento con un editor de tabla, pero conviene no confundir la vista con el dato: lo que se versiona, lo que se fusiona y lo que hay que revisar en una solicitud de cambios es el JSON. Las claves se serializan ordenadas alfabéticamente, lo que hace que los conflictos de fusión sean locales y legibles en lugar de catastróficos, que era exactamente el defecto crónico del formato anterior.
Lo segundo que conviene interiorizar es que el catálogo no existe en tiempo de ejecución. Durante la compilación, una herramienta del sistema lo desmonta y genera los .strings y .stringsdict clásicos dentro de cada carpeta .lproj del paquete. En ejecución, NSLocalizedString y la maquinaria de Foundation hacen exactamente lo mismo que hacían en 2010: buscar una clave en una tabla compilada. No hay coste nuevo, no hay análisis de JSON al arrancar y no hay ninguna magia.
flowchart LR a[Codigo Swift con literales] --> b[Extraccion durante la compilacion] b --> c[Localizable.xcstrings como fuente unica] c --> d[Compilacion a strings y stringsdict] d --> e[Carpetas lproj dentro del paquete] e --> f[Busqueda en tiempo de ejecucion identica a la de siempre]
La migración desde el régimen anterior es, por ese mismo motivo, indolora y reversible. Xcode convierte un .strings o un .stringsdict existente en un catálogo sin perder traducciones, y ambos formatos pueden convivir en el mismo objetivo mientras dura la transición, porque el resultado compilado es idéntico. Conviene aprovechar ese momento para hacer lo único que la conversión automática no hace, que es revisar los comentarios heredados: casi siempre están vacíos, y una migración es la ocasión más barata de rellenarlos.
De ahí se sigue la tesis central del formato, que casi nunca se enuncia: la innovación del String Catalog es enteramente de autoría y de proceso, no de tecnología de ejecución. Es una herramienta de gestión del ciclo de vida de un texto disfrazada de formato de archivo. Entenderlo así evita la pregunta equivocada, que es si el catálogo es más rápido, y lleva a la correcta, que es qué información sobre el estado del trabajo permite representar que antes se guardaba en la cabeza de alguien.
La extracción y el problema de la clave
La extracción la realiza el propio compilador de Swift cuando el ajuste de compilación correspondiente está activo, y ese detalle tiene una consecuencia inmediata y no negociable: solo se extrae lo que es un literal visible en el código fuente. Un texto que llega en una variable no se extrae, porque en el momento de compilar no existe.
Text("Añadir tarea") // se extrae: literal de LocalizedStringKey
Text("Hola, \(nombre)") // se extrae con marcador de posición
Text(verbatim: "SKU-99213") // no se extrae, y es lo correcto
let titulo = String(
localized: "Guardar cambios",
table: "Ajustes",
comment: "Botón principal de la hoja de ajustes de la cuenta"
)
Text(tarea.titulo) // NO se localiza: resuelve al init de String, no al de clave
Existe un tipo intermedio que resuelve el caso más incómodo, que es el de la cadena que hay que elegir lejos de la vista y mostrar más tarde: LocalizedStringResource. Guarda la clave, la tabla y el paquete sin resolverlos, de modo que la resolución ocurre en el momento de dibujar y con la configuración vigente entonces. Es la pieza que permite que una capa de dominio devuelva textos sin convertirse en capa de presentación ni congelar el idioma.
enum ErrorDeCarga: Error {
case sinRed, servidorCaido
var mensaje: LocalizedStringResource {
switch self {
case .sinRed: "No hay conexión" // se resuelve al mostrarse
case .servidorCaido: "El servicio no responde"
}
}
}
La última línea del primer ejemplo es el error más caro y más silencioso de toda la localización en SwiftUI. Text tiene dos inicializadores, uno que recibe LocalizedStringKey y otro que recibe cualquier StringProtocol, y Swift elige el segundo cuando le pasas una variable. El resultado compila, se ve bien en tu idioma y no aparece jamás en el catálogo. La única defensa fiable es tratar cualquier Text con variable como un texto que hay que localizar en su origen, envolviéndolo en LocalizedStringResource allí donde se construye.
El campo de comentario es el único canal de contexto que llega a la persona que traduce, y esa persona ve una lista de frases sin pantalla, sin flujo y sin saber si Open es un verbo o un adjetivo. Un comentario que dice qué es el elemento, dónde aparece y qué papel gramatical juega vale más que cualquier glosario posterior. Dejarlo vacío no ahorra tiempo: lo traslada, multiplicado por el número de idiomas, a una ronda de preguntas o a un error que nadie detecta.
Queda la decisión que más consecuencias tiene a largo plazo: qué se usa como clave. El comportamiento por omisión es que la clave sea el propio texto en el idioma de desarrollo, y tiene dos virtudes fuertes. La primera es que el respaldo ante una traducción ausente es una frase legible en lugar de un identificador roto. La segunda es que escribir la interfaz no exige inventar nombres.
Sus dos defectos son igual de fuertes. Corregir una errata en el original cambia la clave, y con ello todas las traducciones existentes quedan huérfanas y hay que rehacerlas por una coma. Y los homógrafos colisionan: dos textos idénticos en inglés que en otros idiomas son palabras distintas comparten una sola entrada y se vuelven imposibles de traducir bien.
// Clave simbólica cuando el texto de origen es ambiguo o volátil
Text(String(localized: "estado.local.cerrado",
defaultValue: "Cerrado",
comment: "Estado de un establecimiento, adjetivo"))
Text(String(localized: "accion.cerrar.hoja",
defaultValue: "Cerrar",
comment: "Acción de descartar la hoja modal, verbo imperativo"))
La política que mejor resiste el paso del tiempo no es elegir una de las dos, sino combinarlas por criterio: texto como clave para la inmensa mayoría de las cadenas cortas y estables, y clave simbólica con valor por omisión explícito en tres casos concretos, que son los homógrafos, los textos legales o de mercadotecnia que se reescriben a menudo y los párrafos largos donde la probabilidad de retoque es del cien por cien.
El estado de cada cadena
Cada par de clave e idioma tiene un estado, y ese pequeño campo es la aportación real del formato. Una cadena recién extraída y sin traducir aparece como nueva. Una cadena cuyo texto de origen ha cambiado desde la última traducción pasa a necesitar revisión, que es justo la transición que el formato antiguo no sabía representar. Una cadena traducida y vigente queda marcada como tal. Y una cadena que ya no aparece en el código se marca como obsoleta.
Nueva
Extraída y todavía sin traducir en ese idioma. Es el trabajo pendiente y alimenta el porcentaje de completitud que Xcode muestra por idioma.
Por revisar
El texto de origen cambió después de traducirse. La traducción sigue ahí, pero ya no corresponde con seguridad al original.
Obsoleta
No apareció en la última extracción. No significa que sobre: significa que el compilador no la vio en esta configuración.
El estado obsoleto merece una precisión que evita pérdidas de trabajo. El catálogo nunca borra por su cuenta, y hace bien, porque una cadena puede no aparecer en la extracción por motivos que nada tienen que ver con que se haya dejado de usar: vive tras una compilación condicional desactivada, pertenece a una plataforma que no se está compilando o está en un objetivo que se excluyó de ese esquema. Marcar en lugar de borrar convierte una decisión irreversible tomada por una herramienta en una decisión reversible tomada por una persona, que es siempre el reparto correcto.
El porcentaje que Xcode muestra junto a cada idioma resulta útil como termómetro, pero conviene leerlo con la advertencia adecuada: cuenta cadenas, no importancia. Un idioma al noventa y ocho por ciento puede tener sin traducir justo los tres botones del flujo de pago, y un idioma al ochenta puede tener pendiente solo texto de ajustes avanzados. Una métrica honesta pondera por visibilidad, y la forma más simple de conseguirla es separar en tabla propia el conjunto de cadenas críticas y exigir que esa tabla esté siempre al cien por cien.
Junto a los estados hay dos marcas que conviene usar sin timidez. La de no traducir sirve para códigos, nombres de marca y valores técnicos, y evita que alguien traduzca lo que no debe. Y una cadena puede añadirse manualmente al catálogo, lo que resulta imprescindible para textos que no nacen de un literal de Swift, como los que devuelve un servidor o los que se construyen desde datos.
Higiene y organización
La unidad de organización es la tabla, y cada tabla es un archivo. El proyecto arranca con Localizable.xcstrings, pero mantener todo el producto en una sola tabla es una elección que se paga en conflictos de fusión y en tiempos de revisión. Dividir por área funcional reduce la superficie de conflicto y permite además enviar a traducir solo lo que cambió en una función concreta.
Hay un archivo que nunca hay que olvidar y que causa rechazos en revisión con regularidad deprimente: InfoPlist.xcstrings, donde viven las descripciones de uso de permisos. Un usuario que ve en árabe toda la app y en inglés la razón por la que le pides la cámara recibe exactamente el mensaje que no querías dar en el momento de máxima desconfianza.
Junto a él conviene inventariar todas las superficies que producen texto fuera de la app en primer plano, porque son las que más se olvidan y las que más se ven: el contenido de las notificaciones, los nombres y frases de las acciones expuestas al asistente de voz, los títulos de los widgets, los menús contextuales del sistema y los textos de los atajos. Cada una de ellas se lee desde un contexto distinto y algunas se resuelven en un proceso que no es el tuyo, de modo que la comprobación en dispositivo con el idioma cambiado es la única forma fiable de saber que llegan traducidas.
// En un paquete de SPM, el catálogo vive en el módulo y se lee desde su bundle
let texto = String(localized: "perfil.titulo", bundle: .module)
Esa línea encierra una de las causas más frecuentes de traducciones que se ven perfectas en el editor y aparecen en inglés al ejecutar. Un paquete tiene su propio paquete de recursos, y omitir el argumento hace que la búsqueda vaya al paquete principal, donde la clave no existe, con el respaldo silencioso a la clave literal. Un módulo bien construido no expone esa trampa a sus llamadores: encapsula sus textos y publica valores ya resueltos.
Merece la pena llevar la analogía hasta el fondo, porque explica por qué el formato antiguo fallaba de manera tan sistemática y por qué este acierta. Considera lo que es una traducción desde el punto de vista de la teoría de datos: un valor derivado. Existe una fuente de verdad, el texto en el idioma de desarrollo, y existen doce valores computados a partir de ella mediante una función costosa, lenta y ejecutada por humanos. En el momento en que la fuente cambia, los doce derivados quedan inválidos. Esto es, punto por punto, el problema de la invalidación de caché, con la particularidad de que el recálculo tarda días y cuesta dinero. Ahora observa lo que ofrecía un archivo .strings: un mapa de claves a valores, sin marca temporal, sin huella del origen, sin bit de suciedad. Un sistema de caché sin mecanismo de invalidación no es una caché mala, es una fuente de datos incorrectos con retardo variable, y por eso todas las apps localizadas del ecosistema anterior acumulaban textos desincronizados sin excepción y sin que ninguna herramienta lo señalara. Lo que el String Catalog añade es precisamente el bit que faltaba: al cambiar el origen, cada derivado pasa a estado de revisión pendiente. Es el mismo mecanismo que un ETag, que una marca de generación en un sistema de compilación incremental o que la invalidación de dependencias en un grafo de señales, y funciona por la misma razón: hace que la desactualización sea representable, y por tanto detectable, medible y bloqueante. La consecuencia práctica para el diseño de tu proceso es directa. Deja de preguntar cuántas cadenas están traducidas, que es la métrica de un proyecto que se traduce una vez, y empieza a preguntar cuántas están en estado de revisión pendiente, que es la métrica de deuda de un proyecto que sigue vivo. La primera cifra tiende a cien y se queda ahí; la segunda oscila con cada versión y es la única que dice la verdad sobre si tu producto está realmente traducido hoy.
Un String Catalog es un JSON versionable que el compilador genera por extracción de literales y que se convierte en .strings y .stringsdict al compilar: en ejecución no cambia nada. Solo se extrae lo literal, y Text con una variable resuelve al inicializador que no localiza. El comentario es el único contexto que recibe quien traduce. La clave por omisión es el texto de origen, con el coste de que una errata invalida las traducciones y los homógrafos colisionan. Y el estado por cadena, especialmente el de revisión pendiente, es lo que convierte la traducción en un proceso auditable.
- Activa la extracción por compilador, compila y cuenta cuántas cadenas nuevas aparecen: la diferencia con lo que creías tener es tu deuda real.
- Busca todos los
Textque reciben una variable y clasifica cuáles deben localizarse en origen y cuáles son datos que nunca se traducen. - Elige las diez cadenas más visibles de tu app y escribe un comentario que indique elemento, ubicación y papel gramatical; comprueba si alguna es un homógrafo.
- Separa el catálogo único en tablas por área funcional y mide durante dos semanas cuántos conflictos de fusión desaparecen.
- Localiza
InfoPlist.xcstringspara todos tus idiomas y verifica en dispositivo que el diálogo de permisos aparece traducido.