wandres.dev
ANATOMÍA DE UNA APP · manifiesto, recursos y AAB

La clase R y los assets: recursos compilados frente a bytes crudos

Cómo AAPT2 convierte res en una tabla binaria y en una clase R de enteros, cómo se accede a esos identificadores desde Kotlin y desde Compose, y en qué se diferencian de assets, la carpeta de archivos que Android copia sin tocar ni indexar.

⏱ 17 min

Cuando escribes una referencia a un recurso en Kotlin no estás nombrando un archivo: estás usando una constante entera que un compilador generó por ti. Esa constante es una entrada en una tabla binaria que viaja dentro del paquete, y ese rodeo —nombre humano en el código, entero en el binario, tabla que resuelve— es lo que hace posible que el sistema elija variantes en tiempo de ejecución sin buscar por nombre. Al lado vive assets/, una carpeta que Android no compila, no indexa y no adapta: bytes tal cual, para cuando lo que necesitas es un archivo y no un recurso.

🎯 Al terminar esta lección sabrás
  • Entender qué genera AAPT2 a partir de res/: la tabla de recursos y la clase R.
  • Leer un identificador de recurso como el entero estructurado que realmente es.
  • Acceder a recursos desde Kotlin, desde Compose y desde XML con la sintaxis correcta.
  • Decidir con criterio entre un recurso compilado y un archivo en assets/.

Qué produce la compilación de recursos

La herramienta AAPT2 recorre res/, compila cada archivo y produce dos artefactos. El primero es una tabla de recursos empaquetada dentro del paquete: una estructura binaria que, para cada identificador, guarda todas sus variantes junto con los calificadores que las condicionan. El segundo es la clase R, un archivo Kotlin o Java generado con clases anidadas por tipo y una constante entera por recurso.

// Generado por la herramienta, nunca se edita a mano
object R {
    object string { const val saludo: Int = 0x7f0f0021 }
    object drawable { const val logo: Int = 0x7f080012 }
    object color { const val primario: Int = 0x7f060007 }
}

Ese entero no es arbitrario: tiene estructura. El byte más alto identifica el paquete —el valor reservado para la app propia y otro distinto para los recursos del sistema—, el siguiente identifica el tipo de recurso, y los dos últimos bytes son el índice dentro de ese tipo. Por eso un identificador basta para localizar cualquier recurso sin buscar por cadena de texto: es, literalmente, una dirección dentro de la tabla.

flowchart LR
A[Carpeta res con variantes] --> B[AAPT2 compila]
B --> C[Tabla binaria dentro del paquete]
B --> D[Clase R con enteros]
D --> E[Tu codigo pide un identificador]
C --> F[Resources resuelve con la configuracion actual]
E --> F
F --> G[Devuelve la variante correcta]

Conviene notar que existen dos clases R distintas y confundirlas produce errores desconcertantes. La tuya vive bajo el paquete de tu app y contiene tus recursos; la del sistema vive bajo el paquete de la plataforma y contiene los suyos —colores estándar, cadenas como aceptar y cancelar, estilos base—. Referenciar la del sistema es legítimo y a veces necesario, pero implica depender de valores que cambian entre versiones de Android y entre capas de fabricante, así que se hace con moderación y sabiendo que no hay ningún contrato de estabilidad visual detrás.

La consecuencia es que el identificador es estable pero el valor no. Pedir el mismo entero en un dispositivo en inglés y en otro en japonés devuelve textos distintos, porque quien resuelve no es la constante sino la tabla, y la tabla consulta la configuración vigente. El código nunca menciona una variante: menciona una identidad, y el sistema elige la encarnación.

⚠️
Los identificadores no son constantes de compilación en una librería

En un módulo de librería, los valores de R dejaron de ser constantes finales: se asignan al integrarse en la app, porque una librería no sabe qué índices le tocarán al fusionarse con las demás. Esto rompe cualquier uso que exija una constante literal, como una etiqueta de sentencia de selección sobre identificadores. Si te encuentras con un error de compilación que dice que un valor no es constante, casi siempre es esto. La solución no es forzarlo, es dejar de tratar los identificadores como valores literales y usar condicionales normales.

Acceder a los recursos desde el código

En XML, la referencia usa un prefijo con arroba seguido del tipo y el nombre; y para los recursos del sistema se antepone la marca de espacio de nombres android. En código, la ruta pasa siempre por un objeto Resources que se obtiene del contexto, aunque casi nunca lo tocas de forma directa.

// La via clasica, a traves del contexto
val texto = context.getString(R.string.saludo)
val conFormato = context.getString(R.string.elementos, 12)
val margen = context.resources.getDimensionPixelSize(R.dimen.margen_lateral)

// En Compose, funciones dedicadas que ademas reaccionan a la configuracion
@Composable
fun Cabecera() {
    Text(text = stringResource(R.string.saludo))
    Icon(painter = painterResource(R.drawable.logo), contentDescription = null)
}

La diferencia entre ambas vías es más profunda de lo que parece. Las funciones de Compose leen la configuración actual desde la composición local, de modo que un cambio de idioma o de tema recompone lo que corresponda sin que tú escuches nada. La vía clásica devuelve un valor congelado en el momento de la llamada: si la configuración cambia después, ese valor ya no es correcto y depende de que la actividad se recree.

Cada tipo de recurso tiene su función de acceso y devolver el tipo correcto no siempre es obvio. Pedir una dimensión en píxeles no es lo mismo que pedirla redondeada a un entero de píxeles, y pedir un color como entero no es lo mismo que pedir una lista de estados de color que varía según si el elemento está pulsado o deshabilitado. Elegir la función equivocada compila sin problema y falla en tiempo de ejecución, porque la comprobación de tipo ocurre al resolver contra la tabla.

// Cadenas con cantidad variable, que respetan las reglas del idioma
val aviso = context.resources.getQuantityString(R.plurals.mensajes, 3, 3)

// Un color simple frente a una lista de estados
val fijo = ContextCompat.getColor(context, R.color.primario)
val porEstado = ContextCompat.getColorStateList(context, R.color.texto_boton)

// Acceso por nombre: funciona, pero rompe la poda de recursos
val id = context.resources.getIdentifier("icono_1", "drawable", context.packageName)

Esa última línea merece una advertencia. Resolver un identificador a partir de una cadena existe y a veces parece la solución elegante para nombres generados, pero desactiva de golpe el análisis estático: el reductor de recursos no puede saber que ese recurso se usa, lo elimina del paquete de publicación, y la app falla solo en producción. Cuando de verdad necesitas indexar recursos por número, la alternativa correcta es un array de identificadores declarado en XML o una lista en el código, donde cada referencia es explícita y visible para las herramientas.

Hay un matiz sobre el contexto que conviene fijar temprano. El contexto de aplicación y el de actividad no comparten configuración en modo multiventana: el de actividad conoce el tamaño real de la ventana asignada, el de aplicación no. Resolver una dimensión dependiente del tamaño desde el contexto equivocado produce valores correctos en teléfono y erróneos en plegable, que es el tipo de bug que solo aparece en los dispositivos que no tienes.

Assets: bytes que el sistema no toca

assets/ es la otra mitad de la historia y funciona con reglas opuestas. Es un árbol de directorios arbitrario que se copia dentro del paquete tal cual: sin compilar, sin indexar, sin identificadores, sin variantes por calificador. Se accede por ruta relativa a través de un gestor que devuelve un flujo de bytes.

context.assets.open("modelos/clasificador.tflite").use { flujo ->
    val bytes = flujo.readBytes()
}

// El arbol se puede recorrer, cosa imposible con los recursos
val archivos: Array<String> = context.assets.list("modelos") ?: emptyArray()

Hay un caso intermedio que sí admite variación: el gestor de assets respeta un subconjunto de calificadores si organizas subcarpetas con ellos y usas la función de apertura que consulta la configuración. Es una capacidad poco conocida y raramente la mejor opción, porque quien elige assets suele hacerlo justamente para escapar del sistema de variantes. Mencionarla sirve sobre todo para evitar la conclusión equivocada de que los assets son incompatibles con cualquier adaptación.

Las tres diferencias que importan son la ruta frente al identificador, la ausencia de adaptación automática y la posibilidad de recorrer el árbol. Un recurso se pide por identidad y el sistema elige la variante; un asset se pide por ruta y recibes exactamente ese archivo. Los recursos no se pueden enumerar en tiempo de ejecución de forma natural, los assets sí. Y como los assets conservan la jerarquía de carpetas y el nombre exacto, sirven para lo que los recursos no permiten: nombres con mayúsculas, extensiones raras, estructuras anidadas.

🗂️

Recurso compilado

Textos, colores, dimensiones, iconos, temas. Todo lo que deba adaptarse al idioma, la densidad, el tamaño o el tema del sistema.

📦

Asset

Modelos de aprendizaje automático, fuentes de terceros con licencia rara, bases de datos precargadas, contenido web local, archivos de datos voluminosos.

🎞️

La carpeta `raw`

El punto intermedio: archivo sin compilar pero con identificador en R y con calificadores. Útil para sonidos cortos o un JSON pequeño.

⚖️

El criterio

Si el sistema debe elegir por ti entre variantes, es un recurso. Si necesitas la ruta, el nombre exacto o recorrer un árbol, es un asset.

El coste que nadie mide

Un asset no se poda. Cuando se generan los paquetes por dispositivo, los recursos de densidades e idiomas que no aplican pueden quedarse fuera de la descarga; los assets van enteros a todos los dispositivos, siempre. Una carpeta de assets con veinte megabytes de modelos es veinte megabytes en cada instalación, sin excepción. Ese detalle convierte la elección entre recurso y asset en una decisión con impacto directo en el tamaño de descarga, no solo en una cuestión de comodidad de acceso.

Hay una segunda diferencia de coste, menos visible pero igual de real: la compresión. Ciertos tipos de archivo se guardan sin comprimir dentro del paquete para poder mapearlos directamente en memoria en lugar de descomprimirlos al abrirlos. Eso significa que un asset ya comprimido por su propio formato no engorda el paquete al añadirlo, mientras que un archivo de texto voluminoso sí se beneficia de la compresión del contenedor. Cuando un asset pesa de verdad, conviene comprobar en qué régimen cae, porque la diferencia entre mapear y descomprimir se nota en el tiempo de arranque.

📝
Compose desdibujó parte de la frontera

En una app puramente declarativa muchos recursos de la era XML pierden sentido: los layouts, los estilos y buena parte de los temas se expresan ahora en Kotlin, con valores que son constantes normales del lenguaje y no entradas de una tabla. Es tentador concluir que res/ sobra. No sobra, y conviene entender por qué: todo lo que deba variar con la configuración del dispositivo sigue necesitando la tabla, porque una constante de Kotlin no tiene variantes. Los textos siguen en recursos porque hay que traducirlos, los iconos porque hay que adaptarlos, las dimensiones que cambian con el tamaño de ventana porque el sistema las resuelve mejor que un condicional. Lo que se fue a Kotlin es la estructura; lo que se quedó en res/ son los valores que el mundo exterior decide.

La indirección del identificador es lo que compra la adaptación

Hay una pregunta razonable detrás de todo esto: por qué no acceder a los archivos por su nombre y ya está, como en casi cualquier otro sistema. La respuesta es que el nombre directo y la adaptación automática son incompatibles. En cuanto tu código nombra un archivo concreto, ha decidido por el sistema, y esa decisión se toma en el momento equivocado —al escribir el código— con información que entonces no existía. La clase R introduce una indirección deliberada: el código nombra una identidad abstracta, un entero sin significado propio, y la resolución a un valor concreto ocurre después, en el dispositivo, con la configuración real delante. Es exactamente el mismo patrón que una tabla de símbolos en un enlazador o una tabla de métodos virtuales en un lenguaje orientado a objetos: se sustituye una referencia directa por una entrada en una tabla precisamente para poder cambiar el destino sin tocar a quien la usa. El precio es que pierdes acceso al archivo como archivo, y por eso existe assets/ —la vía de escape para cuando lo que quieres de verdad son bytes en una ruta—. Entender la pareja completa es entender un principio general: los recursos son un mecanismo de resolución tardía y por eso se adaptan; los assets son acceso directo y por eso no se adaptan ni se podan. Elegir mal no produce un error de compilación, produce una app que pesa el doble o que se ve mal en la mitad del planeta, y esos son los errores que más tardan en descubrirse.

⚔️ Sigue el rastro de un identificador
  1. Añade un texto nuevo, compila y localiza la clase R generada dentro del directorio de build; anota el entero asignado.
  2. Añade una traducción de ese mismo texto y comprueba que el entero no cambia aunque el valor devuelto sí.
  3. Coloca un archivo en assets/, ábrelo desde el código y enumera el contenido de su carpeta.
  4. Mueve ese mismo archivo a res/raw/, accede a él por identificador y anota qué ganaste y qué perdiste.
  5. Compara el tamaño del paquete con el archivo en assets/ y con el archivo como recurso podable.