Como trabalhar com o Preferences DataStore

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.

Pré-requisitos

Para uma introdução aos componentes de arquitetura, confira a Room com um codelab de View. Para uma introdução ao fluxo, confira o codelab Corrotinas avançadas com fluxo do Kotlin 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 está na ramificação principal. O código da solução está localizado na ramificação preferences_datastore.

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

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

  • Ativar ou desativar a exibição 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ê 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 por meio de um Flow para representar um cenário mais realista.
  • Classe UserPreferencesRepository: contém SortOrder, definida como 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 receber a ordem de classificação.

ui

  • Classes relacionadas à exibição de uma Activity com 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 de mostrar tarefas concluídas e ordem de classificação, incluídas em um objeto TasksUiModel. Toda vez que um desses valores muda, é preciso reconstruir 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 de mostrar tarefas concluídas 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, apenas quando a atividade é iniciada, exibimos um LiveData<TasksUiModel>.

Temos alguns problemas com nosso código:

  • Bloqueamos a linha de execução de IU na E/S de disco ao inicializar UserPreferencesRepository.sortOrder. Isso pode resultar em instabilidade da IU.
  • A sinalização de mostrar tarefas concluídas é mantida apenas na memória. Portanto, ela será redefinida sempre que o usuário abrir o app. Assim como o SortOrder, ela precisa ser mantida para sobreviver ao fechamento do app.
  • Estamos usando SharedPreferences para manter os dados, mas um MutableStateFlow, que modificamos manualmente, permanece na memória para podermos ser notificados de mudanças. Esse processo é facilmente interrompido se o valor for modificado em algum outro lugar no 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 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.

Embora as sinalizações de mostrar tarefas concluídas e ordem de classificação sejam mostradas para o usuário, elas são representadas como dois objetos diferentes. Uma das nossas metas será unificar essas duas sinalizações em uma classe UserPreferences.

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

Muitas vezes, você pode precisar armazenar conjuntos de dados pequenos ou simples. Para isso, anteriormente, você pode ter usado SharedPreferences, mas essa API também tem uma série de 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, por meio do listener)

✅ (via Flow)

✅ (via Flow)

API Synchronous

✅ (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 garantias de consistência forte

Gerencia a migração de dados

✅ (de SharedPreferences)

✅ (de SharedPreferences)

Segurança de tipo

✅ com buffers de protocolo

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

** O SharedPreferences gera erros de análise como exceções do 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 o 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, mais simples e menos ambíguos do 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 comprovadamente fornecida por ele vale a pena.

Room x DataStore

Se você precisa 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.

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

  • 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 a seguinte dependência do Preference DataStore:

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

Embora as sinalizações de mostrar tarefas concluídas e ordem de classificação 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 armazená-la em UserPreferencesRepository usando o DataStore. No momento, a exibição da sinalização de conclusão é mantida na memória, em 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

Vamos criar um campo privado DataStore<Preferences> em UserPreferencesRepository, usando o método context.createDataStoreFactory(). O parâmetro obrigatório é o nome do Preferences DataStore.

private val dataStore: DataStore<Preferences> =
        context.createDataStore(name = "user")

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 fará a compilação, mas a funcionalidade que acabamos de criar em UserPreferencesRepository não é usada.

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

Para migrá-las para o DataStore, precisamos atualizar o builder do DataStore e transmitir uma SharedPreferencesMigration para a lista de migrações. O DataStore poderá migrar automaticamente de SharedPreferences para o DataStore. As migrações são executadas antes de ocorrer qualquer acesso aos dados no DataStore. Isso significa que sua 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.

private val dataStore: DataStore<Preferences> =
    context.createDataStore(
        name = USER_PREFERENCES_NAME,
        migrations = listOf(SharedPreferencesMigration(context, USER_PREFERENCES_NAME))
    )

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

Todas as chaves serão 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 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. Em vez disso, crie um valor chamado userPreferencesFlow que seja inicializado com userPreferencesRepository.userPreferencesFlow:

private val userPreferencesFlow = userPreferencesRepository.userPreferencesFlow

Na criação de tasksUiModelFlow, substitua showCompletedFlow e sortOrderFlow por userPreferencesFlow. Substitua os parâmetros de maneira adequada.

Ao chamar filterSortTasks, transmita o showCompleted e sortOrder de 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 suspendem funções. 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, nosso app deve compilar com sucesso. Execute-o para ver se os sinalizadores de mostrar tarefas concluídas e ordem de classificação foram salvos corretamente.

Confira a ramificação de preferências do repositório do codelab para comparar suas mudanças.

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 fluxo Kotlin, gerencia migração de dados, garante consistência e gerencia corrupção de dados.