Привет, меня зовут Сергей, я работаю в команде Мобильного Банка Тинькофф. Недавно Google представила очередной инструмент для хранения данных. На этот раз это библиотека DataStore. В официальном блоге Google пишут, что она должна заменить SharedPreference.
В отличие от SharedPreference, DataStore работает асинхронно. Вся работа с библиотекой выполняется с помощью Kotlin Coroutines и Flow. DataStore позволяет нам хранить данные двумя способами:
-
По принципу ключ значение, аналогично SharedPreference.
-
Хранить типизированные объекты, основанные на protocol buffers.
Все взаимодействие с DataStore происходит через интерфейс
DataStore<T>
, который содержит в себе всего два
элемента:
interface DataStore<T> { val data: Flow<T> suspend fun updateData(transform: suspend (t: T) -> T): T}
Интерфейс очень прост. Все, что мы можем сделать с ним, это
получить объект Flow<T>
для чтения данных и
вызвать метод updateData()
для их записи.
Типы DataStore
-
Preferences DataStore хранит данные по принципу ключ значение и не предоставляет нам никакой типобезопасности.
-
Proto DataStore хранит данные в объектах. Это дает нам типобезопасноть, но описывать схему нужно с помощью protocol buffers.
Поговорим о каждом из них.
Preferences DataStore
Для подключения библиотеки необходимо добавить зависимость в
build.gradle
нашего проекта:
// Preferences DataStoreimplementation "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<Preferences>
. На самом деле Google уже создала реализацию SharedPreferencesMigration. Все, что нам нужно, это описать логику переноса данных для каждого Shared Preference и передать их списком в параметр migrations:
fun getSharedPreferenceMigrationPref(): SharedPreferencesMigration<MutablePreferences> = 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<T>
. Создать подобное поле можно
с помощью extension-функции:
object UserScheme { val FIELD_NAME = preferencesKey<String>("name") val FIELD_LAST_NAME = preferencesKey<String>("last_name") val FIELD_AGE = preferencesKey<Int>("age") val FIELD_ACTIVE = preferencesKey<Boolean>("active")}
Каждый ключ указывает на тип хранимых в нем данных и строковый ключ, по которому эти данные будут читаться. Поскольку при создании ключа мы указываем тип хранимых данных мы получаем проверку на корректность передаваемого типа данных в compile time.
Стоит помнить, что создавать ключи можно только для примитивных
типов данных: Int
, Long
,
Boolean
, Float
, String
. В
противном случае мы получим исключение.
Также мы можем хранить Set<String>
:
val FIELD_STRINGS_SET = preferencesSetKey<Set<String>>("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
Для подключения добавляем зависимость:
// Proto DataStoreimplementation "androidx.datastore:datastore-core:1.0.0-alpha01"
Перед работой с Proto DataStore нужно выполнить несколько действий:
-
В
build.gradle
добавить плагин:
plugins {id "com.google.protobuf" version "0.8.12"}
-
Подключить зависимость в
build.gradle:
implementation "com.google.protobuf:protobuf-javalite:3.10.0"
-
Создать модель данных, используя protocol buffers.
Для этого нужно создать файл в 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<T>
, в котором мы и опишем логику записи/чтения нашего файла:
object UserSerializer : Serializer<User> { 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<T>
, который
вернет нам реактивный поток. Результатом этого 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. Дает нам очень простой и удобный инструмент для миграции данных. Гарантирует согласованность данных и обработку поврежденных данных.
В данный момент библиотека находится в альфе, но вы всегда можете проверить последнюю версию в документации.