Pruebas y WorkManager avanzados

1. Introducción

En el codelab Trabajo en segundo plano con WorkManager, aprendiste a ejecutar trabajos en segundo plano (no en el subproceso principal) con WorkManager. En este codelab, seguirás aprendiendo sobre la funcionalidad de WorkManager para garantizar trabajos únicos, además de etiquetarlos, cancelarlos y restringirlos. Cuando completes el codelab, habrás aprendido a escribir pruebas automatizadas para verificar que tus trabajadores funcionen de manera correcta y muestren los resultados esperados. También aprenderás a usar el Inspector de tareas en segundo plano que proporciona Android Studio, para inspeccionar a los trabajadores en cola.

Qué compilarás

En este codelab, aprenderás a garantizar trabajos únicos, etiquetar trabajos y cancelarlos, además de implementar restricciones en ellos. Luego, aprenderás a escribir pruebas de IU automatizadas para la app Blur-O-Matic que verifican la funcionalidad de los tres trabajadores creados en el codelab Trabajo en segundo plano con WorkManager:

  • BlurWorker
  • CleanupWorker
  • SaveImageToFileWorker

Qué aprenderás

  • Cómo garantizar un trabajo único
  • Cómo cancelar un trabajo
  • Cómo definir restricciones de trabajo
  • Cómo escribir pruebas automatizadas para verificar la funcionalidad de los trabajadores
  • Conceptos básicos para inspeccionar a los trabajadores en cola con el Inspector de tareas en segundo plano

Requisitos

2. Cómo prepararte

Descarga el código

Haz clic en el siguiente vínculo para descargar todo el código de este codelab:

Descargar ZIP

Si lo prefieres, también puedes clonar el codelab desde GitHub:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-workmanager.git
$ cd basic-android-kotlin-compose-training-workmanager
$ git checkout intermediate

Abre el proyecto en Android Studio.

3. Garantiza el trabajo único

Ahora que sabes cómo encadenar trabajadores, es momento de abordar otra poderosa función de WorkManager: las secuencias de trabajo únicas.

A veces, querrás que solo una cadena de trabajo se ejecute a la vez. Por ejemplo, tal vez tengas una cadena de trabajo que sincroniza tus datos locales con el servidor. Sería bueno permitir que la primera sincronización de datos termine antes de comenzar una nueva. Para hacerlo, deberás usar beginUniqueWork() en lugar de beginWith() y proporcionarle un nombre de String único. Esto nombrará la cadena completa de solicitudes de trabajo para que puedas hacer consultas y búsquedas en todas ellas.

También debes pasar un objeto ExistingWorkPolicy. Este objeto le indica al SO Android lo que sucede si el trabajo ya existe. Los valores de ExistingWorkPolicy posibles son REPLACE, KEEP, APPEND o APPEND_OR_REPLACE.

En esta app, debes usar REPLACE porque, si un usuario decide desenfocar otra imagen antes de que termine la actual, debes detener la actual y comenzar a desenfocar la imagen nueva.

También debes asegurarte de que, si un usuario hace clic en Start cuando una solicitud de trabajo ya está en cola, la app reemplazará la solicitud de trabajo anterior por la solicitud nueva. No tiene sentido seguir trabajando en la solicitud anterior porque la app la reemplaza por la nueva.

En el archivo data/WorkManagerBluromaticRepository.kt, dentro del método applyBlur(), completa los siguientes pasos:

  1. Quita la llamada a la función beginWith() y agrega una llamada a la función beginUniqueWork().
  2. Para el primer parámetro de la función beginUniqueWork(), pasa la constante IMAGE_MANIPULATION_WORK_NAME.
  3. Para el segundo parámetro, el parámetro existingWorkPolicy, pasa ExistingWorkPolicy.REPLACE.
  4. Para el tercer parámetro, crea una nueva OneTimeWorkRequest para CleanupWorker.

data/WorkManagerBluromaticRepository.kt

import androidx.work.ExistingWorkPolicy
import com.example.bluromatic.IMAGE_MANIPULATION_WORK_NAME
...
// REPLACE THIS CODE:
// var continuation = workManager.beginWith(OneTimeWorkRequest.from(CleanupWorker::class.java))
// WITH
var continuation = workManager
    .beginUniqueWork(
        IMAGE_MANIPULATION_WORK_NAME,
        ExistingWorkPolicy.REPLACE,
        OneTimeWorkRequest.from(CleanupWorker::class.java)
    )
...

Blur-O-Matic ahora desenfoca solo una imagen a la vez.

4. Etiqueta y actualiza la IU en función del estado del trabajo

El siguiente cambio que realices será sobre lo que mostrará la app cuando se ejecute el trabajo. La información que se muestra sobre los trabajos en cola determina cómo debe cambiar la IU.

En esta tabla, se muestran tres métodos diferentes a los que puedes llamar para obtener información sobre el trabajo:

Tipo

Método de WorkManager

Descripción

Obtener trabajo con un ID

getWorkInfoByIdLiveData()

Esta función muestra un solo elemento LiveData<WorkInfo> para una WorkRequest específica por su ID.

Obtener trabajo con un nombre de cadena único

getWorkInfosForUniqueWorkLiveData()

Esta función muestra LiveData<List<WorkInfo>> para todo el trabajo en una cadena única de WorkRequests.

Obtener trabajo con una etiqueta

getWorkInfosByTagLiveData()

Esta función muestra LiveData<List<WorkInfo>> para una etiqueta.

Un objeto WorkInfo contiene detalles sobre el estado actual de una WorkRequest, incluido lo siguiente:

Estos métodos muestran LiveData. LiveData es un contenedor de datos observables optimizado para ciclos de vida. Para convertirlo en un flujo de objetos WorkInfo, se llama a .asFlow().

Como te interesa cuándo se guarda la imagen final, debes agregar una etiqueta a la WorkRequest de SaveImageToFileWorker para obtener su WorkInfo desde el método getWorkInfosByTagLiveData().

Otra opción es usar el método getWorkInfosForUniqueWorkLiveData(), que muestra información sobre las tres WorkRequests (CleanupWorker, BlurWorker y SaveImageToFileWorker). La desventaja de este método es que necesitas código adicional para encontrar específicamente la información de SaveImageToFileWorker necesaria.

Cómo etiquetar la solicitud de trabajo

El etiquetado del trabajo se realiza en el archivo data/WorkManagerBluromaticRepository.kt, dentro de la función applyBlur().

  1. Cuando crees la solicitud de trabajo SaveImageToFileWorker, etiqueta el trabajo con una llamada al método addTag() y pasa la constante TAG_OUTPUT de String.

data/WorkManagerBluromaticRepository.kt

import com.example.bluromatic.TAG_OUTPUT
...
val save = OneTimeWorkRequestBuilder<SaveImageToFileWorker>()
    .addTag(TAG_OUTPUT) // <- Add this
    .build()

En lugar de un ID de WorkManager, debes usar una etiqueta en tu trabajo, ya que si tu usuario desenfoca varias imágenes, todas las WorkRequest para guardar imágenes tendrán la misma etiqueta, pero no el mismo ID.

Cómo obtener WorkInfo

Usa la información WorkInfo de la solicitud de trabajo SaveImageToFileWorker en la lógica para decidir qué elementos componibles se muestran en la IU en función de BlurUiState.

ViewModel consume esta información desde la variable outputWorkInfo del repositorio.

Ahora que etiquetaste la solicitud de trabajo SaveImageToFileWorker, puedes completar los siguientes pasos para recuperar su información:

  1. En el archivo data/WorkManagerBluromaticRepository.kt, llama al método workManager.getWorkInfosByTagLiveData() para propagar la variable outputWorkInfo.
  2. Pasa la constante TAG_OUTPUT para el parámetro del método.

data/WorkManagerBluromaticRepository.kt

...
override val outputWorkInfo: Flow<WorkInfo?> =
    workManager.getWorkInfosByTagLiveData(TAG_OUTPUT)
...

La llamada del método getWorkInfosByTagLiveData() muestra LiveData. LiveData es un contenedor de datos observables optimizado para ciclos de vida. La función .asFlow() lo convierte en un flujo.

  1. Encadena una llamada a la función .asFlow() para convertir el método en un flujo. Debes convertir el método para que la app pueda trabajar con un flujo de Kotlin en lugar de LiveData.

data/WorkManagerBluromaticRepository.kt

import androidx.lifecycle.asFlow
...
override val outputWorkInfo: Flow<WorkInfo?> =
    workManager.getWorkInfosByTagLiveData(TAG_OUTPUT).asFlow()
...
  1. Encadena una llamada a la función de transformación .mapNotNull() para asegurarte de que el flujo contenga valores.
  2. Para la regla de transformación, si el elemento no está vacío, selecciona el primer elemento de la colección. De lo contrario, se mostrará un valor nulo. La función de transformación los quitará si son nulos.

data/WorkManagerBluromaticRepository.kt

import kotlinx.coroutines.flow.mapNotNull
...
    override val outputWorkInfo: Flow<WorkInfo?> =
        workManager.getWorkInfosByTagLiveData(TAG_OUTPUT).asFlow().mapNotNull {
            if (it.isNotEmpty()) it.first() else null
        }
...
  1. Debido a que la función de transformación .mapNotNull() garantiza que existe un valor, puedes quitar de forma segura el ? del tipo de flujo, ya que no necesita ser un tipo anulable.

data/WorkManagerBluromaticRepository.kt

...
    override val outputWorkInfo: Flow<WorkInfo> =
...
  1. También debes quitar ? de la interfaz de BluromaticRepository.

data/BluromaticRepository.kt

...
interface BluromaticRepository {
//    val outputWorkInfo: Flow<WorkInfo?>
    val outputWorkInfo: Flow<WorkInfo>
...

La información WorkInfo se emite como un Flow del repositorio. Luego, el ViewModel lo consume.

Cómo actualizar el BlurUiState

El ViewModel usa la WorkInfo que emite el repositorio del flujo outputWorkInfo para establecer el valor de la variable blurUiState.

El código de la IU usa el valor de la variable blurUiState para determinar qué elementos componibles se muestran.

Completa los siguientes pasos para realizar la actualización de blurUiState:

  1. Propaga la variable blurUiState con el flujo outputWorkInfo del repositorio.

ui/BlurViewModel.kt

// ...
// REMOVE
// val blurUiState: StateFlow<BlurUiState> = MutableStateFlow(BlurUiState.Default)

// ADD
val blurUiState: StateFlow<BlurUiState> = bluromaticRepository.outputWorkInfo
// ...
  1. Luego, debes asignar los valores en el flujo a los estados BlurUiState, según el estado del trabajo.

Cuando finalice el trabajo, establece la variable blurUiState en BlurUiState.Complete(outputUri = "").

Cuando se cancele el trabajo, establece la variable blurUiState en BlurUiState.Default.

De lo contrario, establece la variable blurUiState en BlurUiState.Loading.

ui/BlurViewModel.kt

import androidx.work.WorkInfo
import kotlinx.coroutines.flow.map
// ...

    val blurUiState: StateFlow<BlurUiState> = bluromaticRepository.outputWorkInfo
        .map { info ->
            when {
                info.state.isFinished -> {
                    BlurUiState.Complete(outputUri = "")
                }
                info.state == WorkInfo.State.CANCELLED -> {
                    BlurUiState.Default
                }
                else -> BlurUiState.Loading
            }
        }

// ...
  1. Como estás interesado en un StateFlow, convierte el flujo encadenando una llamada a la función .stateIn().

La llamada a la función .stateIn() requiere tres argumentos:

  1. Para el primer parámetro, pasa viewModelScope, que es el alcance de la corrutina vinculado al ViewModel.
  2. Para el segundo parámetro, pasa SharingStarted.WhileSubscribed(5_000). Este parámetro controla cuándo se inicia y se detiene el uso compartido.
  3. Para el tercer parámetro, pasa BlurUiState.Default, que es el valor inicial del flujo de estado.

ui/BlurViewModel.kt

import kotlinx.coroutines.flow.stateIn
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
// ...

    val blurUiState: StateFlow<BlurUiState> = bluromaticRepository.outputWorkInfo
        .map { info ->
            when {
                info.state.isFinished -> {
                    BlurUiState.Complete(outputUri = "")
                }
                info.state == WorkInfo.State.CANCELLED -> {
                    BlurUiState.Default
                }
                else -> BlurUiState.Loading
            }
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = BlurUiState.Default
        )

// ...

El ViewModel expone la información del estado de la IU como un StateFlow a través de la variable blurUiState. El flujo convierte un Flow frío en un StateFlow caliente mediante una llamada a la función stateIn().

Cómo actualizar la IU

En el archivo ui/BluromaticScreen.kt, debes obtener el estado de la IU a partir de la variable blurUiState de ViewModel y actualizar la IU.

Un bloque when controla la IU de la app. Este bloque when tiene una rama para cada uno de los tres estados de BlurUiState.

La IU se actualiza en el elemento componible BlurActions dentro de su elemento componible Row. Completa los siguientes pasos:

  1. Quita el código Button(onStartClick) dentro elemento componible Row y reemplázalo por un bloque when con blurUiState como su argumento.

ui/BluromaticScreen.kt

...
    Row(
        modifier = modifier,
        horizontalArrangement = Arrangement.Center
    ) {
        // REMOVE
        // Button(
        //     onClick = onStartClick,
        //     modifier = Modifier.fillMaxWidth()
        // ) {
        //     Text(stringResource(R.string.start))
        // }
        // ADD
        when (blurUiState) {
        }
    }
...

Cuando la app se abre, se encuentra en su estado predeterminado. En el código, este estado se representa como BlurUiState.Default.

  1. Dentro del bloque when, crea una rama para este estado, como se muestra en el siguiente ejemplo de código:

ui/BluromaticScreen.kt

...
    Row(
        modifier = modifier,
        horizontalArrangement = Arrangement.Center
    ) {
        when (blurUiState) {
            is BlurUiState.Default -> {}
        }
    }
...

En el estado predeterminado, la app muestra el botón Start.

  1. Para el parámetro onClick en el estado BlurUiState.Default, pasa la variable onStartClick, que se pasa al elemento componible.
  2. Para el parámetro stringResourceId, pasa el ID de recurso de strings de R.string.start.

ui/BluromaticScreen.kt

...
    Row(
        modifier = modifier,
        horizontalArrangement = Arrangement.Center
    ) {
        when (blurUiState) {
            is BlurUiState.Default -> {
                Button(
                    onClick = onStartClick,
                    modifier = Modifier.fillMaxWidth()
                ) {
                    Text(stringResource(R.string.start))
                }
        }
    }
...

Cuando la app está desenfocando una imagen, el estado es: BlurUiState.Loading. Para este estado, la app muestra el botón Cancel Work y un indicador de progreso circular.

  1. Para el parámetro onClick del botón en el estado BlurUiState.Loading, pasa la variable onCancelClick, que se pasa al elemento componible.
  2. Para el parámetro stringResourceId del botón, pasa el ID de recurso de strings de R.string.cancel_work.

ui/BluromaticScreen.kt

import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
...
    Row(
        modifier = modifier,
        horizontalArrangement = Arrangement.Center
    ) {
        when (blurUiState) {
            is BlurUiState.Default -> {
                Button(onStartClick) { Text(stringResource(R.string.start)) }
            }
            is BlurUiState.Loading -> {
               FilledTonalButton(onCancelClick) { Text(stringResource(R.string.cancel_work)) }
               CircularProgressIndicator(modifier = Modifier.padding(dimensionResource(R.dimen.padding_small)))
            }
        }
    }
...

El último estado que se debe configurar es el BlurUiState.Complete, que ocurre después de que una imagen se desenfoca y se guarda. En este momento, la app solo muestra el botón Start.

  1. Para su parámetro onClick en el estado BlurUiState.Complete, pasa la variable onStartClick.
  2. Para su parámetro stringResourceId, pasa el ID de recurso de strings de R.string.start.

ui/BluromaticScreen.kt

...
    Row(
        modifier = modifier,
        horizontalArrangement = Arrangement.Center
    ) {
        when (blurUiState) {
            is BlurUiState.Default -> {
                Button(onStartClick) { Text(stringResource(R.string.start)) }
            }
            is BlurUiState.Loading -> {
                FilledTonalButton(onCancelClick) { Text(stringResource(R.string.cancel_work)) }
                CircularProgressIndicator(modifier = Modifier.padding(dimensionResource(R.dimen.padding_small)))
            }
            is BlurUiState.Complete -> {
                Button(onStartClick) { Text(stringResource(R.string.start)) }
            }
        }
    }
...

Ejecuta tu app

  1. Ejecuta la app y haz clic en Start.
  2. Consulta la ventana del Inspector de tareas en segundo plano para ver la correspondencia entre los distintos estados y la IU que se muestra.

SystemJobService es el componente responsable de administrar ejecuciones de Worker.

Mientras se ejecutan los trabajadores, la IU muestra el botón Cancel Work y un indicador de progreso circular.

3395cc370b580b32.png

c5622f923670cf67.png

Una vez que los trabajadores finalizan, la IU se actualiza para mostrar el botón Start como estaba previsto.

97252f864ea042aa.png

81ba9962a8649e70.png

5. Muestra el resultado final

En esta sección, configurarás la app para que muestre un botón con la etiqueta See File cuando haya una imagen desenfocada lista para mostrarse.

Cómo crear el botón See File

El botón See File solo se muestra cuando BlurUiState es Complete.

  1. Abre el archivo ui/BluromaticScreen.kt y navega hasta el elemento componible BlurActions.
  2. Para agregar espacio entre el botón Start y el botón See File, agrega un elemento Spacer componible dentro del bloque BlurUiState.Complete.
  3. Agrega un nuevo elemento componible FilledTonalButton.
  4. Para el parámetro onClick, pasa onSeeFileClick(blurUiState.outputUri).
  5. Agrega un elemento componible Text al parámetro de contenido de Button.
  6. Para el parámetro text de Text, usa el ID de recurso de strings R.string.see_file.

ui/BluromaticScreen.kt

import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width

// ...
is BlurUiState.Complete -> {
    Button(onStartClick) { Text(stringResource(R.string.start)) }
    // Add a spacer and the new button with a "See File" label
    Spacer(modifier = Modifier.width(dimensionResource(R.dimen.padding_small)))
    FilledTonalButton({ onSeeFileClick(blurUiState.outputUri) })
    { Text(stringResource(R.string.see_file)) }
}
// ...

Cómo actualizar BlurUIState

El estado BlurUiState se establece en el ViewModel y depende del estado de la solicitud de trabajo y, posiblemente, de la variable bluromaticRepository.outputWorkInfo.

  1. En el archivo ui/BlurViewModel.kt, dentro de la transformación map(), crea una nueva variable outputImageUri.
  2. Propaga el URI de esta imagen guardada de la nueva variable desde el objeto de datos outputData.

Puedes recuperar esta string con la clave KEY_IMAGE_URI.

ui/BlurViewModel.kt

import com.example.bluromatic.KEY_IMAGE_URI

// ...
.map { info ->
    val outputImageUri = info.outputData.getString(KEY_IMAGE_URI)
    when {
// ...
  1. Si el trabajador finaliza y la variable se propaga, significa que existe una imagen desenfocada para mostrar.

Puedes verificar si se propaga esta variable si llamas a outputImageUri.isNullOrEmpty().

  1. Actualiza la rama isFinished para verificar también que la variable se propague y, luego, pasa la variable outputImageUri al objeto de datos BlurUiState.Complete.

ui/BlurViewModel.kt

// ...
.map { info ->
    val outputImageUri = info.outputData.getString(KEY_IMAGE_URI)
    when {
        info.state.isFinished && !outputImageUri.isNullOrEmpty() -> {
            BlurUiState.Complete(outputUri = outputImageUri)
        }
        info.state == WorkInfo.State.CANCELLED -> {
// ...

Cómo crear el código de evento de clic para See File

Cuando un usuario hace clic en el botón See File, su controlador onClick llama a la función asignada. Esta función pasa como un argumento en la llamada al elemento componible BlurActions().

El propósito de esta función es mostrar la imagen guardada desde su URI. Llama a la función auxiliar showBlurredImage() y pasa el URI. La función auxiliar crea un intent y lo usa para iniciar una nueva actividad para mostrar la imagen guardada.

  1. Abre el archivo ui/BluromaticScreen.kt.
  2. En la función BluromaticScreenContent(), en la llamada a la función de componibilidad BlurActions(), comienza a crear una función lambda para el parámetro onSeeFileClick que tome un solo parámetro llamado currentUri. Este enfoque almacena el URI de la imagen guardada.

ui/BluromaticScreen.kt

// ...
BlurActions(
    blurUiState = blurUiState,
    onStartClick = { applyBlur(selectedValue) },
    onSeeFileClick = { currentUri ->
    },
    onCancelClick = { cancelWork() },
    modifier = Modifier.fillMaxWidth()
)
// ...
  1. Dentro del cuerpo de la función lambda, llama a la función auxiliar showBlurredImage().
  2. Para el primer parámetro, pasa la variable context.
  3. Para el segundo parámetro, pasa la variable currentUri.

ui/BluromaticScreen.kt

// ...
BlurActions(
    blurUiState = blurUiState,
    onStartClick = { applyBlur(selectedValue) },
    // New lambda code runs when See File button is clicked
    onSeeFileClick = { currentUri ->
        showBlurredImage(context, currentUri)
    },
    onCancelClick = { cancelWork() },
    modifier = Modifier.fillMaxWidth()
)
// ...

Cómo ejecutar tu app

Ejecuta la app. Ahora verás el nuevo botón See File en el que se puede hacer clic, que te llevará al archivo guardado:

9d76d5d7f231c6b6.png

926e532cc24a0d4f.png

6. Cómo cancelar el trabajo

5cec830cc8ef647e.png

Anteriormente, agregaste el botón Cancel Work, por lo que ahora puedes agregar el código para que realice una acción. Con WorkManager, puedes cancelar trabajos usando el ID, la etiqueta y el nombre de una cadena única.

En este caso, quieres cancelar el trabajo con su nombre de cadena único, ya que quieres cancelar todo el trabajo de la cadena, no solo un paso en particular.

Cómo cancelar el trabajo por nombre

  1. Abre el archivo data/WorkManagerBluromaticRepository.kt.
  2. En la función cancelWork(), llama a la función workManager.cancelUniqueWork().
  3. Pasa el nombre de cadena único IMAGE_MANIPULATION_WORK_NAME para que la llamada solo cancele el trabajo programado con ese nombre.

data/WorkManagerBluromaticRepository.kt

override fun cancelWork() {
    workManager.cancelUniqueWork(IMAGE_MANIPULATION_WORK_NAME)
}

Según el principio de diseño de separación de problemas, las funciones de componibilidad no deben interactuar directamente con el repositorio. Las funciones de componibilidad interactúan con ViewModel, y este último interactúa con el repositorio.

Este enfoque es un buen principio de diseño a seguir porque los cambios en tu repositorio no requieren que cambies tus funciones de componibilidad, ya que no interactúan directamente.

  1. Abre el archivo ui/BlurViewModel.kt.
  2. Crea una función nueva llamada cancelWork() para cancelar el trabajo.
  3. Dentro de la función, en el objeto bluromaticRepository, llama al método cancelWork().

ui/BlurViewModel.kt

/**
 * Call method from repository to cancel any ongoing WorkRequest
 * */
fun cancelWork() {
    bluromaticRepository.cancelWork()
}

Cómo configurar un evento de clic de cancelación de trabajo

  1. Abre el archivo ui/BluromaticScreen.kt.
  2. Navega a la función de componibilidad BluromaticScreen().

ui/BluromaticScreen.kt

fun BluromaticScreen(blurViewModel: BlurViewModel = viewModel(factory = BlurViewModel.Factory)) {
    val uiState by blurViewModel.blurUiState.collectAsStateWithLifecycle()
    val layoutDirection = LocalLayoutDirection.current
    Surface(
        modifier = Modifier
            .fillMaxSize()
            .statusBarsPadding()
            .padding(
                start = WindowInsets.safeDrawing
                    .asPaddingValues()
                    .calculateStartPadding(layoutDirection),
                end = WindowInsets.safeDrawing
                    .asPaddingValues()
                    .calculateEndPadding(layoutDirection)
            )
    ) {
        BluromaticScreenContent(
            blurUiState = uiState,
            blurAmountOptions = blurViewModel.blurAmount,
            applyBlur = blurViewModel::applyBlur,
            cancelWork = {},
            modifier = Modifier
                .verticalScroll(rememberScrollState())
                .padding(dimensionResource(R.dimen.padding_medium))
        )
    }
}

Dentro de la llamada al elemento componible BluromaticScreenContent, quieres que se ejecute el método cancelWork() de ViewModel cuando un usuario haga clic en el botón.

  1. Asigna el parámetro cancelWork al valor blurViewModel::cancelWork.

ui/BluromaticScreen.kt

// ...
        BluromaticScreenContent(
            blurUiState = uiState,
            blurAmountOptions = blurViewModel.blurAmount,
            applyBlur = blurViewModel::applyBlur,
            cancelWork = blurViewModel::cancelWork,
            modifier = Modifier
                .verticalScroll(rememberScrollState())
                .padding(dimensionResource(R.dimen.padding_medium))
        )
// ...

Cómo ejecutar tu app y cancelar el trabajo

Ejecuta la app y comprueba que se compile correctamente. Comienza a desenfocar una imagen y haz clic en Cancel Work. Se cancelará toda la cadena.

81ba9962a8649e70.png

Después de cancelar el trabajo, solo se mostrará el botón Start porque WorkInfo.State es CANCELLED. Este cambio hace que la variable blurUiState se establezca en BlurUiState.Default. Como resultado, la IU se restablece a su estado inicial y solo muestra el botón Start.

El Inspector de tareas en segundo plano muestra el estado Cancelled previsto.

7656dd320866172e.png

7. Restricciones de trabajos

Por último, WorkManager admite Constraints. Una restricción es un requisito que debes cumplir antes de ejecutar una WorkRequest.

Algunos ejemplos de restricciones son requiresDeviceIdle() y requiresStorageNotLow().

  • Para la restricción requiresDeviceIdle(), si se le pasa un valor de true, el trabajo se ejecuta solo cuando el dispositivo está inactivo.
  • Para la restricción requiresStorageNotLow(), si se le pasa un valor de true, el trabajo se ejecuta solo cuando no queda poco almacenamiento.

Para Blur-O-Matic, debes agregar la restricción de que el nivel de carga de la batería del dispositivo no debe ser bajo antes de ejecutar la solicitud de trabajo de blurWorker. Esta restricción implica que tu solicitud de trabajo se aplaza y solo se ejecuta cuando el dispositivo no tenga batería baja.

Cómo crear una restricción de batería no baja

En el archivo data/WorkManagerBluromaticRepository.kt, completa los siguientes pasos:

  1. Navega al método applyBlur().
  2. Después del código que declara la variable continuation, crea una variable nueva llamada constraints, que contendrá un objeto Constraints para la restricción que se creará.
  3. Crea un compilador para un objeto Constraints. Para ello, llama a la función Constraints.Builder() y asígnala a la nueva variable.

data/WorkManagerBluromaticRepository.kt

import androidx.work.Constraints

// ...
    override fun applyBlur(blurLevel: Int) {
        // ...

        val constraints = Constraints.Builder()
// ...
  1. Encadena el método setRequiresBatteryNotLow() a la llamada y pásale un valor de true para que WorkRequest solo se ejecute cuando la batería del dispositivo no esté baja.

data/WorkManagerBluromaticRepository.kt

// ...
    override fun applyBlur(blurLevel: Int) {
        // ...

        val constraints = Constraints.Builder()
            .setRequiresBatteryNotLow(true)
// ...
  1. Compila el objeto mediante el encadenamiento de una llamada al método .build().

data/WorkManagerBluromaticRepository.kt

// ...
    override fun applyBlur(blurLevel: Int) {
        // ...

        val constraints = Constraints.Builder()
            .setRequiresBatteryNotLow(true)
            .build()
// ...
  1. Para agregar el objeto de restricción a la solicitud de trabajo blurBuilder, encadena una llamada al método .setConstraints() y pasa el objeto de restricción.

data/WorkManagerBluromaticRepository.kt

// ...
blurBuilder.setInputData(createInputDataForWorkRequest(blurLevel, imageUri))

blurBuilder.setConstraints(constraints) // Add this code
//...

Cómo realizar una prueba con el emulador

  1. En un emulador, cambia la opción Charge level en la ventana Extended Controls para que sea del 15% o inferior, y simular una situación de batería baja; con Charger connection en AC charger y Battery status en Not charging.

9b0084cb6e1a8672.png

  1. Ejecuta la app y haz clic en Start para desenfocar la imagen.

El nivel de carga de la batería del emulador es bajo, por lo que WorkManager no ejecuta la solicitud de trabajo blurWorker debido a la restricción. Está en cola, pero se aplaza hasta que se cumpla la restricción. Puedes ver este aplazamiento en la pestaña del Inspector de tareas en segundo plano.

7518cf0353d04f12.png

  1. Después de confirmar que no se ejecutó, aumenta lentamente el nivel de carga de la batería.

La restricción se alcanza después de que el nivel de carga de la batería alcanza aproximadamente el 25%, y se ejecuta el trabajo aplazado. Este resultado aparecerá en la pestaña del Inspector de tareas en segundo plano.

ab189db49e7b8997.png

8. Escribe pruebas para las implementaciones de Worker

Cómo probar WorkManager

La escritura de pruebas para Workers y la realización de pruebas con la API de WorkManager puede ser contradictorio. El trabajo que se realiza en un Worker no tiene acceso directo a la IU; es estrictamente una lógica empresarial. Por lo general, la lógica empresarial se prueba con pruebas de unidades locales. Sin embargo, debes recordar el codelab sobre el trabajo en segundo plano con WorkManager que WorkManger requiere un contexto de Android para ejecutarse. El contexto no está disponible de forma predeterminada en las pruebas de unidades locales. Por lo tanto, debes realizar las pruebas de Worker con pruebas de IU, aunque no haya elementos directos de la IU para probar.

Cómo configurar dependencias

Debes agregar tres dependencias de Gradle a tu proyecto. Las dos primeras habilitan JUnit y Espresso para pruebas de IU. La tercera dependencia proporciona la API de prueba de trabajo.

app/build.gradle.kts

dependencies {
    // Espresso
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
    // Junit
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    // Work testing
    androidTestImplementation("androidx.work:work-testing:2.8.1")
}

Debes usar la versión estable más reciente de work-runtime-ktx en tu app. Si cambias la versión, asegúrate de hacer clic en Sync Now para sincronizar tu proyecto con los archivos de Gradle actualizados.

Cómo crear una clase de prueba

  1. Crea un directorio para las pruebas de tu IU en el directorio app > src. a7768e9b6ea994d3.png

20cc54de1756c884.png

  1. Crea una nueva clase de Kotlin en el directorio androidTest/java llamada WorkerInstrumentationTest.

Cómo escribir una prueba de CleanupWorker

Sigue los pasos a continuación para escribir una prueba que verifique la implementación de CleanupWorker. Intenta implementar esta verificación por tu cuenta siguiendo las instrucciones. La solución se proporciona al final de los pasos.

  1. En WorkerInstrumentationTest.kt, crea una variable lateinit que contenga una instancia de Context.
  2. Crea un método setUp() anotado con @Before.
  3. En el método setUp(), inicializa la variable de contexto lateinit con un contexto de aplicación de ApplicationProvider.
  4. Crea una función de prueba llamada cleanupWorker_doWork_resultSuccess().
  5. En la prueba cleanupWorker_doWork_resultSuccess(), crea una instancia de CleanupWorker.

WorkerInstrumentationTest.kt

class WorkerInstrumentationTest {
   private lateinit var context: Context

   @Before
   fun setUp() {
       context = ApplicationProvider.getApplicationContext()
   }

   @Test
   fun cleanupWorker_doWork_resultSuccess() {
   }
}

Cuando escribes la app Blur-O-Matic, usas OneTimeWorkRequestBuilder para crear trabajadores. Para probar Workers, se requieren diferentes compiladores de trabajos. La API de WorkManager proporciona dos compiladores diferentes:

Ambos compiladores te permiten probar la lógica empresarial de tu trabajador. Para CoroutineWorkers, como CleanupWorker, BlurWorker y SaveImageToFileWorker, usa TestListenableWorkerBuilder para las pruebas, ya que controla las complejidades de subprocesos de la corrutina.

  1. Los CoroutineWorker se ejecutan de forma asíncrona, dado el uso de corrutinas. Para ejecutar el trabajador en paralelo, usa runBlocking. Proporciona un cuerpo de lambda vacío para comenzar, pero usa runBlocking para indicarle al trabajador que doWork() directamente, en lugar de ponerlo en cola.

WorkerInstrumentationTest.kt

class WorkerInstrumentationTest {
   private lateinit var context: Context

   @Before
   fun setUp() {
       context = ApplicationProvider.getApplicationContext()
   }

   @Test
   fun cleanupWorker_doWork_resultSuccess() {
       val worker = TestListenableWorkerBuilder<CleanupWorker>(context).build()
       runBlocking {
       }
   }
}
  1. En el cuerpo de la lambda de runBlocking, llama a doWork() en la instancia de CleanupWorker que creaste en el paso 5 y guárdalo como valor.

Quizás recuerdes que CleanupWorker borra todos los archivos PNG guardados en la estructura de archivos de la app de Blur-O-Matic. Este proceso abarca la entrada y salida de archivos, lo que significa que se pueden generar excepciones mientras se intenta borrar archivos. Por este motivo, el intento de borrar archivos se une en un bloque try.

CleanupWorker.kt

...
            return@withContext try {
                val outputDirectory = File(applicationContext.filesDir, OUTPUT_PATH)
                if (outputDirectory.exists()) {
                    val entries = outputDirectory.listFiles()
                    if (entries != null) {
                        for (entry in entries) {
                            val name = entry.name
                            if (name.isNotEmpty() && name.endsWith(".png")) {
                                val deleted = entry.delete()
                                Log.i(TAG, "Deleted $name - $deleted")
                            }
                        }
                    }
                }
                Result.success()
            } catch (exception: Exception) {
                Log.e(
                    TAG,
                    applicationContext.resources.getString(R.string.error_cleaning_file),
                    exception
                )
                Result.failure()
            }

Ten en cuenta que, al final del bloque try, se muestra Result.success(). Si el código llega a Result.success(), no se produce un error al acceder al directorio de archivos.

Ahora es el momento de crear una aserción que indique que el trabajador tuvo éxito.

  1. Confirma que el resultado del trabajador es ListenableWorker.Result.success().

Observa el siguiente código de solución:

WorkerInstrumentationTest.kt

class WorkerInstrumentationTest {
   private lateinit var context: Context

   @Before
   fun setUp() {
       context = ApplicationProvider.getApplicationContext()
   }

   @Test
   fun cleanupWorker_doWork_resultSuccess() {
       val worker = TestListenableWorkerBuilder<CleanupWorker>(context).build()
       runBlocking {
           val result = worker.doWork()
           assertTrue(result is ListenableWorker.Result.Success)
       }
   }
}

Cómo escribir una prueba de BlurWorker

Sigue estos pasos para escribir una prueba y verificar la implementación de BlurWorker. Intenta implementar esta verificación por tu cuenta siguiendo las instrucciones. La solución se proporciona al final de los pasos.

  1. En WorkerInstrumentationTest.kt, crea una nueva función de prueba llamada blurWorker_doWork_resultSuccessReturnsUri().

BlurWorker necesita una imagen para procesarse. Por lo tanto, compilar una instancia de BlurWorker requiere algunos datos de entrada que incluyan esa imagen.

  1. Fuera de la función de prueba, crea una entrada de URI de prueba. El URI de prueba es un par que contiene una clave y un valor de URI. Usa el siguiente código de ejemplo para el par clave-valor:
KEY_IMAGE_URI to "android.resource://com.example.bluromatic/drawable/android_cupcake"
  1. Compila un BlurWorker dentro de la función blurWorker_doWork_resultSuccessReturnsUri() y asegúrate de pasar la entrada de URI de prueba que crees como datos de trabajo a través del método setInputData().

Al igual que con la prueba CleanupWorker, debes llamar a la implementación del trabajador dentro de runBlocking.

  1. Crea un bloque runBlocking.
  2. Llama a doWork() dentro del bloque runBlocking.

A diferencia de CleanupWorker, BlurWorker tiene algunos datos de salida que están listos para que los puedas probar.

  1. Para acceder a los datos de salida, extrae el URI del resultado de doWork().

WorkerInstrumentationTest.kt

@Test
fun blurWorker_doWork_resultSuccessReturnsUri() {
    val worker = TestListenableWorkerBuilder<BlurWorker>(context)
        .setInputData(workDataOf(mockUriInput))
        .build()
    runBlocking {
        val result = worker.doWork()
        val resultUri = result.outputData.getString(KEY_IMAGE_URI)
    }
}
  1. Crea una aserción de que el trabajador es correcto. Por ejemplo, observa el siguiente código de BlurWorker:

BlurWorker.kt

val resourceUri = inputData.getString(KEY_IMAGE_URI)
val blurLevel = inputData.getInt(BLUR_LEVEL, 1)

...
val picture = BitmapFactory.decodeStream(
    resolver.openInputStream(Uri.parse(resourceUri))
)

val output = blurBitmap(picture, blurLevel)

// Write bitmap to a temp file
val outputUri = writeBitmapToFile(applicationContext, output)

val outputData = workDataOf(KEY_IMAGE_URI to outputUri.toString())

Result.success(outputData)
...

El BlurWorker toma el URI y el nivel de desenfoque de los datos de entrada y crea un archivo temporal. Si la operación se realiza correctamente, se mostrará un par clave-valor que contiene el URI. Para verificar que el contenido de la salida sea correcto, crea una aserción de que los datos de salida contienen la clave KEY_IMAGE_URI.

  1. Crea una aserción de que los datos de salida contienen un URI que comienza con la string "file:///data/user/0/com.example.bluromatic/files/blur_filter_outputs/blur-filter-output-".
  1. Verifica tu prueba con el siguiente código de solución:

WorkerInstrumentationTest.kt

    @Test
    fun blurWorker_doWork_resultSuccessReturnsUri() {
        val worker = TestListenableWorkerBuilder<BlurWorker>(context)
            .setInputData(workDataOf(mockUriInput))
            .build()
        runBlocking {
            val result = worker.doWork()
            val resultUri = result.outputData.getString(KEY_IMAGE_URI)
            assertTrue(result is ListenableWorker.Result.Success)
            assertTrue(result.outputData.keyValueMap.containsKey(KEY_IMAGE_URI))
            assertTrue(
                resultUri?.startsWith("file:///data/user/0/com.example.bluromatic/files/blur_filter_outputs/blur-filter-output-")
                    ?: false
            )
        }
    }

Cómo escribir una prueba de SaveImageToFileWorker

Como lo indica su nombre, SaveImageToFileWorker escribe un archivo en el disco. Recuerda que, en WorkManagerBluromaticRepository, agregas SaveImageToFileWorker a WorkManager como una continuación de BlurWorker. Por lo tanto, tiene los mismos datos de entrada. Toma el URI de los datos de entrada, crea un mapa de bits y, luego, lo escribe en el disco como un archivo. Si la operación se realiza correctamente, el resultado será una URL de imagen. La prueba para SaveImageToFileWorker es muy similar a la de BlurWorker. La única diferencia son los datos de salida.

Intenta escribir una prueba para SaveImageToFileWorker por tu cuenta. Cuando termines, puedes consultar la solución que se muestra a continuación. Recuerda el enfoque que usaste para la prueba BlurWorker:

  1. Compila el trabajador y pasa los datos de entrada.
  2. Crea un bloque runBlocking.
  3. Llama a doWork() en el trabajador.
  4. Comprueba que el resultado se haya realizado correctamente.
  5. Verifica el resultado de la clave y el valor correctos.

Esta es la solución:

@Test
fun saveImageToFileWorker_doWork_resultSuccessReturnsUrl() {
    val worker = TestListenableWorkerBuilder<SaveImageToFileWorker>(context)
        .setInputData(workDataOf(mockUriInput))
        .build()
    runBlocking {
        val result = worker.doWork()
        val resultUri = result.outputData.getString(KEY_IMAGE_URI)
        assertTrue(result is ListenableWorker.Result.Success)
        assertTrue(result.outputData.keyValueMap.containsKey(KEY_IMAGE_URI))
        assertTrue(
            resultUri?.startsWith("content://media/external/images/media/")
                ?: false
        )
    }
}

9. Depura WorkManager con el Inspector de tareas en segundo plano

Cómo inspeccionar trabajadores

Las pruebas automatizadas son una excelente manera de verificar la funcionalidad de tus trabajadores. Sin embargo, no son tan útiles cuando intentas depurar un trabajador. Afortunadamente, Android Studio cuenta con una herramienta que te permite visualizar, supervisar y depurar tus trabajadores en tiempo real. El Inspector de tareas en segundo plano funciona en emuladores y dispositivos con nivel de API 26 o posterior.

En esta sección, conocerás algunas de las funciones que proporciona el Inspector de tareas en segundo plano para inspeccionar a los trabajadores en Blur-O-Matic.

  1. Inicia la app de Blur-O-Matic en un dispositivo o emulador.
  2. Ve a View > Tool Windows > App Inspection.

798f10dfd8d74bb1.png

  1. Selecciona la pestaña del Inspector de tareas en segundo plano.

d601998f3754e793.png

  1. Si es necesario, selecciona el dispositivo y el proceso en ejecución en el menú desplegable.

En las imágenes de ejemplo, el proceso es com.example.bluromatic. Puede seleccionar el proceso automáticamente. Si selecciona el proceso equivocado, puedes cambiarlo.

6428a2ab43fc42d1.png

  1. Haz clic en el menú desplegable Workers. Actualmente, no hay trabajadores en ejecución, lo cual tiene sentido, ya que no se intentó desenfocar una imagen.

cf8c466b3fd7fed1.png

  1. En la app, selecciona More blurred y haz clic en Start. De inmediato, verás contenido en el menú desplegable Workers.

Verás algo como esto en el menú desplegable Workers.

569a8e0c1c6993ce.png

En la tabla de trabajadores, se muestra el nombre del trabajador, el servicio (en este caso, SystemJobService), el estado de cada uno y una marca de tiempo. En la captura de pantalla del paso anterior, observa que BlurWorker y CleanupWorker completaron correctamente su trabajo.

También puedes cancelar el trabajo con el inspector.

  1. Selecciona un trabajador en cola y haz clic en Cancel Selected Worker 7108c2a82f64b348.png en la barra de herramientas.

Cómo inspeccionar los detalles de la tarea

  1. Haz clic en un trabajador en la tabla Workers. 97eac5ad23c41127.png

Cuando lo hagas, se abrirá la ventana Task Details.

9d4e17f7d4afa6bd.png

  1. Revisa la información que se muestra en Task Details. 59fa1bf4ad8f4d8d.png

En los detalles, se muestran las siguientes categorías:

  • La sección Description enumera el nombre de la clase de trabajador con el paquete completamente calificado, así como la etiqueta asignada y el UUID de ese trabajador.
  • La sección Execution muestra las restricciones del trabajador (si existen), la frecuencia de ejecución, su estado y la clase que creó y puso en cola este trabajador. Recuerda que el BlurWorker tiene una restricción que le impide ejecutarse cuando la batería está baja. Cuando inspeccionas un trabajador que tiene restricciones, aparece en esta sección.
  • La sección WorkContinuation muestra dónde se encuentra este trabajador en la cadena de trabajo. Para verificar los detalles de otro trabajador de la cadena de trabajo, haz clic en su UUID.
  • La sección Results muestra la hora de inicio, la cantidad de reintentos y los datos de salida del trabajador seleccionado.

Vista de gráfico

Recuerda que los trabajadores de Blur-O-Matic están encadenados. El Inspector de tareas en segundo plano ofrece una vista de gráfico que representa visualmente las dependencias de los trabajadores.

En la esquina de la ventana del Inspector de tareas en segundo plano, hay dos botones para activar o desactivar: Show Graph View y Show List View.

4cd96a8b2773f466.png

  1. Haz clic en Show Graph View 6f871bb00ad8b11a.png:

ece206da18cfd1c9.png

La vista de gráfico indica con exactitud la dependencia de Worker implementada en la app de Blur-O-Matic.

  1. Haz clic en Show List View 669084937ea340f5.png para salir de la vista de gráfico.

Funciones adicionales

La app de Blur-O-Matic solo implementa Workers para completar tareas en segundo plano. Sin embargo, puedes leer más sobre las herramientas disponibles para inspeccionar otros tipos de tareas en segundo plano en la documentación del Inspector de tareas en segundo plano.

10. Obtén el código de la solución

Para descargar el código del codelab terminado, puedes usar estos comandos:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-workmanager.git
$ cd basic-android-kotlin-compose-training-workmanager
$ git checkout main

También puedes descargar el repositorio como un archivo ZIP, descomprimirlo y abrirlo en Android Studio.

Descargar ZIP

Si deseas ver el código de la solución para este codelab, míralo en GitHub.

11. Felicitaciones

¡Felicitaciones! Aprendiste sobre la funcionalidad adicional de WorkManger, escribiste pruebas automatizadas para los trabajadores de Blur-O-Matic y usaste el Inspector de tareas en segundo plano para examinarlas. En este codelab, aprendiste lo siguiente:

  • Cómo asignar nombres a cadenas de WorkRequest únicas
  • Cómo etiquetar WorkRequest
  • Cómo actualizar la IU en función de WorkInfo
  • Cómo cancelar una WorkRequest
  • Cómo agregar restricciones a una WorkRequest
  • Cómo usar API de prueba de WorkManager
  • Cómo abordar las implementaciones de trabajadores de prueba
  • Cómo probar CoroutineWorker
  • Cómo inspeccionar manualmente a los trabajadores y verificar su funcionalidad