WorkManager avançado e testes

1. Introdução

No codelab Trabalho em segundo plano com o WorkManager, você aprendeu a executar trabalhos em segundo plano (não na linha de execução principal) usando o WorkManager. Neste codelab, você vai continuar aprendendo sobre a funcionalidade do WorkManager para garantir trabalho exclusivo, inclusão de tags, cancelamento e restrições de trabalho. E também vamos abordar como criar testes automatizados para verificar se os workers funcionam corretamente e retornam os resultados esperados. Vamos mostrar também como usar o Inspetor de tarefas em segundo plano, fornecido pelo Android Studio, para inspecionar workers em fila.

O que você vai criar

Neste codelab, você vai garantir o trabalho exclusivo, a inclusão de tags, o cancelamento e a implementação de restrições. Em seguida, você vai aprender a criar testes de interface automatizados no app Blur-O-Matic que verificam a funcionalidade dos três workers criados no codelab Trabalho em segundo plano com o WorkManager:

  • BlurWorker
  • CleanupWorker
  • SaveImageToFileWorker

O que você vai aprender

  • Como garantir um trabalho exclusivo.
  • Como cancelar um trabalho.
  • Como definir restrições de trabalho.
  • Como criar testes automatizados para verificar a funcionalidade do worker.
  • Os conceitos básicos da inspeção de workers na fila com o Inspetor de tarefas em segundo plano.

O que é necessário

2. Etapas da configuração

Fazer o download do código

Clique no link abaixo para fazer o download de todo o código para este codelab:

Se preferir, clone o código no 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

Abra o projeto no Android Studio.

3. Garantir trabalho exclusivo

Agora que você sabe como encadear workers, é hora de usar outro recurso poderoso do WorkManager: sequências de trabalho únicas.

Às vezes, você quer que apenas uma cadeia de trabalho seja executada por vez. Por exemplo, talvez você tenha uma cadeia de trabalho que sincroniza seus dados locais com o servidor. É provável que você queira que a primeira sincronização de dados seja concluída antes de iniciar uma nova. Para fazer isso, use beginUniqueWork() em vez de beginWith() e forneça um nome de String exclusivo. Esse nome é usado em toda a cadeia de solicitações de trabalho para que você as possa consultar em conjunto.

Também é necessário transmitir um objeto ExistingWorkPolicy. Esse objeto informa ao SO Android o que acontece se o trabalho já existe. Os valores possíveis para ExistingWorkPolicy são REPLACE, KEEP, APPEND ou APPEND_OR_REPLACE.

Neste app, você quer usar REPLACE porque, se um usuário decidir desfocar outra imagem antes de terminar a atual, você precisa interromper o desfoque da imagem atual e começar a desfocar a nova.

Também é importante garantir que, se um usuário clica em Start quando uma solicitação de trabalho já está na fila, o app vai substituir a solicitação de trabalho anterior pela nova. Não faz sentido continuar trabalhando na solicitação anterior, porque o app a substitui pela nova.

No arquivo data/WorkManagerBluromaticRepository.kt, no método applyBlur(), siga estas etapas:

  1. Remova a chamada para a função beginWith() e adicione uma chamada à função beginUniqueWork().
  2. No primeiro parâmetro para a função beginUniqueWork(), transmita a constante IMAGE_MANIPULATION_WORK_NAME.
  3. No segundo parâmetro, existingWorkPolicy, transmita ExistingWorkPolicy.REPLACE.
  4. No terceiro parâmetro, crie uma nova OneTimeWorkRequest para o 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)
    )
...

O Blur-O-Matic agora desfoca apenas uma imagem por vez.

4. Marcar e atualizar a interface com base no status do trabalho

A próxima mudança que você vai fazer é sobre o que o app mostra quando o trabalho é executado. As informações retornadas sobre os trabalhos na fila determinam como a interface precisa mudar.

Esta tabela mostra três métodos diferentes para chamar informações de trabalho:

Tipo

Método da WorkManager

Descrição

Acessar o trabalho usando um ID

getWorkInfoByIdLiveData()

Essa função retorna um único LiveData<WorkInfo> para uma WorkRequest específica usando o ID.

Acessar o trabalho usando um nome da cadeia única

getWorkInfosForUniqueWorkLiveData()

Essa função retorna LiveData<List<WorkInfo>> para todo o trabalho em uma cadeia exclusiva de WorkRequests.

Acessar o trabalho usando uma tag

getWorkInfosByTagLiveData()

Essa função retorna a LiveData<List<WorkInfo>> de uma tag.

Um objeto WorkInfo contém detalhes sobre o estado atual de uma WorkRequest, incluindo:

Esses métodos retornam LiveData. LiveData é um detentor de dados observáveis com reconhecimento de ciclo de vida. Convertemos esse elemento em um fluxo de objetos WorkInfo chamando .asFlow().

Como você tem interesse em saber quando a imagem final é salva, adicione uma tag à WorkRequest de SaveImageToFileWorker para receber WorkInfo do método getWorkInfosByTagLiveData().

Outra opção é usar o método getWorkInfosForUniqueWorkLiveData(), que retorna informações sobre as três WorkRequests (CleanupWorker, BlurWorker e SaveImageToFileWorker). A desvantagem desse método é que você precisa de outro código para encontrar especificamente as informações necessárias da SaveImageToFileWorker.

Marcar a solicitação de trabalho

A inclusão de tags no trabalho é feita no arquivo data/WorkManagerBluromaticRepository.kt, na função applyBlur().

  1. Ao criar a solicitação de trabalho SaveImageToFileWorker, marque o trabalho chamando o método addTag() e transmitindo a TAG_OUTPUT da constante String.

data/WorkManagerBluromaticRepository.kt

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

Em vez de um ID do WorkManager, use uma tag para rotular o trabalho porque, se o usuário desfocar várias imagens, todas as WorkRequests das imagens salvas vão ter a mesma tag, mas não o mesmo ID.

Acessar o WorkInfo

Use as informações do objeto WorkInfo da solicitação de trabalho SaveImageToFileWorker na lógica para decidir quais elementos combináveis precisam ser mostrados na interface com base no BlurUiState.

O ViewModel consome essas informações da variável outputWorkInfo do repositório.

Agora que você marcou a solicitação de trabalho SaveImageToFileWorker, siga estas etapas para extrair as informações:

  1. No arquivo data/WorkManagerBluromaticRepository.kt, chame o método workManager.getWorkInfosByTagLiveData() para preencher a variável outputWorkInfo.
  2. Transmita a constante TAG_OUTPUT ao parâmetro do método.

data/WorkManagerBluromaticRepository.kt

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

A chamada do método getWorkInfosByTagLiveData() retorna LiveData. LiveData é um detentor de dados observáveis com reconhecimento de ciclo de vida. A função .asFlow() o converte em um fluxo.

  1. Encadeie uma chamada para a função .asFlow() para converter o método em um fluxo. Converta o método para que o app funcione com um fluxo Kotlin em vez do LiveData.

data/WorkManagerBluromaticRepository.kt

import androidx.lifecycle.asFlow
...
override val outputWorkInfo: Flow<WorkInfo?> =
    workManager.getWorkInfosByTagLiveData(TAG_OUTPUT).asFlow()
...
  1. Encadeie uma chamada para a função de transformação .mapNotNull() (link em inglês) para garantir que o fluxo contenha valores.
  2. Para a regra de transformação, se o elemento não estiver vazio, selecione o primeiro item da coleção. Caso contrário, retorne um valor nulo. A função de transformação vai remover os valores 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. Como a função de transformação .mapNotNull() garante a existência de um valor, é possível remover o ? do tipo do fluxo com segurança, já que ele não precisa mais ser um tipo anulável.

data/WorkManagerBluromaticRepository.kt

...
    override val outputWorkInfo: Flow<WorkInfo> =
...
  1. Também é necessário remover o ? da interface BluromaticRepository.

data/BluromaticRepository.kt

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

As informações WorkInfo são emitidas como um Flow no repositório. Em seguida, o ViewModel as consome.

Atualizar o BlurUiState

O ViewModel usa a WorkInfo emitida pelo repositório no fluxo outputWorkInfo para definir o valor da variável blurUiState.

O código da interface usa o valor da variável blurUiState para determinar quais elementos combináveis são mostrados.

Conclua as etapas abaixo para executar a atualização da blurUiState:

  1. Preencha a variável blurUiState com o fluxo outputWorkInfo do repositório.

ui/BlurViewModel.kt

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

// ADD
val blurUiState: StateFlow<BlurUiState> = bluromaticRepository.outputWorkInfo
// ...
  1. É necessário mapear os valores no fluxo para os estados da BlurUiState, dependendo do status do trabalho.

Quando o trabalho for concluído, defina a variável blurUiState como BlurUiState.Complete(outputUri = "").

Quando o trabalho for cancelado, defina a variável blurUiState como BlurUiState.Default.

Caso contrário, defina o blurUiState como 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 você tem interesse em um StateFlow, converta o fluxo encadeando uma chamada para a função .stateIn().

A chamada para a função .stateIn() requer três argumentos:

  1. Para o primeiro parâmetro, transmita viewModelScope, que é o escopo da corrotina vinculado ao ViewModel.
  2. Para o segundo parâmetro, transmita SharingStarted.WhileSubscribed(5_000). Esse parâmetro controla o início e a interrupção do compartilhamento.
  3. Para o terceiro parâmetro, transmita BlurUiState.Default, que é o valor inicial do fluxo 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
        )

// ...

O ViewModel expõe as informações do estado da interface como um StateFlow usando a variável blurUiState. O fluxo converte de um Flow frio para um StateFlow quente chamando a função stateIn() (link em inglês).

Atualizar a interface

No arquivo ui/BluromaticScreen.kt, você extrai o estado da interface da variável blurUiState do ViewModel e o atualiza.

Um bloco when controla a interface do app. Esse bloco when tem uma ramificação para cada um dos três estados do BlurUiState.

A interface é atualizada no elemento combinável BlurActions dentro do elemento Row. Siga estas etapas:

  1. Remova o código Button(onStartClick), no elemento combinável Row, e substitua-o por um bloco when com blurUiState como 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) {
        }
    }
...

Quando o app é aberto, ele fica no estado padrão. Esse estado no código é representado como BlurUiState.Default.

  1. No bloco when, crie uma ramificação para esse estado, conforme mostrado no exemplo de código abaixo:

ui/BluromaticScreen.kt

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

Para o estado padrão, o app mostra o botão Start.

  1. Para o parâmetro onClick no estado BlurUiState.Default, transmita a variável onStartClick, que está sendo transmitida ao elemento combinável.
  2. Para o parâmetro stringResourceId, transmita o ID de recurso de string 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))
                }
        }
    }
...

Quando o app está desfocando ativamente uma imagem, esse é o estado BlurUiState.Loading. Para esse estado, o app mostra o botão Cancel Work (cancelar) e um indicador de progresso circular.

  1. Para o parâmetro onClick do botão no estado BlurUiState.Loading, transmita a variável onCancelClick, que está sendo transmitida ao elemento combinável.
  2. Para o parâmetro stringResourceId do botão, transmita o ID de recurso de string 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)))
            }
        }
    }
...

O último estado a ser configurado é o BlurUiState.Complete, que ocorre depois que uma imagem é desfocada e salva. No momento, o app mostra apenas o botão Start.

  1. Para o parâmetro onClick no estado BlurUiState.Complete, transmita a variável onStartClick.
  2. Para o parâmetro stringResourceId, transmita o ID de recurso de string 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)) }
            }
        }
    }
...

Executar o app

  1. Execute o app e clique em Start.
  2. Consulte a janela Background Task Inspector para conferir como os vários estados correspondem à interface mostrada.

SystemJobService é o componente responsável por gerenciar as execuções do worker.

Enquanto os workers estão em execução, a interface mostra o botão Cancel Work e um indicador de progresso circular.

3395cc370b580b32.png

c5622f923670cf67.png

Depois que os workers terminam, a interface é atualizada para mostrar o botão Start conforme esperado.

97252f864ea042aa.png

81ba9962a8649e70.png

5. Mostrar a saída final

Nesta seção, você vai configurar o app para ter um botão See File (mostrar arquivo) sempre que uma imagem desfocada estiver pronta para exibição.

Criar o botão See File.

O botão See File é mostrado apenas quando o BlurUiState está no estado Complete.

  1. Abra o arquivo ui/BluromaticScreen.kt e navegue até o elemento combinável BlurActions.
  2. Para adicionar um espaço entre o botão Start e o botão See File, adicione um elemento combinável Spacer ao bloco BlurUiState.Complete.
  3. Adicione um novo elemento combinável FilledTonalButton.
  4. Para o parâmetro onClick, transmita onSeeFileClick(blurUiState.outputUri).
  5. Adicione um elemento combinável Text para o parâmetro de conteúdo do Button.
  6. Para o parâmetro text do Text, use o ID do recurso de string 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)) }
}
// ...

Atualizar o blurUiState

O estado BlurUiState é definido no ViewModel e depende do estado da solicitação de trabalho e, possivelmente, da variável bluromaticRepository.outputWorkInfo.

  1. No arquivo ui/BlurViewModel.kt, na transformação map(), crie uma nova variável outputImageUri.
  2. Preencha o URI dessa nova imagem salva da variável no objeto de dados outputData.

É possível extrair essa string com a chave 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. Se o worker termina e a variável está preenchida, isso indica que há uma imagem desfocada para ser mostrada.

Para verificar se essa variável é preenchida, chame outputImageUri.isNullOrEmpty().

  1. Atualize a ramificação isFinished para verificar também se a variável está preenchida e, em seguida, transmita a variável outputImageUri ao objeto de dados 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 -> {
// ...

Criar o código para o evento de clique do botão "See File"

Quando um usuário clica no botão See File, o gerenciador onClick chama a função atribuída. Essa função é transmitida como um argumento na chamada para o elemento combinável BlurActions().

O objetivo dessa função é mostrar a imagem salva no URI. Ela chama a função auxiliar showBlurredImage() e transmite o URI. A função auxiliar cria uma intent que é usada para iniciar uma nova atividade para mostrar a imagem salva.

  1. Abra o arquivo ui/BluromaticScreen.kt.
  2. Na função BluromaticScreenContent(), na chamada para a função combinável BlurActions(), comece criando uma função lambda para o parâmetro onSeeFileClick que usa um único parâmetro, chamado currentUri. Essa abordagem armazena o URI da imagem salva.

ui/BluromaticScreen.kt

// ...
BlurActions(
    blurUiState = blurUiState,
    onStartClick = { applyBlur(selectedValue) },
    onSeeFileClick = { currentUri ->
    },
    onCancelClick = { cancelWork() },
    modifier = Modifier.fillMaxWidth()
)
// ...
  1. No corpo da função lambda, chame a função auxiliar showBlurredImage().
  2. Para o primeiro parâmetro, transmita a variável context.
  3. Para o segundo parâmetro, transmita a variável 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()
)
// ...

Executar seu app

Execute o app. Agora você vai notar o novo botão clicável See File, que leva ao arquivo salvo:

9d76d5d7f231c6b6.png

926e532cc24a0d4f.png

6. Cancelar um trabalho

5cec830cc8ef647e.png

Anteriormente, você adicionou o botão Cancel Work e agora pode adicionar o código para que ele faça algo. Com o WorkManager, é possível cancelar trabalhos usando o ID, a tag e o nome da cadeia exclusiva.

Nesse caso, é preciso cancelar o trabalho com o nome da cadeia única, porque você quer cancelar todo o trabalho na cadeia, não apenas uma etapa específica.

Cancelar um trabalho por nome

  1. Abra o arquivo data/WorkManagerBluromaticRepository.kt.
  2. Na função cancelWork(), chame a workManager.cancelUniqueWork().
  3. Transmita o nome da cadeia única IMAGE_MANIPULATION_WORK_NAME para que a chamada cancele apenas o trabalho programado com esse nome.

data/WorkManagerBluromaticRepository.kt

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

Seguindo o princípio de design de separação de conceitos (link em inglês), as funções combináveis não podem interagir diretamente com o repositório. As funções combináveis interagem com o ViewModel, que interage com o repositório.

Essa abordagem é um bom princípio de design a ser seguido, porque mudanças no seu repositório não exigem que você mude as funções combináveis, porque elas não interagem diretamente.

  1. Abra o arquivo ui/BlurViewModel.kt.
  2. Crie uma nova função com o nome cancelWork() para cancelar o trabalho.
  3. Na função, no objeto bluromaticRepository, chame o método cancelWork().

ui/BlurViewModel.kt

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

Configurar o evento de clique para o botão "Cancel Work"

  1. Abra o arquivo ui/BluromaticScreen.kt.
  2. Navegue até a função combinávelBluromaticScreen().

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))
        )
    }
}

Na chamada para o elemento combinável BluromaticScreenContent, você quer que o método cancelWork() do ViewModel seja executado quando um usuário clicar no botão.

  1. Atribua o valor blurViewModel::cancelWork ao parâmetro 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))
        )
// ...

Executar o app e cancelar um trabalho

Execute o app. A compilação vai acontecer sem problemas. Comece a desfocar uma imagem e clique em Cancel Work. A cadeia inteira será cancelada.

81ba9962a8649e70.png

Depois que o trabalho é cancelado, apenas o botão Start aparece, porque o WorkInfo.State fica no estado CANCELLED. Essa mudança faz com que a variável blurUiState seja definida como BlurUiState.Default, o que redefine a interface de volta ao estado inicial e mostra apenas o botão Start.

A guia Background Task Inspector mostra o status Cancelled esperado.

7656dd320866172e.png

7. Restrições de trabalho

Por último, mas não menos importante, o WorkManager oferece suporte a Constraints (restrições). Uma restrição é um requisito que precisa ser atendido antes que uma WorkRequest seja executada.

Alguns exemplos de restrições são requiresDeviceIdle() e requiresStorageNotLow().

  • Para a restrição requiresDeviceIdle(), se ela recebe um valor de true, o trabalho é executado apenas se o dispositivo está inativo.
  • Para a restrição requiresStorageNotLow(), se ela recebe um valor de true, o trabalho é executado apenas se o armazenamento não está baixo.

Para o Blur-O-Matic, adicione a restrição de que o nível de carga da bateria do dispositivo não pode estar baixo antes de executar a solicitação de trabalho blurWorker. Com essa restrição, a solicitação de trabalho é adiada e só é executada quando a bateria do dispositivo não está baixa.

Criar restrição para bateria baixa

No arquivo data/WorkManagerBluromaticRepository.kt, siga estas etapas:

  1. Navegue até o método applyBlur().
  2. Depois que o código declarar a variável continuation, crie uma nova variável chamada constraints, que contém um objeto Constraints para a restrição que está sendo criada.
  3. Crie um builder para um objeto Constraints chamando a função Constraints.Builder() e o atribua à nova variável.

data/WorkManagerBluromaticRepository.kt

import androidx.work.Constraints

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

        val constraints = Constraints.Builder()
// ...
  1. Encadeie o método setRequiresBatteryNotLow() à chamada e transmita um valor de true para que o WorkRequest seja executado apenas quando a bateria do dispositivo não estiver baixa.

data/WorkManagerBluromaticRepository.kt

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

        val constraints = Constraints.Builder()
            .setRequiresBatteryNotLow(true)
// ...
  1. Crie o objeto encadeando uma chamada ao método .build().

data/WorkManagerBluromaticRepository.kt

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

        val constraints = Constraints.Builder()
            .setRequiresBatteryNotLow(true)
            .build()
// ...
  1. Para adicionar o objeto de restrição à solicitação de trabalho blurBuilder, encadeie uma chamada ao método .setConstraints() e transmita o objeto de restrição.

data/WorkManagerBluromaticRepository.kt

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

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

Testar com emulador

  1. Em um emulador, mude o Charge level (nível da bateria) na janela Extended Controls para 15% ou menos para simular um cenário de bateria fraca, Charger connection para AC charger e Baterry status para Not charging.

9b0084cb6e1a8672.png

  1. Execute o app e clique em Start para desfocar a imagem.

O nível de carga da bateria do emulador fica definido como baixo. Portanto, o WorkManager não executa a solicitação de trabalho blurWorker devido à restrição. Ela é colocada na fila, mas adiada até que a restrição seja atendida. É possível conferir esse adiamento na guia Background Task Inspector.

7518cf0353d04f12.png

  1. Depois de confirmar que o trabalho não foi executado, aumente lentamente o nível de carga da bateria.

A restrição é atendida depois que o nível de carga da bateria atinge aproximadamente 25%. Após isso, o trabalho adiado é executado. Esse resultado aparece na guia Background Task Inspector.

ab189db49e7b8997.png

8. Criar testes para implementações de workers

Como testar o WorkManager

Criar testes para workers e usar a API do WorkManager pode não ser intuitivo. O trabalho feito em um worker não tem acesso direto à interface; ele é estritamente uma lógica de negócios. Normalmente, você testa a lógica de negócios com testes de unidade locais. No entanto, como mostrado no codelab "Trabalho em segundo plano com o WorkManager", a API exige um contexto do Android. Por padrão, o contexto não está disponível em testes de unidade locais. Portanto, você precisa trabalhar em testes de workers com testes de interface, mesmo que não haja elementos de interface diretos a serem testados.

Configurar dependências

Você precisa adicionar três dependências do Gradle ao projeto. As duas primeiras ativam o JUnit e o Espresso para testes de interface. A terceira dependência fornece a API de testes de trabalho.

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")
}

Você precisa usar a versão estável mais recente do work-runtime-ktx no app. Se você mudar a versão, clique em Sync Now para sincronizar seu projeto com os arquivos do Gradle atualizados.

Criar uma classe de testes

  1. Crie um diretório para seus testes de interface no diretório app > src. a7768e9b6ea994d3.png

20cc54de1756c884.png

  1. Crie uma nova classe do Kotlin com o nome WorkerInstrumentationTest no diretório androidTest/java.

Criar um teste do CleanupWorker

Siga estas etapas para criar um teste e verificar a implementação do CleanupWorker. Tente implementar essa verificação por conta própria com base nas instruções. A solução será fornecida no final das etapas.

  1. Em WorkerInstrumentationTest.kt, crie uma variável lateinit para armazenar uma instância do Context.
  2. Crie um método setUp() com a anotação @Before.
  3. No método setUp(), inicialize a variável de contexto lateinit com um contexto de aplicativo de ApplicationProvider.
  4. Crie uma função de testes com o nome cleanupWorker_doWork_resultSuccess().
  5. No teste cleanupWorker_doWork_resultSuccess(), crie uma instância do CleanupWorker.

WorkerInstrumentationTest.kt

class WorkerInstrumentationTest {
   private lateinit var context: Context

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

   @Test
   fun cleanupWorker_doWork_resultSuccess() {
   }
}

Ao criar o app Blur-O-Matic, você usa o OneTimeWorkRequestBuilder para criar workers. O teste de workers exige diferentes builders de trabalho. A API do WorkManager fornece dois builders diferentes:

Com esses dois builders, é possível testar a lógica de negócios do worker. Para CoroutineWorkers, por exemplo, CleanupWorker, BlurWorker e SaveImageToFileWorker, use o TestListenableWorkerBuilder para os testes, porque ele processa as complexidades da linha de execução da corrotina.

  1. CoroutineWorkers são executados de forma assíncrona, devido ao uso de corrotinas. Para executar o worker em paralelo, use runBlocking. Forneça um corpo lambda vazio para começar, mas use runBlocking para instruir o worker a doWork() (realizar o trabalho) diretamente, em vez de enfileirar o worker.

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. No corpo lambda de runBlocking, chame doWork() na instância do CleanupWorker que você criou na Etapa 5 e salve como um valor.

O CleanupWorker exclui todos os arquivos PNG salvos na estrutura de arquivos do app Blur-O-Matic. Esse processo envolve a entrada/saída do arquivo, o que significa que exceções podem ser geradas ao tentar excluir arquivos. Por isso, a tentativa de excluir arquivos é encapsulada em um bloco 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()
            }

Observe que, no final do bloco try, Result.success() é retornado. Se o código chegar a Result.success(), não vai haver erros ao acessar o diretório do arquivo.

Agora, é hora de fazer uma declaração indicando que o worker foi executado corretamente.

  1. Afirme que o resultado do worker é ListenableWorker.Result.success().

Confira o código da solução abaixo:

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)
       }
   }
}

Criar um teste do BlurWorker

Siga estas etapas para criar um teste e verificar a implementação do BlurWorker. Tente implementar essa verificação por conta própria com base nas instruções. A solução será fornecida no final das etapas.

  1. Em WorkerInstrumentationTest.kt, crie uma nova função de teste com o nome blurWorker_doWork_resultSuccessReturnsUri().

O BlurWorker precisa de uma imagem para processar. Portanto, a criação de uma instância do BlurWorker requer alguns dados de entrada que incluem essa imagem.

  1. Fora da função de teste, crie uma entrada de URI fictício. O URI fictício é um par que contém uma chave e um valor de URI. Use o código de exemplo abaixo para o par de chave-valor:
KEY_IMAGE_URI to "android.resource://com.example.bluromatic/drawable/android_cupcake"
  1. Crie um BlurWorker dentro da função blurWorker_doWork_resultSuccessReturnsUri() e transmita a entrada de URI fictício criada como dados de trabalho pelo método setInputData().

Assim como no teste do CleanupWorker, é necessário chamar a implementação do worker dentro de runBlocking.

  1. Crie um bloco runBlocking.
  2. Chame doWork() dentro do bloco runBlocking.

Ao contrário do CleanupWorker, o BlurWorker tem alguns dados de saída prontos para teste.

  1. Para acessar os dados de saída, extraia o URI do 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. Faça uma declaração de que o worker teve sucesso. Por exemplo, confira este código do 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)
...

O BlurWorker usa o URI e o nível de desfoque dos dados de entrada e cria um arquivo temporário. Se a operação tiver sucesso, ela vai retornar um par de chave-valor contendo o URI. Para verificar se o conteúdo da saída está correto, faça uma declaração de que os dados de saída contêm a chave KEY_IMAGE_URI.

  1. Faça uma declaração de que os dados de saída contêm um URI que começa com a string "file:///data/user/0/com.example.bluromatic/files/blur_filter_outputs/blur-filter-output-"
  1. Verifique seu teste em relação a este código da solução:

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
            )
        }
    }

Criar um teste do SaveImageToFileWorker

Como o próprio nome indica, o SaveImageToFileWorker grava um arquivo no disco. Não esqueça que, no WorkManagerBluromaticRepository, você adiciona o SaveImageToFileWorker ao WorkManager como uma continuação após o BlurWorker. Ele tem os mesmos dados de entrada. Ele usa o URI dos dados de entrada, cria um bitmap e grava esse bitmap no disco como um arquivo. Se a operação for bem-sucedida, a saída resultante será um URL de imagem. O teste para o SaveImageToFileWorker é muito semelhante ao do BlurWorker. A única diferença são os dados de saída.

Tente criar um teste do SaveImageToFileWorker por conta própria. Quando terminar, verifique a solução abaixo. Lembre-se da abordagem adotada para o teste do BlurWorker:

  1. Crie o worker, transmitindo os dados de entrada.
  2. Crie um bloco runBlocking.
  3. Chame doWork() no worker.
  4. Verifique se o resultado foi bem-sucedido.
  5. Verifique a saída para encontrar a chave e o valor corretos.

Confira a solução:

@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. Depurar o WorkManager com o Inspetor de tarefas em segundo plano

Inspecionar workers

Os testes automatizados são uma ótima maneira de verificar a funcionalidade dos workers. No entanto, eles não são tão úteis quando você está tentando depurar um worker. Felizmente, o Android Studio tem uma ferramenta que permite visualizar, monitorar e depurar os workers em tempo real. O Inspetor de tarefas em segundo plano funciona para emuladores e dispositivos com o nível 26 da API ou mais recente.

Nesta seção, você vai aprender alguns dos recursos que o Inspetor de tarefas em segundo plano oferece para inspecionar os workers no Blur-O-Matic.

  1. Inicie o app Blur-O-Matic em um dispositivo ou emulador.
  2. Navegue até View > Tool Windows > App Inspection.

798f10dfd8d74bb1.png

  1. Selecione a guia Background Task Inspector.

d601998f3754e793.png

  1. Se necessário, selecione o dispositivo e o processo em execução no menu suspenso.

Nas imagens de exemplo, o processo é com.example.bluromatic. Ele pode ser selecionado automaticamente para você. Se um processo errado for selecionado, você poderá selecionar outro.

6428a2ab43fc42d1.png

  1. Clique no menu suspenso Workers. No momento, não há workers em execução, o que faz sentido porque não houve tentativa de desfocar uma imagem.

cf8c466b3fd7fed1.png

  1. No app, selecione More blurred e clique em Start. Um conteúdo aparece de imediato na lista suspensa Workers.

Agora, uma tela semelhante a esta vai aparecer no menu suspenso Workers.

569a8e0c1c6993ce.png

A tabela Worker mostra o nome do worker, o serviço (SystemJobService, neste caso), o status de cada um e um carimbo de data/hora. Na captura de tela da etapa anterior, observe que BlurWorker e CleanupWorker concluíram o trabalho.

Também é possível cancelar um trabalho usando o inspetor.

  1. Selecione um worker na fila e clique em Cancel Selected Worker 7108c2a82f64b348.png na barra de ferramentas.

Inspecionar detalhes da tarefa

  1. Clique em um worker na tabela Workers. 97eac5ad23c41127.png

Isso vai abrir a janela Task Details.

9d4e17f7d4afa6bd.png

  1. Revise as informações que aparecem em Task Details. 59fa1bf4ad8f4d8d.png

Os detalhes mostram estas categorias:

  • Description (descrição): lista o nome da classe de worker com o pacote totalmente qualificado, além da tag atribuída e o UUID desse worker.
  • Execution (execução): mostra as restrições do worker, se houver, além da frequência de execução, o estado dele e qual classe criou e enfileirou esse worker. O BlurWorker tem uma restrição que impede a execução quando a bateria está fraca. Quando você inspeciona um worker que tem restrições, ele aparece nesta seção.
  • WorkContinuation (continuação de trabalho): mostra a localização desse worker na cadeia de trabalho. Para verificar os detalhes de outro worker na cadeia, clique no UUID dele.
  • Results (resultados): mostra o horário de início, a contagem de tentativas e os dados de saída do worker selecionado.

Visualização em gráfico

Não esqueça que os workers no Blur-O-Matic estão encadeados. O Inspetor de tarefas em segundo plano oferece uma visualização em gráfico que representa as dependências do worker visualmente.

No canto da janela Background Task Inspector, há dois botões para escolher entre Show Graph View e Show List View.

4cd96a8b2773f466.png

  1. Clique em Show Graph View6f871bb00ad8b11a.png:

ece206da18cfd1c9.png

A visualização em gráfico indica com precisão a dependência do worker implementada no app Blur-O-Matic.

  1. Clique em Show List View 669084937ea340f5.png para sair da visualização em gráfico.

Mais recursos

O app Blur-O-Matic implementa apenas workers para concluir tarefas em segundo plano. Você pode ler mais sobre as ferramentas disponíveis para inspecionar outros tipos de trabalho em segundo plano na documentação do Inspetor de tarefas em segundo plano.

10. Acessar o código da solução

Para fazer o download do código do codelab concluído, use estes 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

Se preferir, você pode fazer o download do repositório como um arquivo ZIP, descompactar e abrir no Android Studio.

Confira o código da solução deste codelab no GitHub (link em inglês).

11. Parabéns

Parabéns! Você aprendeu sobre mais funcionalidades do WorkManger, criou testes automatizados para workers do Blur-O-Matic e usou o Inspetor de tarefas em segundo plano para análise. Neste codelab, você aprendeu como:

  • Nomear cadeias WorkRequest exclusivas.
  • Incluir tags em WorkRequests.
  • Atualizar a interface com base no WorkInfo.
  • Cancelar uma WorkRequest.
  • Adicionar restrições a uma WorkRequest.
  • Usar a API de teste do WorkManager.
  • Abordar a implementação de workers de testes.
  • Testar CoroutineWorkers.
  • Inspecionar workers manualmente e verificar a funcionalidade deles.