Pull to refresh

Proto DataStore + AndroidX Preferences на Kotlin

Reading time11 min
Views4.6K

С тех пор, как команда Google AndroidX представила на замену библиотеки SharedPreferences новую библиотеку DataStore, прошел почти год, однако популяризация новой либы явно не стоит в активных задачах. Иначе я не могу объяснить 1) неполноценный гайд, следуя лишь по которому, у вас вообще не соберется проект из-за отсутствия всех необходимых зависимостей и дополнительных build-задач для системы сборки, и 2) отсутствие не hello-world подобных примеров в CodeLabs, кроме одного, и то, заточенного не под пример использования библиотеки с нуля, а под миграцию с SharedPreferences на Preferences DataStore. Аналогично все статьи на Medium буквально или другими словами повторяют все то же, что написано в гайде Google, либо используют неправильные подходы для работы с DataStore, предлагая заворачивать асинхронный io-код в runBlocking прямо на ui-потоке.

А еще неплохо бы соединить "тыл" с "фронтом", так сказать: у Google имеется библиотека AndroidX Preferences из обоймы Jetpack, которая позволяет в два клика накидать готовый material-design фрагмент для управления настройками приложения и излюбленным способом кодогенерации освободить разработчика от написания boilerplate. Однако эта библиотека в качестве хранилища предлагает использовать устаревшие нынче SharedPreferences, а официального гайда по соединению с DataStore нет. В этой заметке я хотел бы своим способом устранить два описанных недостатка.

Создание каркаса для работы с DataStore

Библиотека DataStore делится на две части: аналог предыдущей под названием Preferences DataStore, которая хранит значения настроек в парах ключ-значение и не является типобезопасной, и вторая, которая хранит настройки в файле формата Protocol buffers и является типобезопасной. Она более гибкая и универсальная, поэтому я выбрал ее для своих экспериментов.

Для описания схемы настроек нужно создать дополнительный файл в проекте. Во-первых, надо переключить проводник студии или идеи в режим Project, чтобы была видна вся структура папок, и потом создать в папке app/src/main/proto/ файл с расширением *.proto (а не pb, как рекомендует Google - с ним ни плагин для проверки синтаксиса, автодополнения и т.п., ни build-задача, генерирующая соответствующий класс, не станет работать).

Синтаксис Protocol buffer очень хорошо описан самим Google, не имеет смысла копировать. Приведу текст файла, с которым буду экспериментировать в проекте:

syntax = "proto3";

option java_package = "полное.название.вашего.пакета";
option java_multiple_files = true;

message ProtoSettings {
  bool translate_to_ru = 1;
  map<string, int64> last_sync = 2;
  int32 refresh_interval = 3;
}

В настройках будут храниться булевый флаг для определения необходимости перевода текста в приложении, целочисленное значение, задающее период для синхронизации локальных данных с сервером, и словарь с ключом-строкой и значением-long Kotlin, в которое будет записываться unix-время последней синхронизации данных (данные делятcя на data классы, поэтому в ключе можно хранить simple name этого класса).

Теперь укажем все необходимые зависимости и задачи для build.gradle-файла модуля:

plugins {
    ...
    id "com.google.protobuf" version "0.8.12"
}
...
dependencies {
	  ...
    //DataStore
    implementation "androidx.datastore:datastore:1.0.0-beta01"
    implementation "com.google.protobuf:protobuf-javalite:3.11.0"
    implementation "androidx.preference:preference-ktx:1.1.1"
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.11.0"
    }

    generateProtoTasks {
        all().each { task ->
            task.builtins {
                java {
                    option 'lite'
                }
            }
        }
    }
}

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

Теперь скажу об основной и важной особенности DataStore: библиотека использует асинхронный фоновый способ для записи/чтения из файловой системы, а сами настройки после чтения из файла выдаются с помощью Flow. Запись же настроек осуществляется с помощью сгенерированных set-методов для каждого значения и паттерна builder. Особенности с Flow заставляют очень сильно менять подход к такой простой штуке, как настройки приложения, ведь иначе, как с помощью вызова терминальных операторов collect & Co их теперь не получишь.

Важно! не используйте deprecated-методы Flow toList или toSet, поскольку они повесят ваш метод навсегда (flow never completes, so this terminal operation never completes).

Для дальнейшей работы нужно написать еще немного boilerplate кода, который должен создать экземпляр класса настроек для работы приложения. Для этого есть только один способ, и почему Google не завернул его в кодогенерацию, ума не приложу:

@Suppress("BlockingMethodInNonBlockingContext")
object SettingsSerializer : Serializer<ProtoSettings> {
    override val defaultValue: ProtoSettings = ProtoSettings.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): ProtoSettings {
        return try {
            ProtoSettings.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            Log.e("SETTINGS", "Cannot read proto. Create default.")
            defaultValue
        }
    }

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

Этот объект Serializer должен создаваться как синглтон при начале работы приложения (до первого обращения к настройкам в общем случае) с помощью расширения контекста приложения.

И сделаем класс-обертку для чтения и записи настроек, который только на первый взгляд будет избыточным: во-первых, он позволит завернуть методы обновления настроек в нужные билдеры, чтобы не плодить их по всему проекту, во-вторых, в классе можно хранить другую текущую информацию, ценную лишь на время работы приложения, и функции-конверторы, ну и плюс этот класс будет с помощью Hilt легко встраиваться в качестве зависимости:

class Settings @Inject constructor(val settings: DataStore<ProtoSettings>) {

  companion object {
        const val HOUR_TO_MILLIS = 60 * 60 * 1000   // hours to milliseconds
        const val TRANSLATE_SWITCH = "translate_to_ru"
        const val REFRESH_INTERVAL_BAR = "refresh_interval"
        const val IS_PREFERENCES_CHANGED = "preferences_changed"
    }
  
    val saved get() = settings.data.take(1)
    
    suspend fun translateToRu(value: Boolean) = settings.updateData {
        it.toBuilder().setTranslateToRu(value).build()
    }

    suspend fun saveLastSync(cls: String) = settings.updateData {
        it.toBuilder().putLastSync(cls, System.currentTimeMillis()).build()
    }

    suspend fun refreshInterval(hours: Int) = settings.updateData {
        it.toBuilder().setRefreshInterval(hours * HOUR_TO_MILLIS).build()
    }

    fun checkNeedSync(cls: String) = saved.map {
        it.lastSyncMap[cls]?.run {
            System.currentTimeMillis() - this > saved.refreshInterval
        } ?: true
    }
}

@Module
@InstallIn(SingletonComponent::class)
class SettingsModule {

    @Provides
    @Singleton
    fun provideSettings(@ApplicationContext context: Context) = Settings(context.dataStore)

    private val Context.dataStore: DataStore<ProtoSettings> by dataStore(
        fileName = "settings.proto",
        serializer = SettingsSerializer
    )
}

Из кода видно, что сам доступ к настройкам я инкапсулировал в дополнительном свойстве saved, которое читает настройки из flow при помощи оператора take(1). После разных экспериментов этот способ мне показался самым оптимальным для ситуации, когда в одной функции настройки и читаются, и записываются. Если использовать просто collect, то каждый раз, когда функция будет записывать в настройки, станет происходить emit нового значения и заново вызываться эта же функция. Если использовать first(), то будет взято лишь первоначальное состояние настроек и flow закроется. Если использовать last(), то этот оператор будет способен повесить функцию, т.к. он не закрывает flow.

Использование настроек из DataStore

Опишу в практическом плане эту ситуацию. Предположим, у нас есть репозиторий, который возвращает определенное состояние. Его в Kotlin общепринято представлять алгебраическим типом данных, реализованным с помощью sealed класса:

sealed class Result
    data class Success<out T>(val data: T): Result()
    data class Error(val msg: String, val error: ErrorType): Result()
    object Loading : Result()

Вот как будет выглядеть функция репозитория, возвращающая состояние при запросе данных:

fun <T> fetchItems(
        itemsType: String,
        remoteApiCallback: suspend () -> Response<ApiResponse<T>>,
        localApiCallback: suspend () -> List<T>,
        saveApiCallback: suspend (List<T>) -> Unit,
    ): Flow<Result> = settings.checkNeedSync(itemsType).transform { needSync ->
        var remoteFailed = true
        emit(Loading)
        localApiCallback().let { local ->
            if (needSync || local.isEmpty()) {
                if (networkHelper.isNetworkConnected()) {
                    remoteApiCallback().apply {
                        if (isSuccessful) body()?.docs?.let { remote ->
                            settings.saveLastSync(itemsType)
                            remoteFailed = false
                            emit(Success(remote))
                            saveApiCallback(remote)
                        }
                        else emit(Error(errorBody().toString(), ErrorType.REMOTE_API_ERROR))
                    }
                } else emit(Error("No internet connection!", ErrorType.NO_INTERNET_CONNECTION))
            }

            if (remoteFailed)
                emit(if (local.isNotEmpty()) Success(local) else Error("No local saved data", ErrorType.NO_SAVED_DATA))
        }
    }
        .flowOn(Dispatchers.IO)
        .catch { e ->
            ...
        }

Эта функция принимает в параметрах строку с типом данных (которая нужна для получения времени последней синхронизации этого типа данных) и три коллбэка: для чтения данных с сервера, для чтения из локального кэша и для записи в локальный кэш. Функция с конкретным типом данных будет выглядеть примерно так:

fun getSomething() = fetchItems<Something>("Something", remoteApi::getSomething, localApi::getSomething, localApi::saveSomething)
fun getSmthOther() = fetchItems<Other>("Other", remoteApi::getSmthOther, localApi::getSmthOther, localApi::saveSmthOther)
    

Если не использовать строку с типом как параметр, то можно с помощью волшебного слова reified прочитать внутри функции метаинформацию типа, например, T::class.simpleName, однако для этого функцию придется делать inline, а коллбеки crossinline/noinline, но тогда следует оценивать преимущества такого действия. Именно здесь inline функция не даст никаких преимуществ, но создаст избыточный код, о чем непременно подскажет студия/идея, поэтому лучше просто включить строковый параметр.

Функция checkNeedSync возвращает тоже flow, как видно из кода класса SettingsRepository, а чтобы репозиторий вернул уже свой flow типа Result лучше всего подойдет оператор transform. Общий алгоритм функции такой: сначала передается состояние Loading (чтобы на ui можно было начать какую-то анимацию загрузки), затем читаются локальные данные. Если они устаревшие или отсутствуют, данные загружаются с сервера, обновляются локально, а в настройках переписывается время последнего обновления данных этого типа. В теории, так как внутри checkNeedSync настройки читаются один раз (take (1)), emit нового состояния настроек из-за обновления времени синхронизации не заставит checkNeedSync перечитывать их и выдавать новое значение для функции fetchItems. В противном случае данные бы передались минимум дважды - сначала те, которые загружены с сервера, а потом они же, но загруженные локально при дублированном запуске функции. Если бы произошло снова обновление настроек, то функция зациклилась и повесила приложение.

Добавляем фрагмент для редактирования настроек

Дальше используем библиотеки androidX для редактирования настроек и навигации. Описание AndroidX Preference располагается в разделе User interface/Settings, видимо чтобы не смешивать с библиотекой SharedPreferences (при этом Google создает другую путаницу между нашим DataStore и своим очередным слоем абстракции PreferenceDataStore).

Файл разметки preferences.xml
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:android="http://schemas.android.com/apk/res/android">

    <PreferenceCategory android:title="@string/experimentalTitle">

        <SwitchPreferenceCompat
            android:defaultValue="false"
            android:key="translate_to_ru"
            android:summaryOff="@string/aiTranslateOffText"
            android:summaryOn="@string/aiTranslateOnText"
            android:title="@string/aiTranslateTitle" />
    </PreferenceCategory>
    <PreferenceCategory android:title="@string/synchronizeTitle">

        <SeekBarPreference
            android:defaultValue="2"
            android:key="refresh_interval"
            android:title="@string/refreshIntervalTitle"
            android:summary="@string/refreshSummary"
            android:max="24"
            app:min="0"
            app:seekBarIncrement="1"
            app:showSeekBarValue="true" />
    </PreferenceCategory>
</PreferenceScreen>

Внешний вид экрана настроек с подобной разметкой:

Создаваемый автоматически экран настроек использует material design стиль для всех своих вложенных элементов, подробное описание которых можно найти в соответствующем разделе guides. Отмечу, что все элементы могут иметь специфичные атрибуты вроде summaryOff/summaryOn - небольшой текст под заголовком настройки, меняющийся в зависимости от её состояния, что хорошо подходит для чекбоксов и переключателей. Также имеется default value. Обязательным атрибутом является key, именно по его значению нужный элемент настроек становится доступен в коде.

С помощью библиотеки Navigation фрагмент с настройками легко встраивается в нужное место. Я, к примеру, вызываю его из меню главного экрана:

override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            ...
            R.id.preferences -> findNavController().navigate(MainFragmentDirections.actionShowPreferences())
        }
        return super.onOptionsItemSelected(item)
    }

А чтобы иметь возможность из фрагмента настроек передавать значение при возврате (к примеру, флаг того, что настройки изменились), воспользуемся возможностью библиотеки Navigation сохранять данные через SavedStateHandle, для чего в методе onCreateView главного фрагмента добавим следующий observer для BackStack'а:

findNavController().currentBackStackEntry?.let {
            it.savedStateHandle.getLiveData<Boolean>(Settings.IS_PREFERENCES_CHANGED).observe(viewLifecycleOwner) { isChanged ->
                if (isChanged) {
                    viewModel.armRefresh()
                    it.savedStateHandle.remove<Boolean>(Settings.IS_PREFERENCES_CHANGED)
                }
            }
        }

Удалить значение после использования необходимо, чтобы при пересоздании того же фрагмента не вызвался снова этот коллбэк, т.к. значения эти передаются как LiveData, а значит, последнее сохраненное значение будет передаваться каждому новому подписчику.

В самом фрагменте настроек не забудем написать код, изменяющий состояние настроек в DataStore и сохраняющий флаг изменения в savedStateHandle для вызывающего фрагмента. Для этого библиотека предоставляет метод findPreference, аналогичный findViewById, и коллбэк setOnPreferenceChangeListener:

override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
        setPreferencesFromResource(R.xml.preferences, rootKey)
        requireActivity().title = getString(R.string.preferencesTitle)

        val translateSwitch = findPreference<SwitchPreferenceCompat>(Settings.TRANSLATE_SWITCH)?.apply {
            setOnPreferenceChangeListener { _, value ->
                lifecycleScope.launch { settings.translateToRu(value as Boolean) }
                findNavController().previousBackStackEntry?.let {
                    it.savedStateHandle[Settings.IS_PREFERENCES_CHANGED] = true
                }
                true
            }
        }

        val refreshSeekBar = findPreference<SeekBarPreference>(Settings.REFRESH_INTERVAL_BAR)?.apply {
            setOnPreferenceChangeListener { _, value ->
                lifecycleScope.launch { settings.refreshInterval(value as Int) }
                findNavController().previousBackStackEntry?.let {
                    it.savedStateHandle[Settings.IS_PREFERENCES_CHANGED] = true
                }
                true
            }
        }

        settings.saved.collectOnFragment(this) {
            translateSwitch?.isChecked = it.translateToRu
            refreshSeekBar?.value = it.refreshInterval / Settings.HOUR_TO_MILLIS
        }
    }
Код расширения collectOnFragment для удобочитаемой работы с flow
fun <T> Flow<T>.collectOnFragment(
    fragment: Fragment,
    state: Lifecycle.State = Lifecycle.State.RESUMED,
    block: (T) -> Unit
) {
    fragment.lifecycleScope.launch {
        flowWithLifecycle(fragment.lifecycle, state)
            .collect {
                block(it)
            }
    }
}

Отмечу, что коллбэк setOnPreferenceChangeListener не является типобезопасным и передает value с типом Any, так что неизбежны приведения типов наподобие value as Boolean или value as Int, о чем следует помнить и быть внимательным.

На этом все. Мы выяснили, как работать в асинхронном стиле на Kotlin с DataStore, а не только как заворачивать чтение и запись настроек в runBlocking функции, что предлагают писатели 4-min-to-read-заметок на медиуме (и даже сам Google, хоть и выделяет это место красным предупреждающим цветом).

Также убедились, насколько легко и быстро Jetpack-библиотеки позволяют интегрировать работу с настройками приложения в рабочий ui c готовым material design стилем.

В участках кода есть места, которые я не стал объяснять или приводить полностью в силу неважности или очевидности (например, значение константы HOUR_TO_MILLIS), если же у вас не получится собрать аналогичный проект по моему рецепту, пишите в комментариях, я постараюсь дополнить все неясные места. Отмечу, что все участки кода я взял из полностью рабочего и протестированного проекта, так что за его работоспособность не стоит волноваться.

Спасибо за чтение.

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+1
Comments3

Articles