wandres.dev
ACCESIBILIDAD · semántica y TalkBack

El árbol de semántica

Debajo de cada fotograma de Compose hay una segunda estructura que nadie ve y que decide si tu aplicación es utilizable por quien no mira la pantalla. Esta lección disecciona el árbol de semántica: por qué existe como estructura paralela al árbol de layout, en qué momento del ciclo de composición se construye, cómo cada modificador aporta pares de clave y valor a un nodo, qué significan exactamente las dos versiones del árbol que Compose mantiene a la vez, cómo se traduce ese árbol al protocolo de accesibilidad de la plataforma que consume `TalkBack`, y con qué herramientas concretas se inspecciona para dejar de programar a ciegas.

⏱ 22 min

Una interfaz de Compose produce píxeles, pero los píxeles no son un contrato: son un resultado. Si lo único que existiera fuera el mapa de bits, la accesibilidad sería un problema de visión artificial y estaría irremediablemente rota. Por eso el runtime mantiene, en paralelo al árbol que mide y dibuja, una segunda estructura cuyo propósito es describir el significado de lo que hay en pantalla en términos independientes de su apariencia: qué es cada cosa, cómo se llama, en qué estado está y qué se le puede hacer. Ese es el árbol de semántica, y es el único artefacto de tu aplicación que consumen los servicios de accesibilidad, los tests de interfaz y las herramientas de automatización. Programar accesibilidad en Compose no consiste en añadir atributos sueltos a los elementos visibles: consiste en darse cuenta de que estás construyendo dos árboles a la vez y de que casi nunca miras el segundo.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el árbol de semántica es una estructura separada del árbol de layout y qué invariantes garantiza esa separación.
  • Describir cuándo y cómo se construye el árbol durante el ciclo de composición, medida y dibujo.
  • Distinguir el árbol fusionado del árbol sin fusionar y saber cuál consume cada cliente.
  • Inspeccionar el árbol real de una pantalla con el Layout Inspector y con un volcado de test.

Dos árboles sobre el mismo fotograma

Compose produce, a partir de tus funciones componibles, un árbol de nodos de layout que sabe medirse, colocarse y pintarse. Ese árbol responde preguntas geométricas: dónde estás, cuánto ocupas, en qué orden te dibujo. No responde ninguna pregunta semántica, y no debería: un Box con un fondo circular y un glifo dentro es, geométricamente, indistinguible de un adorno decorativo o de un botón de borrado irreversible.

Esa indistinguibilidad no es un defecto del motor de layout, es su virtud. Un sistema de composición que intentara adivinar el significado a partir de la forma sería frágil, impredecible y estaría permanentemente equivocado en los casos que importan. La alternativa sensata es exigir la declaración, y toda la arquitectura que sigue es una consecuencia de haber tomado esa decisión.

El árbol de semántica existe para responder justo esas preguntas. Cada nodo semántico es, esencialmente, un mapa de propiedades tipadas —una SemanticsConfiguration— colgado de una posición del árbol y asociado a la geometría del nodo de layout correspondiente. Las claves de ese mapa están definidas en SemanticsProperties y las acciones en SemanticsActions, y entre ambas describen todo lo que un agente externo necesita saber para operar la interfaz sin verla.

La distinción entre ambos grupos de claves no es decorativa. Las propiedades son descriptivas y responden a preguntas: cómo te llamas, qué eres, en qué estado estás, qué rango de valores admites. Las acciones son ejecutables y responden a órdenes: púlsate, despliégate, desplázate hasta aquí, sustituye tu texto por este otro. Un servicio de accesibilidad no simula toques sobre la pantalla cuando el usuario activa un elemento; invoca la acción declarada en el nodo. Por eso un control cuya interactividad no está declarada en el árbol resulta literalmente inalcanzable aunque esté a la vista y aunque responda perfectamente al dedo.

flowchart TD
A[Funciones componibles] --> B[Arbol de nodos de layout]
B --> C[Medida y dibujo]
B --> D[Arbol de semantica]
D --> E[Version sin fusionar]
D --> F[Version fusionada]
F --> G[Puente de accesibilidad]
G --> H[Servicios del sistema]
E --> I[Tests de interfaz]

La consecuencia práctica de esta separación es más profunda de lo que parece. Significa que la accesibilidad no es una propiedad emergente del dibujo, sino una declaración explícita que tú haces o dejas de hacer. Un componente puede ser perfectamente legible para un ojo humano y absolutamente opaco para el árbol, porque nada en el acto de pintar un vector genera información semántica. Y significa también lo contrario: puedes describir con precisión algo que apenas ocupa dieciséis píxeles, porque la riqueza semántica no está limitada por la superficie.

Conviene subrayar que el árbol semántico no es una copia reducida del árbol de layout. Su forma es distinta: tiene muchos menos nodos, porque solo aparecen los que declararon algo, y su topología cambia por efecto de las fusiones que estudiaremos más adelante. Lo único que ambos comparten es la geometría, y la comparten porque el servicio de accesibilidad necesita saber dónde está cada nodo para dibujar el rectángulo de enfoque y para resolver la exploración táctil, es decir, para responder qué hay debajo del dedo cuando el usuario arrastra por la pantalla.

Cuándo y cómo se construye

El árbol no se construye en un pase aparte. Los modificadores semánticos son modificadores de nodo como cualquier otro, y su información se agrega al nodo de layout durante la composición. Cuando el sistema necesita el árbol semántico —porque un servicio de accesibilidad está activo, porque un test lo pide o porque cambió algo marcado como relevante— lo recorre desde la raíz componiendo las configuraciones de todos los modificadores presentes en cada cadena.

Modifier
    .semantics { contentDescription = "Archivar conversacion" }
    .semantics { stateDescription = "Sin archivar" }

Ese recorrido de la raíz hacia abajo no es gratuito ni constante: el árbol se cachea y se invalida por subárboles, de modo que un cambio local no obliga a reconstruir la pantalla entera. Lo que sí conviene retener es que la construcción ocurre después de la composición y depende del resultado final de la cadena de modificadores, no del orden en que escribiste las funciones componibles.

Las dos llamadas no compiten: sus configuraciones se combinan sobre el mismo nodo. Esta es la primera propiedad que conviene internalizar, porque explica por qué los componentes de Material ya traen semántica y por qué tus adiciones se suman a la suya en lugar de reemplazarla. Un Button de Material declara internamente el rol, la acción de pulsación y el estado de habilitado; cuando tú añades una descripción, estás enriqueciendo un nodo que ya existía.

Existe una excepción deliberada a esa acumulación. clearAndSetSemantics no combina: descarta por completo lo que hayan declarado los descendientes y sustituye la configuración por la que tú escribas. Es una herramienta quirúrgica y peligrosa, porque elimina de un plumazo acciones que los componentes internos habían declarado, y volveremos a ella cuando estudiemos la agrupación.

Las propiedades no son cadenas sueltas sino claves tipadas, y esa tipificación tiene una consecuencia que casi nadie explota: el conjunto de propiedades es extensible. Puedes definir claves propias con SemanticsPropertyKey y consumirlas desde tus tests, lo que te permite exponer al árbol información que ningún servicio de accesibilidad usará pero que hace tus pruebas mucho más precisas y menos frágiles que buscar por texto visible.

val EstadoDeCarga = SemanticsPropertyKey<String>("EstadoDeCarga")
var SemanticsPropertyReceiver.estadoDeCarga by EstadoDeCarga

Modifier.semantics { estadoDeCarga = "reintentando" }

Ese mismo mecanismo tipado es el que usan por dentro las propiedades estándar, y verlo escrito ayuda a desmitificar el bloque semantics: no es una sintaxis mágica, es un receptor con propiedades delegadas sobre un mapa.

@Composable
fun Medidor(valor: Float, modifier: Modifier = Modifier) {
    Canvas(
        modifier = modifier.semantics {
            progressBarRangeInfo = ProgressBarRangeInfo(valor, 0f..1f)
            stateDescription = "Nivel al ${(valor * 100).toInt()} por ciento"
        },
    ) { dibujarArco(valor) }
}

La segunda propiedad estructural que conviene fijar es que la semántica se declara en la cadena de modificadores, no en la función componible. Esto implica que un componente reutilizable puede recibir un Modifier desde fuera y que quien lo usa puede enriquecer o corregir su semántica sin tocar su implementación. Aceptar y aplicar el Modifier recibido, en el orden correcto, deja de ser una convención estética para convertirse en un requisito de accesibilidad: un componente que ignora el modificador que le pasan es un componente cuya semántica nadie puede reparar desde el punto de uso.

ℹ️
El coste de mantener el árbol no es cero, pero tampoco es el que temes

Compose no materializa el árbol semántico en cada fotograma por capricho. La estructura se invalida y se recalcula de forma perezosa cuando cambian los modificadores relevantes, y el puente hacia el sistema solo se activa cuando hay un servicio de accesibilidad escuchando. Esto tiene dos lecturas. La primera es tranquilizadora: escribir semántica generosa no penaliza a los usuarios que no la consumen. La segunda es incómoda: como la mayor parte del tiempo el puente está apagado, una semántica rota no produce ningún síntoma visible durante el desarrollo. El árbol se degrada en silencio.

Fusionado y sin fusionar: dos vistas del mismo grafo

Compose expone el árbol semántico en dos versiones simultáneas, y confundirlas es la fuente más común de desconcierto al depurar accesibilidad.

El árbol sin fusionar contiene un nodo por cada elemento que declaró alguna propiedad semántica. Es la vista fiel y granular: si una tarjeta contiene un título, un subtítulo y un icono, aparecen tres nodos de texto distintos. El árbol fusionado aplica las reglas de mezcla: los nodos marcados con mergeDescendants absorben la semántica de su subárbol y la presentan como un único nodo cuyas propiedades resultan de combinar las de sus hijos.

Merece la pena insistir en que no se trata de dos estructuras almacenadas por separado sino de dos proyecciones del mismo grafo. La información nunca se pierde: el árbol fusionado es una lectura del árbol completo aplicando las reglas de absorción sobre la marcha. Por eso puedes pedir cualquiera de las dos vistas en cualquier momento sin coste de mantenimiento adicional, y por eso una fusión mal puesta no destruye datos, solo los oculta al consumidor.

🌳

Sin fusionar

Un nodo por declaración. Es lo que usan por defecto los tests cuando buscas un texto concreto dentro de un contenedor, y lo que necesitas para razonar sobre qué componente aportó qué propiedad.

🍃

Fusionado

Un nodo por unidad de interacción. Es lo que ve el servicio de accesibilidad, y por tanto lo que determina cuántos gestos de deslizamiento necesita alguien para atravesar tu pantalla.

La regla de oro es que el usuario de TalkBack recorre el árbol fusionado. Cuando alguien te describe que su pantalla obliga a dar catorce deslizamientos para pasar de una tarjeta a la siguiente, lo que está describiendo es un árbol fusionado que no fusionó nada. Y cuando un test falla diciendo que no encuentra un texto que evidentemente está en pantalla, lo que suele ocurrir es que ese texto fue absorbido por una fusión y el aserto estaba mirando la vista equivocada.

En la API de pruebas esa dualidad se controla con un único parámetro. Los buscadores aceptan useUnmergedTree, cuyo valor por omisión es falso, de modo que por defecto estás consultando el árbol fusionado, que es también el que usan los servicios reales. Poner el parámetro a verdadero es la forma correcta de alcanzar un texto concreto dentro de una unidad fusionada sin deshacer la fusión, y es lo que evita el antipatrón más común de todos: retirar una fusión legítima porque un test no encontraba el nodo.

Hay una tercera vía de acceso que conviene situar aquí porque también vive en el árbol: Modifier.testTag. Añade al nodo una etiqueta arbitraria que los buscadores pueden localizar y que los servicios de accesibilidad ignoran por completo. Es el mecanismo correcto para anclar tests sin contaminar la semántica real, y su existencia deja clara una jerarquía de intenciones que conviene respetar. Busca por texto o descripción cuando quieras verificar lo que el usuario percibe; busca por etiqueta de prueba solo cuando necesites alcanzar un nodo cuya identidad no tiene expresión semántica legítima. Escribir una contentDescription inventada para poder localizar un nodo desde un test es corromper el contrato público para resolver un problema privado.

El puente hacia el sistema y cómo mirar dentro

El árbol semántico de Compose no es lo que consume directamente el servicio de accesibilidad. La plataforma habla un protocolo más antiguo, basado en objetos AccessibilityNodeInfo organizados en una jerarquía de nodos virtuales colgados de una única vista anfitriona. Compose implementa un delegado que traduce su árbol fusionado a esa jerarquía bajo demanda, mapeando propiedades a atributos del protocolo y acciones semánticas a acciones de accesibilidad.

La razón de esa arquitectura es histórica y no va a cambiar. El sistema de accesibilidad de Android nació con la jerarquía de vistas clásica y espera encontrar objetos con identidad estable a los que interrogar. Compose no tiene vistas: tiene una sola superficie sobre la que dibuja todo. La solución que adoptó, y que ya usaban antes componentes complejos como los mapas o los editores de texto enriquecido, consiste en declarar nodos virtuales que existen únicamente a efectos de accesibilidad y que el anfitrión fabrica al vuelo cuando el servicio los pide.

Entender que existe esa traducción explica varias asimetrías desconcertantes. Explica por qué algunas propiedades semánticas ricas se aplanan a una cadena de texto al llegar al servicio. Explica por qué el orden de recorrido no siempre coincide con el orden de declaración. Y explica por qué conviene verificar el comportamiento con el servicio real y no solo con el volcado del árbol: entre ambos hay una capa de traducción con criterio propio.

Hay además una consecuencia de rendimiento que conviene conocer. Como toda la jerarquía cuelga de una sola vista anfitriona, cada cambio semántico obliga a notificar al sistema y a regenerar los nodos virtuales afectados. Compose agrupa y aplaza esas notificaciones, pero una pantalla que muta su semántica en cada fotograma —un contador que se actualiza sesenta veces por segundo con stateDescription, por ejemplo— puede degradar sensiblemente la experiencia cuando hay un servicio activo, y el síntoma solo aparece en los dispositivos de los usuarios que lo tienen encendido. Es un caso raro pero vale la pena tenerlo presente al declarar semántica sobre valores de alta frecuencia.

Para inspeccionar el árbol tienes dos instrumentos complementarios, y conviene usar los dos porque responden preguntas distintas. El Layout Inspector de Android Studio muestra, junto al árbol de composición, las propiedades semánticas de cada nodo sobre el dispositivo en ejecución, y sirve sobre todo para investigaciones exploratorias: pinchas un elemento en la pantalla y ves qué declaró. El volcado desde un test, en cambio, produce una representación textual completa que puedes versionar, difear y leer en una revisión de código.

@get:Rule val rule = createComposeRule()

@Test fun volcarElArbol() {
    rule.setContent { PantallaDeConversaciones() }
    rule.onRoot(useUnmergedTree = true).printToLog("SEMANTICA")
    rule.onRoot().printToLog("SEMANTICA_FUSIONADA")
}

Ese par de volcados enfrentados es, con diferencia, la herramienta de diagnóstico más rentable de todo el nivel: la diferencia entre ambos es exactamente el trabajo que hicieron tus fusiones.

El volcado imprime, para cada nodo, su identificador, su rectángulo en coordenadas de pantalla y la lista completa de propiedades y acciones que declara. Leerlo entero la primera vez resulta abrumador y es precisamente el ejercicio que hay que hacer, porque revela con crudeza cuánta información que creías declarada no está y cuántos nodos que creías inexistentes ocupan un turno del recorrido. Un patrón que aparece casi siempre en el primer volcado de una pantalla real es una hilera de nodos de texto sin agrupar donde el diseño muestra un bloque compacto, y otro es la ausencia total de acciones en elementos que el usuario pulsa a diario porque su interactividad se implementó con un detector de gestos crudo en lugar de con clickable.

📝
Un detector de gestos no declara nada

Implementar la pulsación con pointerInput y detectTapGestures funciona perfectamente con el dedo y no genera ninguna semántica: el nodo no anuncia que es pulsable, no recibe foco, no aparece para Switch Access y no puede activarse desde un servicio de accesibilidad. Si tienes una razón de peso para gestionar el gesto a mano, declara la acción por separado con Modifier.semantics y su bloque onClick, para que el contrato exista aunque la implementación sea propia.

La semántica es la única interfaz pública de tu interfaz

Hay una manera de mirar el árbol semántico que reordena todo lo demás, y es dejar de verlo como una capa de asistencia para verlo como lo que técnicamente es: la única API pública que tu interfaz expone al mundo exterior. Todo lo que no está en el árbol semántico, sencillamente, no existe fuera del proceso de dibujo. No existe para TalkBack, no existe para Switch Access, no existe para el control por voz, no existe para tus tests instrumentados, no existe para las herramientas de automatización de QA ni para los agentes que en 2026 empiezan a operar aplicaciones en nombre del usuario. Los píxeles son un artefacto de salida; el árbol es el contrato. Cuando lo aceptas, la accesibilidad deja de ser una lista de tareas de cumplimiento normativo que alguien añade al final del sprint y se convierte en un problema de diseño de API, con todo lo que eso implica: se piensa antes de escribir la implementación, se revisa en el pull request como se revisa cualquier firma pública, se rompe cuando alguien refactoriza sin cuidado y merece tests de regresión. Y como toda buena API, su calidad no se mide por la cantidad de propiedades que declara sino por la fidelidad con que describe el modelo mental del usuario. Un árbol semántico excelente es indistinguible de una buena documentación de la pantalla: leerlo de arriba abajo debería contarte, sin ambigüedad y sin ruido, qué se puede hacer aquí y en qué situación está el sistema. Si al volcarlo no reconoces tu propia pantalla, el problema no es de accesibilidad. Es que no sabes qué construiste.

⚔️ Haz visible el árbol invisible
  1. Coge la pantalla más compleja de tu aplicación y vuelca los dos árboles, fusionado y sin fusionar, en el mismo test. Cuenta los nodos de cada uno.
  2. Identifica tres nodos del árbol fusionado que no sepas explicar y rastrea qué modificador o qué componente de Material los generó.
  3. Abre el Layout Inspector sobre esa misma pantalla y localiza un elemento que dibujes tú a mano. Comprueba si aporta alguna propiedad semántica o si es un hueco.
  4. Enciende TalkBack y recorre la pantalla contando deslizamientos. Compara ese número con la cantidad de nodos del árbol fusionado y explica la diferencia.
  5. Escribe en una frase, sin mirar el código, cuál crees que es el contrato semántico de esa pantalla. Después vuelve al volcado y comprueba cuánto se parece.