company_banner

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

    Привет, меня зовут Сергей, я работаю в команде Мобильного Банка Тинькофф. Недавно 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 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<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 DataStore
    implementation  "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"

    Для этого нужно создать файл в 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. Дает нам очень простой и удобный инструмент для миграции данных. Гарантирует согласованность данных и обработку поврежденных данных.

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

    Tinkoff
    it’s Tinkoff — просто о сложном

    Похожие публикации

    Комментарии 13

      0

      Порог входа в андройд разработку неуклонно растет. Теперь что бы просто пользоваться Preferences уже нужно знать корутины.

        +1
        Ну корутины знать в целом нужно. Либо RX Java, что еще труднее. Ибо сейчас почти нет приложений в которых бы кто-то писал на хендлерах и т.п. Ну и корутины это часть Kotlin SDK по сути. Так что да.
          0

          Да, конечно, я сам очень рад, что андройд разработка становится современной, код с использованием подобных инструментов более выразительным. Но новичку это все постичь все больше непросто.

            +2
            Ну вот спорный вопрос. Что легче хендлеры или корутины=) Ну а вообще с одной стороны более тяжело, т.к. требования растут, с другой стороны ресурсов для получения знаний становится в разы больше.
        +2
        DataStore идеально подходит для небольших простых наборов данных и не поддерживает частичные обновления или ссылочную целостность.

        Ради чего тогда весь сыр-бор?
          +1
          А Preference Screens теперь предлагается руками рисовать? C Shared Preferences можно просто xml-ку скормить PreferenceFragmentCompat-у и всё.
            +1
            Так это же самая первая альфа (на самом деле уже вторая вышла), к релизу может и подтянут обертку для экранов
              0
              Есть большие сомнения на этот счёт. «Обертка для экранов» по сложности больше самого механизма хранения. И никакой информации о том, что эта фича в разработке, нет.

              Я к тому, что Гугл как всегда лукавит — взяли хранение настроек, нашли там недостатки, и полностью проигнорировали вопросы взаимодействия пользователя с этими настройками. Просто key-value хранилищ и без гугла хватало — тот же SnappyDB или Hawk.
            0
            Хм…
            Preferences DataStore — хранит данные по принципу «ключ — значение» и не предоставляет нам никакой типобезопасности.

            А как же: EncryptedSharedPreferences?

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

            Мне кажется что данный подход требует от нас слишком много тело движений и кучу реализаций пусть и маленьких на первый взгляд.

            Ведь я могу взять: EncryptedSharedPreferences, сохранить в нём Json объект в виде строки. Зашифровать и сохранить. А потом точно так же доставать наружу. Вроде проще, нет?
              0
              EncryptedSharedPreferences работает аналогично SharedPreferences в части интерфейсов чтения/записи, отличие в том что дополнительно сами поля в хранимом XML будут зашифрованы. А так если нужен любой тип, кроме примитива или массива примитивов — самостоятельно сериализуйте, типобезопасности из коробки нет.
              0
              Спасибо за обзор! Полезно было почитать

              Заметил несколько неточностей:
              В отличие от SharedPreference, DataStore работает асинхронно.

              это не совсем так, apply тоже работает асинхронно

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

              может только при записи?
                0
                Про apply я в целом упомянул.
                Про «может только при записи?». — у вас же для чтения есть интерфейс который отдает на чтение Flow. Посмотрите в самом вверху статьи;)
                  0
                  в отличие от Shared Preference, который предоставляет асинхронный API только при чтении данных.

                  имею в виду, что Shared Preference не предоставляет асинхронный API для чтения.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое