Como trabalhar com o Proto DataStore

O que é o DataStore?

O DataStore é uma solução nova e melhorada de armazenamento de dados que visa substituir SharedPreferences. Criado com base em corrotinas e fluxo Kotlin, o DataStore oferece duas implementações diferentes: Proto DataStore, que permite armazenar objetos tipados (com suporte de buffers de protocolo) e Preferences DataStore, que armazena pares de valores-chave. Os dados são armazenados de forma assíncrona, consistente e transacional, superando algumas das desvantagens de SharedPreferences.

O que você aprenderá

  • O que é o DataStore e por que usá-lo.
  • Como adicionar o DataStore ao seu projeto.
  • As diferenças entre o Preferences e o Proto DataStore e as vantagens de cada um.
  • Como usar o Proto DataStore.
  • Como migrar de SharedPreferences para o Proto DataStore.

O que você criará

Neste codelab, você começará com um app de amostra que exibe uma lista de tarefas que podem ser filtradas pelo status concluído e podem ser classificadas por prioridade e prazo.

fcb2ffa4e6b77f33.gif

A sinalização booleana para o filtro Mostrar tarefas concluídas é salva na memória. A ordem de classificação é mantida no disco usando um objeto SharedPreferences.

Como o DataStore tem duas implementações diferentes: Preferences DataStore e Proto DataStore, você aprenderá como usar o Proto DataStore com as seguintes tarefas em cada implementação:

  • Manter o filtro de status concluído no DataStore.
  • Migrar a ordem de classificação de SharedPreferences para o DataStore.

Também recomendamos que você consulte o codelab Preferences DataStore para entender melhor a diferença entre os dois.

Pré-requisitos

Para uma introdução aos componentes de arquitetura, confira o codelab Room com View. Para uma introdução sobre fluxos, confira o codelab Corrotinas avançadas com Kotlin Flow e LiveData.

Nesta etapa, você fará o download do código para todo o codelab e executará um app simples de exemplo.

Para começar o mais rápido possível, preparamos um projeto inicial para você desenvolver.

Se você tiver o git instalado, basta executar o comando abaixo. Para verificar se o git está instalado, digite git --version no terminal ou na linha de comando e verifique se ele é executado corretamente.

 git clone https://github.com/googlecodelabs/android-datastore

O estado inicial fica na ramificação mestre. O código da solução está na ramificação proto_datastore (link em inglês).

Se você não tiver o git, clique no botão a seguir para fazer o download de todo o código deste codelab:

Fazer o download do código-fonte

  1. Descompacte o código e abra o projeto no Android Studio versão 3.6 ou mais recente.
  2. Execute a configuração de execução do app em um dispositivo ou emulador.

b3c0dfdb92dfed77.png

O app será executado e exibirá a lista de tarefas:

16eb4ceb800bf131.png

O app permite que você veja uma lista de tarefas. Cada tarefa tem as seguintes propriedades: nome, status concluído, prioridade e prazo.

Para simplificar o código que iremos usar, o app permite apenas duas ações:

  • Ativar ou desativar a exibição de tarefas concluídas. Elas ficam ocultas por padrão.
  • Classificar as tarefas por prioridade, por prazo ou por prioridade e prazo.

O app segue a arquitetura recomendada no "Guia para a arquitetura do app". Veja o que você encontrará em cada pacote:

data

  • A classe modelo Task.
  • A classe TasksRepository responsável por fornecer as tarefas. Para simplificar, ela retorna dados fixados no código e os expõe com um Flow para representar um cenário mais realista.
  • A classe UserPreferencesRepository que contém a SortOrder, definida como uma enum. A ordem de classificação atual é salva em SharedPreferences como uma String, com base no nome do valor da enumeração. Ela expõe métodos síncronos para salvar e acessar a ordem de classificação.

ui

  • Classes relacionadas à exibição de uma Activity usando uma RecyclerView.
  • A classe TasksViewModel é responsável pela lógica da IU.

TasksViewModel contém todos os elementos necessários para criar os dados que precisam ser exibidos na IU: a lista de tarefas, as sinalizações para mostrar tarefas concluídas e a ordem de classificação, incluídas em um objeto TasksUiModel. Toda vez que um desses valores mudar, precisaremos recriar um novo TasksUiModel. Para isso, combinamos três elementos:

  • Um Flow<List<Task>> recuperado do TasksRepository.
  • Um MutableStateFlow<Boolean> que contém a sinalização mais recente para mostrar tarefas concluídas, que é mantida apenas na memória.
  • Um MutableStateFlow<SortOrder> que contém o valor mais recente da SortOrder.

Para garantir que estamos atualizando a IU corretamente, um LiveData<TasksUiModel> será exposto apenas quando a atividade for iniciada.

Temos alguns problemas com nosso código:

  • Bloqueamos a linha de execução de IU na E/S do disco ao inicializar UserPreferencesRepository.sortOrder. Isso pode resultar em instabilidade da IU.
  • A sinalização para mostrar tarefas concluídas é mantida somente na memória. Portanto, ela será redefinida sempre que o usuário abrir o app. Assim como a SortOrder, ela precisa sobreviver ao fechamento do app.
  • Estamos usando SharedPreferences para persistir os dados, mas um MutableStateFlow, que modificamos manualmente, é mantido na memória para podermos receber notificações de mudanças. Esse processo pode ser facilmente corrompido se o valor for modificado em algum outro lugar do aplicativo.
  • Em UserPreferencesRepository, dois métodos para atualizar a ordem de classificação são expostos: enableSortByDeadline() e enableSortByPriority(). Esses dois métodos dependem do valor da ordem de classificação atual. No entanto, se um for chamado antes do outro terminar, o valor final estará errado. Além disso, esses métodos podem resultar em instabilidade na IU e em violações no modo restrito à medida que são chamados na linha de execução de IU.

Embora as sinalizações para mostrar tarefas concluídas e a ordem de classificação sejam preferências do usuário, elas são representadas como dois objetos diferentes. Um dos nossos objetivos é unificar essas duas sinalizações em uma classe UserPreferences.

Vamos ver como usar o DataStore para nos ajudar com esses problemas.

Muitas vezes, você poderá precisar armazenar conjuntos de dados pequenos ou simples. Para isso, anteriormente, você pode ter usado SharedPreferences, mas essa API também tem algumas desvantagens. O objetivo da biblioteca Jetpack DataStore é resolver esses problemas, criando uma API simples, segura e assíncrona para armazenar dados. Ela oferece duas implementações diferentes:

  • Preferences DataStore
  • Proto DataStore

Recurso

SharedPreferences

PreferencesDataStore

ProtoDataStore

API assíncrona

✅ (apenas para ler valores modificados, usando listeners)

✅ (usando Flow)

✅ (usando Flow)

API síncrona

✅ (mas não seguro para chamada na linha de execução de IU)

Seguro para chamada na linha de execução de IU

❌*

✅ (o trabalho é movido internamente para Dispatchers.IO)

✅ (o trabalho é movido internamente para Dispatchers.IO)

Pode sinalizar erros

Protegido contra exceções de tempo de execução

❌**

Tem uma API transacional com forte garantia de consistência

Gerencia a migração de dados

✅ (de SharedPreferences)

✅ (de SharedPreferences)

Segurança de tipo

✅ com buffers de protocolo

  • SharedPreferences tem uma API síncrona que pode parecer segura para chamadas na linha de execução de IU, mas que realiza operações de E/S de disco. Além disso, apply() bloqueia a linha de execução de IU em fsync(). Chamadas fsync() pendentes serão acionadas sempre que um serviço for iniciado ou interrompido e toda vez que uma atividade for iniciada ou interrompida em qualquer lugar do aplicativo. A linha de execução de IU é bloqueada durante chamadas fsync() pendentes programadas por apply(), geralmente tornando-se uma fonte de ANRs.

** SharedPreferences gera erros de análise como exceções de tempo de execução.

Preferences x Proto DataStore

Embora tanto o Preferences quanto o Proto DataStore permitam salvar dados, eles fazem isso de maneiras diferentes:

  • O Preference DataStore, assim como SharedPreferences, acessa dados com base em chaves, sem definir um esquema antecipadamente.
  • O Proto DataStore define o esquema usando buffers de protocolo. Usar Protobufs permite manter dados fortemente categorizados. Eles são mais rápidos, menores, simples e menos ambíguos que XML e outros formatos de dados semelhantes. Embora o Proto DataStore exija que você aprenda um novo mecanismo de serialização, acreditamos que a vantagem de categorização fornecida por ele valha a pena.

Room x DataStore

Se você precisar de atualizações parciais, integridade referencial ou conjuntos de dados grandes/complexos, use a Room em vez do DataStore. O DataStore é ideal para conjuntos de dados pequenos e simples e não é compatível com atualizações parciais ou integridade referencial.

Um dos pontos negativos de SharedPreferences e do Preferences DataStore é que não há como definir um esquema ou garantir que as chaves sejam acessadas com o tipo correto. O Proto DataStore resolve esse problema usando buffers de protocolo para definir o esquema. Com o uso de arquivos proto, o DataStore sabe que tipos estão armazenados e somente os fornecerá, eliminando a necessidade de usar chaves.

Veja como adicionar o Proto DataStore e Protobufs ao projeto, o que são buffers de protocolo e como usá-los com o Proto DataStore e como migrar de SharedPreferences para o DataStore.

Como adicionar dependências

Para trabalhar com o Proto DataStore e fazer com que o Protobuf gere o código para nosso esquema, será necessário fazer várias mudanças no arquivo build.gradle:

  • Adicione o plug-in Protobuf.
  • Adicione as dependências do Protobuf e do Proto DataStore.
  • Configure o Protobuf.
plugins {
    ...
    id "com.google.protobuf" version "0.8.12"
}

dependencies {
    implementation  "androidx.datastore:datastore-core:1.0.0-alpha04"
    implementation  "com.google.protobuf:protobuf-javalite:3.10.0"
    ...
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.10.0"
    }

    // Generates the java Protobuf-lite code for the Protobufs in this project. See
    // https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation
    // for more information.
    generateProtoTasks {
        all().each { task ->
            task.builtins {
                java {
                    option 'lite'
                }
            }
        }
    }
}

Buffers de protocolo são um mecanismo de serialização de dados estruturados. Você define como quer que os dados sejam estruturados uma vez e, em seguida, o compilador gera um código-fonte para gravar e ler os dados estruturados com facilidade.

Criar o arquivo proto

O esquema é definido em um arquivo proto. No nosso codelab, temos duas preferências de usuário: mostrar tarefas concluídas e ordem de classificação. Elas são representadas como dois objetos diferentes. Um dos nossos objetivos é unificar as duas sinalizações em uma classe UserPreferences que é armazenada no DataStore. Em vez de definir essa classe em Kotlin, ela será definida com o esquema protobuf.

Confira o Guia da linguagem proto para ver informações detalhadas sobre a sintaxe. Neste codelab, vamos nos concentrar apenas nos tipos de que precisamos.

Crie um novo arquivo chamado user_prefs.proto no diretório app/src/main/proto. Se essa estrutura de pastas não for exibida, alterne para a Visualização Project. Em protobufs, cada estrutura é definida usando uma palavra-chave message e cada membro da estrutura é definido dentro da mensagem, com base no tipo e no nome, e é atribuída uma ordem baseada em 1. Vamos definir uma mensagem UserPreferences que, por enquanto, tem apenas um valor booleano chamado showCompleted.

syntax = "proto3";

option java_package = "com.codelab.android.datastore";
option java_multiple_files = true;

message UserPreferences {
  // filter for showing / hiding completed tasks
  bool show_completed = 1;
}

Criar o serializador

Para informar ao DataStore como ler e gravar o tipo de dados que definimos no arquivo proto, precisamos implementar um serializador. O serializador também define o valor padrão a ser retornado se não houver dados no disco. Crie um novo arquivo com o nome UserPreferencesSerializer no pacote data:

object UserPreferencesSerializer : Serializer<UserPreferences> {
    override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()
    override fun readFrom(input: InputStream): UserPreferences {
        try {
            return UserPreferences.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override fun writeTo(t: UserPreferences, output: OutputStream) = t.writeTo(output)
}

Como criar o DataStore

A sinalização para mostrar tarefas concluídas é mantida na memória no TasksViewModel. Vamos criar um campo privado DataStore<UserPreferences> em UserPreferencesRepository com base no método de extensão Context.createDataStore(). O método tem dois parâmetros obrigatórios:

  • O nome do arquivo em que o DataStore atuará.
  • O serializador para o tipo usado com o DataStore. Para o nosso caso: UserPreferencesSerializer
private val dataStore: DataStore<UserPreferences> =
    context.createDataStore(
        fileName = "user_prefs.pb",
        serializer = UserPreferencesSerializer)

Como ler dados do Proto DataStore

O Proto DataStore expõe os dados armazenados em um Flow<UserPreferences>. Vamos criar um valor userPreferencesFlow: Flow<UserPreferences> público que é atribuído a dataStore.data:

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data

Como gerenciar exceções ao ler dados

À medida que o DataStore lê dados de um arquivo, IOExceptions são geradas quando ocorre um erro ao ler os dados. Podemos lidar com elas usando a transformação do fluxo catch (link em inglês) e simplesmente registrando o erro:

private val TAG: String = "UserPreferencesRepo"

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
    .catch { exception ->
        // dataStore.data throws an IOException when an error is encountered when reading data
        if (exception is IOException) {
            Log.e(TAG, "Error reading sort order preferences.", exception)
            emit(UserPreferences.getDefaultInstance())
        } else {
            throw exception
        }
    }

Como gravar dados no Proto DataStore

Para gravar dados, o DataStore oferece uma função de suspensão DataStore.updateData(), em que o parâmetro é o estado atual de UserPreferences. Para atualizá-lo, teremos que transformar o objeto de preferências em um builder, definir o novo valor e depois criar as novas preferências.

updateData() atualiza os dados de maneira transacional em uma operação atômica de leitura-gravação-modificação. A corrotina será concluída quando os dados forem armazenados no disco.

Vamos criar uma função de suspensão que nos permita atualizar a propriedade de mostrar tarefas concluídas das UserPreferences, com o nome updateShowCompleted(), que chama dataStore.updateData() e define o novo valor:

suspend fun updateShowCompleted(completed: Boolean) {
    dataStore.updateData { preferences ->
        preferences.toBuilder().setShowCompleted(completed).build()
    }
}

Nesse ponto, o app poderá ser compilado, mas a funcionalidade que acabamos de criar em UserPreferencesRepository não será usada.

Como definir os dados a serem salvos em proto

A ordem de classificação é salva em SharedPreferences. Vamos migrá-la para o DataStore. Para fazer isso, vamos começar atualizando as UserPreferences no arquivo proto para que ele também armazene a ordem de classificação. Como a ordem de classificação é uma enum, será necessário defini-la em UserPreference. enums são definidas em protobufs de maneira semelhante ao Kotlin.

O valor padrão para enumerações é o primeiro listado na definição de tipo. No entanto, ao migrar de SharedPreferences, precisamos saber se o valor recebido é o valor padrão ou o definido anteriormente em SharedPreferences. Para facilitar isso, definimos um novo valor para nossa enumeração SortOrder: UNSPECIFIED e o listamos primeiro para que ele seja o valor padrão.

Nosso arquivo user_prefs.proto ficará assim:

syntax = "proto3";

option java_package = "com.codelab.android.datastore";
option java_multiple_files = true;

message UserPreferences {
  // filter for showing / hiding completed tasks
  bool show_completed = 1;

  // defines tasks sorting order: no order, by deadline, by priority, by deadline and priority
  enum SortOrder {
    UNSPECIFIED = 0;
    NONE = 1;
    BY_DEADLINE = 2;
    BY_PRIORITY = 3;
    BY_DEADLINE_AND_PRIORITY = 4;
  }

  // user selected tasks sorting order
  SortOrder sort_order = 2;
}

Limpe e recrie seu projeto para garantir que um novo objeto UserPreferences seja gerado com o novo campo.

Agora que a SortOrder está definida no arquivo proto, é possível remover a declaração de UserPreferencesRepository. Excluir:

enum class SortOrder {
    NONE,
    BY_DEADLINE,
    BY_PRIORITY,
    BY_DEADLINE_AND_PRIORITY
}

Confira se a importação correta da SortOrder está sendo usada em todos os lugares:

import com.codelab.android.datastore.UserPreferences.SortOrder

No TasksViewModel.filterSortTasks(), estamos fazendo ações diferentes com base no tipo SortOrder. Agora que também adicionamos a opção UNSPECIFIED, precisamos adicionar outro caso à instrução when(sortOrder). Como não queremos lidar com outras opções no momento, podemos simplesmente gerar uma UnsupportedOperationException para outros casos.

Nossa função filterSortTasks() estará assim agora:

private fun filterSortTasks(
    tasks: List<Task>,
    showCompleted: Boolean,
    sortOrder: SortOrder
): List<Task> {
    // filter the tasks
    val filteredTasks = if (showCompleted) {
        tasks
    } else {
        tasks.filter { !it.completed }
    }
    // sort the tasks
    return when (sortOrder) {
        SortOrder.UNSPECIFIED -> filteredTasks
        SortOrder.NONE -> filteredTasks
        SortOrder.BY_DEADLINE -> filteredTasks.sortedByDescending { it.deadline }
        SortOrder.BY_PRIORITY -> filteredTasks.sortedBy { it.priority }
        SortOrder.BY_DEADLINE_AND_PRIORITY -> filteredTasks.sortedWith(
            compareByDescending<Task> { it.deadline }.thenBy { it.priority }
        )
        // We shouldn't get any other values
        else -> throw UnsupportedOperationException("$sortOrder not supported")
    }
}

Como migrar de SharedPreferences

Para ajudar na migração, o DataStore define a classe SharedPreferencesMigration. Vamos criá-la em UserPreferencesRepository. O bloco migrate oferece dois parâmetros:

  • SharedPreferencesView que nos permite recuperar dados de SharedPreferences
  • Dados atuais de UserPreferences

Será necessário retornar um objeto UserPreferences.

Ao implementar o bloco migrate, será necessário executar as seguintes etapas:

  1. Verifique o valor sortOrder em UserPreferences.
  2. Se for SortOrder.UNSPECIFIED, significa que precisamos recuperar o valor de SharedPreferences. Se não encontrar a SortOrder, poderemos usar SortOrder.NONE como padrão.
  3. Depois de recebermos a ordem de classificação, será necessário converter o objeto UserPreferences em um builder, definir a ordem de classificação e, em seguida, criar o objeto novamente chamando build(). Nenhum outro campo será afetado por essa mudança.
  4. Se o valor sortOrder nas UserPreferences não for SortOrder.UNSPECIFIED, será possível retornar os dados atuais que recebemos em migrate, porque a migração já terá sido executada.
private val sharedPrefsMigration = SharedPreferencesMigration(
    context,
    USER_PREFERENCES_NAME
) { sharedPrefs: SharedPreferencesView, currentData: UserPreferences ->
        // Define the mapping from SharedPreferences to UserPreferences
        if (currentData.sortOrder == SortOrder.UNSPECIFIED) {
            currentData.toBuilder().setSortOrder(
                SortOrder.valueOf(
                    sharedPrefs.getString(
                        SORT_ORDER_KEY,SortOrder.NONE.name)!!
                )
            ).build()
        } else {
            currentData
        }
    }

Agora que definimos a lógica de migração, precisamos fazer que o DataStore a use. Para isso, atualize o builder do DataStore e atribua ao parâmetro migrations uma nova lista que contenha uma instância de SharedPreferencesMigration:

private val dataStore: DataStore<UserPreferences> = context.createDataStore(
    fileName = "user_prefs.pb",
    serializer = UserPreferencesSerializer,
    migrations = listOf(sharedPrefsMigration)
)

Como salvar a ordem de classificação no DataStore

Para atualizar a ordem de classificação quando enableSortByDeadline() e enableSortByPriority() são chamados, precisamos fazer o seguinte:

  • Chame as respectivas funcionalidades no lambda de dataStore.updateData().
  • Como updateData() é uma função de suspensão, enableSortByDeadline() e enableSortByPriority() também precisam ser transformadas em funções de suspensão.
  • Use as UserPreferences atuais recebidas de updateData() para criar a nova ordem de classificação.
  • Atualize as UserPreferences convertendo-as para builder, depois defina a nova ordem de classificação e crie as preferências novamente.

Veja como é a implementação de enableSortByDeadline(). Você poderá fazer as mudanças em enableSortByPriority() por conta própria.

suspend fun enableSortByDeadline(enable: Boolean) {
    // updateData handles data transactionally, ensuring that if the sort is updated at the same
    // time from another thread, we won't have conflicts
    dataStore.updateData { preferences ->
        val currentOrder = preferences.sortOrder
        val newSortOrder =
            if (enable) {
                if (currentOrder == SortOrder.BY_PRIORITY) {
                    SortOrder.BY_DEADLINE_AND_PRIORITY
                } else {
                    SortOrder.BY_DEADLINE
                }
            } else {
                if (currentOrder == SortOrder.BY_DEADLINE_AND_PRIORITY) {
                    SortOrder.BY_PRIORITY
                } else {
                    SortOrder.NONE
                }
            }
        preferences.toBuilder().setSortOrder(newSortOrder).build()
    }
}

Agora que UserPreferencesRepository armazena sinalizações de mostrar tarefas concluídas e ordem de classificação no DataStore e expõe um Flow<UserPreferences>, vamos atualizar o TasksViewModel para usá-las.

Remova showCompletedFlow e sortOrderFlow. Crie um valor chamado userPreferencesFlow que será inicializado com userPreferencesRepository.userPreferencesFlow:

private val userPreferencesFlow = userPreferencesRepository.userPreferencesFlow

Na criação do tasksUiModelFlow, substitua showCompletedFlow e sortOrderFlow por userPreferencesFlow. Substitua os parâmetros corretamente.

Ao chamar filterSortTasks, transmita showCompleted e sortOrder das userPreferences. O código ficará assim:

private val tasksUiModelFlow = combine(
        repository.tasks,
        userPreferencesFlow
    ) { tasks: List<Task>, userPreferences: UserPreferences ->
        return@combine TasksUiModel(
            tasks = filterSortTasks(
                tasks,
                userPreferences.showCompleted,
                userPreferences.sortOrder
            ),
            showCompleted = userPreferences.showCompleted,
            sortOrder = userPreferences.sortOrder
        )
    }

A função showCompletedTasks() agora precisa ser atualizada para chamar userPreferencesRepository.updateShowCompleted(). Como esta é uma função de suspensão, crie uma nova corrotina no viewModelScope:

fun showCompletedTasks(show: Boolean) {
    viewModelScope.launch {
        userPreferencesRepository.updateShowCompleted(show)
    }
}

As funções de userPreferencesRepository, enableSortByDeadline() e enableSortByPriority(), agora são funções de suspensão, portanto, também precisam ser chamadas em uma nova corrotina, iniciada no viewModelScope:

fun enableSortByDeadline(enable: Boolean) {
    viewModelScope.launch {
       userPreferencesRepository.enableSortByDeadline(enable)
    }
}

fun enableSortByPriority(enable: Boolean) {
    viewModelScope.launch {
        userPreferencesRepository.enableSortByPriority(enable)
    }
}

Limpar UserPreferencesRepository

Vamos remover os campos e métodos que não são mais necessários. Você poderá excluir o seguinte:

  • _sortOrderFlow
  • sortOrderFlow
  • updateSortOrder()
  • private val sortOrder: SortOrder
  • private val sharedPreferences

Agora, o app será compilado corretamente. Execute-o para ver se os sinalizadores para mostrar as tarefas concluídas e a ordem de classificação foram salvos corretamente.

Confira a ramificação proto do repositório do codelab para comparar as mudanças.

Agora que você migrou para o Proto DataStore, vamos recapitular o que aprendemos:

  • SharedPreferences vem com uma série de desvantagens. Uma API síncrona que pode parecer segura para chamadas na linha de execução de IU, nenhum mecanismo de sinalização de erros, a falta da API transacional, entre outras.
  • O DataStore é um substituto de SharedPreferences, sendo a solução para a maioria das limitações da API.
  • O DataStore tem uma API totalmente assíncrona que usa corrotinas e fluxos Kotlin, gerencia a migração de dados, garante a consistência de dados e gerencia a corrupção deles.