1. Antes de começar
Este codelab ensina sobre a camada de dados e como ela se encaixa na arquitetura geral do app.
Figura 1. Diagrama mostrando que as camadas de domínio e de interface dependem da camada de dados.
Você vai criar a camada de dados para um app de gerenciamento de tarefas, fontes para um banco de dados local e um serviço de rede, além de um repositório que expõe, atualiza e sincroniza dados.
Pré-requisitos
- Este é um codelab intermediário, e você precisa ter um conhecimento básico de como apps Android são criados. Consulte abaixo os recursos de aprendizado para iniciantes.
- Experiência com Kotlin, incluindo lambdas, corrotinas (links em inglês) e fluxos. Para aprender a criar em Kotlin nos apps Android, acesse a Unidade 1 do curso Noções básicas do Android em Kotlin.
- Conhecimento básico sobre as bibliotecas Hilt (injeção de dependência) e Room (armazenamento de banco de dados).
- Alguma experiência com o Jetpack Compose. As Unidades 1 a 3 do curso Noções básicas do Android com o Compose são um ótimo lugar para aprender sobre esse kit de ferramentas.
- Opcional: leia os guias sobre visão geral da arquitetura e camada de dados.
- Opcional: conclua o codelab sobre a biblioteca Room.
O que você vai aprender
Neste codelab, você aprende a:
- Criar repositórios, fontes e modelos para um gerenciamento de dados eficaz e escalonável.
- Expor dados a outras camadas da arquitetura.
- Processar atualizações de dados assíncronas e tarefas complexas e de longa duração.
- Sincronizar dados entre várias fontes.
- Criar testes que verificam o comportamento dos repositórios e das fontes de dados.
O que você vai criar
Vamos criar um app de gerenciamento de tarefas que permite adicionar e marcar tarefas como concluídas.
Não será necessário criar o app do zero. Você vai trabalhar em um app que já tem uma camada de interface. Essa camada tem telas e detentores de estado no nível de tela implementados usando ViewModels.
Durante o codelab, a camada de dados será adicionada e conectada à de interface para que o app fique totalmente funcional.
Figura 2. Captura de tela de uma lista de tarefas. | Figura 3. Captura de tela dos detalhes das tarefas. |
2. Começar a configuração
- Faça o download do código neste link (em inglês):
https://github.com/android/architecture-samples/archive/refs/heads/data-codelab-start.zip
- Outra opção é clonar o repositório do GitHub:
git clone https://github.com/android/architecture-samples.git git checkout data-codelab-start
- Abra o Android Studio e carregue o projeto
architecture-samples
.
Estrutura da pasta
- Abra o explorador "Project" na visualização Android.
A pasta java/com.example.android.architecture.blueprints.todoapp
é subdividida em várias outras pastas.
Figura 4. Captura de tela mostrando a janela do explorador "Project" do Android Studio na visualização "Android".
<root>
contém classes no nível do app, como a de navegação, de atividade principal e do aplicativo.addedittask
contém o recurso de interface que permite ao usuário adicionar e editar tarefas.data
contém a camada de dados. Você trabalha principalmente nessa pasta.di
contém os módulos da Hilt para injeção de dependência.tasks
contém o recurso de interface que permite ao usuário visualizar e atualizar listas de tarefas.util
contém classes de utilitários.
Há também duas pastas de teste, o que é indicado pelo texto entre parênteses no final do nome delas.
androidTest
segue a mesma estrutura de<root>
, mas contém testes instrumentados.test
segue a mesma estrutura de<root>
, mas contém testes locais.
Executar o projeto
- Clique no ícone verde de reprodução na barra de ferramentas da parte de cima da tela.
Figura 5. Captura de tela mostrando a configuração de execução, o dispositivo de destino e o botão de execução do Android Studio.
A tela de lista de tarefas será mostrada com um ícone de carregamento que nunca desaparece.
Figura 6. Captura de tela do app no estado inicial com um ícone de carregamento infinito.
Ao final do codelab, uma lista de tarefas vai aparecer nessa tela.
É possível consultar o código final no codelab na ramificação data-codelab-final
.
git checkout data-codelab-final
Não se esqueça de salvar suas mudanças primeiro.
3. Saiba mais sobre a camada de dados
Neste codelab, você vai criar a camada de dados do app.
Como o nome sugere, essa é uma camada de arquitetura que gerencia os dados do app. Ela também contém a lógica de negócios, as regras comerciais do mundo real que determinam como os dados do app precisam ser criados, armazenados e modificados. Essa separação de conceitos torna a camada de dados reutilizável, permitindo que ela apareça em várias telas, compartilhe informações entre diferentes partes do app e reproduza a lógica de negócios fora da interface para testes de unidade.
Os principais tipos de componentes que fazem parte da camada de dados são os modelos, as fontes e os repositórios.
Figura 7. Diagrama que mostra os tipos de componentes na camada de dados, incluindo dependências entre modelos, fontes e repositórios.
Modelos de dados
Os dados do app geralmente são representados como modelos, que são representações dos dados na memória.
Como esse é um app de gerenciamento de tarefas, é necessário ter um modelo de dados para uma tarefa. Confira a classe Task
:
data class Task(
val id: String
val title: String = "",
val description: String = "",
val isCompleted: Boolean = false,
) { ... }
Um ponto importante sobre esse modelo é que ele é imutável. Outras camadas não podem mudar as propriedades da tarefa. Elas precisam usar a camada de dados se quiserem fazer mudanças.
Modelos de dados internos e externos
Task
é um exemplo de um modelo de dados externo. Ele é exposto externamente pela camada de dados e pode ser acessado por outras camadas. Depois, você define modelos de dados internos, que são usados apenas dentro da camada de dados.
É recomendado definir um modelo de dados para cada representação de um modelo de negócios. Neste app, há três modelos de dados.
Nome do modelo | Externo ou interno à camada de dados? | Representa | Fonte de dados associada |
| Externo | Uma tarefa que pode ser usada em qualquer lugar do app, armazenada apenas na memória ou ao salvar o estado do aplicativo | N/A |
| Interno | Uma tarefa armazenada em um banco de dados local |
|
| Interno | Uma tarefa que foi recuperada de um servidor de rede |
|
Fontes de dados
Uma fonte de dados é uma classe responsável pela leitura e gravação de dados em uma única fonte, como um banco de dados ou um serviço de rede.
Neste app, há duas fontes de dados:
TaskDao
é a fonte de dados local, que lê e grava e um banco de dados.NetworkTaskDataSource
é uma fonte de dados de rede, que lê e grava em um servidor de rede.
Repositórios
Um repositório precisa gerenciar um único modelo de dados. Neste app, você cria um repositório que gerencia modelos de Task
. O repositório:
- Expõe uma lista de modelos de
Task
. - Fornece métodos para criar e atualizar um modelo de
Task
. - Executa a lógica de negócios, por exemplo, criando um ID exclusivo para cada tarefa.
- Combina ou mapeia modelos de dados internos usando fontes de dados em modelos de
Task
. - Sincroniza fontes de dados.
Começar a programar o código
- Mude para a visualização Android e amplie o pacote
com.example.android.architecture.blueprints.todoapp.data
:
Figura 8. A janela explorador "Project" mostrando pastas e arquivos.
A classe Task
já foi criada para que o restante do app seja compilado. Agora, você vai criar a maioria das classes da camada de dados do zero adicionando as implementações aos arquivos .kt
vazios fornecidos.
4. Armazenar dados localmente
Nesta etapa, você vai criar uma fonte de dados e um modelo de dados para um banco de dados da biblioteca Room que armazena tarefas localmente no dispositivo.
Figura 9. Diagrama mostrando a relação entre o repositório de tarefas, o modelo, a fonte de dados e o banco de dados.
Criar um modelo de dados
Para armazenar em um banco de dados da biblioteca Room, você precisa criar uma entidade do banco de dados.
- Abra o arquivo
LocalTask.kt
dentro dedata/source/local
e depois adicione o seguinte código a ele:
@Entity(
tableName = "task"
)
data class LocalTask(
@PrimaryKey val id: String,
var title: String,
var description: String,
var isCompleted: Boolean,
)
A classe LocalTask
representa dados armazenados em uma tabela chamada task
no banco de dados da biblioteca Room. Ela é fortemente acoplada a essa biblioteca e não pode ser usada para outras fontes de dados, como o DataStore.
O prefixo Local
no nome da classe é usado para indicar que esses dados são armazenados localmente. Ele também é usado para distinguir essa classe do modelo de dados Task
, que é exposto a outras camadas no app. Em outras palavras, LocalTask
é interno à camada de dados e Task
é externo.
Criar uma fonte de dados
Agora que você tem um modelo de dados, gere uma fonte para criar, ler, atualizar e excluir (CRUD, link em inglês) modelos LocalTask
. Já que você está usando a biblioteca Room, pode utilizar um objeto de acesso a dados (a anotação @Dao
) como a fonte de dados local.
- Crie uma interface Kotlin no arquivo chamado
TaskDao.kt
.
@Dao
interface TaskDao {
@Query("SELECT * FROM task")
fun observeAll(): Flow<List<LocalTask>>
@Upsert
suspend fun upsert(task: LocalTask)
@Upsert
suspend fun upsertAll(tasks: List<LocalTask>)
@Query("UPDATE task SET isCompleted = :completed WHERE id = :taskId")
suspend fun updateCompleted(taskId: String, completed: Boolean)
@Query("DELETE FROM task")
suspend fun deleteAll()
}
Os métodos para leitura de dados têm o prefixo observe
. Essas são funções sem suspensão que retornam um Flow
. Cada vez que um dado muda, um novo item é emitido no stream. Esse recurso útil da biblioteca Room (e de muitas outras bibliotecas de armazenamento de dados), significa que você pode detectar mudanças em vez de sondar o banco de dados em busca de novos dados.
Os métodos para gravação de dados são funções de suspensão porque realizam operações de E/S.
Atualizar o esquema do banco de dados
A próxima etapa é atualizar o banco de dados para que ele armazene modelos de LocalTask
.
- Abra o
ToDoDatabase.kt
e mudeBlankEntity
paraLocalTask
. - Remova a
BlankEntity
e quaisquer instruçõesimport
redundantes. - Adicione um método para retornar o objeto de acesso a dados (DAO, na sigla em inglês) chamado
taskDao
.
A classe atualizada ficará assim:
@Database(entities = [LocalTask::class], version = 1, exportSchema = false)
abstract class ToDoDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
}
Atualizar a configuração da Hilt
Este projeto usa a biblioteca Hilt para injeção de dependência. A Hilt precisa saber como criar o TaskDao
para que ele possa ser injetado nas classes que o usam.
- Abra
di/DataModules.kt
e adicione o seguinte método aoDatabaseModule
:
@Provides
fun provideTaskDao(database: ToDoDatabase) : TaskDao = database.taskDao()
Agora, você tem tudo o que precisa para ler e gravar tarefas em um banco de dados local.
5. Testar a fonte de dados local
Na última etapa, você criou uma boa quantidade de código, mas como saber se ele funciona da maneira correta? É fácil cometer um erro com todas essas consultas SQL no TaskDao
. Crie testes para verificar se o TaskDao
se comporta como deveria.
Os testes não fazem parte do app, então eles precisam ser colocados em uma pasta diferente. Há duas pastas de teste indicadas pelo texto entre parênteses no final dos nomes dos pacotes:
Figura 10. Captura de tela mostrando o teste e as pastas androidTest no explorador "Project".
androidTest
contém testes executados em um emulador ou dispositivo Android. Eles são conhecidos como testes instrumentados.test
contém testes executados na sua máquina host, também conhecidos como testes locais.
O TaskDao
requer um banco de dados da biblioteca Room, que só pode ser criado em um dispositivo Android. Para testar, você precisa criar um teste instrumentado.
Criar a classe de teste
- Abra a pasta
androidTest
e depois oTaskDaoTest.kt
. Dentro desse arquivo, crie uma classe vazia chamadaTaskDaoTest
.
class TaskDaoTest {
}
Adicionar um banco de dados de teste
- Adicione
ToDoDatabase
e inicialize essa classe antes de cada teste.
private lateinit var database: ToDoDatabase
@Before
fun initDb() {
database = Room.inMemoryDatabaseBuilder(
getApplicationContext(),
ToDoDatabase::class.java
).allowMainThreadQueries().build()
}
Isso cria um banco de dados na memória antes dos testes. Um banco de dados na memória é muito mais rápido do que o baseado em disco. Isso faz com que ele seja uma boa opção para testes automatizados. Nesses casos, os dados não precisam persistir por mais tempo do que os testes.
Adicionar um teste
Adicione um teste que verifica se uma classe LocalTask
pode ser inserida e se a mesma LocalTask
pode ser lida usando TaskDao
.
Todos os testes neste codelab seguem a estrutura considerando, quando, então (link em inglês):
Considerando | Um banco de dados vazio |
Quando | Uma tarefa é inserida e você começa a observar o stream de tarefas |
Então | O primeiro item do stream de tarefas corresponde à tarefa que foi inserida |
- Comece criando um teste com falha. Isso confirma se o teste está mesmo sendo executado e se os objetos corretos e as dependências deles estão sendo testados.
@Test
fun insertTaskAndGetTasks() = runTest {
val task = LocalTask(
title = "title",
description = "description",
id = "id",
isCompleted = false,
)
database.taskDao().upsert(task)
val tasks = database.taskDao().observeAll().first()
assertEquals(0, tasks.size)
}
- Execute o teste clicando em Play ao lado do teste no gutter.
Figura 11. Captura de tela mostrando o botão "Play" do teste no gutter do editor de código.
O teste vai apresentar uma falha na janela de resultados com a mensagem expected:<0> but was:<1>
. Isso é esperado porque o número de tarefas no banco de dados é um, não zero.
Figura 12. Captura de tela mostrando um teste com falha.
- Remova a instrução
assertEquals
. - Adicione o código para testar se apenas uma tarefa é fornecida pela fonte de dados e se essa é a mesma tarefa que foi inserida.
A ordem dos parâmetros de assertEquals
precisa ser sempre o valor esperado seguido pelo valor real**.**
assertEquals(1, tasks.size)
assertEquals(task, tasks[0])
- Execute o teste de novo. O teste vai aparecer como aprovado na janela de resultados.
Figura 13. Captura de tela mostrando um teste aprovado.
6. Criar uma fonte de dados de rede
É ótimo que as tarefas possam ser salvas localmente no dispositivo, mas e se você também quiser salvar e carregar essas tarefas em um serviço de rede? Talvez seu app Android seja apenas uma das maneiras para os usuários adicionarem tarefas na lista. Elas também poderiam ser gerenciadas por um site ou um aplicativo de computador. Ou talvez você queira apenas fornecer um backup on-line para que os usuários possam restaurar os dados do app mesmo se trocarem de dispositivo.
Nesses cenários, geralmente você tem um serviço baseado em rede que todos os clientes (incluindo seu app Android) podem usar para carregar e salvar dados.
Na próxima etapa, você vai criar uma fonte de dados para se comunicar com esse serviço de rede. Para os fins deste codelab, usamos um serviço simulado que não se conecta a um serviço de rede ativo, mas dá uma ideia de como isso poderia ser implementado em um app real.
Sobre o serviço de rede
No exemplo, a API da rede é muito simples. Ela realiza apenas duas operações:
- Salvar todas as tarefas, substituindo dados gravados anteriormente.
- Carregar todas as tarefas, criando uma lista daquelas que estão salvas no momento no serviço de rede.
Criar um modelo dos dados de rede
Ao receber dados de uma API de rede, é comum que eles sejam representados de forma distinta de como ocorre localmente. A representação de rede de uma tarefa pode ter campos adicionais ou usar tipos ou nomes de campos diferentes para representar os mesmos valores.
Para considerar essas diferenças, crie um modelo de dados específico para a rede.
- Abra o arquivo
NetworkTask.kt
encontrado emdata/source/network
e adicione o seguinte código para representar os campos:
data class NetworkTask(
val id: String,
val title: String,
val shortDescription: String,
val priority: Int? = null,
val status: TaskStatus = TaskStatus.ACTIVE
) {
enum class TaskStatus {
ACTIVE,
COMPLETE
}
}
Estas são as diferenças entre LocalTask
e NetworkTask
:
- A descrição da tarefa é chamada de
shortDescription
em vez dedescription
. - O campo
isCompleted
é representado como um tipo enumerado destatus
, que tem dois valores possíveis:ACTIVE
eCOMPLETE
. - Ele contém um campo
priority
extra, que é um número inteiro.
Criar a fonte de dados de rede
- Abra
TaskNetworkDataSource.kt
e crie uma classe chamadaTaskNetworkDataSource
com o seguinte conteúdo:
class TaskNetworkDataSource @Inject constructor() {
// A mutex is used to ensure that reads and writes are thread-safe.
private val accessMutex = Mutex()
private var tasks = listOf(
NetworkTask(
id = "PISA",
title = "Build tower in Pisa",
shortDescription = "Ground looks good, no foundation work required."
),
NetworkTask(
id = "TACOMA",
title = "Finish bridge in Tacoma",
shortDescription = "Found awesome girders at half the cost!"
)
)
suspend fun loadTasks(): List<NetworkTask> = accessMutex.withLock {
delay(SERVICE_LATENCY_IN_MILLIS)
return tasks
}
suspend fun saveTasks(newTasks: List<NetworkTask>) = accessMutex.withLock {
delay(SERVICE_LATENCY_IN_MILLIS)
tasks = newTasks
}
}
private const val SERVICE_LATENCY_IN_MILLIS = 2000L
Esse objeto simula interação com o servidor, incluindo um atraso simulado de dois segundos cada vez que a função loadTasks
ou saveTasks
é chamada. Isso pode representar latência de resposta da rede ou do servidor.
Também são incluídos alguns dados de teste que você usa posteriormente para verificar se as tarefas podem ser carregadas da rede.
Se a API do seu servidor real usa HTTP, considere usar uma biblioteca como Ktor ou Retrofit (links em inglês) para criar sua fonte de dados de rede.
7. Criar o repositório de tarefas
As peças estão todas se encaixando.
Figura 14. Diagrama mostrando as dependências de DefaultTaskRepository
.
Temos duas fontes: uma para dados locais (TaskDao
) e outra para dados de rede (TaskNetworkDataSource
). Cada uma delas permite leitura e gravação, além de ter a própria representação de uma tarefa (LocalTask
e NetworkTask
, respectivamente).
Agora, é hora de criar um repositório que use essas fontes de dados e forneça uma API para que outras camadas da arquitetura possam acessar esses dados de tarefas.
Expor os dados
- Abra o
DefaultTaskRepository.kt
no pacotedata
e crie uma classe chamadaDefaultTaskRepository
, que tenhaTaskDao
eTaskNetworkDataSource
como dependências.
class DefaultTaskRepository @Inject constructor(
private val localDataSource: TaskDao,
private val networkDataSource: TaskNetworkDataSource,
) {
}
Os dados precisam ser expostos usando fluxos. Isso permite que os autores de chamadas sejam notificados sobre mudanças nesses dados ao longo do tempo.
- Adicione um método chamado
observeAll
, que retorna um stream de modelosTask
usando umFlow
.
fun observeAll() : Flow<List<Task>> {
// TODO add code to retrieve Tasks
}
Os repositórios precisam expor dados de uma única fonte de verdade. Ou seja, os dados precisam vir de apenas uma fonte. Pode ser um cache na memória, um servidor remoto ou, neste caso, o banco de dados local.
As tarefas no banco de dados local podem ser acessadas usando TaskDao.observeAll
, que convenientemente retorna um fluxo. No entanto, esse é um fluxo de modelos de LocalTask
, em que LocalTask
é um modelo interno que não deve ser exposto a outras camadas da arquitetura.
Você precisa transformar LocalTask
em uma Task
. Esse é um modelo externo que faz parte da API da camada de dados.
Mapear modelos internos para externos
Para fazer essa conversão, é necessário mapear os campos de LocalTask
para aqueles em Task
.
- Crie funções de extensão (link em inglês) para isso em
LocalTask
.
// Convert a LocalTask to a Task
fun LocalTask.toExternal() = Task(
id = id,
title = title,
description = description,
isCompleted = isCompleted,
)
// Convenience function which converts a list of LocalTasks to a list of Tasks
fun List<LocalTask>.toExternal() = map(LocalTask::toExternal) // Equivalent to map { it.toExternal() }
Agora, sempre que você precisar transformar LocalTask
em Task
, basta chamar toExternal
.
- Use a função
toExternal
recém-criada dentro deobserveAll
:
fun observeAll(): Flow<List<Task>> {
return localDataSource.observeAll().map { tasks ->
tasks.toExternal()
}
}
Sempre que os dados das tarefas mudam no banco de dados local, uma nova lista de modelos LocalTask
é emitida no fluxo. Cada LocalTask
é então mapeada para uma Task
.
Muito bem! Agora, outras camadas podem usar observeAll
para conseguir todos os modelos de Task
do banco de dados local e ser notificadas sempre que esses modelos de Task
mudarem.
Atualizar os dados
Um app de afazeres não será muito útil se você não puder criar e atualizar tarefas. Agora, você vai adicionar métodos para fazer isso.
Métodos para criar, atualizar ou excluir dados são operações únicas e precisam ser implementados usando funções suspend
.
- Adicione um método chamado
create
, que usa umtitle
e umadescription
como parâmetros e retorna o ID da tarefa recém-criada.
suspend fun create(title: String, description: String): String {
}
Observe que a API da camada de dados proíbe uma Task
de ser criada por outras camadas, fornecendo apenas um método create
que aceita parâmetros individuais, e não uma Task
. Essa abordagem envolve:
- A lógica de negócios para criar um ID de tarefa exclusivo.
- O local onde a tarefa é armazenada após a criação inicial.
- Adicione um método para criar um ID de tarefa.
// This method might be computationally expensive
private fun createTaskId() : String {
return UUID.randomUUID().toString()
}
- Crie um ID de tarefa usando o método
createTaskId
recém-adicionado.
suspend fun create(title: String, description: String): String {
val taskId = createTaskId()
}
Não bloquear a linha de execução principal
Opa, peraí. E se a criação do ID da tarefa tiver um custo computacional elevado? Talvez seja usada criptografia para criar uma chave de hash para o ID, o que leva vários segundos. Essa criação pode causar instabilidade na interface se for chamada na linha de execução principal.
A camada de dados tem a responsabilidade de garantir que tarefas complexas ou de longa duração não bloqueiem a linha de execução principal.
Para corrigir isso, especifique um dispatcher de corrotina a ser usado para executar essas instruções.
- Primeiro, adicione um
CoroutineDispatcher
como uma dependência para oDefaultTaskRepository
. Use o qualificador@DefaultDispatcher
já criado (definido emdi/CoroutinesModule.kt
) para instruir a Hilt a injetar essa dependência comDispatchers.Default
. O dispatcherDefault
é especificado porque é otimizado para o trabalho intensivo da CPU. Leia mais sobre dispatchers de corrotina neste link.
class DefaultTaskRepository @Inject constructor(
private val localDataSource: TaskDao,
private val networkDataSource: TaskNetworkDataSource,
@DefaultDispatcher private val dispatcher: CoroutineDispatcher,
)
- Agora, faça a chamada para
UUID.randomUUID().toString()
dentro de um blocowithContext
.
val taskId = withContext(dispatcher) {
createTaskId()
}
Leia mais sobre linhas de execução na camada de dados.
Criar e armazenar a tarefa
- Agora que você tem um ID de tarefa, use-o junto com os parâmetros fornecidos para criar uma
Task
.
suspend fun create(title: String, description: String): String {
val taskId = withContext(dispatcher) {
createTaskId()
}
val task = Task(
title = title,
description = description,
id = taskId,
)
}
Antes de inserir a tarefa na fonte de dados local, ela precisa ser mapeada para uma LocalTask
.
- Adicione a seguinte função de extensão ao final de
LocalTask
. Essa é a função de mapeamento inversa paraLocalTask.toExternal
, que você criou anteriormente.
fun Task.toLocal() = LocalTask(
id = id,
title = title,
description = description,
isCompleted = isCompleted,
)
- Use isso dentro de
create
para inserir a tarefa na fonte de dados local e depois retornar otaskId
.
suspend fun create(title: String, description: String): Task {
...
localDataSource.upsert(task.toLocal())
return taskId
}
Concluir a tarefa
- Crie um método extra (
complete
) que marca aTask
como concluída.
suspend fun complete(taskId: String) {
localDataSource.updateCompleted(taskId, true)
}
Agora você tem alguns métodos úteis para criar e concluir tarefas.
Sincronizar os dados
Neste app, a fonte de dados de rede é usada como um backup on-line que é atualizado sempre que os dados são gravados localmente. Os dados são carregados usando a rede sempre que o usuário solicita uma atualização.
Os diagramas a seguir resumem o comportamento de cada tipo de operação.
Tipo de operação | Métodos de repositório | Etapas | Movimentação de dados |
Carregar |
| Carregar os dados do banco de dados local | Figura 15. Diagrama mostrando o fluxo da fonte de dados local para o repositório de tarefas. |
Salvar |
| 1. Gravar os dados para o banco de dados local 2. Copiar todos os dados para a rede, substituindo tudo | Figura 16. Diagrama mostrando o fluxo de dados do repositório de tarefas para a fonte de dados local e, em seguida, para a fonte de dados de rede. |
Atualizar |
| 1. Carregar dados da rede 2. Copiar para o banco de dados local, substituindo tudo | Figura 17. Diagrama mostrando o fluxo da fonte de dados de rede para a local e, em seguida, para o repositório de tarefas. |
Salvar e atualizar os dados da rede
Seu repositório já carrega tarefas da fonte de dados local. Para concluir o algoritmo de sincronização, você precisa criar métodos para salvar e atualizar os dados da fonte de dados de rede.
- Primeiro, crie funções de mapeamento de
LocalTask
paraNetworkTask
e vice-versa dentro deNetworkTask.kt
. Isso vale também para colocar as funções dentro deLocalTask.kt
.
fun NetworkTask.toLocal() = LocalTask(
id = id,
title = title,
description = shortDescription,
isCompleted = (status == NetworkTask.TaskStatus.COMPLETE),
)
fun List<NetworkTask>.toLocal() = map(NetworkTask::toLocal)
fun LocalTask.toNetwork() = NetworkTask(
id = id,
title = title,
shortDescription = description,
status = if (isCompleted) { NetworkTask.TaskStatus.COMPLETE } else { NetworkTask.TaskStatus.ACTIVE }
)
fun List<LocalTask>.toNetwork() = map(LocalTask::toNetwork)
Aqui, você pode notar a vantagem de ter modelos separados para cada fonte de dados. O mapeamento de um tipo de dado para outro é encapsulado em funções separadas.
- Adicione o método
refresh
no fim doDefaultTaskRepository
.
suspend fun refresh() {
val networkTasks = networkDataSource.loadTasks()
localDataSource.deleteAll()
val localTasks = withContext(dispatcher) {
networkTasks.toLocal()
}
localDataSource.upsertAll(networkTasks.toLocal())
}
Isso substitui todas as tarefas locais pelas de rede. O withContext
é usado para a operação toLocal
em massa porque há um número desconhecido de tarefas, e cada operação de mapeamento pode ter um custo computacional elevado.
- Adicione o método
saveTasksToNetwork
no fim doDefaultTaskRepository
.
private suspend fun saveTasksToNetwork() {
val localTasks = localDataSource.observeAll().first()
val networkTasks = withContext(dispatcher) {
localTasks.toNetwork()
}
networkDataSource.saveTasks(networkTasks)
}
Isso substitui todas as tarefas de rede pelas da fonte de dados local.
- Agora, atualize os métodos que atualizam as tarefas
create
ecomplete
para que os dados locais sejam salvos na rede quando forem modificados.
suspend fun create(title: String, description: String): String {
...
saveTasksToNetwork()
return taskId
}
suspend fun complete(taskId: String) {
localDataSource.updateCompleted(taskId, true)
saveTasksToNetwork()
}
Acabar com a espera do autor da chamada
Se você executasse esse código, perceberia que o método saveTasksToNetwork
gera um bloqueio. Isso significa que os autores de chamada de create
e complete
são forçados a esperar até que os dados sejam salvos na rede para que possam ter certeza de que a operação foi concluída. Na fonte de dados de rede simulada, esse tempo é de apenas dois segundos, mas em um app real pode ser muito maior, ou até infinito se não houver uma conexão de rede.
Isso é desnecessariamente restritivo e é provável que gere uma experiência ruim para o usuário. Ninguém quer esperar para criar uma tarefa, especialmente quando está ocupado.
Uma solução melhor é usar um escopo de corrotina diferente para salvar os dados na rede. Isso permite que a operação seja concluída em segundo plano sem que o autor da chamada tenha que esperar pelo resultado.
- Adicione um escopo de corrotina como um parâmetro para
DefaultTaskRepository
.
class DefaultTaskRepository @Inject constructor(
// ...other parameters...
@ApplicationScope private val scope: CoroutineScope,
)
O qualificador @ApplicationScope
da Hilt (definido em di/CoroutinesModule.kt
) é usado para injetar um escopo que segue o ciclo de vida do app.
- Una o código dentro de
saveTasksToNetwork
comscope.launch
.
private fun saveTasksToNetwork() {
scope.launch {
val localTasks = localDataSource.observeAll().first()
val networkTasks = withContext(dispatcher) {
localTasks.toNetwork()
}
networkDataSource.saveTasks(networkTasks)
}
}
Agora, saveTasksToNetwork
retorna imediatamente, e as tarefas são salvas na rede em segundo plano.
8. Testar o repositório de tarefas
Ufa! Você adicionou várias funcionalidades à sua camada de dados. É hora de verificar se tudo isso funciona, criando testes de unidade para o DefaultTaskRepository
.
Você precisa instanciar o objeto em teste (DefaultTaskRepository
) com dependências de teste para as fontes de dados locais e de rede. Primeiro, você precisa criar essas dependências.
- Na janela do explorador "Project", abra a pasta
(test)
, depois a pastasource.local
e o arquivoFakeTaskDao.kt.
Figura 18. Captura de tela mostrando o arquivo FakeTaskDao.kt
na estrutura de pastas do explorador "Project".
- Adicione o seguinte conteúdo:
class FakeTaskDao(initialTasks: List<LocalTask>) : TaskDao {
private val _tasks = initialTasks.toMutableList()
private val tasksStream = MutableStateFlow(_tasks.toList())
override fun observeAll(): Flow<List<LocalTask>> = tasksStream
override suspend fun upsert(task: LocalTask) {
_tasks.removeIf { it.id == task.id }
_tasks.add(task)
tasksStream.emit(_tasks)
}
override suspend fun upsertAll(tasks: List<LocalTask>) {
val newTaskIds = tasks.map { it.id }
_tasks.removeIf { newTaskIds.contains(it.id) }
_tasks.addAll(tasks)
}
override suspend fun updateCompleted(taskId: String, completed: Boolean) {
_tasks.firstOrNull { it.id == taskId }?.let { it.isCompleted = completed }
tasksStream.emit(_tasks)
}
override suspend fun deleteAll() {
_tasks.clear()
tasksStream.emit(_tasks)
}
}
Em um app real, você também criaria uma dependência falsa para substituir TaskNetworkDataSource
, fazendo com que os objetos falsos e reais implementassem uma interface comum. Neste codelab, entretanto, ela será usada diretamente.
- Dentro do
DefaultTaskRepositoryTest
, adicione o seguinte:
Uma regra que define o dispatcher principal a ser usado em todos os testes. |
Alguns dados de teste. |
As dependências de teste para as fontes de dados locais e de rede. |
O objeto em teste: |
class DefaultTaskRepositoryTest {
private var testDispatcher = UnconfinedTestDispatcher()
private var testScope = TestScope(testDispatcher)
private val localTasks = listOf(
LocalTask(id = "1", title = "title1", description = "description1", isCompleted = false),
LocalTask(id = "2", title = "title2", description = "description2", isCompleted = true),
)
private val localDataSource = FakeTaskDao(localTasks)
private val networkDataSource = TaskNetworkDataSource()
private val taskRepository = DefaultTaskRepository(
localDataSource = localDataSource,
networkDataSource = networkDataSource,
dispatcher = testDispatcher,
scope = testScope
)
}
Muito bem! Você já pode começar a programar testes de unidade. Há três áreas principais que precisam ser testadas: leituras, gravações e sincronização de dados.
Testar os dados expostos
Confira como você pode testar se o repositório está expondo os dados corretamente. O teste é realizado na estrutura considerando, quando, então (link em inglês). Exemplos:
Considerando | Que a fonte de dados local tem algumas tarefas |
Quando | O stream de tarefas vem do repositório usando |
Então | O primeiro item no stream de tarefas corresponde à representação externa das tarefas na fonte de dados local |
- Crie um teste chamado
observeAll_exposesLocalData
com o seguinte conteúdo:
@Test
fun observeAll_exposesLocalData() = runTest {
val tasks = taskRepository.observeAll().first()
assertEquals(localTasks.toExternal(), tasks)
}
Use a função first
para conseguir o primeiro item do stream de tarefas.
Testar as atualizações de dados
Agora, programe um teste que verifique se uma tarefa foi criada e salva na fonte de dados de rede.
Considerando | Um banco de dados vazio |
Quando | Uma tarefa é criada chamando |
Então | A tarefa é criada nas fontes de dados local e de rede |
- Crie um teste chamado
onTaskCreation_localAndNetworkAreUpdated
.
@Test
fun onTaskCreation_localAndNetworkAreUpdated() = testScope.runTest {
val newTaskId = taskRepository.create(
localTasks[0].title,
localTasks[0].description
)
val localTasks = localDataSource.observeAll().first()
assertEquals(true, localTasks.map { it.id }.contains(newTaskId))
val networkTasks = networkDataSource.loadTasks()
assertEquals(true, networkTasks.map { it.id }.contains(newTaskId))
}
Em seguida, verifique se quando uma tarefa é concluída, ela é gravada corretamente na fonte de dados local e salva na de rede.
Considerando | Que a fonte de dados local contém uma tarefa |
Quando | Essa tarefa é concluída chamando |
Então | Os dados locais e de rede também são atualizados |
- Crie um teste chamado
onTaskCompletion_localAndNetworkAreUpdated
.
@Test
fun onTaskCompletion_localAndNetworkAreUpdated() = testScope.runTest {
taskRepository.complete("1")
val localTasks = localDataSource.observeAll().first()
val isLocalTaskComplete = localTasks.firstOrNull { it.id == "1" } ?.isCompleted
assertEquals(true, isLocalTaskComplete)
val networkTasks = networkDataSource.loadTasks()
val isNetworkTaskComplete =
networkTasks.firstOrNull { it.id == "1"} ?.status == NetworkTask.TaskStatus.COMPLETE
assertEquals(true, isNetworkTaskComplete)
}
Testar as atualizações de dados
Por fim, teste se a operação de atualização foi concluída.
Considerando | Que a fonte de dados de rede contém dados |
Quando | A função |
Então | Os dados locais são os mesmos que os da rede |
- Crie um teste chamado
onRefresh_localIsEqualToNetwork
.
@Test
fun onRefresh_localIsEqualToNetwork() = runTest {
val networkTasks = listOf(
NetworkTask(id = "3", title = "title3", shortDescription = "desc3"),
NetworkTask(id = "4", title = "title4", shortDescription = "desc4"),
)
networkDataSource.saveTasks(networkTasks)
taskRepository.refresh()
assertEquals(networkTasks.toLocal(), localDataSource.observeAll().first())
}
Pronto! Execute os testes e confirme se todos resultam em uma aprovação.
9. Atualizar a camada de interface
Agora que você sabe que a camada de dados funciona, está na hora de conectá-la à de interface.
Atualizar o modelo de visualização para a tela da lista de tarefas
Comece com TasksViewModel
. Esse é o modelo de visualização para mostrar a primeira tela do app: a lista de todas as tarefas ativas no momento.
- Abra essa classe e adicione
DefaultTaskRepository
como um parâmetro construtor.
@HiltViewModel
class TasksViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val taskRepository: DefaultTaskRepository,
) : ViewModel () { /* ... */ }
- Inicialize a variável
tasksStream
usando o repositório.
private val tasksStream = taskRepository.observeAll()
Seu modelo de visualização agora tem acesso a todas as tarefas fornecidas pelo repositório e vai receber uma nova lista de tarefas sempre que os dados forem editados. Isso é feito com uma única linha de código.
- Tudo o que resta é conectar as ações do usuário aos seus métodos correspondentes no repositório. Encontre e atualize o método
complete
para:
fun complete(task: Task, completed: Boolean) {
viewModelScope.launch {
if (completed) {
taskRepository.complete(task.id)
showSnackbarMessage(R.string.task_marked_complete)
} else {
...
}
}
}
- Faça o mesmo com
refresh
.
fun refresh() {
_isLoading.value = true
viewModelScope.launch {
taskRepository.refresh()
_isLoading.value = false
}
}
Atualizar o modelo de visualização para a tela de adição de tarefas
- Abra o
AddEditTaskViewModel
e adicione oDefaultTaskRepository
como um parâmetro construtor, assim como você fez na etapa anterior.
class AddEditTaskViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val taskRepository: DefaultTaskRepository,
)
- Atualize o método
create
para o seguinte:
private fun createNewTask() = viewModelScope.launch {
taskRepository.create(uiState.value.title, uiState.value.description)
_uiState.update {
it.copy(isTaskSaved = true)
}
}
Executar o app
- Chegou o momento que você estava esperando: a execução do app. A tela vai mostrar a mensagem You have no tasks! (Você não tem tarefas!).
Figura 19. Captura da tela de tarefas do app quando não há tarefas.
- Toque nos três pontos no canto superior direito e pressione Refresh.
Figura 20. Captura da tela de tarefas do app com o menu de ação aparecendo.
Será mostrado um ícone de carregamento por dois segundos e, em seguida, as tarefas de teste que você adicionou.
Figura 21. Captura da tela de tarefas do app com duas tarefas aparecendo.
- Agora, toque no sinal de adição no canto inferior direito para adicionar uma nova tarefa. Preencha os campos de título e descrição.
Figura 22. Captura da tela de adição de tarefas do app.
- Toque no botão de marcação no canto inferior direito para salvar a tarefa.
Figura 23. Captura da tela de tarefas do app após a adição de uma tarefa.
- Assinale a caixa de seleção ao lado da tarefa para marcá-la como concluída.
Figura 24. Captura da tela de tarefas do app mostrando uma tarefa que foi concluída.
10. Parabéns!
Você criou uma camada de dados para um app.
A camada de dados é uma parte essencial da arquitetura do seu aplicativo. Ela é uma base sobre a qual outras camadas podem ser construídas. Portanto, a criação correta permite que seu app seja dimensionado de acordo com as necessidades dos usuários e da sua empresa.
O que você aprendeu
- A função da camada de dados na arquitetura de apps Android.
- Como criar fontes de dados e modelos.
- A função dos repositórios e como eles expõem os dados e fornecem métodos únicos para atualizá-los.
- Quando mudar o dispatcher de corrotina e por que é importante fazer isso.
- Sincronização de dados usando várias fontes.
- Como criar testes de unidade e instrumentados para classes comuns da camada de dados.
Um novo desafio
Se você quiser outro desafio, implemente estes recursos:
- Reativar uma tarefa depois que ela tiver sido marcada como concluída.
- Editar o título e a descrição de uma tarefa tocando nela.
Não vamos explicar como fazer isso. Agora é com você! Se tiver dúvidas, dê uma olhada no app totalmente funcional na ramificação main
.
git checkout main
Próximas etapas
Para saber mais sobre a camada de dados, consulte a documentação oficial e o guia para priorização off-line. Você também pode aprender sobre as outras camadas de arquitetura: a de interface e a de domínio.
Para conferir um exemplo mais complexo do mundo real, analise o app Now in Android (link em inglês).