wandres.dev
NIVEL DIOS: SÍNTESIS · construir y sostener

El modelo mental completo: los cuatro contratos

Treinta y seis niveles de temario se dejan comprimir en cuatro contratos que una aplicación de Android firma y no puede romper: uno con el sistema operativo, que le presta un proceso y se lo quita cuando quiere; uno con la interfaz, que solo sabe dibujar el estado que le entregas; uno con los datos, que tienen dueño, latencia y un instante de verdad; y uno con el canal de distribución, que impone la firma, el formato del artefacto y una fecha de caducidad anual. Esta lección reconstruye esos cuatro contratos como un solo sistema acoplado, muestra dónde cada decisión de arquitectura es en realidad la elección de qué contrato honrar primero, y establece el vocabulario que las cuatro lecciones siguientes van a usar sin volver a explicarlo.

⏱ 28 min

Llega un momento en la formación de cualquier desarrollador de Android en el que el temario deja de parecer una lista de bibliotecas y empieza a parecer una sola idea vista desde cuatro ángulos. Ese momento es este. Todo lo que hemos estudiado —el ciclo de vida de una Activity, la recomposición de Compose, las migraciones de Room, los límites de arranque en segundo plano, la verificación de dominios, el perfil de arranque, la política de la tienda— no son veinticuatro temas independientes que un profesional memoriza, sino consecuencias derivables de cuatro compromisos que la plataforma te impone y que no admiten renegociación. El sistema te presta un proceso y se reserva el derecho de matarlo sin avisar. La interfaz no guarda nada: es una función pura del estado que le entregas en cada instante. Los datos tienen un dueño único y un tiempo propio, distinto del tiempo de la pantalla. Y el artefacto que publicas envejece por decreto, porque cada año la plataforma sube el listón mínimo y lo que no se adapta deja de distribuirse. Quien tiene estos cuatro contratos vivos en la cabeza deduce el noventa por ciento de las respuestas sin consultar nada; quien no los tiene, memoriza recetas y se rompe en cuanto el caso real se desvía del ejemplo. La diferencia entre ambos perfiles no es cuánto han leído, sino si organizaron lo leído alrededor del esqueleto correcto.

🎯 Al terminar esta lección sabrás
  • Enunciar los cuatro contratos de una aplicación de Android y derivar de cada uno las decisiones de diseño que impone.
  • Reconocer en cualquier síntoma observable cuál de los cuatro contratos se está incumpliendo.
  • Explicar cómo se acoplan entre sí y por qué optimizar uno en solitario degrada los otros tres.
  • Usar el modelo como herramienta de diagnóstico ante código ajeno y ante decisiones de arquitectura nuevas.

Contrato uno: el proceso es prestado

El primer contrato es el que más cuesta interiorizar porque contradice la intuición de quien viene de escritorio o de servidor: tu aplicación no es dueña de su proceso. El sistema lo crea cuando hace falta, lo mantiene mientras haya memoria y motivo, y lo destruye sin ceremonia cuando el usuario se va a otra cosa y el dispositivo necesita recursos. No hay señal, no hay callback garantizado de despedida, no hay oportunidad de salvar nada en el último instante. La consecuencia directa es que toda la jerarquía de estado de tu aplicación tiene que estar clasificada por supervivencia: qué muere con la recomposición, qué muere con el cambio de configuración, qué muere con el proceso y qué sobrevive a la desinstalación del propio sistema.

class DetalleViewModel(
    private val estadoGuardado: SavedStateHandle,
    private val repositorio: PedidoRepository,
) : ViewModel() {

    // Sobrevive al cambio de configuracion y a la muerte del proceso.
    private val pedidoId: String = checkNotNull(estadoGuardado["pedidoId"])

    // Sobrevive al cambio de configuracion. Muere con el proceso.
    val estado: StateFlow<DetalleUiState> = repositorio.observar(pedidoId)
        .map(::aUiState)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DetalleUiState.Cargando)
}

La jerarquía completa, ordenada de menor a mayor supervivencia, se puede enunciar en cinco escalones y conviene tenerla memorizada porque resuelve discusiones enteras: lo que vive en la recomposición, lo que vive en el árbol de composición, lo que vive en el ámbito de pantalla, lo que vive en el proceso y lo que vive en el disco. Cada escalón cuesta más que el anterior en complejidad y en latencia, de modo que la regla es subir solo lo imprescindible y ser capaz de justificar cada ascenso.

var filtro by remember { mutableStateOf("") }                   // muere al salir del arbol
var borrador by rememberSaveable { mutableStateOf("") }         // sobrevive a la rotacion
val estado by viewModel.estado.collectAsStateWithLifecycle()    // sobrevive a la pantalla
val id: String = savedStateHandle["pedidoId"]!!                 // sobrevive al proceso
val tema: Flow<Tema> = dataStore.data.map { it.tema }           // sobrevive a todo

Esa clasificación no es una recomendación estilística sino la definición operativa de corrección. Un identificador de navegación que solo vive en el ViewModel produce una aplicación que funciona en todas las pruebas y falla en el mundo real, porque el usuario atiende una llamada de diez minutos y vuelve a una pantalla en blanco. El mismo contrato explica por qué la ejecución en segundo plano se ha ido cerrando año tras año: si el proceso es prestado, el trabajo que sobrevive a la pantalla no puede vivir en el proceso, tiene que estar declarado ante el sistema con WorkManager o justificado ante el usuario con un servicio en primer plano. Y explica también el modelo de permisos, que es la misma idea aplicada a las capacidades: no posees el acceso a la cámara ni a la ubicación, lo tienes cedido y revocable, y todo código que asuma lo contrario tiene una ruta de fallo sin escribir.

⚠️
El síntoma clásico de incumplir el contrato uno

La aplicación funciona perfectamente durante el desarrollo y produce informes de fallo irreproducibles en producción, concentrados en dispositivos con poca memoria. Casi siempre es lo mismo: estado que se supone vivo y que el sistema ya destruyó. Reprodúcelo a voluntad con la opción de no conservar actividades y forzando la muerte del proceso desde la línea de órdenes; si tu aplicación no sobrevive a eso, no está terminada.

Contrato dos: la interfaz es una función del estado

El segundo contrato es el que Compose convirtió en explícito. Una función componible no dibuja: describe. Recibe un estado, emite una descripción de lo que debe verse y el sistema decide cuándo volver a ejecutarla. De ahí se sigue que la interfaz no puede ser la dueña de nada importante, porque su ejecución es impredecible en frecuencia, en orden y en hilo, y porque el mismo estado debe producir siempre la misma descripción. El flujo unidireccional no es una moda arquitectónica importada: es la única forma de convivir con un motor que puede reejecutar tu código de dibujo sesenta veces por segundo sin pedirte permiso.

@Composable
fun DetallePantalla(viewModel: DetalleViewModel = hiltViewModel()) {
    val estado by viewModel.estado.collectAsStateWithLifecycle()

    when (val actual = estado) {
        DetalleUiState.Cargando -> Cargador()
        is DetalleUiState.Error -> ErrorReintentable(actual.mensaje, viewModel::reintentar)
        is DetalleUiState.Contenido -> Contenido(actual.pedido, onConfirmar = viewModel::confirmar)
    }
}

Conviene señalar el corolario que más código simplifica: si la interfaz es una función del estado, entonces el estado debe ser exhaustivo y no un puñado de banderas sueltas. Un tipo sellado que enumera los casos posibles hace imposible la combinación absurda —cargando y con error a la vez, contenido vacío sin saber si es que no hay nada o que aún no llegó— y convierte el diseño de la pantalla en un ejercicio de cobertura de casos que el compilador verifica. Las pantallas que fallan de formas inexplicables casi siempre tienen un estado modelado con tres booleanos independientes, es decir, ocho combinaciones de las cuales solo cuatro tienen sentido.

sealed interface DetalleUiState {
    data object Cargando : DetalleUiState
    data class Error(val mensaje: TextoUi, val reintentable: Boolean) : DetalleUiState
    data class Contenido(val pedido: Pedido, val sincronizando: Boolean) : DetalleUiState
}

Las tres fases —composición, medida y dibujo— son la herramienta con la que este contrato se paga barato. Leer un estado en la fase equivocada convierte un desplazamiento fluido en una recomposición por fotograma, y ese es el origen de casi todos los problemas de rendimiento de Compose que no son de listas mal identificadas. El estado que solo afecta a la posición se lee en el modificador de disposición; el que solo afecta al color, en la fase de dibujo; y solo el que cambia la estructura del árbol merece una recomposición. Esta jerarquía de coste tiene una equivalencia exacta en el contrato anterior: en ambos casos se trata de clasificar por alcance y no pagar en el nivel más caro lo que se puede resolver en el más barato.

💡
La pregunta que resuelve casi todo el diseño de una pantalla

Ante cualquier fragmento de interfaz, pregúntate quién sobrevive a qué. Si el valor debe sobrevivir a la rotación, no vive en remember. Si debe sobrevivir a la muerte del proceso, no vive en el ViewModel. Si debe sobrevivir al cierre de sesión, no vive en memoria. Y si debe sobrevivir a la reinstalación, no vive en el dispositivo. Cuatro preguntas encadenadas y la ubicación correcta del estado queda determinada sin discusión de equipo.

Contrato tres: los datos tienen dueño y tienen tiempo

El tercer contrato dice que en cualquier instante hay exactamente una fuente de verdad para cada dato, y que esa fuente es local. La red no es una fuente de verdad: es una sugerencia con latencia variable, disponibilidad intermitente y coste en batería y en datos del usuario. Cuando una aplicación pinta directamente lo que devuelve una llamada de red, ha delegado su corrección en una condición que no controla, y el resultado son pantallas que parpadean, listas que se reordenan solas y estados vacíos que aparecen porque el metro entró en un túnel. La disciplina de escribir primero en la base local y observar la base local desde la interfaz resuelve simultáneamente el modo sin conexión, la coherencia entre pantallas y la supervivencia a la muerte del proceso, que es el contrato uno pagándose solo.

class PedidoRepository(
    private val dao: PedidoDao,
    private val api: PedidoApi,
) {
    fun observar(id: String): Flow<Pedido> = dao.observar(id)

    suspend fun refrescar(id: String): Result<Unit> = runCatching {
        dao.insertar(api.obtener(id).aEntidad())
    }
}

Este contrato tiene una consecuencia que conviene enunciar sin rodeos porque contradice el diseño de casi toda API que consumirás: la respuesta de la red no debe llegar nunca a la pantalla. Llega a la base local, y la pantalla observa la base local. Ese rodeo aparente es lo que hace que dos pantallas que muestran el mismo pedido no puedan discrepar, que volver de un segundo plano no obligue a recargar, que una escritura fallida sea reversible y que el modo sin conexión no sea una funcionalidad sino el comportamiento por defecto. Cuando un equipo se salta el rodeo por velocidad, no ahorra trabajo: lo aplaza y lo multiplica.

El tiempo es la otra mitad del contrato y la que se olvida. Cada dato tiene un instante de validez, una frecuencia de cambio y un coste de obtención, y esos tres números determinan dónde vive y cuándo se refresca. Una preferencia del usuario cambia una vez al mes y debe estar disponible antes del primer fotograma: DataStore. Un catálogo cambia a diario y se lee mil veces: base local con refresco programado. Un saldo cambia por segundos y equivocarse tiene consecuencias: lectura en línea con estado explícito de obsolescencia visible para el usuario. Confundir estas categorías produce, en un extremo, aplicaciones que consumen batería refrescando lo inmutable, y en el otro, aplicaciones que muestran cifras equivocadas con total aplomo.

flowchart TD
A[Sistema operativo] -->|presta y retira el proceso| B[Proceso de la app]
B --> C[Estado observable]
C -->|se dibuja como| D[Composicion]
D -->|emite eventos| C
C -->|lee y escribe| E[Fuente de verdad local]
E -->|sincroniza cuando puede| F[Red]
E -->|sobrevive a la muerte del proceso| A
B --> G[Artefacto firmado y publicado]
G -->|targetSdk y politica| A

Contrato cuatro: el artefacto envejece

El cuarto contrato es el que casi ningún curso enseña y el que más aplicaciones ha retirado del mercado. Lo que publicas no es código fuente sino un artefacto firmado, y ese artefacto está sujeto a un calendario que no controlas. Cada año la plataforma publica una versión, y unos meses después el nivel de API objetivo mínimo para poder publicar actualizaciones sube. Una aplicación que no se toca durante dos años no se queda igual: se queda fuera. A eso se suman los cambios de comportamiento que se activan precisamente al subir ese número, de modo que la actualización obligatoria nunca es un cambio de una cifra sino una migración con riesgo real. El formato de distribución añade su propia capa: la tienda regenera y vuelve a firmar el paquete que llega a cada dispositivo, lo que significa que la huella que tu equipo maneja localmente no es la que verifica la plataforma, y ahí nacen los fallos de enlaces verificados y de integridad que aparecen solo en producción.

Hay una segunda mitad de este contrato que se descubre siempre tarde, y es que la firma tiene consecuencias sobre el producto y no solo sobre la publicación. La clave con la que se firma determina la identidad de la aplicación ante el sistema, ante los servicios que verifican su integridad y ante los enlaces verificados de tu dominio; perderla equivale a no poder actualizar nunca más y obligar a todos los usuarios a instalar una aplicación distinta desde cero, con sus datos abandonados en la anterior. Por eso la firma gestionada por la tienda, que a mucha gente le incomoda al principio por ceder la custodia, es la decisión correcta para casi todos los equipos: convierte un riesgo existencial e irreversible en un procedimiento operativo.

El formato de distribución cierra el círculo con el primer contrato de una forma elegante. Como la tienda genera un artefacto ajustado a cada dispositivo, lo que se descarga contiene solo la densidad de pantalla, la arquitectura y el idioma que ese dispositivo necesita, y ese ahorro es exactamente el que decide si una persona con almacenamiento lleno y datos limitados llega a instalarte. La misma lógica se extiende a la entrega de funcionalidades bajo demanda y a los perfiles de arranque incluidos en el artefacto, que el sistema usa para compilar por adelantado los caminos críticos. Publicar deja de ser un trámite del final y pasa a ser una parte del diseño de rendimiento.

🔱

Proceso prestado

Clasifica todo el estado por supervivencia. Lo que no sobrevive al proceso y debería, es un fallo pendiente de reproducirse.

🎨

Interfaz derivada

La pantalla es una función del estado. No guarda, no decide y no sabe de red. Cualquier excepción a esto se paga en recomposiciones.

💾

Verdad local

Una sola fuente de verdad por dato, y es local. La red sincroniza; no manda. El modo sin conexión sale gratis si esto se respeta.

📦

Artefacto perecedero

Firma, formato y nivel objetivo tienen calendario. Publicar no es el final del trabajo sino el comienzo de la obligación de mantenerlo.

Los cuatro contratos están acoplados, y ese acoplamiento es la razón por la que optimizar uno solo siempre empeora el conjunto. Guardar más estado en memoria acelera la interfaz y agrava el problema de la muerte del proceso. Refrescar más a menudo mejora la frescura del dato y consume la batería que el sistema vigila para decidir a quién castiga con restricciones. Subir el nivel objetivo cumple la política y activa cambios de comportamiento que rompen justo el trabajo en segundo plano que sostenía la sincronización. Un profesional no busca el óptimo de cada eje sino el punto donde ninguno de los cuatro está incumplido, y sabe nombrar qué está sacrificando cuando decide.

Por qué Android se diseñó así y qué revela sobre el resto de tu carrera

Conviene detenerse en la causa raíz, porque explica la coherencia de todo lo demás y porque la lección trasciende esta plataforma. Android no impuso estos cuatro contratos por gusto arquitectónico: los impuso porque su problema de diseño fundamental era ejecutar aplicaciones escritas por desconocidos, con incentivos desalineados y calidad desconocida, sobre hardware ajeno, con batería finita, en un dispositivo que el usuario percibe como suyo y no como una máquina de ejecutar tu producto. Cada uno de los cuatro contratos es una respuesta directa a ese problema. El proceso es prestado porque, si no lo fuera, la aplicación con el peor comportamiento del dispositivo determinaría la experiencia de todas las demás; el sistema necesita poder desalojar sin negociar, y por tanto ninguna aplicación puede tener derecho a persistir. La interfaz es declarativa porque el modelo anterior, en el que cada componente guardaba su propio estado mutable, hacía imposible reconstruir una pantalla desde cero tras un desalojo, que es exactamente lo que el primer contrato exige constantemente. La verdad es local porque una plataforma móvil vive en la intermitencia, y una arquitectura que trata la conectividad como el caso normal y la desconexión como el error está describiendo un mundo que no existe. Y el artefacto caduca porque la seguridad de un ecosistema con miles de millones de dispositivos no se puede sostener sobre la voluntad individual de actualizar: hay que forzar el suelo hacia arriba, con calendario público y sin excepciones, aceptando el coste enorme que eso impone a cada equipo. Lo revelador es que estas cuatro decisiones no son propias de Android sino de cualquier sistema que ejecute código ajeno bajo restricciones duras. Un navegador moderno impone las mismas cuatro: proceso desalojable, interfaz derivada del estado, almacenamiento local con sincronización oportunista y caducidad de las API por calendario. Una plataforma de funciones sin servidor impone las mismas cuatro con otros nombres. Un orquestador de contenedores también. Quien entiende que la pregunta de fondo siempre es la misma —cómo se ejecuta código en el que no confías sobre recursos que no le pertenecen— deja de aprender plataformas y empieza a reconocerlas. Ese cambio, y no la acumulación de API memorizadas, es lo que separa a quien lleva diez años repitiendo su primer año de quien lleva diez años acumulando criterio transferible.

⚔️ Audita una aplicación real contra los cuatro contratos
  1. Coge una aplicación que hayas escrito y clasifica cada pieza de estado en las cuatro categorías de supervivencia; anota las que estén en el nivel equivocado.
  2. Activa la opción de no conservar actividades, fuerza la muerte del proceso desde la línea de órdenes y documenta cada pantalla que no vuelva íntegra.
  3. Pon el dispositivo en modo avión y recorre la aplicación entera; cada pantalla que muestre un error en lugar de datos revela un incumplimiento del contrato tres.
  4. Consulta qué nivel de API objetivo será obligatorio el año que viene, lista los cambios de comportamiento que te afectarían y estima el trabajo.
  5. Escribe media página explicando qué contrato estás sacrificando deliberadamente en tu proyecto actual y por qué es la decisión correcta.