Android preferences delegate

В данной статье разобран пример создания делегата для 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"
    }
}
  • +12
  • 1,7k
  • 9
Поделиться публикацией

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

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

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

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

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


        2 apply() желательно вызывать отдельно, так как если будет много вызовов setValue(), то на каждый apply() будет отдельное сохранение в файл. Общий Editor может частично решить эту проблему.

        Тогда нужен дополнительный механизм сохранения, который будет работать внутри класса, что повышает сложность.
        По моему опыту в приложении с 30+ экранами таких делегатов штук 10-15, и они вызываются реже чем раз в 5 секунд, так что сильно задумываться о скорости работы не приходилось

        0
        Я бы это сделал проще
        interface SharedPreferencesHolder {
            val sharedPreferences: SharedPreferences
        }
        
        abstract class CommonPreferenceDelegate<T>(val name: String? = null) : ReadWriteProperty<SharedPreferencesHolder, T> {
            private fun getPreferenceKey(property: KProperty<*>) = name ?: property.name
        
            final override fun getValue(thisRef: SharedPreferencesHolder, property: KProperty<*>): T {
                return getValue(thisRef.sharedPreferences, getPreferenceKey(property))
            }
        
            final override fun setValue(thisRef: SharedPreferencesHolder, property: KProperty<*>, value: T) {
                setValue(thisRef.sharedPreferences, getPreferenceKey(property), value)
            }
        
            abstract fun getValue(prefs: SharedPreferences, key: String): T
            abstract fun setValue(prefs: SharedPreferences, key: String, value: T)
        }
        
        class BooleanPreferenceDelegate(private val defValue: Boolean, name: String? = null) : CommonPreferenceDelegate<Boolean>(name) {
            override fun getValue(prefs: SharedPreferences, key: String) = prefs.getBoolean(key, defValue)
            override fun setValue(prefs: SharedPreferences, key: String, value: Boolean) {
                prefs.edit().putBoolean(key, value).commit()
            }
        }
        
        fun SharedPreferencesHolder.boolean(defValue: Boolean = false, name: String? = null) = BooleanPreferenceDelegate(defValue, name)
        
        class Foo(override val sharedPreferences: SharedPreferences) : SharedPreferencesHolder {
            val isUserLogged by boolean(false)
        }

        Ну и нафаршировать сверху классов для остальных типов.


        NotFoundRealizationException

        Реализация по-бассурмански будет implementation

          0

          Да implementation суда подходит лучше.


          Считаю плохой идей давать возможность не задавать key ключа, при изменении названия переменной необходимо помнить, что нужно будет указать name прошлым именем, чтобы не потерять данные.
          На мой взгляд получилось не особо проще, но это уже риторический вопрос

            0

            Ну key можно и за'require'ить, не суть дело.
            Просто Ваше решение очень теряет в типобезопасности — попробуйте туда добавить Set<String> без unchecked cast'a.
            Ну и хранит инстанс SharedPreferences в каждом делегате.

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

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