Del escritorio al móvil

Tu experiencia de servidor y escritorio traducida a Android: pantallas que rotan y mueren, ciclo de vida, navegación, redes, almacenamiento local, permisos, cámara, GPS y publicación. Misma lógica de negocio, nuevas reglas del juego.

12 capítulos Kotlin 2.x Jetpack Compose Android 15 (API 35) Modo claro / oscuro
12
Capítulos
60+
Ejemplos de código
3
Partes progresivas
1
App lista para publicar
Cómo usar este manual: es el tercero de la serie: antes pasa por el manual del lenguaje Kotlin y, si un término te suena extraño (Activity, ViewModel, recomposición), consulta el glosario. Los ejemplos provienen de una sola aplicación guía («Pedidos») que crece capítulo a capítulo hasta publicarse.

1 · Un nuevo paradigma

Básico ~12 min

En el servidor tu proceso vive durante meses: tiene memoria dedicada, red confiable y tú decides cuándo reiniciarlo. En Android tu aplicación es una invitada: el sistema la puede matar en cualquier momento para recuperar memoria, la pantalla rota y destruye todo lo que estabas haciendo, y un solo hilo se encarga de dibujar y atender cada toque del usuario.

Esta es la diferencia de fondo entre desarrollar escritorio o backend y desarrollar móvil: no cambia la lógica de negocio, cambia el contrato con el sistema operativo.

AspectoServidor / escritorioAndroid
Vida del procesoMeses; la controlas túEl sistema puede matarla sin aviso (LMK)
MemoriaDedicada y ampliaCompartida, vigilada y reclamable
Hilo principalSin restriccionesDibuja la interfaz; bloquearlo >5 s dispara un ANR
RedConfiable, en centro de datosIntermitente, costosa, con latencia variable
ActualizaciónDespliegue continuoPasa por revisión de la tienda
UsuariosMiles concurrentesUno solo, con la app al frente

El hilo principal y el ANR

Todos tus composables se ejecutan en el hilo principal. Si haces ahí una llamada al API o lees un archivo grande, la interfaz se congela: el usuario ve la aplicación muerta y, a los ~5 segundos, Android muestra el diálogo «La aplicación no responde» (ANR) y ofrece cerrarla. La regla es idéntica a la de cualquier servidor de interfaz gráfica: nada lento en el hilo que atiende eventos.

  • Nunca bloquees el hilo principal: red, disco y cálculos van a corrutinas con Dispatchers.
  • Asume que el proceso morirá sin aviso: persiste todo lo importante.
  • Respeta el ciclo de vida: libera cámara, GPS y observadores al salir de pantalla.
  • Cuida la batería: menos trabajo en segundo plano, mejor.
  • Diseña primero para pantallas pequeñas y conexiones malas.
Aplicación guía: durante todo el manual construiremos «Pedidos», una aplicación de pedidos para repartidores. Cada capítulo añade una pieza real: ciclo de vida, navegación, red, almacenamiento local, permisos, cámara, GPS y publicación final en Google Play.

Puntos clave

  • Android controla la memoria y la vida de tu proceso; tu código debe sobrevivir a eso.
  • El hilo principal es territorio sagrado: solo dibujo y eventos rápidos.
  • Las reglas del servidor (concurrencia, tolerancia a fallos) aplican, pero con otras prioridades.

2 · Rotación y cambios de configuración

Básico ~15 min

Girar el teléfono, cambiar el idioma, plegar el plegable o activar el modo oscuro son cambios de configuración. Ante cualquiera de ellos, el sistema destruye y recrea la Activity actual: toda tu interfaz se vuelve a componer desde cero. En escritorio redimensionar una ventana no toca tus objetos; aquí, sí.

El estado que se esfuma (y el que no)

Compose ofrece tres niveles de protección. Elige según qué tan caro sería perder el dato:

@Composable
fun ContadorPedidos() {
    // 1. remember: vive solo mientras exista esta composición.
    //    Se pierde al rotar.
    var borrador by remember { mutableStateOf("") }

    // 2. rememberSaveable: sobrevive a rotaciones e incluso a la
    //    muerte del proceso (viaja en un Bundle del sistema).
    var filtro by rememberSaveable { mutableStateOf("") }

    Column {
        OutlinedTextField(
            value = filtro,
            onValueChange = { filtro = it },
            label = { Text("Buscar pedido") }
        )
    }
}

ViewModel: memoria de la pantalla

Para datos más grandes (listas descargadas, resultados calculados) usa un ViewModel: sobrevive a todas las rotaciones mientras la pantalla exista. Es tu equivalente mental del bean de sesión… pero por pantalla y sin persistencia garantizada.

class PedidosViewModel : ViewModel() {
    private val _pedidos = MutableStateFlow<List<Pedido>>(vacio())
    val pedidos: StateFlow<List<Pedido>> = _pedidos.asStateFlow()

    init {
        // Carga inicial: si el usuario rota a mitad, el resultado
        // ya está aquí cuando la pantalla nueva recolecte el flujo.
        viewModelScope.launch {
            _pedidos.value = repositorio.obtenerPedidos()
        }
    }
}

@Composable
fun ListadoScreen(viewModel: PedidosViewModel = viewModel()) {
    val pedidos by viewModel.pedidos.collectAsStateWithLifecycle()
    // ...
}
HerramientaRotaciónMuerte del procesoUso ideal
rememberNoNoEstado efímero de dibujo (animaciones)
rememberSaveableCampos pequeños: textos, pestañas, filtros
ViewModelNoListas, resultados de red, lógica de pantalla
Room / DataStoreTodo lo que importa de verdad

Adaptarse a la configuración actual

@Composable
fun PanelPedidos() {
    val config = LocalConfiguration.current
    if (config.screenWidthDp >= 600) {
        Fila { Listado(); Detalle() }   // tablet / horizontal
    } else {
        Column { Listado() }            // teléfono vertical
    }
}
Ojo con el Bundle: rememberSaveable guarda en el paquete del sistema, con límites estrictos (~1 MB por transacción). Nunca metas imágenes ni listas enormes: usa ViewModel + Room.

Puntos clave

  • Toda rotación destruye y recrea la pantalla: es normal, no un error.
  • rememberSaveable para campos pequeños; ViewModel para estado grande; Room para lo definitivo.
  • Diseña pensando en sobrevivir: prueba girando el dispositivo a mitad de cada flujo.

3 · Ciclo de vida en la práctica

Intermedio ~15 min

El glosario definió los estados; aquí los vas a usar. La Activity pasa por una secuencia fija de callbacks y tu trabajo es enganchar —y desenganchar— recursos en el momento correcto.

CallbackQué significaQué haces ahí
onCreate()Nace la pantallaInflar interfaz; leer el Bundle de restauración
onStart()Visible, sin focoPreparar lo que se muestra
onResume()Al frente e interactivaCámara activa, GPS, animaciones
onPause()Perdió el focoPausar lo costoso rápido
onStop()Ya no es visibleLiberar recursos pesados
onDestroy()Muere definitivamenteÚltima limpieza

Recolectar flujos solo cuando la pantalla está visible

El patrón más importante del capítulo: si observas un flujo mientras la pantalla está oculta, desperdicias batería y actualizas algo que nadie ve. Con Compose, la forma segura:

@Composable
fun PedidosScreen(viewModel: PedidosViewModel = viewModel()) {
    // collectAsStateWithLifecycle suspende la recolección cuando la
    // pantalla pasa a segundo plano y la reanuda al volver.
    val estado by viewModel.estado.collectAsStateWithLifecycle()

    when (estado) {
        is UiState.Cargando -> Indicador()
        is UiState.Exito   -> Listado(estado.pedidos)
        is UiState.Error   -> AvisoError(estado.mensaje)
    }
}

Efectos atados al ciclo de vida

@Composable
fun CronometroPedidos() {
    var segundos by remember { mutableIntStateOf(0) }

    LaunchedEffect(Unit) {
        // Nace con la composición y muere con ella: si la pantalla
        // sale de escena, la corrutina se cancela sola.
        while (true) {
            delay(1_000); segundos++
        }
    }
    Text("Pedido en camino: ${segundos}s")
}

Para recursos que exigen limpieza manual (sensores, receptores), DisposableEffect te da un bloque onDispose:

DisposableEffect(lifecycleOwner) {
    val observador = LifecycleEventObserver { _, evento ->
        registrar("Evento: $evento")
    }
    lifecycleOwner.lifecycle.addObserver(observador)
    onDispose {
        lifecycleOwner.lifecycle.removeObserver(observador)
    }
}
Fuga clásica: guardar un Context de Activity dentro de un singleton o ViewModel. El Context muere con la pantalla; retenerlo impide que el recolector libere toda la interfaz. Usa contextoAplicacion para objetos longevos.

Puntos clave

  • Engancha en onResume/LaunchedEffect, suelta en onStop/onDispose.
  • collectAsStateWithLifecycle es el estándar para observar flujos desde Compose.
  • Todo recurso adquirido debe tener su liberación garantizada.

4 · Navegación entre pantallas

Intermedio ~18 min

Una aplicación móvil no apila ventanas como un escritorio: mantiene una pila de destinos (back stack), igual que el historial de un navegador. La biblioteca Navigation de Jetpack la gestiona por ti con rutas tipo URL.

implementation("androidx.navigation:navigation-compose:2.8.5")

Definir el grafo de navegación

@Composable
fun AppPedidos() {
    val navController = rememberNavController()

    NavHost(navController, startDestination = "listado") {
        composable("listado") {
            ListadoScreen(
                alAbrir = { id -> navController.navigate("detalle/$id") },
                alNuevo = { navController.navigate("nuevo") }
            )
        }
        composable(
            route = "detalle/{id}",
            arguments = listOf(navArgument("id") { type = NavType.LongType })
        ) { entrada ->
            DetalleScreen(pedidoId = entrada.arguments!!.getLong("id"))
        }
        composable("nuevo") {
            NuevoPedidoScreen(alGuardar = { navController.popBackStack() })
        }
    }
}

Cada composable(...) registra una ruta; los argumentos van entre llaves y se leen del bundle de la entrada. Navegar a detalle/42 empuja ese destino en la pila; el botón atrás hace pop automáticamente.

Reglas de la pila

  • Atrás siempre desapila: nunca «navegues» hacia atrás a mano.
  • Tras guardar algo, usa popBackStack() en vez de navegar al listado (evitaría duplicarlo).
  • Para flujos terminados (inicio de sesión → principal) limpia con popUpTo:
navController.navigate("listado") {
    popUpTo("ingreso") { inclusive = true }  // borra la pantalla de ingreso
}

Enlaces profundos

Una notificación o página web puede abrir un destino concreto añadiendo un filtro de intención al destino:

composable(
    "detalle/{id}",
    deepLinks = listOf(navDeepLink { uriPattern = "pedidos://pedido/{id}" })
) { /* ... */ }

Puntos clave

  • Rutas tipo URL + pila automática: mentalidad de router web.
  • Los argumentos viven en el bundle del destino; declara su tipo.
  • popBackStack y popUpTo mantienen la pila limpia tras operaciones terminadas.

5 · Consumir APIs: Retrofit y MVVM

Avanzado ~20 min

Si has usado clientes HTTP declarativos (Feign, Refit), ya sabes usar Retrofit: describes el API como una interfaz Kotlin con anotaciones y la biblioteca genera las llamadas. La arquitectura completa sigue MVVM:

// Capa de red: contrato del API
interface PedidosApi {
    @GET("pedidos")
    suspend fun listar(): List<PedidoDto>

    @POST("pedidos")
    suspend fun crear(@Body pedido: PedidoNuevoDto): PedidoDto
}

// DTOs: espejo exacto del JSON que viaja (como tus contratos OpenAPI)
@Serializable
data class PedidoDto(
    val id: Long,
    val cliente: String,
    val total: Double,
    val estado: String
)

La respuesta del servidor para GET /pedidos:

[ {"id": 42, "cliente": "Rosa Quispe", "total": 89.90, "estado": "EN_CAMINO"}, {"id": 43, "cliente": "Julio Vargas", "total": 15.50, "estado": "PENDIENTE"} ]

Armar el cliente

val api = Retrofit.Builder()
    .baseUrl("https://api.pedidos.pe/")
    // Interceptor: añade el token de sesión a cada petición (como tu filtro HTTP)
    .client(
        OkHttpClient.Builder()
            .addInterceptor { cadena ->
                val peticion = cadena.request().newBuilder()
                    .addHeader("Authorization", "Bearer ${sesion.token()}")
                    .build()
                cadena.proceed(peticion)
            }
            .build()
    )
    .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
    .build()
    .create(PedidosApi::class.java)

Repositorio + ViewModel con estados sellados

// Estado de pantalla: un tipo sellado evita estados imposibles
sealed interface UiState {
    data object Cargando : UiState
    data class Exito(val pedidos: List<PedidoDto>) : UiState
    data class Error(val mensaje: String) : UiState
}

class PedidosViewModel(private val repo: PedidosRepositorio) : ViewModel() {
    private val _estado = MutableStateFlow<UiState>(UiState.Cargando)
    val estado: StateFlow<UiState> = _estado.asStateFlow()

    fun cargar() = viewModelScope.launch {
        _estado.value = UiState.Cargando
        _estado.value = try {
            UiState.Exito(repo.listar())
        } catch (e: IOException) {
            UiState.Error("Sin conexión. Reintenta.")
        } catch (e: HttpException) {
            UiState.Error("El servidor respondió ${e.code()}")
        }
    }
}
  • La pantalla nunca toca la red: solo observa su estado.
  • El repositorio es el único que decide si va al servidor o a la caché local.
  • Los errores son parte del modelo, no excepciones que rompen la interfaz.

Puntos clave

  • Retrofit = cliente declarativo; los DTOs se convierten solos con kotlinx.serialization.
  • MVVM: Vista → ViewModel (StateFlow) → Repositorio → Red/Base local.
  • Modela los tres estados (cargando, éxito, error) con interfaces selladas.

6 · Almacenamiento local: DataStore y Room

Intermedio ~18 min

Como tu proceso puede morir en cualquier momento, lo importante vive en disco. Android ofrece dos herramientas según el dato:

  • DataStore: ajustes y sesiones (pares clave-valor u objetos pequeños), asíncrono y basado en flujos.
  • Room: base de datos relacional sobre SQLite con consultas verificadas en compilación — tu ORM de bolsillo.

Sesión de usuario con DataStore

val Context.sesionStore by preferencesDataStore(name = "sesion")

class SesionRepositorio(private val contexto: Context) {
    private val CLAVE_TOKEN = stringPreferencesKey("token")

    val token: Flow<String?> =
        contexto.sesionStore.data.map { it[CLAVE_TOKEN] }

    suspend fun guardar(token: String) {
        contexto.sesionStore.edit { it[CLAVE_TOKEN] = token }
    }

    suspend fun cerrarSesion() {
        contexto.sesionStore.edit { it.remove(CLAVE_TOKEN) }
    }
}

Con esto la pantalla de inicio decide a dónde navegar: si hay token, directo al listado; si no, al ingreso.

Pedidos sin conexión con Room

@Entity(tableName = "pedidos")
data class PedidoEntidad(
    @PrimaryKey val id: Long,
    val cliente: String,
    val total: Double,
    val estado: String
)

@Dao
interface PedidoDao {
    // Consulta viva: emite la lista actual y CADA vez que cambie
    @Query("SELECT * FROM pedidos ORDER BY id DESC")
    fun observarTodos(): Flow<List<PedidoEntidad>>

    @Upsert
    suspend fun guardar(pedidos: List<PedidoEntidad>)
}

@Database(entities = [PedidoEntidad::class], version = 1)
abstract class AppBaseDatos : RoomDatabase() {
    abstract fun pedidoDao(): PedidoDao
}

El patrón fuera-de-línea primero

El repositorio combina ambos mundos: descarga del servidor, guarda en Room y la interfaz solo escucha la consulta viva de Room. Resultado: la aplicación abre al instante con los últimos datos conocidos incluso sin conexión.

class PedidosRepositorio(private val api: PedidosApi, private val dao: PedidoDao) {

    fun observar(): Flow<List<PedidoEntidad>> = dao.observarTodos()

    suspend fun sincronizar() {
        runCatching { dao.guardar(api.listar().map { it.aEntidad() }) }
    }
}
Legado: verás SharedPreferences en proyectos veteranos: síncrono y propenso a bloquear el hilo principal. Para código nuevo usa DataStore.

Puntos clave

  • DataStore para sesión y ajustes; Room para datos relacionales.
  • Las consultas de Room devuelven Flow: la interfaz se actualiza sola ante cambios.
  • Fuera-de-línea primero: servidor → Room → interfaz.

7 · El modelo de permisos

Intermedio ~15 min

En el escritorio instalas un programa y ya. En Android los permisos sensibles se conceden en tiempo de ejecución: el usuario ve un diálogo del sistema y puede decir que no — o revocar el permiso más tarde desde ajustes. Tu interfaz debe funcionar igualmente.

TipoEjemplosCómo se concede
NormalINTERNET, vibraciónSolo al declararlo en el manifiesto
PeligrosoCámara, ubicación fina, micrófono, contactosDiálogo explícito al usuario
EspecialNotificaciones (Android 13+), alarmas exactasDiálogo o pantalla de ajustes

Declarar y pedir

Primero declara la intención en el AndroidManifest.xml:

<manifest ...>
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.CAMERA" />
    <application ...></application>
</manifest>

Luego pide en el momento justo con un lanzador de resultados:

@Composable
fun PantallaFoto(alTenerPermiso: () -> Unit) {
    val contexto = LocalContext.current

    val pedirCamara = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { concedido ->
        if (concedido) alTenerPermiso()
        // si no: mostrar explicación y botón hacia ajustes
    }

    Button(onClick = {
        val estado = ContextCompat.checkSelfPermission(
            contexto, Manifest.permission.CAMERA)
        if (estado == PackageManager.PERMISSION_GRANTED) alTenerPermiso()
        else pedirCamara.launch(Manifest.permission.CAMERA)
    }) {
        Text("Tomar foto del pedido")
    }
}
  • Pide en contexto: «para escanear tu entrega necesitamos la cámara», nunca en frío al abrir la aplicación.
  • Si el usuario eligió «no volver a preguntar», guía a ajustes con Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).
  • Diseña degradaciones útiles: sin ubicación, permitir escribir la dirección a mano.
Prueba esto: rota el dispositivo mientras el diálogo está abierto. El lanzador sobrevive gracias al sistema; si gestionas permisos a mano con callbacks, perderías el resultado.

Puntos clave

  • Manifiesto = intención; diálogo en tiempo de ejecución = concesión real.
  • El «no» es un resultado válido de tu interfaz, no una excepción.
  • Contexto y alternativa manual antes de insistir.

8 · Cámara con CameraX

Avanzado ~20 min

CameraX abstrae las diferencias entre fabricantes con tres piezas: vista previa, captura de foto y análisis. Todo se ata al ciclo de vida, así que la cámara se libera sola al salir de pantalla.

implementation("androidx.camera:camera-camera2:1.4.1")
implementation("androidx.camera:camera-view:1.4.1")
implementation("androidx.camera:camera-lifecycle:1.4.1")

Vista previa dentro de Compose

@Composable
fun VistaPrevia(lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current) {
    val proveedor = remember { ProcessCameraProvider.getInstance(contexto) }

    AndroidView(
        factory = { ctx ->
            val vista = PreviewView(ctx)
            proveedor.addListener({
                val camara = proveedor.get()
                val preview = Preview.Builder().build().also {
                    it.setSurfaceProvider(vista.surfaceProvider)
                }
                camara.unbindAll()
                camara.bindToLifecycle(lifecycleOwner,
                    CameraSelector.DEFAULT_BACK_CAMERA, preview)
            }, ContextCompat.getMainExecutor(ctx))
            vista
        },
        modifier = Modifier.fillMaxSize()
    )
}

Capturar una foto

val captura = remember { ImageCapture.Builder().build() }

// al pulsar «Registrar entrega»:
val archivo = File(contexto.filesDir, "entrega_${System.currentTimeMillis()}.jpg")
val opciones = ImageCapture.OutputFileOptions.Builder(archivo).build()

captura.takePicture(opciones, executor,
    object : ImageCapture.OnImageSavedCallback {
        override fun onImageSaved(r: ImageCapture.OutputFileResults) {
            viewModel.adjuntarEvidencia(archivo.absolutePath)
        }
        override fun onError(e: ImageCaptureException) { avisar(e) }
    })
  • bindToLifecycle apaga la cámara al pausar: batería y privacidad resueltas.
  • Guarda dentro de filesDir: carpeta privada, sin permisos de almacenamiento.
  • Para subir la foto, comparte el archivo vía FileProvider o lee sus bytes.

Puntos clave

  • Casos de uso (Preview, ImageCapture) se combinan y atan al ciclo de vida.
  • El permiso de cámara (capítulo 7) va antes de crear cualquier caso de uso.
  • Almacenamiento interno privado = cero drama de permisos de archivos.

9 · Ubicación GPS

Avanzado ~20 min

Para «Pedidos» el repartidor necesita su posición en tiempo real. La API estándar es FusedLocationProviderClient, de los servicios de Google Play: fusiona satélite, Wi-Fi y antenas celulares para dar la mejor posición gastando lo mínimo.

implementation("com.google.android.gms:play-services-location:21.3.0")

Permisos en el manifiesto (y diálogo en tiempo de ejecución como viste en el capítulo 7):

<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

Convertir callbacks en Flow

La API es de callbacks; con callbackFlow la convertimos a un flujo de corrutinas y la limpieza queda garantizada:

fun FusedLocationProviderClient.flujoUbicaciones(): Flow<Location> =
    callbackFlow {
        val peticion = LocationRequest.Builder(
            Priority.PRIORITY_HIGH_ACCURACY, 5_000L  // cada 5 s
        ).build()

        val callback = object : LocationCallback() {
            override fun onLocationResult(r: LocationResult) {
                r.lastLocation?.let { trySend(it) }
            }
        }
        requestLocationUpdates(peticion, callback, Looper.getMainLooper())

        // awaitClose corre al cancelar el flujo (pantalla oculta, ViewModel muerto)
        awaitClose { removeLocationUpdates(callback) }
    }

Consumir desde el ViewModel

class RepartoViewModel(
    private val cliente: FusedLocationProviderClient
) : ViewModel() {

    @OptIn(ExperimentalCoroutinesApi::class)
    val ubicacion: StateFlow<Location?> = cliente
        .flujoUbicaciones()
        .flowWithLifecycle(ProcessLifecycleOwner.get().lifecycle)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
}
  • WhileSubscribed: el GPS solo trabaja mientras alguien mira la pantalla.
  • Prioridad alta solo cuando el reparto está activo; para historial usa PRIORITY_BALANCED_POWER_ACCURACY.
  • Verifica que los servicios de ubicación estén encendidos antes de prometer resultados.
Batería: pedir ubicación fina cada segundo es la vía rápida a desinstalaciones. Ajusta intervalo y prioridad al caso de uso real y detén las actualizaciones tan pronto dejen de necesitarse.

Puntos clave

  • FusedLocationProviderClient = mejor posición con menos energía.
  • callbackFlow + awaitClose convierte cualquier API de callbacks en flujo seguro.
  • La vida del flujo debe seguir la de la necesidad, no la del proceso.

10 · Listas y grillas eficientes

Intermedio ~15 min

Las listas perezosas componen únicamente los elementos visibles. Son el reemplazo directo de RecyclerView, sin adaptadores ni holders: tú declaras qué dibujar por elemento y el sistema hace el reciclaje.

Listado vertical con claves estables

@Composable
fun Listado(pedidos: List<PedidoEntidad>, alAbrir: (Long) -> Unit) {
    LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
        items(
            items = pedidos,
            key = { it.id },          // identidad estable: animaciones y estado correctos
            contentType = { "pedido" } // ayuda al reciclaje
        ) { pedido ->
            TarjetaPedido(pedido) { alAbrir(pedido.id) }
        }
    }
}

@Composable
fun TarjetaPedido(pedido: PedidoEntidad, onClick: () -> Unit) {
    Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
        Column(Modifier.padding(16.dp)) {
            Text("#${pedido.id} · ${pedido.cliente}",
                 style = MaterialTheme.typography.titleMedium)
            Text("S/. ${"%.2f".format(pedido.total)} — ${pedido.estado}",
                 style = MaterialTheme.typography.bodyMedium,
                 color = MaterialTheme.colorScheme.secondary)
        }
    }
}

Grilla para el catálogo

LazyVerticalGrid(
    columns = GridCells.Fixed(2),
    contentPadding = PaddingValues(8.dp),
    horizontalArrangement = Arrangement.spacedBy(8.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp)
) {
    items(productos, key = { it.codigo }) { producto ->
        TarjetaProducto(producto)
    }
}
  • Sin key, Compose identifica elementos por posición: al insertar arriba, todo se recompone y los estados internos se mezclan.
  • La clave debe ser estable y única (identificador de base de datos), no el índice.
  • Cabeceras, pies y separadores van con item { } e itemsIndexed.
Miles de pedidos: combina listas perezosas con consultas paginadas de Room (PagingSource) o límites en SQL (LIMIT/OFFSET). Cargar 50 000 filas en memoria es mala idea hasta en escritorio.

Puntos clave

  • LazyColumn/LazyVerticalGrid: cero adaptadores, reciclaje automático.
  • Claves estables obligatorias para listas que cambian.
  • Pagina en la fuente de datos; la lista perezosa no te salva de una consulta gigante.

11 · Segundo plano y notificaciones

Avanzado ~20 min

En tu servidor un trabajo por lotes corre en un scheduler y listo. En Android, si el usuario minimiza la aplicación mientras sincronizas, el proceso puede morir a mitad. Para trabajo que debe terminar aunque la pantalla muera existe WorkManager: una cola persistente con reintentos.

implementation("androidx.work:work-runtime-ktx:2.10.0")

Sincronizar pedidos pendientes

class SincronizadorPedidos(contexto: Context, params: WorkerParameters) :
    CoroutineWorker(contexto, params) {

    override suspend fun doWork(): Result {
        return try {
            repositorio.sincronizar()          // sube lo guardado sin conexión
            Result.success()
        } catch (e: IOException) {
            Result.retry()                      // reintenta con backoff exponencial
        } catch (e: Exception) {
            Result.failure()
        }
    }
}

// Programación (por ejemplo, al cerrar el formulario):
val peticion = OneTimeWorkRequestBuilder<SincronizadorPedidos>()
    .setConstraints(
        Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
    )
    .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30_000L, TimeUnit.MILLISECONDS)
    .build()

WorkManager.getInstance(contexto).enqueue(peticion)

Para tareas periódicas (sincronizar cada hora) usa PeriodicWorkRequestBuilder<SincronizadorPedidos>(1, TimeUnit.HOURS). El sistema decide el momento exacto según batería y red — como un cron con criterio propio.

Notificaciones

Desde Android 13 mostrar notificaciones exige permiso explícito (POST_NOTIFICATIONS, se pide igual que en el capítulo 7). Además toda notificación vive en un canal:

fun crearCanal(contexto: Context) {
    val canal = NotificationChannel(
        "pedidos", "Estado de pedidos", NotificationManager.IMPORTANCE_DEFAULT
    ).apply { description = "Avisos cuando cambia tu pedido" }
    contexto.getSystemService(NotificationManager::class.java)
        .createNotificationChannel(canal)
}

fun avisarPedidoListo(contexto: Context, pedidoId: Long) {
    val intento = Intent(contexto, MainActivity::class.java).apply {
        data = Uri.parse("pedidos://pedido/$pedidoId")   // deep link del capítulo 4
    }
    val pendiente = PendingIntent.getActivity(
        contexto, pedidoId.toInt(), intento,
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
    )
    val aviso = NotificationCompat.Builder(contexto, "pedidos")
        .setSmallIcon(R.drawable.ic_pedido)
        .setContentTitle("Tu pedido está listo")
        .setContentText("Pedido #$pedidoId salió a reparto")
        .setContentIntent(pendiente)
        .setAutoCancel(true)
        .build()

    NotificationManagerCompat.from(contexto).notify(pedidoId.toInt(), aviso)
}
Buena práctica: pide el permiso de notificaciones en contexto («quiero avisarte cuando tu pedido salga») y degrada con elegancia: la aplicación sigue útil sin avisos; los cambios quedan visibles en pantalla.

Puntos clave

  • Trabajo crítico → WorkManager: sobrevive a muertes del proceso y reintenta solo.
  • Restricciones (red, batería) declaran CUÁNDO puede correr el trabajo.
  • Notificación = canal + permiso (Android 13+) + intención de contenido para reabrir la app.

12 · Publicar la aplicación

Meta final ~15 min

Última milla: de tu máquina a Google Play. El flujo es el mismo espíritu que publicar un artefacto: firmar, empaquetar, subir a un repositorio — solo que el «repositorio» revisa cada versión.

Lista de verificación previa

  • Versión: incrementa versionCode (entero interno) y actualiza versionName.
  • SDK: revisa minSdk (mínimo soportado) y targetSdk (debe cumplir las políticas vigentes).
  • Firma: genera tu keystore y guárdala con tu vida: perderla = perder la aplicación.
  • Limpieza: sin registros verbosos, sin URLs de desarrollo, ofuscación activada (isMinifyEnabled = true).

Firmar

keytool -genkeypair -v -keystore pedidos.jks -alias pedidos \
  -keyalg RSA -keysize 4096 -validity 10000
// app/build.gradle.kts
android {
    signingConfigs {
        create("release") {
            storeFile = file(System.getenv("KEYSTORE") ?: "pedidos.jks")
            storePassword = System.getenv("KEYSTORE_PASS")
            keyAlias = "pedidos"
            keyPassword = System.getenv("KEY_PASS")
        }
    }
    buildTypes {
        release {
            isMinifyEnabled = true
            signingConfig = signingConfigs.getByName("release")
        }
    }
}

Empaquetar y subir

Google Play recibe un Android App Bundle (.aab): Google genera los APK optimizados por dispositivo. Como un artefacto compilado por destino, pero automático:

./gradlew bundleRelease
# resultado: app/build/outputs/bundle/release/app-release.aab

En Play Console subes el .aab a una pista: interna (tu equipo), cerrada (probadores invitados), abierta (beta pública) y producción. Cada promoción pasa revisión de Google.

Automatizar en CI

# .github/workflows/release.yml (resumen)
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '17' }
      - run: ./gradlew bundleRelease
        env:
          KEYSTORE: ${{ secrets.KEYSTORE_B64 }}
          KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }}
          KEY_PASS: ${{ secrets.KEY_PASS }}
Meta cumplida. Partiste de tu experiencia de escritorio y backend y terminaste con una aplicación Android completa: sobrevive a rotaciones y muertes del proceso, navega, consume APIs, persiste sin conexión, usa cámara y GPS, trabaja en segundo plano y está lista para la tienda. Siguiente parada natural: pruebas automatizadas e inyección con Hilt en el manual cuatro.