Como trabalhar com o Preferences DataStore

1. Introdução

O que é o Datastore?

O Datastore é uma solução nova e melhorada de armazenamento de dados que visa substituir o SharedPreferences. Criado em corrotinas e fluxo Kotlin, ele oferece duas implementações diferentes: Proto DataStore, que armazena objetos categorizados (com suporte de buffers de protocolo) e Preferences DataStore, que armazena pares de chave-valor. 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 Preferences DataStore.
  • Como migrar de SharedPreferences para o Preferences 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.

Neste codelab, você aprenderá a usar o Preferences DataStore concluindo as seguintes tarefas:

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

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

O que é necessário

Para uma introdução aos componentes da arquitetura, consulte o codelab Room com View. Para uma introdução ao fluxo, confira o codelab Corrotinas avançadas com fluxo do Kotlin e LiveData.

2. Etapas da configuração

Nesta etapa, você vai fazer o download do código para o codelab inteiro 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 master. O código da solução está na ramificação preferences_datastore (link em inglês).

Se você não tiver o git, clique no botão abaixo 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 Arctic Fox.
  2. Execute a configuração de execução do app em um dispositivo ou emulador.

b3c0dfdb92dfed77.png

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

d3972939a2de88ba.png

3. Visão geral do projeto

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 com que precisamos trabalhar, o app permite que você faça apenas duas ações:

  • Ativar ou desativar a visibilidade de Mostrar tarefas concluídas: por padrão, as tarefas ficam ocultas
  • Classificar as tarefas por prioridade, por prazo ou por prazo e prioridade

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

data

  • A classe modelo Task.
  • Classe TasksRepository: responsável por fornecer as tarefas. Para simplificar, ele retorna dados fixados no código e os expõe usando 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.

O 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 showCompleted e sortOrder, incluídas em um objeto TasksUiModel. Toda vez que um desses valores mudar, vamos precisar criar um novo TasksUiModel. Para isso, combinamos três elementos:

  • Um Flow<List<Task>> extraído do TasksRepository.
  • Um MutableStateFlow<Boolean> que contém a sinalização showCompleted mais recente, 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> vai 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 showCompleted é mantida somente na memória. Portanto, ela vai 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, disponibilizamos dois métodos para atualizar a ordem de classificação: 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 resultado vai ser o valor final errado. Além disso, esses métodos podem resultar em instabilidades na IU e violações no modo restrito à medida que são chamados na linha de execução de IU.

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

4. DataStore: noções básicas

Muitas vezes, você pode precisar armazenar conjuntos de dados pequenos ou simples. Você pode ter usado SharedPreferences antes para fazer isso, mas essa API também tem várias desvantagens. O objetivo da biblioteca Jetpack DataStore é resolver esses problemas com a criação de 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 Async

✅ (apenas para ler valores alterados, usando um listener)

✅ (via Flow e RxJava 2 e 3 Flowable)

✅ (via Flow e RxJava 2 e 3 Flowable)

API Synchronous

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

É seguro para chamadas na linha de execução de IU

❌1

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

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

Pode sinalizar erros

Protegido contra exceções de execução

❌2

Tem uma API transacional com garantias de consistência forte

Gerencia a migração de dados

Segurança de tipo

✅ com buffers de protocolo

1 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 vão ser 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 sendo uma fonte de erros ANRs.

2 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ê precisa de atualizações parciais, integridade referencial ou conjuntos de dados grandes/complexos, use o Room em vez do DataStore. O DataStore é ideal para conjuntos de dados pequenos e simples e não oferece suporte a atualizações parciais ou integridade referencial.

5. Visão geral do Preferences DataStore

A API Preferences DataStore é semelhante à SharedPreferences, mas tem várias diferenças importantes:

  • Gerenciamento de atualizações de dados de maneira transacional.
  • Exposição de um fluxo representando o estado atual dos dados.
  • Não tem métodos de persistência de dados (apply(), commit()).
  • Não retorna referências mutáveis para seu estado interno.
  • Exposição de uma API semelhante a Map e MutableMap com chaves digitadas.

Vamos ver como adicioná-lo ao projeto e migrar SharedPreferences para o DataStore.

Como adicionar dependências

Atualize o arquivo build.gradle para adicionar esta dependência do Preference DataStore:

implementation "androidx.datastore:datastore-preferences:1.0.0"

6. Como persistir dados no Preferences DataStore

Embora as sinalizações showCompleted e sortOrder sejam mostradas para o usuário, elas são representadas como dois objetos diferentes. Uma das nossas metas é unificar as duas sinalizações em uma classe UserPreferences e a armazenar em UserPreferencesRepository usando o DataStore. No momento, a sinalização showCompleted é mantida na memória no TasksViewModel.

Vamos começar criando uma classe de dados UserPreferences em UserPreferencesRepository. Por enquanto, ela precisa ter apenas um campo: showCompleted. Adicionaremos a ordem de classificação mais tarde.

data class UserPreferences(val showCompleted: Boolean)

Como criar o DataStore

Para criar uma instância DataStore, usamos a delegação preferencesDataStore com o Context como receptor. Para simplificar, neste codelab, vamos fazer isso em TasksActivity:

private const val USER_PREFERENCES_NAME = "user_preferences"

private val Context.dataStore by preferencesDataStore(
    name = USER_PREFERENCES_NAME
)

A delegação preferencesDataStore garante que tenhamos uma única instância do DataStore com esse nome no aplicativo. Atualmente, UserPreferencesRepository é implementado como um Singleton, porque ele contém o sortOrderFlow e evita que ele seja vinculado ao ciclo de vida da TasksActivity. Como o UserPreferenceRepository funciona somente com os dados do DataStore e não cria ou contém nenhum objeto novo, já podemos remover a implementação do Singleton:

  • Remova o companion object.
  • Torne o constructor público.

O UserPreferencesRepository precisa receber uma instância DataStore como parâmetro de construtor. Por enquanto, podemos deixar Context como um parâmetro por ser necessário para o SharedPreferences, mas ele vai ser removido mais tarde.

class UserPreferencesRepository(
    private val userPreferencesStore: DataStore<UserPreferences>,
    context: Context
) { ... }

Vamos atualizar a construção do UserPreferencesRepository na TasksActivity e transmitir o dataStore:

viewModel = ViewModelProvider(
    this,
    TasksViewModelFactory(
        TasksRepository,
        UserPreferencesRepository(dataStore, this)
    )
).get(TasksViewModel::class.java)

Como ler dados do Preferences DataStore

O Preferences DataStore expõe os dados armazenados em um Flow<Preferences> que será emitido sempre que uma preferência mudar. Não queremos expor todo o objeto Preferences e sim o objeto UserPreferences. Para fazer isso, vamos mapear o Flow<Preferences>, buscar o valor booleano que interessa a você, com base em uma chave e construir um objeto UserPreferences.

Portanto, a primeira coisa que precisamos fazer é definir a chave show_completed. Esse é um valor booleanPreferencesKey que podemos declarar como membro em um objeto PreferencesKeys particular.

private object PreferencesKeys {
  val SHOW_COMPLETED = booleanPreferencesKey("show_completed")
}

Vamos expor um userPreferencesFlow: Flow<UserPreferences>, construído com base em dataStore.data: Flow<Preferences>, que será mapeado para recuperar a preferência certa:

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
    .map { preferences ->
        // Get our show completed value, defaulting to false if not set:
        val showCompleted = preferences[PreferencesKeys.SHOW_COMPLETED]?: false
        UserPreferences(showCompleted)
    }

Como gerenciar exceções durante a leitura de dados

Conforme o DataStore lê dados de um arquivo, IOExceptions são gerados quando ocorre um erro de leitura. Podemos lidar com isso usando o operador de fluxo catch() antes de map() e emitindo emptyPreferences() caso a exceção gerada seja IOException. Se um tipo diferente de exceção for gerada, prefira gerá-la novamente.

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) {
            emit(emptyPreferences())
        } else {
            throw exception
        }
    }.map { preferences ->
        // Get our show completed value, defaulting to false if not set:
        val showCompleted = preferences[PreferencesKeys.SHOW_COMPLETED]?: false
        UserPreferences(showCompleted)
    }

Como gravar dados no Preferences DataStore

Para gravar dados, o DataStore oferece uma função DataStore.edit(transform: suspend (MutablePreferences) -> Unit) de suspensão, que aceita um bloco transform que nos permite atualizar de maneira transacional o estado no DataStore.

As MutablePreferences transmitidas para o bloco de transformação serão atualizadas com as edições executadas anteriormente. Todas as mudanças em MutablePreferences no bloco transform serão aplicadas ao disco após transform ser concluído e antes de edit ser concluído. Definir um valor em MutablePreferences não afetará as outras preferências.

Observação: não tente modificar MutablePreferences fora do bloco de transformação.

Vamos criar uma função de suspensão que nos permita atualizar a propriedade showCompleted de UserPreferences, nomeada updateShowCompleted(), que chama dataStore.edit() e define o novo valor:

suspend fun updateShowCompleted(showCompleted: Boolean) {
    dataStore.edit { preferences ->
        preferences[PreferencesKeys.SHOW_COMPLETED] = showCompleted
    }
}

edit() pode gerar uma IOException se um erro for encontrado durante a leitura ou gravação no disco. Se qualquer outro erro ocorrer no bloco de transformação, ele será gerado por edit().

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

7. SharedPreferences para Preferences DataStore

A ordem de classificação é salva em SharedPreferences. Vamos transferir para o DataStore. Para fazer isso, vamos começar atualizando UserPreferences para também armazenar a ordem de classificação:

data class UserPreferences(
    val showCompleted: Boolean,
    val sortOrder: SortOrder
)

Como migrar de SharedPreferences

A fim de migrar para o DataStore, precisamos atualizar o builder do DataStore e transmitir uma SharedPreferencesMigration à lista de migrações. O DataStore vai poder migrar automaticamente de SharedPreferences para o DataStore. As migrações são executadas antes de qualquer dado ser acessado no DataStore. Isso significa que a migração precisa ser bem-sucedida antes de DataStore.data emitir valores e antes que DataStore.edit() possa atualizar os dados.

Observação: as chaves só são migradas de SharedPreferences uma vez. Portanto, é necessário parar de usar o SharedPreferences antigo após o código ser migrado para o DataStore.

Primeiro, vamos atualizar a criação do DataStore na TasksActivity:

private const val USER_PREFERENCES_NAME = "user_preferences"

private val Context.dataStore by preferencesDataStore(
    name = USER_PREFERENCES_NAME,
    produceMigrations = { context ->
        // Since we're migrating from SharedPreferences, add a migration based on the
        // SharedPreferences name
        listOf(SharedPreferencesMigration(context, USER_PREFERENCES_NAME))
    }
)

Em seguida, adicione sort_order ao PreferencesKeys:

private object PreferencesKeys {
    ...
    // Note: this has the the same name that we used with SharedPreferences.
    val SORT_ORDER = stringPreferencesKey("sort_order")
}

Todas as chaves vão ser migradas para o nosso DataStore e excluídas das preferências do usuário em SharedPreferences. Agora, usando Preferences será possível conseguir e atualizar a SortOrder com base na chave SORT_ORDER.

Como ler a ordem de classificação do DataStore

Atualize o userPreferencesFlow para recuperar a ordem de classificação na transformação map():

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
    .catch { exception ->
        if (exception is IOException) {
            emit(emptyPreferences())
        } else {
            throw exception
        }
    }.map { preferences ->
        // Get the sort order from preferences and convert it to a [SortOrder] object
        val sortOrder =
            SortOrder.valueOf(
                preferences[PreferencesKeys.SORT_ORDER] ?: SortOrder.NONE.name)

        // Get our show completed value, defaulting to false if not set:
        val showCompleted = preferences[PreferencesKeys.SHOW_COMPLETED] ?: false
        UserPreferences(showCompleted, sortOrder)
    }

Como salvar a ordem de classificação no DataStore

Atualmente, UserPreferencesRepository expõe apenas uma maneira síncrona de definir a sinalização da ordem de classificação e tem um problema de simultaneidade. Expomos dois métodos para atualizar a ordem de classificação: 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.

Como o DataStore garante que as atualizações de dados aconteçam de maneira transacional, não teremos mais esse problema. Faça as seguintes mudanças:

  • Atualize enableSortByDeadline() e enableSortByPriority() para que sejam funções suspend que usam o dataStore.edit().
  • No bloco de transformação de edit(), receberemos currentOrder do parâmetro Preferences, em vez de recuperá-lo do campo _sortOrderFlow.
  • Em vez de chamar updateSortOrder(newSortOrder), é possível atualizar diretamente a ordem de classificação nas preferências.

Veja como é a implementação.

suspend fun enableSortByDeadline(enable: Boolean) {
    // edit handles data transactionally, ensuring that if the sort is updated at the same
    // time from another thread, we won't have conflicts
    dataStore.edit { preferences ->
        // Get the current SortOrder as an enum
        val currentOrder = SortOrder.valueOf(
            preferences[PreferencesKeys.SORT_ORDER] ?: SortOrder.NONE.name
        )

        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[PreferencesKeys.SORT_ORDER] = newSortOrder.name
    }
}

suspend fun enableSortByPriority(enable: Boolean) {
    // edit handles data transactionally, ensuring that if the sort is updated at the same
    // time from another thread, we won't have conflicts
    dataStore.edit { preferences ->
        // Get the current SortOrder as an enum
        val currentOrder = SortOrder.valueOf(
            preferences[PreferencesKeys.SORT_ORDER] ?: SortOrder.NONE.name
        )

        val newSortOrder =
            if (enable) {
                if (currentOrder == SortOrder.BY_DEADLINE) {
                    SortOrder.BY_DEADLINE_AND_PRIORITY
                } else {
                    SortOrder.BY_PRIORITY
                }
            } else {
                if (currentOrder == SortOrder.BY_DEADLINE_AND_PRIORITY) {
                    SortOrder.BY_DEADLINE
                } else {
                    SortOrder.NONE
                }
            }
        preferences[PreferencesKeys.SORT_ORDER] = newSortOrder.name
    }
}

Agora é possível remover o parâmetro de construtor context e todos os usos de SharedPreferences.

8. Atualizar TasksViewModel para usar UserPreferencesRepository

Agora que o UserPreferencesRepository armazena as sinalizações show_completed e sort_order no DataStore e expõe um Flow<UserPreferences>, vamos atualizar o TasksViewModel para usar esses elementos.

Remova showCompletedFlow e sortOrderFlow. Crie um valor com o nome userPreferencesFlow que vai 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 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

Agora, o app vai ser compilado corretamente. Execute o app para ver se as sinalizações show_completed e sort_order foram salvas corretamente.

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

9. Resumo

Agora que você migrou para o Preferences 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 lida com casos de corrupção deles.