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.
1 · Un nuevo paradigma
Básico ~12 minEn 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.
| Aspecto | Servidor / escritorio | Android |
|---|---|---|
| Vida del proceso | Meses; la controlas tú | El sistema puede matarla sin aviso (LMK) |
| Memoria | Dedicada y amplia | Compartida, vigilada y reclamable |
| Hilo principal | Sin restricciones | Dibuja la interfaz; bloquearlo >5 s dispara un ANR |
| Red | Confiable, en centro de datos | Intermitente, costosa, con latencia variable |
| Actualización | Despliegue continuo | Pasa por revisión de la tienda |
| Usuarios | Miles concurrentes | Uno 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.
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 minGirar 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()
// ...
}| Herramienta | Rotación | Muerte del proceso | Uso ideal |
|---|---|---|---|
remember | No | No | Estado efímero de dibujo (animaciones) |
rememberSaveable | Sí | Sí | Campos pequeños: textos, pestañas, filtros |
ViewModel | Sí | No | Listas, resultados de red, lógica de pantalla |
| Room / DataStore | Sí | Sí | Todo 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
}
}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.
rememberSaveablepara 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 minEl 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.
| Callback | Qué significa | Qué haces ahí |
|---|---|---|
onCreate() | Nace la pantalla | Inflar interfaz; leer el Bundle de restauración |
onStart() | Visible, sin foco | Preparar lo que se muestra |
onResume() | Al frente e interactiva | Cámara activa, GPS, animaciones |
onPause() | Perdió el foco | Pausar lo costoso rápido |
onStop() | Ya no es visible | Liberar 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)
}
}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 enonStop/onDispose. collectAsStateWithLifecyclees el estándar para observar flujos desde Compose.- Todo recurso adquirido debe tener su liberación garantizada.
4 · Navegación entre pantallas
Intermedio ~18 minUna 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.
popBackStackypopUpTomantienen la pila limpia tras operaciones terminadas.
5 · Consumir APIs: Retrofit y MVVM
Avanzado ~20 minSi 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:
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 minComo 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() }) }
}
}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 minEn 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.
| Tipo | Ejemplos | Cómo se concede |
|---|---|---|
| Normal | INTERNET, vibración | Solo al declararlo en el manifiesto |
| Peligroso | Cámara, ubicación fina, micrófono, contactos | Diálogo explícito al usuario |
| Especial | Notificaciones (Android 13+), alarmas exactas | Diá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.
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 minCameraX 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) }
})bindToLifecycleapaga 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
FileProvidero 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 minPara «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.
Puntos clave
- FusedLocationProviderClient = mejor posición con menos energía.
callbackFlow+awaitCloseconvierte 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 minLas 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 { }eitemsIndexed.
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 minEn 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)
}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 actualizaversionName. - SDK: revisa
minSdk(mínimo soportado) ytargetSdk(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.aabEn 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 }}