Saltar al contenido
K
Android: la plataformaPaso a paso40 min de lectura · 5 min de máquina Intermedio

Un recordatorio con WorkManager

Al terminar: Programar un recordatorio diario con un Worker de WorkManager y comprobar que sobrevive a matar el proceso.

Al terminar esta lección, Marcador tendrá un botón «Recordarme el partido» que programa el aviso y lo deja en manos del sistema. Importa porque un recordatorio que se pierde cuando el sistema cierra la app no sirve de nada: la gracia es que el trabajo sobreviva. Lo comprobarás de la manera dura: matando el proceso de la app con adb shell am kill y viendo cómo el sistema lo resucita, ejecuta el trabajo y publica la notificación.

Antes del paso: una sola entrada por nombre

Con enqueueUniqueWork, WorkManager guarda las tareas pendientes por su nombre. Si llega otra con un nombre que ya existe, la política decide: REPLACE la pone en lugar de la anterior y KEEP se queda con la anterior e ignora la nueva. Antes de usarlo en el proyecto, mira la misma idea en un ejemplo ajeno: una consigna con taquillas numeradas, donde el número hace de nombre.

ejemplo mínimo: taquillas con número (con comentarios)
// Una consigna con taquillas numeradas: cada taquilla guarda un objeto.
// `MutableMap<Int, String>` es el TIPO de un mapa que se puede modificar: claves Int (el número
// de taquilla) y valores String (lo que hay dentro). `mutableMapOf<Int, String>()` crea uno vacío de ese tipo.
// Guarda un objeto en el mapa que recibe. Si la taquilla está ocupada y no se fuerza, no hace nada.
fun guardar(taquillas: MutableMap<Int, String>, numero: Int, objeto: String, forzar: Boolean) {
    if (numero in taquillas && !forzar) return   // «in»: ¿existe ya esa clave? Si sí y no se fuerza, sale sin guardar
    taquillas[numero] = objeto                   // guarda; si la taquilla existía, la pisa
}
 
fun main() {
    val taquillas = mutableMapOf<Int, String>()  // número de taquilla -> lo que hay dentro
    guardar(taquillas, 7, "mochila", forzar = false)
    guardar(taquillas, 7, "paraguas", forzar = false)   // ocupada y sin forzar: se ignora
    println("${taquillas.size} ocupada -> ${taquillas[7]}")
    guardar(taquillas, 7, "paraguas", forzar = true)    // forzando: pisa la mochila
    println("${taquillas.size} ocupada -> ${taquillas[7]}")
}

Lo que imprime:

1 ocupada -> mochila
1 ocupada -> paraguas

La taquilla 7 es una sola en los dos casos; lo que cambia es qué objeto queda dentro. Con WorkManager, el nombre único hace de número de taquilla y la política decide quién se queda. En WorkManager se escribe así, con la petición ya creada (peticion): la primera llamada y la segunda comparten nombre, y la política dice quién se queda.

ejemplo mínimo: la misma idea con WorkManager (con comentarios)
val trabajos = WorkManager.getInstance(contexto)
trabajos.enqueueUniqueWork("ordenar", ExistingWorkPolicy.KEEP, primera)     // la cola «ordenar» queda con primera
trabajos.enqueueUniqueWork("ordenar", ExistingWorkPolicy.KEEP, segunda)     // KEEP: ya hay una pendiente, esta se ignora
trabajos.enqueueUniqueWork("ordenar", ExistingWorkPolicy.REPLACE, tercera)  // REPLACE: tercera ocupa el sitio de primera

Lo que se ve: de las tres peticiones, solo una se ejecuta (la que quede en la cola al llegar la hora). Una cola es, aquí, el nombre único: todas las tareas con el mismo nombre comparten un solo sitio. El modelo de código para el ejercicio es SaludoWorker de Trabajo en segundo plano: el mismo worker, los mismos datos de entrada y el mismo enqueueUniqueWork.

Ejercicio · dos políticas, dos colas

Reto · Ejercicio: un saludo por cola

Practicas WorkManager de verdad, con la librería que ya instalaste, y ves qué hace cada política cuando llega una tarea con un nombre que ya existe.

Dónde. En la vista Android, clic derecho sobre com.sebastiannarvaez.marcador → New → Package, y escribe .ejercicios.saludo_unico (la vista Android pega ese nombre al paquete). Después, clic derecho sobre la carpeta nueva → New → Kotlin Class/File, elige File y llámalo SaludoUnico. La carpeta ejercicios es la que creaste en Rotación y muerte del proceso. Esa carpeta se queda: los ejercicios no se borran.

Qué hacer. Escribe una clase worker (no la marques como private: WorkManager la crea por su cuenta) que reciba dos textos, el nombre de su cola y a quién saluda, y escriba en Logcat, con el tag Ejercicio, «cola -> hola, nombre». Si le falta alguno de los dos, termina con fallo. Después escribe una función, probarPoliticas, que reciba un Context y encole cuatro saludos con 5 segundos de retraso: Ana y luego Leo con el nombre único «saludo-keep» y la política que conserva, y Ana y luego Leo con el nombre único «saludo-replace» y la política que sustituye.

Cómo se prueba. Añade en MainActivity.kt, dentro de onCreate, justo debajo de anotar("onCreate …"), esta llamada temporal, con la ruta completa para no necesitar import (this es la pantalla, que también es un Context). Termina en un comentario para que la reconozcas al borrarla:

MainActivity.kt: la línea temporal del ejercicio
com.sebastiannarvaez.marcador.ejercicios.saludo_unico.probarPoliticas(this) // EJERCICIO: quitar al terminar

Si MainActivity sale en rojo, pulsa Option+Enter (Alt+Enter en Windows y Linux) sobre el error y acepta el import. Ejecuta con Run. Unos 5 segundos después (a veces algo más: WorkManager no es exacto) debes ver en Logcat, con tag:Ejercicio, estas dos líneas, en cualquier orden:

D Ejercicio: saludo-keep -> hola, Ana
D Ejercicio: saludo-replace -> hola, Leo

Con la política que conserva se ejecuta el primero y se ignora el segundo. Con la que sustituye, el segundo ocupa el sitio del primero.

Pistas. Todo está en Trabajo en segundo plano: CoroutineWorker y doWork; inputData.getString(…) ?: return Result.failure(); Data.Builder().putString(…); OneTimeWorkRequestBuilder, setInitialDelay(5, TimeUnit.SECONDS), enqueueUniqueWork(nombre, ExistingWorkPolicy.KEEP o REPLACE, petición) y companion object para las claves.

Ver solución
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/saludo_unico/SaludoUnico.kt
package com.sebastiannarvaez.marcador.ejercicios.saludo_unico
 
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import java.util.concurrent.TimeUnit
 
// La tarea: escribe en Logcat el nombre de su cola y a quién saluda.
class SaludoWorker(contexto: Context, parametros: WorkerParameters) : CoroutineWorker(contexto, parametros) {
 
    override suspend fun doWork(): Result {
        val cola = inputData.getString(CLAVE_COLA) ?: return Result.failure()
        val nombre = inputData.getString(CLAVE_NOMBRE) ?: return Result.failure()
        Log.d("Ejercicio", "$cola -> hola, $nombre")
        return Result.success()
    }
 
    companion object {
        const val CLAVE_COLA = "cola"
        const val CLAVE_NOMBRE = "nombre"
    }
}
 
// Dos saludos con el mismo nombre único y KEEP, y otros dos con otro nombre y REPLACE.
fun probarPoliticas(contexto: Context) {
    encolar(contexto, "saludo-keep", ExistingWorkPolicy.KEEP, "Ana")
    encolar(contexto, "saludo-keep", ExistingWorkPolicy.KEEP, "Leo")
    encolar(contexto, "saludo-replace", ExistingWorkPolicy.REPLACE, "Ana")
    encolar(contexto, "saludo-replace", ExistingWorkPolicy.REPLACE, "Leo")
}
 
private fun encolar(contexto: Context, cola: String, politica: ExistingWorkPolicy, nombre: String) {
    val datos = Data.Builder()
        .putString(SaludoWorker.CLAVE_COLA, cola)
        .putString(SaludoWorker.CLAVE_NOMBRE, nombre)
        .build()
    val peticion = OneTimeWorkRequestBuilder<SaludoWorker>()
        .setInitialDelay(5, TimeUnit.SECONDS)
        .setInputData(datos)
        .build()
    WorkManager.getInstance(contexto).enqueueUniqueWork(cola, politica, peticion)
}

Un detalle: si giras el móvil dentro de esos 5 segundos, la línea temporal vuelve a encolar. La política que conserva sigue con la Ana pendiente, y la que sustituye pone un Leo nuevo y el reloj vuelve a empezar. Las líneas de Logcat son las mismas.

Antes del paso, borra la línea temporal (la que termina en // EJERCICIO: quitar al terminar) y comprueba que MainActivity.kt vuelve a estar limpio: Cmd+F (Ctrl+F en Windows y Linux) con «ejercicios» no debe dar ningún resultado. Si Android Studio añadió un import, bórralo también.

Paso · El recordatorio del partido

Objetivo. Un RecordatorioWorker y un botón «Recordarme el partido». Al pulsarlo, entre uno y dos minutos después aparecerá la notificación «El partido empieza en una hora», incluso si en medio matas el proceso. (En la app real el retraso sería de una hora; en la demo es de 60 segundos, que dan margen de sobra para matar el proceso aunque vayas despacio.)

app
kotlin+java → com.sebastiannarvaez.marcador
core
notifications
RecordatorioWorker.ktse crea
Notificaciones.ktse modifica
MainActivity.ktse modifica
Tres ficheros de código: uno se crea y dos se modifican.

Cómo crear RecordatorioWorker.kt. Va junto a Notificaciones.kt, en core → notifications: avisar es de toda la app, y un recordatorio también. En la vista Android, despliega app → kotlin+java → com.sebastiannarvaez.marcador (el paquete sin (test) ni (androidTest)) → core, clic derecho sobre notifications → New → Kotlin Class/File. En la ventanita elige File, escribe RecordatorioWorker y pulsa Intro: Android Studio crea el fichero con la línea package ya escrita y lo abre. Notificaciones.kt está en esa misma carpeta; MainActivity.kt, en la raíz: ábrelos con doble clic. Si algún import aparece en rojo, colócate sobre él y pulsa Option+Enter para que Android Studio lo añada; si te ofrece varios, elige el de androidx.work.

Piezas de este paso. CoroutineWorker, inputData y companion object ya los usaste en el ejercicio de arriba; aquí van otra vez como recordatorio, junto a las que son nuevas (los ejemplos están en Trabajo en segundo plano).

  • CoroutineWorker: la clase de WorkManager que representa una tarea y te deja usar suspend dentro. Un worker es esa tarea.
  • inputData.getString(CLAVE_PARTIDO) ?: return Result.failure(): lee un texto del paquete de datos; si no está (null), sale de la función con fallo. Es el operador ?: de Nulabilidad con un return a la derecha, como en el saludo de Trabajo en segundo plano.
  • companion object { … }: el sitio dentro de la clase para constantes y funciones que se llaman sin crear un objeto (RecordatorioWorker.programar(…)), como en el ejemplo de Trabajo en segundo plano.
  • @SuppressLint("MissingPermission"): silencia un aviso del editor porque la comprobación del permiso está en puedeNotificar, como en la lección de permisos.
  • NotificationCompat.Builder(contexto, canal) y notify(id, notificacion): construir y publicar la notificación, igual que con el aviso de gol.
  • applicationContext: el contexto de la aplicación entera, que un worker usa en lugar de una pantalla (un worker no tiene Activity). Si no lo recuerdas, repasa Context y Application.

Intenta escribirlo tú.

  1. Un segundo canal de notificaciones, con el id recordatorios y el nombre «Recordatorios», de importancia normal, que se crea junto al de goles.
  2. RecordatorioWorker: una tarea que recibe el resumen del partido, lo anota en Logcat con el tag Trabajo y publica (si hay permiso) una notificación en ese canal con el título «El partido empieza en una hora» y el resumen como texto. Si no le llega el resumen, falla.
  3. Dentro del worker, una función, programar, que se llama sin crear un worker: recibe el resumen y un retraso en segundos (una hora si no se dice otro) y deja la tarea en manos de WorkManager con un nombre único, de modo que pulsar dos veces deja un solo recordatorio.
  4. Un botón «Recordarme el partido» que programa el recordatorio del primer partido con 60 segundos y lo confirma con un mensaje breve: «Recordatorio en 60 s (en la app real, 1 h)».

Antes de abrir el bloque, decide qué hace la tarea al ejecutarse y por qué lleva un nombre único. Lo que debes ver entre uno y dos minutos después de pulsar el botón, aunque mates el proceso: la notificación «El partido empieza en una hora» con Rayo FC 2-1 Toros.

Qué se cambia y dónde (obligatorio):

  1. Notificaciones.kt: la constante nueva va debajo de CANAL_GOLES. Después, sustituyes el cuerpo entero de crearCanalDeGoles por el de abajo: ahora crea dos canales. Su nombre se ha quedado corto, pero no lo cambias: la llama MarcadorApplication y cambiarlo obligaría a tocar también ese fichero, y esta lección ya tiene bastante.
  2. MainActivity.kt: el botón va dentro de la Column, justo después del botón «Activar avisos de gol» (después de su llave de cierre }, antes del siguiente elemento). Añade arriba import com.sebastiannarvaez.marcador.core.notifications.RecordatorioWorker.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código

Notificaciones.kt, la constante:

app/src/main/java/com/sebastiannarvaez/marcador/core/notifications/Notificaciones.kt
const val CANAL_RECORDATORIOS = "recordatorios"

Notificaciones.kt, la función completa (el resto del fichero no cambia):

app/src/main/java/com/sebastiannarvaez/marcador/core/notifications/Notificaciones.kt
fun crearCanalDeGoles(contexto: Context) {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
    val canal = NotificationChannel(CANAL_GOLES, "Goles", NotificationManager.IMPORTANCE_HIGH).apply {
        description = "Avisa cuando se marca un gol"
    }
    val recordatorios = NotificationChannel(CANAL_RECORDATORIOS, "Recordatorios", NotificationManager.IMPORTANCE_DEFAULT)
    val gestor = contexto.getSystemService(NotificationManager::class.java)
    gestor.createNotificationChannel(canal)
    gestor.createNotificationChannel(recordatorios)
}

Los bloques siguientes llevan como título la ruta del fichero en tu proyecto; en la vista Android están en app → kotlin+java → com.sebastiannarvaez.marcador (MainActivity.kt) y en core → notifications (RecordatorioWorker.kt y Notificaciones.kt).

RecordatorioWorker.kt, fichero nuevo entero:

app/src/main/java/com/sebastiannarvaez/marcador/core/notifications/RecordatorioWorker.kt
package com.sebastiannarvaez.marcador.core.notifications
 
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import java.util.concurrent.TimeUnit
 
// Un Worker es UNA tarea que WorkManager ejecuta cuando toca, aunque tu app no
// este abierta e incluso si el movil se reinicia (WorkManager guarda las tareas
// en una base de datos propia). `CoroutineWorker` permite usar `suspend` dentro.
//
// WorkManager es para trabajo DIFERIBLE y GARANTIZADO: «avisame antes del partido»,
// «sube esto cuando haya red». NO es para algo que el usuario ve avanzar ahora
// mismo, ni para hacerlo en un instante exacto (puede retrasarse unos minutos).
//
// FOREGROUND SERVICE, la otra opcion: es para trabajo que el usuario NOTA mientras
// ocurre (musica, navegacion GPS, una descarga larga) y obliga a mostrar una
// notificacion permanente. Marcador no lo necesita: un recordatorio es diferible.
// Regla practica: si puede esperar, WorkManager; si no puede esperar y dura, un
// servicio en primer plano (y con permiso y tipo declarados en el Manifest).
class RecordatorioWorker(contexto: Context, parametros: WorkerParameters) : CoroutineWorker(contexto, parametros) {
 
    // Se ejecuta en un hilo de fondo (Dispatchers.Default por defecto): aqui NO se
    // toca la pantalla. Devuelve Result.success(), failure() o retry().
    override suspend fun doWork(): Result {
        val partido = inputData.getString(CLAVE_PARTIDO) ?: return Result.failure()
        Log.d("Trabajo", "RecordatorioWorker ejecutado: $partido")
        publicar(partido)
        return Result.success()
    }
 
    @SuppressLint("MissingPermission")
    private fun publicar(partido: String) {
        if (!puedeNotificar(applicationContext)) return
        val notificacion = NotificationCompat.Builder(applicationContext, CANAL_RECORDATORIOS)
            .setSmallIcon(android.R.drawable.ic_dialog_info)
            .setContentTitle("El partido empieza en una hora")
            .setContentText(partido)
            .setAutoCancel(true)
            .build()
        applicationContext.getSystemService(NotificationManager::class.java).notify(ID_NOTIFICACION, notificacion)
    }
 
    companion object {
        const val CLAVE_PARTIDO = "partido"
        private const val ID_NOTIFICACION = 1000
        private const val NOMBRE_UNICO = "recordatorio-partido"
 
        // En la app real serian 60 minutos; para verlo en una demo se puede pasar menos.
        fun programar(contexto: Context, partido: String, retrasoSegundos: Long = 60 * 60) {
            val peticion = OneTimeWorkRequestBuilder<RecordatorioWorker>()
                .setInitialDelay(retrasoSegundos, TimeUnit.SECONDS)
                .setInputData(Data.Builder().putString(CLAVE_PARTIDO, partido).build())
                .build()
            // Trabajo UNICO con nombre: si pulsas dos veces, la segunda sustituye a la
            // primera en vez de acumular dos recordatorios.
            WorkManager.getInstance(contexto)
                .enqueueUniqueWork(NOMBRE_UNICO, ExistingWorkPolicy.REPLACE, peticion)
        }
    }
}

MainActivity.kt, el botón nuevo (el resto del fichero no cambia):

app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
                    Button(onClick = {
                        // 60 segundos de retraso: en la app real serían 60 * 60 (una hora).
                        RecordatorioWorker.programar(applicationContext, Ejemplo.rayoContraToros.resumen(), retrasoSegundos = 60)
                        Toast.makeText(this@MainActivity, "Recordatorio en 60 s (en la app real, 1 h)", Toast.LENGTH_SHORT).show()
                    }) { Text("Recordarme el partido") }

Cómo se lee programar: OneTimeWorkRequestBuilder<RecordatorioWorker>() empieza una petición de una sola ejecución; .setInitialDelay(retrasoSegundos, TimeUnit.SECONDS) espera ese tiempo; .setInputData(…) mete el texto del partido en el paquete que doWork() leerá con inputData; y enqueueUniqueWork(NOMBRE_UNICO, REPLACE, peticion) la encola con nombre, de modo que si pulsas el botón dos veces la segunda petición sustituye a la primera y no se acumulan dos avisos.

Dos detalles más del worker. doWork() corre en un hilo de fondo (por defecto en Dispatchers.Default, de Dispatchers y withContext): ahí no se toca la pantalla. Y publicar sale sin hacer nada si falta el permiso de notificaciones: el worker se ejecuta igual, pero no verás la notificación.

Comprueba que sobrevive a la muerte del proceso

Con el emulador arrancado como en La Activity (si adb no se reconoce, el paso del PATH está en Instalar Android Studio y el SDK), comprueba que el selector de configuración de arriba dice app y pulsa Run. En el emulador, pulsa Activar avisos de gol y concede el permiso (el botón del diálogo se llama Allow o «Permitir», según el idioma).

Las órdenes am kill y pidof están en la Chuleta de adb. Abre la Terminal de Android Studio (View → Tool Windows → Terminal) y haz esto en orden:

  1. Vacía Logcat con adb logcat -c.
  2. En el emulador, pulsa Recordarme el partido (sale el aviso «Recordatorio en 60 s») y después Home (el botón de inicio, como en Notificaciones): la app queda en segundo plano, que es lo único que am kill puede matar.
  3. Mata el proceso y comprueba que ya no existe:
adb shell am kill com.sebastiannarvaez.marcador
adb shell pidof com.sebastiannarvaez.marcador

pidof no debe imprimir nada. Si imprime un número, el proceso sigue vivo: justo después de pulsar Home el sistema aún no da la app por oculta. Espera unos segundos y repite las dos órdenes hasta que pidof salga vacío. No tienes que correr: el aviso no llega hasta pasado un minuto. 4. Deja Logcat escuchando, sin -d para que no termine:

adb logcat -s Ciclo Trabajo

Espera a que salga la línea RecordatorioWorker ejecutado: el sistema la lanza al cabo de un minuto, y a veces tarda casi dos, porque WorkManager no es exacto. Entonces pulsa Ctrl+C.

En Logcat verás algo como esto (el número de pid cambia en cada ejecución; las primeras líneas de MainActivity son las de pulsar Home, y puede haber otras líneas del tag Ciclo de antes):

D Ciclo   : MainActivity@c764bd7 onPause
D Ciclo   : MainActivity@c764bd7 onStop
D Ciclo   : MainActivity@c764bd7 onSaveInstanceState
D Ciclo   : MarcadorApplication onCreate (pid 9840)
D Trabajo : RecordatorioWorker ejecutado: Rayo FC 2-1 Toros

Lee lo que dice: las líneas de MainActivity son de antes del kill. Después de ellas aparece un pid nuevo, se crea MarcadorApplication y después de ese onCreate no vuelve a salir ninguna MainActivity. El sistema resucitó el proceso solo para ejecutar el trabajo. En la barra de notificaciones verás «El partido empieza en una hora».

Si RecordatorioWorker ejecutado sale sin un MarcadorApplication onCreate nuevo delante, el proceso no murió: pidof seguía dando un número y no lo repetiste hasta que saliera vacío. Repite la prueba desde el paso 1.

Dos cosas quedan fuera de esta prueba: apagar el emulador entre medias, y «Forzar detención» desde los ajustes del sistema, que cancela el trabajo pendiente.

Punto de control

  • MainActivity.kt no contiene la palabra ejercicios (la línea temporal del ejercicio ya está borrada).
  • En app → kotlin+java → com.sebastiannarvaez.marcador → core → notifications está RecordatorioWorker.kt, y Notificaciones.kt y MainActivity.kt no tienen nada en rojo.
  • La app arranca con Run y en la pantalla hay un botón Recordarme el partido. Al pulsarlo sale el aviso «Recordatorio en 60 s (en la app real, 1 h)».
  • Tras am kill, pidof no imprime nada, y en Logcat aparecen MarcadorApplication onCreate y RecordatorioWorker ejecutado: Rayo FC 2-1 Toros, sin ninguna línea de MainActivity después del onCreate de la Application.
  • La notificación «El partido empieza en una hora» aparece en la barra de notificaciones.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.Tras am kill, en Logcat ves MarcadorApplication onCreate con un pid nuevo y RecordatorioWorker ejecutado, pero ninguna línea de MainActivity. ¿Qué demuestra?

  2. 2.Pulsas dos veces «Recordarme el partido». ¿Por qué se programa un solo aviso?

  3. 3.En doWork(), inputData.getString(CLAVE_PARTIDO) devuelve null porque nadie metió el texto del partido. Con ?: return Result.failure(), ¿qué pasa?

  4. 4.En el worker, publicar empieza con if (!puedeNotificar(applicationContext)) return. Si el usuario no concedió el permiso, ¿qué ocurre al llegar la hora?

Faltan 4 de 4