Обзор DataStore Library. Прощаемся с SharedPreference?

2e74ed41019e3d87e72eb15f05603581.png

Привет, меня зовут Сергей, я работаю в команде Мобильного Банка Тинькофф. Недавно Google представила очередной инструмент для хранения данных. На этот раз это библиотека DataStore. В официальном блоге Google пишут, что она должна заменить SharedPreference. 

В отличие от SharedPreference, DataStore работает асинхронно. Вся работа с библиотекой выполняется с помощью Kotlin Coroutines и Flow. DataStore позволяет нам хранить данные двумя способами:

  • По принципу «ключ — значение», аналогично SharedPreference.

  • Хранить типизированные объекты, основанные на protocol buffers.

Все взаимодействие с DataStore происходит через интерфейс DataStore, который содержит в себе всего два элемента:

interface DataStore {
   val data: Flow
   suspend fun updateData(transform: suspend (t: T) -> T): T
}

Интерфейс очень прост. Все, что мы можем сделать с ним, это получить объект Flow для чтения данных и вызвать метод updateData() для их записи.

Типы DataStore

  • Preferences DataStore — хранит данные по принципу «ключ — значение» и не предоставляет нам никакой типобезопасности.

  • Proto DataStore — хранит данные в объектах. Это дает нам типобезопасноть, но описывать схему нужно с помощью protocol buffers.

Поговорим о каждом из них.

Preferences DataStore

1c8de6a01c4a7d780b2bdcaf297cbd18.png

Для подключения библиотеки необходимо добавить зависимость в build.gradle нашего проекта:

// Preferences DataStore
implementation "androidx.datastore:datastore-preferences:1.0.0-alpha01"

Как получить экземпляр Preferences DataStore

Для этого нам предоставляется extension-функция, которую можно вызвать из объекта Context:

context.createDataStore(
    name = "user_data_store",
    corruptionHandler = null
    migrations = emptyList(),
    scope = CoroutineScope(Dispatchers.IO + Job())
)

Здесь есть четыре параметра. Давайте рассмотрим каждый из них.

  • name — обязательный параметр. Это название нашего DataStore. Под капотом будет создан файл, путь которого формируется на основании параметра name.

File(context.filesDir, "datastore/" + name + ".preferences_pb")
  • corruptionHandler — этот параметр необязательный. CorruptionHandler вызывается, если DataStore бросает CorruptionException при попытке чтения данных. Если CorruptionHandler успешно подменит данные, то исключение будет поглощено. Если в процессе подмены данных мы получим еще одно исключение, то оно будет добавлено к оригинальному исключению, после чего нам будет выброшено оригинальное исключение.

  • migrations — необязательный параметр, который позволяет легко мигрировать из SharedPreference. Сюда принимается список объектов DataMigration. На самом деле Google уже создала реализацию SharedPreferencesMigration. Все, что нам нужно, это описать логику переноса данных для каждого Shared Preference и передать их списком в параметр migrations:

fun getSharedPreferenceMigrationPref(): SharedPreferencesMigration =
   SharedPreferencesMigration(
       context = context,
       sharedPreferencesName = "pref_name",
       deleteEmptyPreferences = true,
       shouldRunMigration = { true },
       migrate = { prefs, userPref ->
           userPref[FIELD_NAME] = prefs.getString(KEY_NAME)
           userPref[FIELD_LAST_NAME] = prefs.getString(KEY_LAST_NAME)
           userPref[FIELD_AGE] = prefs.getInt(KEY_AGE, 0)
           userPref[FIELD_ACTIVE] = prefs.getBoolean(KEY_IS_ACTIVE, false)
           userPref
       }
   )

В отличие от обычных Shared Preference, в качестве ключа здесь не строка, но об этом мы поговорим чуть позже. 

  • scope — тоже необязательный параметр. Здесь можно указать, в каком Coroutine Scope мы хотим выполнять операции с DataStore. По умолчанию там Dispatchers.IO.

Создание ключей

Чтобы сделать запись в DataStore, нам необходимо определить ключи, под которыми будут храниться наши данные. Как упоминалось выше, это не строки. Поля имеют тип Preferences.Key. Создать подобное поле можно с помощью extension-функции:

object UserScheme {
   val FIELD_NAME = preferencesKey("name")
   val FIELD_LAST_NAME = preferencesKey("last_name")
   val FIELD_AGE = preferencesKey("age")
   val FIELD_ACTIVE = preferencesKey("active")
}

Каждый ключ указывает на тип хранимых в нем данных и строковый ключ, по которому эти данные будут читаться. Поскольку при создании ключа мы указываем тип хранимых данных — мы получаем проверку на корректность передаваемого типа данных в compile time. 

Стоит помнить, что создавать ключи можно только для примитивных типов данных: Int, Long, Boolean, Float, String. В противном случае мы получим исключение. 

Также мы можем хранить Set:  

val FIELD_STRINGS_SET = preferencesSetKey>("strings_set")

Скорее всего, количество типов будет расширяться, так как сейчас методы prefrencesKey() и prefrencesSetKey() на вход принимают дженерик и ограничение по типам сделано руками.

Запись данных

Для записи данных DataStore предоставляет нам два метода для изменения данных:

DataStore.updateData

coroutineScope.launch {
   prefDataStore.updateData { prefs ->
       prefs.toMutablePreferences().apply {
           set(UserScheme.FIELD_NAME, "John")
           set(UserScheme.FIELD_LAST_NAME, "Show")
           set(UserScheme.FIELD_AGE, 100)
           set(UserScheme.FIELD_IS_ACTIVE, false)
       }
   }
}

DataStore.edit

coroutineScope.launch {
   prefDataStore.edit { prefs ->
       prefs[UserScheme.FIELD_NAME] = "John"
       prefs[UserScheme.FIELD_LAST_NAME] = "Show"
       prefs[UserScheme.FIELD_AGE] = 100
       prefs[UserScheme.FIELD_IS_ACTIVE] = false
   }
} 

В обоих случаях мы получаем объект Preferences с разницей лишь в том, что во втором случае приведение к мутабельности спрятано под капотом «функции обертки» edit().

Preferences очень похожа на Generic Map, в которую мы в качестве ключа указываем определенные нами ранее preferenceKey. Для работы с Preferences есть всего четыре метода get(), contains(), asMap() и set(). Метод set() доступен только в MutablePreferences. Запись в Preferences происходит асинхронно, и корутина завершается после того, как данные сохраняются на диске.

Чтение данных

DataStore предоставляет сохраненные данные в объекте Preferences. Все действия производятся на определенном нами при создании Dispatcher:

coroutineScope.launch {
   prefDataStore.data
       .collect { pref: Preferences ->
           val name: String? = pref[UserScheme.FIELD_NAME]
           val lastName: String? = pref[UserScheme.FIELD_LAST_NAME]
           val age: Int? = pref[UserScheme.FIELD_AGE]
           val isActive: Boolean? = pref[UserScheme.FIELD_IS_ACTIVE]
       }
}

DataStore возвращает объект Flow, который будет возвращать нам либо значение, либо исключение, в случае ошибки чтения с диска.

Proto DataStore

e214085f5c4af1c5275919cdde420ad0.png

Для подключения добавляем зависимость:

// Proto DataStore
implementation  "androidx.datastore:datastore-core:1.0.0-alpha01"

Перед работой с Proto DataStore нужно выполнить несколько действий:

plugins {
   id "com.google.protobuf" version "0.8.12"
}
implementation  "com.google.protobuf:protobuf-javalite:3.10.0"

Для этого нужно создать файл в app/src/main/proto/ с расширением .proto:

syntax = "proto3";

option java_package = "com.example.jetpackdatasource";
option java_multiple_files = true;

message UserProto {
 string name = 1;
 string last_name = 2;
 int32 age = 3;
 bool is_active = 4;
}

Здесь есть подробное руководство по работе с proto buffer файлами.

Это будет наша схема хранения данных. Система сгенерирует модель, которую мы можем сохранять в наш DataStore.

Когда вы все это сделаете, Android Studio предложит установить плагин Protocol Buffer Editor. Он сделает вашу работу с файлами .proto удобной. Плагин будет подсвечивать синтаксические элементы, проводить семантический анализ и др.

Как получить экземпляр Proto DataStore

Для этого у нас тоже есть extension-функция:

context.createDataStore(
       fileName ="user.pb",
       serializer = UserSerializer,
       corruptionHandler = null,
       migrations = emptyList(),
       scope = CoroutineScope(Dispatchers.IO + Job())
)

Здесь все почти то же самое, как и с Preference DataStore. Но есть два отличия:

  • Первое — это путь, по которому будет сохраняться файл префов: File(context.filesDir, "datastore/$fileName").

  • Второе — наличие поля serializer. Давайте рассмотрим его подробнее. Чтобы Proto DataStore понимал, как ему сохранять данные в файл, мы должны к каждому модели прописать свой Serializer. Для этого нужно реализовать интерфейс Serializer, в котором мы и опишем логику записи/чтения нашего файла:

object UserSerializer : Serializer {

   override fun readFrom(input: InputStream): User {
       try {
           return User.parseFrom(input)
       } catch (exception: InvalidProtocolBufferException) {
           throw CorruptionException("Cannot read proto.", exception)
       }
   }

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

В остальном тут все так же, как в Preference DataStore.

Запись данных

Для записи данных DataStore предоставляет нам функцию DataStore.updateData (). Она возвращает текущее состояние сохраненных данных. В качестве параметра мы получаем экземпляр модели, которую мы определили в файле .proto:

coroutineScope.launch {
   protoDataStore.updateData { user ->
       user.toBuilder()
           .setName(nameField.text.toString())
           .setLastName(lastNameField.text.toString())
           .setAge(ageField.text.toString().toIntOrNull() ?: 0)
           .setIsActive(isActiveSwitch.isChecked)
           .build()
   }
}

Модель предоставляет нам билдер для записи данных в DataStore. Для каждого поля, указанного в модели, описанной в .proto-файле, мы имеем свой set-метод.

Чтение данных

Есть два способа для чтения данных из Proto DataStore:

Вызвать методDataStore.updateData(). Так как в нем мы получаем актуальное состояние объекта, ничего не мешает прочитать их отсюда. Нюанс в том, что там нужно вернуть актуальное состояние модели в лямбде:

coroutineScope.launch {
   protoDataStore.updateData { user ->
       val name: String = user.name
       val lastName: String = user.lastName
       val age: Int = user.age
       val isActive: Boolean = user.isActive
       return@updateData user
   }
}

Получить объект data : Flow, который вернет нам реактивный поток. Результатом этого Flow будет актуальный экземпляр хранимой в DataStore модели:

coroutineScope.launch(Dispatchers.Main) {
   protoDataStore.data
       .collect { user ->
           val receivedUser: User = user
       }
}

SharedPreference vs DataStore

  • DataStore предоставляет асинхронный API для записи и чтения данных, в отличие от Shared Preference, который предоставляет асинхронный API только при чтении данных.

  • DataStore безопасен для работы на UI-потоке, так как есть возможность указать подходящий для нас Dispatcher.

  • DataStore защищает от ошибок в рантайме, в то время как Shared Preference может бросить ошибку парсинга в рантайме.

  • Proto DataStore предоставляет лучшую типобезопасность из коробки.

Тут стоит отдельно поговорить о транзакционности.

В Shared Preference транзакционность может быть достигнута за счет связки edit() -> apply()/commit(). Мы должны получить объект SharedPreferences.Editor, внести изменения и все это зафиксировать методами commit() или apply():

val editor: SharedPreferences.Editor = pref.edit()
editor.putString(KEY_LAST_NAME, lastName)
editor.putBoolean(KEY_IS_ACTIVE, isActive)
editor.apply()

В androidx этот же код будет выглядеть вот так:

pref.edit(commit = false) {
   putString(KEY_LAST_NAME, lastName)
   putBoolean(KEY_IS_ACTIVE, isActive)
}

По завершении операций в блоке edit{} внутри функции вызовется commit() или apply(), в зависимости от флага commit.

DataStore создает транзакцию всякий раз при вызове методов DataStore.updateData() или DataStore.edit() и делает запись после выполнения всех операций внутри этих функций.

DataStore vs Room

Если вам нужны частичные обновления, ссылочная целостность или поддержка больших/сложных наборов данных, подумайте об использовании Room вместо DataStore.

DataStore идеально подходит для небольших простых наборов данных и не поддерживает частичные обновления или ссылочную целостность.

Rx Java

В данный момент поддержки RX Java в DataStore нет. Поэтому, если мы хотим в проект на RX затащить DataStore, придется писать свои обертки. Как вариант, можно использовать тулы для совместимости вроде этой. 

Вывод

У SharedPreferences есть несколько недостатков:  

  • Синхронный API, который может показаться безопасным для вызова на UI-потоке, но фактически он выполняет операции дискового ввода-вывода.

  • Отсутствует механизм сигнализации об ошибках, транзакционный API и многое другое.

DataStore — это замена SharedPreferences, которая устраняет большинство этих недостатков. DataStore включает в себя полностью асинхронный API, использующий Kotlin Coroutines и Flow. Дает нам очень простой и удобный инструмент для миграции данных. Гарантирует согласованность данных и обработку поврежденных данных.

В данный момент библиотека находится в альфе, но вы всегда можете проверить последнюю версию в документации.

© Habrahabr.ru