В данной статье разобран пример создания делегата для SharedPreferences, который уменьшает boilerplate и делает использование SharedPrefernces более удобным. Те кто хочет посмотреть результат, может перейти к готовому решению, остальным добро пожаловать под кат.


Одной из насущных задач разработки под android является сохранение между сессиями приложение каких-либо данных. Основные способы для этого: хранить на сервере или в файлах на исполняемом устройстве. Одним из самых первых способов с котором знакомиться любой начинающий разработчик на android это хранение в файле при помощи уже готового инструмента SharedPreferences.


Допустим что нам нужно записывать имя пользователя и далее его отображать где либо в приложении.


  class UserStore(private val preferences: SharedPreferences) {

    fun getUserName(): String? {
        return preferences.getString(USER_NAME, "")
    }

    fun saveUserName(userName: String) {
        preferences.edit().putString(USER_NAME, userName).apply()
    }

    companion object {
        private const val USER_NAME = "user_name"
    }
}

Что такое делегат и как его готовят


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


Что бы сделать класс делегатом необходимо реализовать интерфейс ReadOnlyProperty для val и ReadWriteProperty для var. Передаем SharedPreferences, ключ по которому будет храниться свойство и дефолтное значение через конструктор. В setValue устанавливаем значение в getValue получаем значение.


class StringPreferencesDelegate(
    private val preferences: SharedPreferences,
    private val name: String,
    private val defValue: String
) : ReadWriteProperty<Any?, String?> {

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: String?) {
        preferences.edit().putString(name, value).apply()
    }

    override fun getValue(thisRef: Any?, property: KProperty<*>): String? {
        return preferences.getString(name, defValue)
    }
}

Применяем делегат


class UserStore(private val preferences: SharedPreferences) {

    var userName: String by StringPreferencesDelegate(preferences, USER_NAME, "")

    companion object {
        private const val USER_NAME = "user_name"
    }
}

Назначение свойству делегата осуществляется по ключевому слову by. Теперь каждый раз, когда данное свойство будет запрашиваться или устанавливаться будут запускаться методы getValue и setValue созданного делегата.


Так же аналогично теперь можно поступить с другими полями, к примеру, если нужно так же сохранять телефон пользователя.


    var userPhone: String by StringPreferencesDelegate(preferences, USER_PHONE, "")

Generic


Чтобы не делать для каждого типа данных отдельный делегат воспользуемся обобщениями generic официальная документация.


Обычно первое не осознанное знакомство с generic происходит при создании экземпляра класса List. Для него определяется конкретный тип данных с которым он работает.


val names :List<String> = listOf("Jon","Bob","Max") 

Чтобы задать обобщенный тип данных у класса после его название необходимо указать название этой переменной в угловых скобках.


PreferencesDelegate<TValue>(...)

Теперь необходимо задать, что устанавливаемое значение и дефолтное значение имеют тип TValue.


class PreferencesDelegate<TValue>(
    val preferences: SharedPreferences,
    private val name: String,
    private val defValue: TValue
) : ReadWriteProperty<Any?, TValue> {

    override fun getValue(thisRef: Any?, property: KProperty<*>): TValue {
        ...
    }

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: TValue) {
       ...
    }
}

Соответственно теперь создание экземпляра класса выглядит так:


var userName: String? by StringPreferencesDelegate<String?>(...)

Осталось сделать маппинг получения и установки свойств, определяем тип данных по delaultValue, заодно это дает smart cast этого значения к конкретному типу данных, если что то пошло не так и свойство не типа TValue возвращаем defValue.


override fun getValue(thisRef: Any?, property: KProperty<*>): TValue {
        with(preferences) {
            return when (defValue) {
                is Boolean -> (preferences.getBoolean(name, defValue) as? TValue) ?: defValue
                ...
            }
        }
    }

override fun setValue(thisRef: Any?, property: KProperty<*>, value: TValue) {
        with(preferences.edit()) {
            when (value) {
                is Boolean -> putBoolean(name, value)
                ...
            }
            apply()
        }
    }

С остальными типами данных аналогично.


Кастомная ошибка


Остается вопрос, что делать с веткой else, поскольку тип TValue может быть абсолютно любым.
Хорошим тоном будет сделать свою кастомную ошибку. Если произойдет исключение, тогда будет максимально понятно, что произошло.


        class NotFoundRealizationException(value: Any?) : Exception("not found realization for ${value?.javaClass}")

 ...
  else -> throw NotFoundRealizationException(value)
 ...

Заключение


Итого получаем готовый к применению делегат:


@Suppress("UNCHECKED_CAST")
class PreferencesDelegate<TValue>(
    val preferences: SharedPreferences,
    private val name: String,
    private val defValue: TValue
) : ReadWriteProperty<Any?, TValue> {

    override fun getValue(thisRef: Any?, property: KProperty<*>): TValue {
        with(preferences) {
            return when (defValue) {
                is Boolean -> (getBoolean(name, defValue) as? TValue) ?: defValue
                is Int -> (getInt(name, defValue) as TValue) ?: defValue
                is Float -> (getFloat(name, defValue) as TValue) ?: defValue
                is Long -> (getLong(name, defValue) as TValue) ?: defValue
                is String -> (getString(name, defValue) as TValue) ?: defValue
                else -> throw NotFoundRealizationException(defValue)
            }
        }
    }

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: TValue) {
        with(preferences.edit()) {
            when (value) {
                is Boolean -> putBoolean(name, value)
                is Int -> putInt(name, value)
                is Float -> putFloat(name, value)
                is Long -> putLong(name, value)
                is String -> putString(name, value)
                else -> throw NotFoundRealizationException(value)
            }
            apply()
        }
    }

    class NotFoundRealizationException(defValue: Any?) : Exception("not found realization for $defValue")
}

Пример применения


class UserStore(private val preferences: SharedPreferences) {

    var userName: String by PreferencesDelegate(preferences, USER_NAME, "")
    var userPhone: String by PreferencesDelegate(preferences, USER_PHONE, "")
    var isShowLicence: Boolean by PreferencesDelegate(preferences, USER_LICENCE, false)

    companion object {
        private const val USER_NAME = "user_name"
        private const val USER_PHONE = "user_phone"
        private const val USER_LICENCE = "user_licence"
    }
}

Комментарии (4)


  1. yavfast
    24.07.2019 13:50

    Текущая реализация SharedPreferences очень плохая.
    Из проблем:
    1. Загрузка данных из файла может происходить в UI потоке при создании инстанса SharedPreferences. В новых версиях SDK это уже вроде как починили.
    2. apply() добавляет таску сохранения в очередь, которая общая для всех Preferences. И самое печальное, что пока эта очередь не обработана, активити залипает при закрытии. Для чего это сделано — непонятно. Уж лучше commit() в бэкграунде.
    3. Каждый экземпляр SharedPreferences работает со своей копией данных. Надо следить, чтобы на каждый файл был один инстанс.
    4. На больших данных работает очень медленно.


    1. smile616
      26.07.2019 14:38

      3. Каждый экземпляр SharedPreferences работает со своей копией данных. Надо следить, чтобы на каждый файл был один инстанс.

      Это не так, в документации чётко прописано:
      Interface for accessing and modifying preference data returned by Context#getSharedPreferences. For any particular set of preferences, there is a single instance of this class that all clients share.


  1. Beholder
    24.07.2019 13:54

    Чтобы не делать для каждого типа данных отдельный делегат воспользуемся обобщениями

    Потерять типобезопасность — чего ради? Уж лучше накопипастить правильных классов.


  1. yavfast
    24.07.2019 14:03

    Рекомендации по предложенному делегату:
    1. preferences.edit() можно создать один раз и хранить ссылку в классе.
    2. apply() желательно вызывать отдельно, так как если будет много вызовов setValue(), то на каждый apply() будет отдельное сохранение в файл. Общий Editor может частично решить эту проблему.
    3. В preferences для каждого значения сохраняется также и его тип. Можно предусмотреть прозрачную. конвертацию. Иначе, возможны конфликты, например, Integer <-> Long.