Como criar uma camada de dados

1. Antes de começar

Este codelab ensina sobre a camada de dados e como ela se encaixa na arquitetura geral do app.

A camada de dados na base, sob as camadas de domínio e de interface.

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

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.

Tela de uma lista de tarefas.

Tela de detalhes da tarefa.

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

  1. 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

  1. Outra opção é clonar o repositório do GitHub:
git clone https://github.com/android/architecture-samples.git
git checkout data-codelab-start
  1. 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.

A janela do explorador "Project" do Android Studio na visualização "Android".

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.

A configuração de execução, o dispositivo de destino e o botão de execução do Android Studio.

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.

O app no estado inicial, com um ícone de carregamento infinito.

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.

Os tipos de componentes na camada de dados, incluindo dependências entre modelos, fontes e 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

Task

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

LocalTask

Interno

Uma tarefa armazenada em um banco de dados local

TaskDao

NetworkTask

Interno

Uma tarefa que foi recuperada de um servidor de rede

NetworkTaskDataSource

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:

A janela explorador "Project" mostrando pastas e arquivos.

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.

A relação entre o repositório de tarefas, o modelo, a fonte de dados e o banco de dados.

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 de data/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.

  1. Abra o ToDoDatabase.kt e mude BlankEntity para LocalTask.
  2. Remova a BlankEntity e quaisquer instruções import redundantes.
  3. 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 ao DatabaseModule:
    @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:

O teste e as pastas androidTest no explorador "Project".

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 o TaskDaoTest.kt. Dentro desse arquivo, crie uma classe vazia chamada TaskDaoTest.
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

  1. 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)
}
  1. Execute o teste clicando em Play ao lado do teste no gutter.

O botão "Play" do teste no gutter do editor de código.

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.

Um teste com falha.

Figura 12. Captura de tela mostrando um teste com falha.

  1. Remova a instrução assertEquals.
  2. 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])
  1. Execute o teste de novo. O teste vai aparecer como aprovado na janela de resultados.

Um teste aprovado.

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 em data/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 de description.
  • O campo isCompleted é representado como um tipo enumerado de status, que tem dois valores possíveis: ACTIVE e COMPLETE.
  • 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 chamada TaskNetworkDataSource 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.

As dependências de DefaultTaskRepository.

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

  1. Abra o DefaultTaskRepository.kt no pacote data e crie uma classe chamada DefaultTaskRepository, que tenha TaskDao e TaskNetworkDataSource 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.

  1. Adicione um método chamado observeAll, que retorna um stream de modelos Task usando um Flow.
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.

  1. 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.

  1. Use a função toExternal recém-criada dentro de observeAll:
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.

  1. Adicione um método chamado create, que usa um title e uma description 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.
  1. Adicione um método para criar um ID de tarefa.
// This method might be computationally expensive
private fun createTaskId() : String {
    return UUID.randomUUID().toString()
}
  1. 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.

  1. Primeiro, adicione um CoroutineDispatcher como uma dependência para o DefaultTaskRepository. Use o qualificador @DefaultDispatcher já criado (definido em di/CoroutinesModule.kt) para instruir a Hilt a injetar essa dependência com Dispatchers.Default. O dispatcher Default é 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,
)
  1. Agora, faça a chamada para UUID.randomUUID().toString() dentro de um bloco withContext.
val taskId = withContext(dispatcher) {
    createTaskId()
}

Leia mais sobre linhas de execução na camada de dados.

Criar e armazenar a tarefa

  1. 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.

  1. Adicione a seguinte função de extensão ao final de LocalTask. Essa é a função de mapeamento inversa para LocalTask.toExternal, que você criou anteriormente.
fun Task.toLocal() = LocalTask(
    id = id,
    title = title,
    description = description,
    isCompleted = isCompleted,
)
  1. Use isso dentro de create para inserir a tarefa na fonte de dados local e depois retornar o taskId.
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 a Task 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

observeAll

Carregar os dados do banco de dados local

O fluxo da fonte de dados local para o repositório de tarefas.Figura 15. Diagrama mostrando o fluxo da fonte de dados local para o repositório de tarefas.

Salvar

createcomplete

1. Gravar os dados para o banco de dados local 2. Copiar todos os dados para a rede, substituindo tudo

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.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

refresh

1. Carregar dados da rede 2. Copiar para o banco de dados local, substituindo tudo

O fluxo da fonte de dados de rede para a local e, em seguida, para o repositório de tarefas.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.

  1. Primeiro, crie funções de mapeamento de LocalTask para NetworkTask e vice-versa dentro de NetworkTask.kt. Isso vale também para colocar as funções dentro de LocalTask.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.

  1. Adicione o método refresh no fim do DefaultTaskRepository.
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.

  1. Adicione o método saveTasksToNetwork no fim do DefaultTaskRepository.
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.

  1. Agora, atualize os métodos que atualizam as tarefas create e complete 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.

  1. 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.

  1. Una o código dentro de saveTasksToNetwork com scope.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.

  1. Na janela do explorador "Project", abra a pasta (test), depois a pasta source.local e o arquivo FakeTaskDao.kt.

O arquivo FakeTaskDao.kt na estrutura de pastas do explorador "Project".

Figura 18. Captura de tela mostrando o arquivo FakeTaskDao.kt na estrutura de pastas do explorador "Project".

  1. 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.

  1. 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: DefaultTaskRepository.

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 observeAll

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 create

Então

A tarefa é criada nas fontes de dados local e de rede

  1. 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 complete

Então

Os dados locais e de rede também são atualizados

  1. 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 refresh é chamada

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.

  1. 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 () { /* ... */ }
  1. 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.

  1. 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 {
            ...
       }
    }
}
  1. 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

  1. Abra o AddEditTaskViewModel e adicione o DefaultTaskRepository como um parâmetro construtor, assim como você fez na etapa anterior.
class AddEditTaskViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val taskRepository: DefaultTaskRepository,
)
  1. 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

  1. 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!).

A tela de tarefas do app quando não há tarefas.

Figura 19. Captura da tela de tarefas do app quando não há tarefas.

  1. Toque nos três pontos no canto superior direito e pressione Refresh.

A tela de tarefas do app com o menu de ação aparecendo.

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.

A tela de tarefas do app com duas tarefas aparecendo.

Figura 21. Captura da tela de tarefas do app com duas tarefas aparecendo.

  1. 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.

Tela de adição de tarefas do app.

Figura 22. Captura da tela de adição de tarefas do app.

  1. Toque no botão de marcação no canto inferior direito para salvar a tarefa.

Tela de tarefas do app após a adição de uma tarefa.

Figura 23. Captura da tela de tarefas do app após a adição de uma tarefa.

  1. Assinale a caixa de seleção ao lado da tarefa para marcá-la como concluída.

Tela de tarefas do app mostrando uma tarefa que foi 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).