Empieza por aquí. El resto del curso da por hecho lo que explica este documento. En Flutter, el motor te escondía casi todo: tenías una sola pantalla nativa y dentro pintabas widgets. En Android nativo, esas piezas están a la vista y los seniors dan por hecho que las conoces. Si no las tienes claras, las preguntas de entrevista no se pueden razonar: solo memorizar.
Cada sección termina con un Compruébalo: tápate la respuesta e intenta contestarla en voz alta.
1. La Activity: la ventana de tu app
Una Activity es lo que Android pone en pantalla cuando abres una app: una ventana con su propio ciclo de vida, que el sistema crea y destruye.
En Flutter tenías una sin saberlo:
android/app/src/main/.../MainActivity.kt, que extiende deFlutterActivity. Dentro, el motor de Flutter pintaba todos tus widgets.En una app Compose moderna pasa algo parecido: una sola Activity (
MainActivity) que llama asetContent { … }. Todas las pantallas son composables y la navegación cambia cuál se muestra.
Así es la de Kontor, simplificada:
@AndroidEntryPoint // Hilt puede inyectar aquí
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { // a partir de aquí, todo es Compose
KontorTheme { KontorApp() }
}
}
}
Las apps antiguas tenían una Activity por pantalla, o una Activity con muchos Fragments (trozos de pantalla con su propio ciclo de vida). Lo verás en código legacy: con saber leerlo basta.
Compruébalo: ¿Cuántas Activities suele tener una app Compose moderna y qué hace? → Una, MainActivity; llama a setContent y dentro van todas las pantallas como composables.
2. El AndroidManifest: el DNI de la app
AndroidManifest.xml le dice al sistema qué hay en tu app. Lo que no está ahí, para Android no existe.
<manifest>
<uses-permission android:name="android.permission.INTERNET" />
<application android:name=".KontorApplication">
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
uses-permission: lo que la app necesita (internet, cámara…). Los peligrosos, además, se piden en runtime.application android:name: la claseApplication, que vive lo que vive el proceso. Ahí va@HiltAndroidApp.El
intent-filterconMAIN+LAUNCHERmarca qué Activity se abre al pulsar el icono.
En Flutter lo tocabas para añadir permisos y poco más. Aquí lo vas a leer a menudo.
Compruébalo: ¿Cómo sabe Android qué Activity abrir al pulsar el icono? → Por el intent-filter con MAIN y LAUNCHER en el manifest.
3. El ciclo de vida de la Activity
La Activity pasa por estados, y Android te avisa con callbacks:
onCreate ──▶ onStart ──▶ onResume ← visible e interactiva
│
(llega una llamada, cambias de app, bloqueas el móvil)
▼
onStop ◀──── onPause ← en background
│
▼
onDestroy ← se destruye
| Callback | Cuándo | Equivalente aproximado en Flutter |
|---|---|---|
onCreate | Se crea la Activity | main() + el primer build |
onStart / onResume | Se vuelve visible / interactiva | AppLifecycleState.resumed |
onPause / onStop | Deja de ser interactiva / visible | inactive / paused |
onDestroy | Se destruye | detached |
Con Compose casi nunca escribes estos callbacks a mano. Pero tienes que saber que existen, porque explican todo lo que viene después. Por ejemplo, collectAsStateWithLifecycle() deja de escuchar un Flow en onStop y vuelve en onStart, para no gastar batería pintando una pantalla que nadie ve.
Compruébalo: El usuario pulsa el botón de inicio y deja tu app en background. ¿Qué callbacks se llaman? → onPause y onStop. No onDestroy: la Activity sigue viva, solo que oculta.
4. Cambios de configuración: girar el móvil
Un cambio de configuración es cuando cambia algo del entorno que afecta a cómo se pinta la app:
girar el móvil (vertical ↔ horizontal);
cambiar el idioma del sistema;
activar o desactivar el modo oscuro;
cambiar el tamaño de la ventana (pantalla partida, plegables).
Lo que hace Android: destruye la Activity y la vuelve a crear desde cero, con la configuración nueva. onDestroy y luego onCreate otra vez.
En Flutter esto no te afectaba: el motor seguía vivo y simplemente repintaba con el nuevo tamaño. En Android, si guardas el estado dentro de la Activity o en un remember normal, se pierde al girar.
Por eso existe el ViewModel.
Compruébalo: ¿Qué le pasa a la Activity cuando el usuario gira el móvil? → Se destruye y se recrea. Es un cambio de configuración.
5. El ViewModel: el estado que sobrevive al giro
Un ViewModel guarda el estado y la lógica de una pantalla, y sobrevive a los cambios de configuración. Cuando la Activity se recrea al girar, la nueva Activity se engancha al mismo ViewModel.
@HiltViewModel
class TransferViewModel @Inject constructor(
private val repository: BankRepository,
) : ViewModel() {
private val _state = MutableStateFlow(TransferUiState())
val state: StateFlow<TransferUiState> = _state.asStateFlow()
fun onAmountChange(value: String) {
_state.update { it.copy(amountInput = value) }
}
}
Equivalencia Flutter: es tu Cubit/Bloc o tu ChangeNotifier. La diferencia es que Android decide cuándo vive y cuándo muere, en función de la pantalla.
¿Cuándo muere un ViewModel? Cuando su pantalla se va para siempre:
el usuario pulsa atrás y sale de esa pantalla;
la Activity se cierra con
finish();el sistema mata el proceso (sección 7).
Al morir se llama a onCleared().
Compruébalo: ¿El ViewModel sobrevive al girar el móvil? ¿Y al pulsar atrás para salir de la pantalla? → Al giro sí; es para lo que existe. Al salir con atrás no: se destruye y se llama a onCleared().
6. viewModelScope: las coroutines atadas al ViewModel
viewModelScope es un CoroutineScope que se cancela cuando el ViewModel se destruye. Todo lo que lances ahí se cancela con él.
fun onConfirm() {
viewModelScope.launch {
val result = repository.submitTransfer(request) // tarda 3 segundos
_state.update { it.copy(receipt = result.getOrNull()) }
}
}
Si el usuario pulsa atrás a los 2 segundos:
El ViewModel se destruye →
onCleared().viewModelScopese cancela → la coroutine se cancela.Retrofit cancela la llamada HTTP en el cliente.
Pero la petición puede haber llegado ya al servidor, y el servidor puede haber hecho la transferencia. El cliente nunca sabrá el resultado.
Para casi todo, cancelar es justo lo que quieres: si el usuario sale de la pantalla de búsqueda, no tiene sentido seguir buscando. Para dinero, no: un envío de transferencia debe vivir en un scope que sobreviva a la pantalla (un scope de aplicación inyectado o WorkManager) y apoyarse en una idempotency key (sección 11).
Kontor tiene justo este problema: TransferViewModel.onConfirm() envía en viewModelScope. Saber decir "en mi proyecto lo hice así, y en producción lo cambiaría por esto" es una de las mejores cosas que puedes contar en la entrevista.
Compruébalo: El usuario pulsa "Enviar" y antes de la respuesta pulsa atrás. ¿Qué pasa con la coroutine? ¿Y con la transferencia? → La coroutine se cancela con el viewModelScope. La transferencia puede haberse hecho o no: el cliente no lo sabe.
7. Process death: cuando el sistema mata tu app
Si tu app está en background y el sistema necesita memoria, mata el proceso entero: Activity, ViewModel, singletons, variables en memoria. Todo.
Lo sorprendente es lo que pasa cuando el usuario vuelve:
No vuelve a la pantalla de inicio. Android recrea la app en la misma pantalla en la que estaba, con la misma pila de navegación detrás.
Pero esa pantalla aparece vacía, porque el ViewModel es nuevo.
Para el usuario, la app nunca se cerró: solo ve que se le ha borrado lo que estaba escribiendo. Es un bug muy típico y una pregunta de entrevista casi segura.
No puedes impedir que el sistema mate tu proceso. Lo que sí puedes es guardar un poco de estado para que te lo devuelva:
| Dónde guardas | Sobrevive al giro | Sobrevive a process death | Para qué |
|---|---|---|---|
remember { } en Compose | ❌ | ❌ | Estado efímero de UI: una animación, un diálogo |
ViewModel | ✅ | ❌ | Estado de la pantalla y lógica |
rememberSaveable { } en Compose | ✅ | ✅ | Estado pequeño de UI: texto de un campo, pestaña elegida |
SavedStateHandle en el ViewModel | ✅ | ✅ | Lo que el usuario ha escrito o elegido: IDs, inputs, filtros |
| Room / DataStore (disco) | ✅ | ✅ | Datos de verdad: cuentas, movimientos, preferencias |
@HiltViewModel
class TransferViewModel @Inject constructor(
private val savedState: SavedStateHandle, // Hilt lo inyecta solo
private val repository: BankRepository,
) : ViewModel() {
val iban: StateFlow<String> = savedState.getStateFlow("iban", "")
fun onIbanChange(value: String) {
savedState["iban"] = value // sobrevive a process death
}
}
Qué va en SavedStateHandle: cosas pequeñas y que ha generado el usuario: lo que ha escrito, el ID de la cuenta elegida, el paso del formulario. Qué no: listas de datos de la API. El Bundle tiene un límite de alrededor de 1 MB (si lo superas, crash con TransactionTooLargeException), y además los datos estarían caducados: los saldos de hace tres horas no son los de ahora. Los datos se recargan de su fuente de verdad.
Diferencia con HydratedCubit: HydratedCubit escribe en disco y el estado vuelve siempre, incluso si el usuario cerró la app a mano ayer. SavedStateHandle solo se restaura cuando el sistema recrea la pantalla; si el usuario cierra la app deslizándola desde recientes, ese estado se descarta. Para un formulario de transferencia es lo que quieres.
Cómo probarlo: activa "No conservar actividades" (Don't keep activities) en las opciones de desarrollador, o deja la app en background y ejecuta adb shell am kill <paquete>.
Compruébalo: El usuario está a mitad de formulario, se va a otra app y el sistema mata el proceso. Cuando vuelve, ¿qué ve y qué tendrías que haber hecho? → Ve la misma pantalla de transferencia, pero vacía. Tendría que haber guardado lo escrito y el ID de la cuenta en SavedStateHandle.
8. El hilo principal y el ANR
Android tiene un hilo principal (main thread o UI thread) que pinta la pantalla y responde a los toques. Es el equivalente al isolate principal de Flutter.
Si lo bloqueas, la UI se congela (jank).
Si lo bloqueas unos 5 segundos mientras el usuario intenta tocar algo, Android muestra el diálogo "La app no responde": un ANR (Application Not Responding).
Con coroutines:
Dispatchers.Main: el hilo principal. Para tocar la UI.Dispatchers.IO: para disco y red (esperar).Dispatchers.Default: para CPU (calcular).
suspend fun loadReport(): Report = withContext(Dispatchers.IO) {
database.reportDao().load() // esto ya no bloquea el main thread
}
Ojo: suspend no cambia de hilo por sí sola. Si dentro de una función suspend haces algo bloqueante sin withContext, sigues bloqueando el hilo principal. Retrofit y Room ya cambian de hilo por ti; tu código propio, no.
Compruébalo: ¿Qué es un ANR? → Que el hilo principal está bloqueado unos 5 segundos y Android le ofrece al usuario cerrar la app. Se evita sacando disco, red y cálculos pesados del main thread.
9. Context: el acceso al sistema
Context es el objeto que te da acceso al sistema: recursos (strings, colores), ficheros, preferencias, servicios del sistema. Muchas APIs de Android lo piden.
No tiene nada que ver con el BuildContext de Flutter, aunque se llamen parecido: BuildContext es tu posición en el árbol de widgets; Context es tu acceso al sistema operativo.
Hay dos tipos que importan:
applicationContext: vive lo que vive el proceso. Seguro para guardarlo en singletons.El de la Activity: tiene tema y ventana, pero muere con la Activity. Nunca lo guardes en algo que viva más que ella, como un ViewModel o un singleton: al girar el móvil, la Activity vieja no se podría liberar de memoria. Eso es un memory leak, y LeakCanary te lo chiva en debug.
Compruébalo: ¿Por qué no pasas el Context de la Activity a un ViewModel? → Porque el ViewModel sobrevive al giro y la Activity no: retendrías una Activity destruida en memoria.
10. La navegación y la pila de pantallas
Navigation Compose mantiene una pila de pantallas (back stack), como el Navigator de Flutter:
navController.navigate(TransferDestination)añade una pantalla encima, como unpush.Atrás saca la de arriba, como un
pop, y destruye su ViewModel.
Cada entrada de la pila tiene su propio ViewModel. Por eso dos pantallas distintas tienen ViewModels distintos, y volver atrás limpia el de la pantalla que se va.
La pila también sobrevive a process death: Android la guarda y la restaura. Por eso el usuario vuelve a la misma pantalla.
Compruébalo: ¿Qué le pasa al ViewModel de una pantalla cuando el usuario navega a otra pantalla encima? ¿Y cuando vuelve atrás desde ella? → Al navegar encima, sigue vivo: su pantalla está en la pila, debajo. Cuando sales de ella con atrás, se destruye.
11. Bonus: dinero e idempotencia
No es Android, pero en una entrevista de banca sale seguro, y conecta con todo lo anterior.
El problema: el usuario pulsa "Enviar", la red tarda, salta un timeout o el usuario sale de la pantalla. ¿Se ha hecho la transferencia? No lo sabes. Un timeout no significa que haya fallado: la petición pudo llegar y ejecutarse.
Si el usuario vuelve a pulsar, ¿le cobras dos veces? Su intención era pagar una factura.
La solución: idempotency key. El cliente genera un identificador único (un UUID) que representa esta intención de pago y lo envía en una cabecera. Si al servidor le llega dos veces la misma key, no ejecuta la segunda: devuelve el resultado de la primera. Así reintentar es seguro.
¿Cuándo se genera? Al entrar en la pantalla de revisión ("vas a enviar 1.200 € a Fulano"), no al pulsar el botón:
Si se genera al pulsar, cada pulsación crea una key nueva y el servidor ve dos transferencias distintas.
Se guarda en el
SavedStateHandle, para que sobreviva a giros y a process death.Si el usuario vuelve atrás y cambia el importe, es otra intención: key nueva.
¿Y un timestamp? No sirve: el reintento lleva otro timestamp, dos pagos legítimos pueden coincidir en el mismo milisegundo y los relojes de los móviles no son fiables.
Compruébalo: ¿Por qué la idempotency key no se genera al pulsar "Enviar"? → Porque cada pulsación tendría una key distinta, así que un reintento tras un timeout se ejecutaría dos veces.
12. Resumen en una tabla
Si solo te quedas con una cosa de este documento, que sea esta:
| Evento | Activity | ViewModel / viewModelScope | SavedStateHandle | Disco (Room) |
|---|---|---|---|---|
| Girar el móvil | Se recrea | ✅ Sobrevive | ✅ | ✅ |
| Ir a background | Sigue viva (onStop) | ✅ | ✅ | ✅ |
| Navegar a otra pantalla encima | Sigue viva | ✅ (está en la pila) | ✅ | ✅ |
| Volver atrás y salir de la pantalla | Sigue viva | ❌ onCleared, scope cancelado | ❌ (es de esa pantalla) | ✅ |
| Process death en background | Se recrea en la misma pantalla | ❌ Nuevo y vacío | ✅ Se restaura | ✅ |
| El usuario cierra la app desde recientes | Se destruye | ❌ | ❌ | ✅ |
Siguiente paso
Contesta en voz alta todos los Compruébalo sin mirar.
Haz el nivel 8 del laboratorio (
./scripts/run-level.sh 8): implementas todo esto en código, con tests.Pasa a
docs/ANDROID_FLUENCY.md, que ya da esto por sabido.