Armazenando preferências com o DataStore no Android

Mar 21 2023
Armazenamento permanente seguro para aplicativos Android com alguns truques
O Android está gradualmente abandonando as preferências compartilhadas devido a desvantagens como carregamento de segurança não tipo, não sendo adaptado para aplicativos multiprocessos e falta de carregamento fora do thread principal. O armazenamento de dados tenta resolver todos os problemas, o que o torna uma solução muito mais robusta.

O Android está gradualmente abandonando as preferências compartilhadas devido a desvantagens como carregamento de segurança não tipo, não sendo adaptado para aplicativos multiprocessos e falta de carregamento fora do thread principal. O armazenamento de dados tenta resolver todos os problemas, o que o torna uma solução muito mais robusta.

Um dos obstáculos mostrados na documentação do Android é o uso dos arquivos .proto, mas podemos contorná-lo com um arquivo JSON e uma definição do serializador.

Foto de Steve Johnson no Unsplash

Configurar

Em primeiro lugar, a dependência para o armazenamento de dados precisa ser adicionada. Para a versão atual, vá aqui . O serializador JSON é necessário para decodificar e decodificar arquivos JSON subjacentes.

// inside of the app's build.gradle
plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'org.jetbrains.kotlin.plugin.serialization'
}

...

dependencies {
    implementation "androidx.datastore:datastore:1.0.0"
    implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1"
}

Em segundo lugar, a estrutura de nossas preferências precisa ser feita. O armazenamento de dados armazena os dados em qualquer estrutura de dados predefinida. Mais comumente e familiarmente, os dados podem ser armazenados em arquivos JSON.

No contexto da linguagem kotlin, o aplicativo precisa saber como decodificar e codificar os dados JSON. Para isso, os plugins kotlin nos oferecem o plugin de serialização com implementação JSON.

@Serializable
data class AppSettings(
    val onboarded: Boolean,
    val nickName: String,
    val age: Int
) {

    companion object {
        val default = AppSettings(false, "", -1)
    }
}

Se estruturas de dados mais avançadas forem apresentadas na classe de dados, você precisará criar serializáveis ​​por conta própria para cada tipo de dados personalizado.

Outra observação é definir o valor padrão, que é usado na primeira inicialização da memória local.

Decodificação e codificação do armazenamento de dados

Após a definição da estrutura de dados, vem a definição do serializador, que é usado para carregar e salvar os dados atuais para armazenamento permanente.

A decodificação do arquivo armazenado usa os bytes provenientes de InputStream. Esses são convertidos em uma string e passados ​​para o desserializador definido por nossa classe de dados anotada com @Serializable.

A codificação é o mesmo processo, mas vice-versa.

O uso das funções de suspensão evita o bloqueio do thread principal porque o thread de E/S pode ser usado em vez do principal. Isso evita o bloqueio da rosca principal.

Lembre-se, todo o processo de carregamento e salvamento é personalizável. Se você tiver algum requisito especial para armazenamento de dados ou um caso especial para lidar antes do armazenamento, como criptografia ou outra coisa, aqui é o lugar para fazê-lo.

// serializer with our defined data class AppSettings
object SettingsSerializer : Serializer<AppSettings> {
    
    // default value of the settings for initialization
    override val defaultValue: AppSettings = AppSettings.default

    // reading the inputstream of the stored file
    override suspend fun readFrom(input: InputStream): AppSettings {
        try {
            return Json.decodeFromString(
                deserializer = AppSettings.serializer(),
                string = input.readBytes().decodeToString()
            )
        } catch (exception: SerializationException) {
            throw CorruptionException("Error occured during decoding the storage", exception)
        }
    }
    
    // writting the the output stream with actual datatype
    override suspend fun writeTo(
        t: AppSettings,
        output: OutputStream
    ) = output.write(
        Json.encodeToString(serializer = AppSettings.serializer(), value = t).toByteArray()
    )

}

// context property delegate with the datastore
val Context.settingsDataStore: DataStore<AppSettings> by dataStore(
    fileName = "settings.json",
    serializer = SettingsSerializer
)

Lendo e armazenando novos dados

O armazenamento pode ser chamado de forma síncrona com o runBlocking{}, mas não é a melhor prática fazer isso. Os dados devem ser chamados dentro do razoável CoroutineScopee Dispatcherpara evitar interferência com o thread principal ou pelo menos minimizá-lo.

Com o Hilt, podemos injetar o armazenamento de dados dentro do wrapper personalizado ou em qualquer outro lugar onde precisamos do armazenamento de dados.

Sinta-se à vontade para usar qualquer tipo de injeção de dependência/injeção manual com a qual você esteja familiarizado.

@Module
@InstallIn(SingletonComponent::class)
object DataStoreDI {

    @Provides
    @Singleton
    fun provideAppSettingsDataStore(@ApplicationContext context: Context): DataStore<AppSettings> =
        context.settingsDataStore

}

// constructor injection with hilt
class AppCache @Inject constructor(private val dataStore: DataStore<AppSettings>){

    private val _cacheState = MutableStateFlow<AppSettings>(AppSettings.default)
    var state = _cacheState.asStateFlow()
    
    // loading in the data 
    suspend fun loadInCache() {
        try {
          dataStore.data.collect {
              _cacheState.value = it
          }
        } catch(error: SerializationException) {
          // handle the excetion
        }
    }
    
    // storing the new data 
    suspend fun storeOnboarding(isOnboarded: Boolean) {
        dataStore.updateData { actualSettings: AppSettings ->
            actualSettings.copy(onboarded = isOnboarded)
        }
    }

    suspend fun storeNickName(nickName: String) {
        dataStore.updateData { actualSettings: AppSettings ->
            actualSettings.copy(nickName = nickName))
        }
    }

    suspend fun storeAge(age: Int) {
        dataStore.updateData { actualSettings: AppSettings ->
            actualSettings.copy(age = age)
        }
    }

}

// for example:
@HiltViewModel
class IntroViewModel @Inject constructor(private val appCache: AppCache<AppSettings>): ViewModel() {

    ...    

    init {
        viewModelScope.launch {
            appCache.state.collect{ appSettings ->
              // listening to changes in the settings
              // convert it to other state and create representation in the UI
            }
        }
        viewModelScope.launch {
            // initial loading in the data
            appCache.loadInCache()
        }
    }

    ...
}

Para tornar o processo mais interativo, o estado da memória é declarado com a classe lacrada. Alguns telefones mais baratos podem demorar para carregar algo ou algo dá errado durante o carregamento. Para esses casos, as classes lacradas podem ser usadas para estender a manipulação de estado do DataStore.

sealed class AppCacheState {
    // show loading UI
    object Loading: AppCacheState()
    // we have settings and we can do something
    class Loaded(val settings: AppSettings): AppCacheState()
    // error occured during the loading ... 
    object Error: AppCacheState()
}

class AppCache @Inject constructor(private val dataStore: DataStore<AppSettings>){
  
    // loading as default
    private val _cacheState = MutableStateFlow<AppCacheState>(AppCacheState.Loading)
    override var state = _cacheState.asStateFlow()

    // loading in the data 
    suspend fun loadInCache() {
        try {
          dataStore.data.collect {
              _cacheState.value = AppCacheState.Loaded(it)
          }
        } catch(error: SerializationException) {
              _cacheState.value = AppCacheState.Error
        }
    }
}


@HiltViewModel
class IntroViewModel @Inject constructor(private val appCache: AppCache<AppCacheState>): ViewModel() {

    init {

        viewModelScope.launch {
            appCache.state.collect{
                when(it){
                    AppCacheState.Error -> {
                      // react to error
                    }
                    is AppCacheState.Loaded -> {
                      // react to loaded settings
                    }
                    AppCacheState.Loading -> {
                      // show loading UI
                    }
                }
            }
        }

        viewModelScope.launch {
            appCache.loadInCache()
        }
    }


    fun storeUserOnboarding() {
        viewModelScope.launch(context = Dispatchers.IO) {
            // save user data 
            appCache.store...
        }
    }
}

Mais do desenvolvimento Android:

Recursos: