Granularidad: cuántos intents y dónde está el límite
Entre un intent por cada píxel tocado y un único intent genérico que lo hace todo hay un espectro, y el punto correcto no es una cuestión de gusto sino de dónde queda la decisión. Esta lección da el criterio operativo —un intent por hecho distinguible en la interfaz—, examina los dos extremos y sus patologías, explica cuándo agrupar varios gestos bajo un mismo intent con carga y cuándo separarlos, y cataloga los olores del intent que hace demasiado: el que lleva una función dentro, el que decide en la vista, el que exige un booleano de contexto y el que obliga a leer estado desde fuera del ciclo.
Cuando ya sabes modelar intents, nombrarlos por el hecho, reducirlos con una función pura y sacar el trabajo sucio al borde, queda la única pregunta que ninguna regla mecánica resuelve: cuántos. Dos equipos competentes pueden escribir la misma pantalla con cuatro intents o con quince, y ambos códigos compilan, pasan los tests y funcionan. Pero no envejecen igual. Un vocabulario demasiado fino convierte el reducer en un inventario de gestos donde la intención del usuario se pierde entre el ruido; uno demasiado grueso empuja las decisiones hacia la vista, que es justo lo que MVI existe para impedir. El criterio que ordena el espectro no es la cantidad, sino una pregunta que se hace intent por intent: para emitir este valor, ¿la interfaz necesita saber algo que no debería saber? Si la respuesta es sí, el intent es demasiado grueso, aunque solo haya cuatro. Si el reducer no puede distinguir dos situaciones que el producto trata distinto, es demasiado grueso también. Todo lo demás es preferencia.
- Aplicar el criterio de un intent por hecho distinguible, en lugar de contar por gestos o por métodos.
- Reconocer las patologías de los dos extremos: el vocabulario pulverizado y el vocabulario genérico.
- Decidir cuándo varios gestos comparten un intent con carga y cuándo deben separarse.
- Diagnosticar los olores del intent que hace demasiado y aplicar la corrección correspondiente.
El criterio: un hecho distinguible
Un hecho es distinguible si el reducer puede tener que tratarlo de forma diferente, ahora o de manera previsible en el futuro. Ese es todo el criterio, y es más útil que cualquier heurística de tamaño. Pulsar reintentar y pulsar recargar producen el mismo trabajo hoy, pero el producto los distinguirá en cuanto quiera contarlos por separado o mostrar mensajes distintos: son dos hechos. Escribir en el campo de nombre y escribir en el de apellido pueden compartir un intent con un identificador de campo, porque la reducción es la misma lógica parametrizada y separarlos solo duplicaría ramas idénticas.
Demasiado fino
Un intent por cada evento crudo, incluido el foco, el desplazamiento y cada pulsación de tecla. El reducer se llena de ramas triviales.
Demasiado grueso
Un intent genérico con un campo de tipo y un mapa de datos. El compilador deja de ayudar y el when pierde la exhaustividad.
Parametrizado
Un intent con carga que cubre familias homogéneas: campo editado con su identificador, fila tocada con su clave.
Distinguible
El punto correcto: tantos intents como hechos que el reducer pueda necesitar tratar de forma distinta, ni uno más.
Conviene notar que la pregunta por la granularidad se hace sobre el hecho y no sobre el evento técnico que lo produce. Un desplazamiento genera decenas de eventos por segundo y casi nunca corresponde a ningún hecho que el reducer deba tratar; en cambio, llegar al final de la lista sí lo es, porque de ahí depende cargar la página siguiente. La conversión de muchos eventos crudos en un hecho es trabajo de la vista, y es legítimo: filtrar, agrupar por tiempo o detectar un umbral no es tomar una decisión de negocio, es traducir ruido en noticia. La frontera está en que la vista puede resumir, pero no interpretar.
El extremo fino no es peligroso, solo caro: produce ruido y ramas que no dicen nada, y suele curarse agrupando por familia. El extremo grueso sí es peligroso, porque destruye las garantías. Un intent con un campo de texto llamado accion y un mapa de parámetros es, en la práctica, volver a las llamadas dinámicas: el when ya no puede ser exhaustivo sobre nada, el compilador no detecta el caso olvidado, la carga deja de estar tipada y los tests pasan a afirmar sobre cadenas.
Agrupar sin perder la distinción
La decisión de agrupar debe tomarse mirando el reducer, no la vista. Dos gestos que en pantalla están lejísimos pueden compartir intent si producen la misma transición, y dos botones idénticos y contiguos pueden necesitar intents distintos si el producto los cuenta por separado. La geometría de la interfaz es irrelevante para esta decisión; lo único que cuenta es si el reducer tiene o puede tener algo distinto que decir.
Agrupar es legítimo cuando la lógica de reducción es idéntica salvo por un parámetro. La forma correcta de agrupar es con un tipo, no con un texto.
sealed interface PerfilIntent {
data class CampoEditado(val campo: Campo, val valor: String) : PerfilIntent
data class FilaTocada(val id: String) : PerfilIntent
data object GuardadoPulsado : PerfilIntent
data object DescartadoPulsado : PerfilIntent
}
enum class Campo { Nombre, Apellido, Correo, Telefono }
Hay una segunda forma de agrupar que merece mención aparte: la que reúne intents por familia usando jerarquías anidadas del propio sealed interface. Cuando una pantalla crece y empieza a tener secciones —un formulario, una lista y una barra de filtros— anidar los intents por sección mantiene la exhaustividad y a la vez permite que cada subreducer reciba solo su parte.
sealed interface PantallaIntent {
sealed interface Formulario : PantallaIntent {
data class CampoEditado(val campo: Campo, val valor: String) : Formulario
data object EnvioPulsado : Formulario
}
sealed interface Lista : PantallaIntent {
data class FilaTocada(val id: String) : Lista
data object FinalAlcanzado : Lista
}
}
Esta anidación no cambia la granularidad —siguen siendo los mismos cuatro hechos— pero organiza el vocabulario y prepara la composición del reducer. Es la respuesta correcta a la sensación de que hay demasiados intents en una pantalla grande: casi nunca sobran hechos, lo que falta es estructura.
Cuatro campos comparten un intent porque el reducer hace lo mismo con todos: guardar el valor y recalcular la validación de ese campo. La distinción no se pierde, porque Campo es un tipo cerrado y el reducer puede ramificar sobre él el día que el correo necesite una regla propia. Guardar y descartar, en cambio, no se agrupan bajo un hipotético SalidaPulsada con un booleano, porque son dos hechos con consecuencias opuestas y ese booleano sería una decisión tomada en la vista.
Cuando un intent necesita un booleano para saber qué hacer —AccionPulsada(esGuardar: Boolean)— casi siempre eran dos intents. El booleano es una rama del when que se ha escapado del reducer y ha ido a parar al lugar donde se construyó el valor, que es la vista. Sustitúyelo por dos miembros del tipo suma y observa cómo desaparece un condicional del Composable y aparece una rama en el reducer: ese traslado es exactamente el objetivo.
Los olores del intent que hace demasiado
flowchart TD
A[Intent sospechoso] --> B{Lleva una funcion o un callback}
B -->|si| P1[La logica vive en la vista]
A --> C{Necesita un booleano de modo}
C -->|si| P2[Eran dos intents distintos]
A --> D{La vista lee estado para construirlo}
D -->|si| P3[Duplicacion de verdad]
A --> E{Su nombre tiene la palabra y}
E -->|si| P4[Dos hechos en un solo tipo]
style A fill:#f9e2af,color:#11111b
style P1 fill:#f38ba8,color:#11111b
style P3 fill:#f38ba8,color:#11111bEl primero y más grave es el intent que transporta una función: una lambda, un callback, un manejador. Un intent es un dato y una función no lo es; no se puede comparar, ni serializar, ni registrar, ni reinyectar, de modo que basta uno de estos para cancelar la trazabilidad de toda la pantalla. El segundo es el booleano de modo que ya has visto. El tercero es sutil y frecuente: la vista lee el estado para construir el intent, por ejemplo enviando el texto actual del campo junto con la pulsación de guardar. Si ese texto ya está en el estado, mandarlo otra vez crea dos verdades que pueden divergir; el reducer debe leerlo de donde vive. El cuarto es puramente lingüístico y sorprendentemente fiable: si el nombre natural del intent contiene una conjunción, hay dos hechos dentro.
// Olor: la vista tuvo que decidir y ademas reenvia lo que ya esta en el estado.
data class GuardarPulsado(val texto: String, val esBorrador: Boolean) : PerfilIntent
// Corregido: dos hechos, sin carga redundante.
data object GuardadoPulsado : PerfilIntent
data object BorradorPulsado : PerfilIntent
El tercer olor merece un matiz, porque tiene una excepción legítima. Si el dato que la vista envía no está en el estado ni puede estarlo —la posición exacta de un gesto, la anchura medida de un contenedor, el instante en que ocurrió el toque— entonces mandarlo en el intent no duplica nada: es la única forma de que entre al sistema. La regla precisa no es que el intent no lleve datos, sino que no lleve datos que el reducer ya tiene. Cuando el dato nace en la vista y muere si no se transporta, su sitio es la carga del intent.
Un quinto olor, menos visible, aparece cuando el reducer necesita conocer el origen del intent para decidir. Si en una rama te descubres preguntando si el intent vino del botón de arriba o del de abajo, es que dos hechos distintos se están presentando como uno solo y la desambiguación se ha resuelto con un dato de presentación. La corrección es la misma de siempre: partir en dos miembros y nombrarlos por lo que significan en el dominio, no por dónde estaban en la pantalla.
Cómo evoluciona un vocabulario
La granularidad no se acierta el primer día ni hace falta. Lo importante es que el vocabulario sea barato de refinar, y en Kotlin lo es precisamente por el when exhaustivo: partir un intent en dos rompe la compilación en cada punto que lo consumía y te lleva de la mano por todos los sitios donde hay que decidir. Ese es el motivo por el que empezar algo grueso y afinar después es una estrategia razonable, mientras que empezar genérico y afinar después no lo es: del intent con mapa de parámetros no hay ningún error de compilación que te guíe de vuelta.
flowchart LR
A[Vocabulario inicial] --> B[Aparece una regla nueva]
B --> C{El reducer puede distinguir el caso}
C -->|si| D[Sin cambios: solo una rama nueva]
C -->|no| E[Partir el intent en dos hechos]
E --> F[El compilador senala cada consumidor]
F --> G[Vocabulario refinado]
style G fill:#a6e3a1,color:#11111b
style E fill:#f9e2af,color:#11111bExiste además una prueba de humo excelente para saber si el grano es correcto, y no requiere leer código: enseña la lista de intents a alguien que conozca el producto pero no la implementación. Si puede leerla y reconocer la pantalla —esto es lo que se puede hacer aquí— el vocabulario está a la altura correcta de abstracción. Si le suena a instrucciones de máquina, es demasiado fino; si le parece que falta la mitad de las cosas, es demasiado grueso. Un vocabulario de intents bien dimensionado es un documento que un diseñador o un responsable de producto puede revisar, y eso no es una anécdota: es la señal de que la frontera entre lo que el usuario puede hacer y cómo lo implementas ha quedado dibujada donde tocaba.
La pregunta por el número de intents parece un asunto de estilo y es, en realidad, la última defensa de todo lo que las lecciones anteriores construyeron. Fíjate en que ninguna de las patologías del vocabulario grueso rompe el compilador ni falla un test: simplemente devuelven, una a una, las decisiones a la interfaz. El booleano de modo devuelve una rama. La lambda dentro del intent devuelve un algoritmo entero. El intent genérico con mapa de parámetros devuelve la exhaustividad y con ella la garantía de que ninguna acción quedó sin atender. Cada concesión parece local y barata, y el efecto acumulado es una arquitectura que conserva la forma de MVI sin conservar ninguna de sus propiedades: hay tipos, hay un ciclo, hay un reducer, y aun así la lógica volvió a estar donde estaba antes. Por eso el criterio operativo debe formularse en términos de conocimiento y no de cantidad. La pregunta correcta nunca es cuántos intents tiene esta pantalla, sino qué tuvo que saber la vista para emitir este. Si la respuesta es que ocurrió algo delante de ella, el intent está bien dimensionado, tenga la pantalla cuatro o veinte. Si la respuesta incluye cualquier forma de interpretación —que deslizar significa borrar, que este botón guarda y aquel descarta según una condición, que hay que mandar el texto porque el reducer no lo tiene— entonces el intent es grueso en el único sentido que importa, y la corrección consiste siempre en el mismo movimiento: partirlo en hechos, tipar la carga y dejar que la interpretación caiga en el when. La granularidad, vista así, no es un compromiso entre dos males sino la expresión numérica de una frontera. Un vocabulario bien dimensionado es aquel en el que la vista puede escribirse por alguien que no conozca las reglas del negocio, y el reducer por alguien que no haya visto nunca la pantalla. Cuando ambas cosas son ciertas a la vez, el número de intents que hayas elegido es, casi con seguridad, el correcto.
Con esto se cierra el nivel. Tienes un vocabulario de hechos nombrados en pasado, una función pura que los traduce en estados, un modelo del fallo que vive dentro de ese estado, un borde donde ocurre lo impuro y un criterio para saber cuántos hechos merecen nombre propio. Nada de eso es específico de Orbit ni de Kotlin: es el mismo esqueleto que sostiene Elm, Redux y The Composable Architecture. Lo que la siguiente etapa añadirá es la salida del ciclo —los efectos que la pantalla emite hacia fuera— y ahí volverán todas estas preguntas, formuladas en el otro sentido.
- Cuenta los intents de una pantalla tuya y, para cada uno, escribe en una frase qué tuvo que saber la vista para emitirlo; marca los que exijan interpretación.
- Localiza todo intent con un booleano de modo y pártelo en dos miembros del tipo suma; anota qué condicional desapareció del Composable.
- Busca intents que transporten funciones o callbacks y reescríbelos como datos puros; explica qué capacidad recuperas al hacerlo.
- Agrupa una familia homogénea de campos bajo un intent parametrizado con un
enum class, y justifica por qué esa agrupación no pierde ninguna distinción útil. - Revisa si alguna vista envía datos que ya están en el estado; elimina esa duplicación y describe el escenario concreto en el que ambas copias podrían divergir.