wandres.dev
COMPOSE: LISTAS · Lazy y rendimiento

Claves y contentType: identidad y reutilización

Sin claves explícitas, la identidad de un elemento de lista es su posición, y esa identidad se rompe en cuanto los datos se insertan, se borran o se reordenan: el estado recordado se queda pegado al índice equivocado y las animaciones de movimiento son imposibles. Esta lección define qué exige una clave para ser válida, explica por qué se guarda y se restaura junto al estado, distingue con precisión el papel de la identidad frente al del tipo de contenido, describe el mecanismo de reutilización de composiciones que contentType gobierna, y cataloga los errores frecuentes que convierten una lista correcta en una lista que miente.

⏱ 18 min

Hay una categoría de fallos de lista que nunca aparece en una prueba manual apresurada y que arruina aplicaciones enteras en producción: el usuario marca una casilla en la tercera fila, llega un dato nuevo por arriba, y la casilla marcada aparece ahora en la cuarta. Nadie ha escrito código incorrecto; simplemente nadie ha declarado qué significa que dos elementos sean el mismo. Por defecto, una lista perezosa responde a esa pregunta de la única forma que puede sin ayuda: por posición. Y la posición es la propiedad menos estable que tiene un dato. Declarar claves no es una micro-optimización cosmética, es completar el modelo: decirle al sistema cuál es la identidad de tus datos. El tipo de contenido resuelve un problema distinto y complementario —cuánta estructura de composición se puede reciclar entre elementos— y confundir ambos es el origen de la mitad de los consejos contradictorios que circulan sobre listas.

🎯 Al terminar esta lección sabrás
  • Explicar por qué la identidad posicional rompe el estado y las animaciones de un elemento.
  • Definir las tres condiciones que debe cumplir una clave para ser válida.
  • Distinguir el papel de la identidad frente al del tipo de contenido en la reutilización.
  • Reconocer los errores frecuentes con claves duplicadas, inestables o basadas en el índice.

La identidad por posición y su fragilidad

Cuando no se declara nada, cada elemento de una lista perezosa se identifica por su índice dentro del intervalo. Esa identidad decide tres cosas a la vez: a qué elemento pertenece el estado recordado y guardado, qué composición se reutiliza cuando la lista cambia, y desde dónde hasta dónde animar un movimiento.

Mientras los datos solo se añaden al final, la identidad posicional coincide con la identidad real y todo parece funcionar. Basta una inserción en cabeza para que dejen de coincidir: el elemento que era el índice cero ahora es el uno, pero el estado que vivía en el índice cero se queda donde estaba y pasa a describir a un dato que no le corresponde. Lo mismo ocurre al borrar en medio, al reordenar por otro criterio o al recibir una página de resultados que empieza antes.

// Identidad posicional: el estado se queda pegado al indice, no al dato.
LazyColumn {
    items(mensajes) { mensaje -> FilaMensaje(mensaje) }
}

// Identidad declarada: el estado viaja con el dato aunque cambie de sitio.
LazyColumn {
    items(mensajes, key = { mensaje -> mensaje.id }) { mensaje ->
        FilaMensaje(mensaje)
    }
}

El síntoma no siempre es visible. Si la fila es puramente derivada de sus datos y no recuerda nada, la lista sin claves se comporta correctamente por casualidad, porque no había nada que perder. El fallo aparece en cuanto la fila guarda algo propio: un desplegable abierto, un campo de texto a medio escribir, la posición de un carrusel horizontal anidado o el estado de una animación en curso.

Esa dependencia del azar es lo que hace tan traicionero el problema. Una lista sin claves puede pasar meses en producción sin quejas, hasta el día en que alguien añade un icono que recuerda si está desplegado, o hasta que la fuente de datos deja de ser un catálogo estático y empieza a recibir actualizaciones en vivo. En ese momento el fallo no lo introduce quien lo destapa: llevaba ahí desde el principio, latente, esperando a que alguien pusiera algo que perder en el sitio equivocado.

⚠️
Restaurar la lista tras la muerte del proceso

La posición del scroll se guarda y restaura como índice más desplazamiento, y el estado interno de los elementos guardado con rememberSaveable se archiva asociado a su clave. Por eso una clave tiene que ser de un tipo que el sistema sepa serializar: tipos primitivos, cadenas, o tipos marcados como Parcelable. Una clave que no se pueda guardar provoca una excepción en cuanto el sistema intenta archivar el estado de la pantalla, y ese momento suele ser precisamente el peor: cuando el proceso está a punto de morir en segundo plano.

Qué exige una clave para ser válida

Una clave válida cumple tres condiciones simultáneas, y las tres son estrictas. Debe ser única dentro de la lista completa, incluidos los bloques item sueltos que también aceptan clave. Debe ser estable en el tiempo, es decir, el mismo dato lógico debe producir siempre la misma clave aunque cambien sus campos. Y debe ser guardable, por la razón que acabamos de ver.

Estas tres condiciones no son independientes de la noción de identidad que ya conoces de otros contextos: son literalmente las mismas que se le exigen a una clave primaria. Unicidad para que la correspondencia sea una función, estabilidad para que la correspondencia no cambie bajo los pies de quien la consulta, y serializabilidad para que sobreviva a cruzar una frontera de proceso. Que coincidan no es casualidad, y reconocerlo ahorra tener que memorizarlas.

La primera condición se incumple con más frecuencia de lo que parece, y su castigo es inmediato y ruidoso: una excepción con el mensaje de que la clave ya se había usado. Ocurre al concatenar varias fuentes de datos que comparten espacio de identificadores, al paginar y recibir el mismo elemento dos veces en páginas distintas, o al usar como clave un campo que solo es único dentro de un grupo.

La segunda es la que produce fallos silenciosos. Usar el índice como clave es equivalente a no poner ninguna, con la desventaja de haber escrito código que aparenta corrección. Usar el resultado de hashCode del objeto completo es peor todavía: cualquier edición de un campo cambia la clave, el sistema concluye que ese elemento fue destruido y sustituido por otro distinto, y se pierde su estado y su animación en cada actualización.

🔑

Identificador de dominio

El identificador que ya viene de la base de datos o del servidor es casi siempre la clave correcta: único por construcción, estable por definición y primitivo.

🧩

Clave compuesta

Cuando la lista concatena fuentes distintas, antepón un prefijo por sección para garantizar unicidad global sin inventar identificadores nuevos.

🚫

Nunca el indice

Usar la posición como clave reproduce exactamente el comportamiento por defecto y transmite una falsa sensación de haber resuelto el problema.

⚠️

Nunca el hash mutable

Una clave derivada de campos que cambian convierte cada edición en una destrucción y una creación, y con ella se van el estado y la animación.

Hay un caso frontera que merece nombre propio porque aparece en cuanto se pagina: el elemento optimista, esa fila que la interfaz muestra antes de que el servidor confirme su existencia y que todavía no tiene identificador real. Inventarle uno aleatorio la condena a perder su estado en cuanto llegue la confirmación, porque cambiará de clave; reutilizar el identificador del servidor no es posible porque aún no existe. La salida limpia es que el modelo genere el identificador en el cliente en el momento de crear el elemento y lo mantenga tras la confirmación, con lo que la clave es estable desde el primer instante y el servidor se limita a aceptarla. De nuevo, la solución no está en la lista: está en quién decide la identidad.

// Concatenar fuentes: el prefijo garantiza unicidad global.
LazyColumn {
    items(fijados, key = { "fijado-${it.id}" }) { m -> FilaMensaje(m) }
    items(recientes, key = { "reciente-${it.id}" }) { m -> FilaMensaje(m) }
}

Con claves correctas se desbloquea además el modificador de animación de posición dentro del ámbito de un elemento, que interpola el movimiento cuando un dato cambia de sitio y aplica entradas y salidas cuando aparece o desaparece. Sin claves ese modificador no tiene forma de saber qué se movió a dónde, porque para el sistema nada se ha movido: simplemente el contenido del índice tres ahora es otro.

LazyColumn {
    items(tareas, key = { tarea -> tarea.id }) { tarea ->
        FilaTarea(tarea, modifier = Modifier.animateItem())
    }
}

El tipo de contenido y la reutilización

contentType responde a una pregunta completamente distinta de la que responde key, y por eso conviene enunciarla aparte: cuando un elemento sale de pantalla y otro entra, ¿puede el nuevo aprovechar la estructura de composición que dejó el anterior en lugar de construirla desde cero?

La reutilización no copia datos ni conserva estado. Lo que conserva es el esqueleto: los nodos ya creados, los grupos de la tabla de composición y las estructuras internas asociadas. Si el elemento entrante tiene la misma forma que el saliente, ese esqueleto se rellena con los nuevos valores a un coste mucho menor que el de una composición completa. Si tiene una forma distinta, el intento fracasa y hay que construir todo de nuevo, con el trabajo desperdiciado del intento incluido.

flowchart TD
SALE[Un elemento abandona la ventana visible] --> POOL[Su composicion pasa a la reserva etiquetada con su tipo]
ENTRA[Otro elemento entra por el extremo contrario] --> Q{Hay reserva con el mismo tipo de contenido}
POOL --> Q
Q -->|Si| REUSA[Reutiliza la estructura y solo actualiza los valores]
Q -->|No| NUEVA[Compone el elemento entero desde cero]
style REUSA fill:#a6e3a1,color:#11111b
style NUEVA fill:#f9e2af,color:#11111b

La reserva no es infinita ni global: se mantiene por lista y con un tamaño acotado, dimensionado para cubrir el trasiego normal de un desplazamiento. Eso explica por qué la reutilización rinde tanto en listas homogéneas y tan poco en listas donde cada elemento es distinto de sus vecinos: en el primer caso lo que sale es siempre compatible con lo que entra, y en el segundo la reserva se llena de esqueletos que nadie reclama.

Por defecto todos los elementos comparten un mismo tipo nulo, lo que equivale a decir que todos son intercambiables. En una lista homogénea eso es exacto y no hay nada que hacer. En una lista heterogénea —cabeceras de sección, tarjetas con imagen, filas de texto, separadores, anuncios— es una afirmación falsa, y su consecuencia es un desfile de intentos de reutilización fallidos justo mientras el usuario está arrastrando el dedo.

LazyColumn {
    items(
        items = entradas,
        key = { entrada -> entrada.id },
        contentType = { entrada ->
            when (entrada) {
                is Entrada.Cabecera -> "cabecera"
                is Entrada.Foto -> "foto"
                is Entrada.Texto -> "texto"
            }
        },
    ) { entrada -> FilaEntrada(entrada) }
}

Conviene además no confundir reutilización con conservación de estado, porque el vocabulario invita a ello. Cuando una composición se recicla, todo su estado recordado se reinicia: lo que se ahorra es el trabajo de construir la estructura, no el de repoblarla. Por eso la reutilización nunca puede provocar que aparezcan datos de un elemento en otro; si eso ocurre, la causa está en las claves, no en el tipo de contenido. Ambos mecanismos conviven pero no se cruzan: uno decide qué es el mismo dato, el otro decide qué esqueleto se puede aprovechar.

La asimetría entre ambos parámetros merece quedar fijada porque gobierna las prioridades. Una clave mal puesta produce incorrección: estado en la fila equivocada, animaciones imposibles, datos que el usuario perdió. Un tipo de contenido mal puesto produce solo lentitud: la lista sigue mostrando la verdad, pero trabaja más de lo necesario. La clave es un asunto de corrección y no es opcional; el tipo de contenido es un asunto de rendimiento y solo compensa medirlo en listas realmente heterogéneas.

ℹ️
El tipo no tiene por que ser una cadena

Cualquier objeto comparable por igualdad y estable sirve como tipo de contenido: una cadena, un valor de una enumeración o la propia clase del elemento. Lo que importa es que dos elementos con la misma forma de interfaz compartan valor y que dos con formas distintas no lo compartan. Un error frecuente es dar tipos distintos a variantes que en realidad producen exactamente el mismo árbol, lo que fragmenta la reserva en compartimentos pequeños y anula el beneficio.

Declarar la identidad es completar el modelo de datos, no adornar la vista

Hay una tentación muy extendida de clasificar las claves como un detalle de la capa de interfaz, algo que se añade al final cuando alguien reporta que una casilla se marcó sola. Esa clasificación está equivocada en su raíz, y verla bien cambia cómo se escriben las listas. Una clave no describe la vista: describe una propiedad del dominio, la respuesta a la pregunta de cuándo dos observaciones distintas se refieren a la misma cosa del mundo. Esa pregunta es exactamente la misma que responde la clave primaria de una tabla, la que responde un identificador en un protocolo de red, y la que responde tu propia noción de si el mensaje que acabas de recibir es uno nuevo o una versión editada de otro. Cuando una lista no tiene claves, lo que ocurre en realidad no es que le falte un parámetro: es que el modelo de datos nunca definió su identidad, y el sistema se ve obligado a inventar la peor sustituta disponible, la posición, que ni siquiera es una propiedad del dato sino del accidente de cómo se ordenó la colección hoy. Por eso los fallos que produce son tan característicos y tan difíciles de razonar: no son fallos de dibujado, son fallos ontológicos que se manifiestan en el dibujado. Y por eso el criterio para elegir una clave nunca puede salir de la interfaz. Sale de preguntarse qué hace que este dato sea este dato y no otro, y si esa pregunta no tiene respuesta en tu modelo, el problema no está en la lista: está en el modelo, y la lista solo ha sido lo bastante honesta como para enseñártelo.

⚔️ Rompe y repara la identidad
  1. Construye una lista de filas con una casilla recordada, inserta un elemento en cabeza sin claves y describe exactamente qué se descoloca.
  2. Añade claves de dominio, repite la inserción y comprueba que el estado viaja con el dato.
  3. Introduce a propósito una clave duplicada concatenando dos fuentes y lee el mensaje de la excepción que se produce.
  4. Sustituye la clave por el hashCode del objeto, edita un campo y explica por qué desaparece el estado y la animación.
  5. Convierte la lista en heterogénea con tres formas distintas, declara contentType y argumenta qué efecto tiene y cuál no.