Делегаты в Kotlin для Android

Kotlin & Android

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

Основы

Пляшем от начала: что такое делегат и как он работает? На самом деле, все не так сложно, как кажется

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

Допустим, нам нужно свойство param типа String, всегда содержащее обрезанную строку, то есть без начального и конечного пробелов. Мы можем сделать это в сеттере свойства так:

class Example {

    var param: String = ""
        set(value) {
            field = value.trim()
        }
}

Если вам нужна помощь по синтаксису, перейдите на страницу Свойства в документации по Kotlin. 

Ну а теперь, что если нам нужно переиспользовать эту функциональность в каком-то другом классе? Вот тут-то делегаты и вступают в игру:

class TrimDelegate : ReadWriteProperty<Any?, String> {

    private var trimmedValue: String = ""

    override fun getValue(
        thisRef: Any?,
        property: KProperty<*>
    ): String {
        return trimmedValue
    }

    override fun setValue(
        thisRef: Any?,
        property: KProperty<*>, value: String
    ) {
        trimmedValue = value.trim()
    }
}

Итак, делегат — это класс с двумя методами: для получения и присвоения значения свойству. Чтобы у него было больше информации, он получает свойство, которое работает через инстанс класса KPropertyи объект, у которого есть это свойство через thisRef. Это все! И вот как мы можем использовать этот свежесозданный делегат:

class Example {

    var param: String by TrimDelegate()
}

Что аналогично следующему:

class Example {

    private val delegate = TrimDelegate()
    var param: String
        get() = delegate.getValue(this, ::param)
        set(value) {
            delegate.setValue(this, ::param, value)
        }
}

::param является оператором, который возвращает инстанс класса KProperty свойству.

Очевидно, ничего сверхтаинственного в этих делегатах нет. Но несмотря на их простоту, они часто пригождаются. Рассмотрим некоторые примеры, характерные для Android.

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

Аргументы фрагмента

Часто нам нужно передать некоторые параметры фрагменту. Обычно это выглядит так:

class DemoFragment : Fragment() {
    private var param1: Int? = null
    private var param2: String? = null
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let { args ->
            param1 = args.getInt(Args.PARAM1)
            param2 = args.getString(Args.PARAM2)
        }
    }
    companion object {
        private object Args {
            const val PARAM1 = "param1"
            const val PARAM2 = "param2"
        }
        fun newInstance(param1: Int, param2: String): DemoFragment =
            DemoFragment().apply {
                arguments = Bundle().apply {
                    putInt(Args.PARAM1, param1)
                    putString(Args.PARAM2, param2)
                }
            }
    }
}

Так мы передаем параметры путем создания фрагмента через его статический метод newInstance. Внутри мы помещаем параметры в аргументы фрагмента, чтобы извлечь их позже через onCreate.

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

class DemoFragment : Fragment() {
    private var param1: Int?
        get() = arguments?.getInt(Args.PARAM1)
        set(value) {
            value?.let {
                arguments?.putInt(Args.PARAM1, it)
            } ?: arguments?.remove(Args.PARAM1)
        }
    private var param2: String?
        get() = arguments?.getString(Args.PARAM2)
        set(value) {
            arguments?.putString(Args.PARAM2, value)
        }
    companion object {
        private object Args {
            const val PARAM1 = "param1"
            const val PARAM2 = "param2"
        }
        fun newInstance(param1: Int, param2: String): DemoFragment =
            DemoFragment().apply {
                this.param1 = param1
                this.param2 = param2
            }
    }
}

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

И все же, есть ли путь для того, чтобы сделать код красивым впоследствии? Ответ положительный. И как вы уже могли догадаться, мы будем пользоваться делегированными свойствами.

Для начала подготовимся. Аргументы фрагмента хранятся в объекте Bundle, у которого есть отдельные методы для значений разных типов. Так что сделаем функцию расширения, которая пытается поместить значение случайного типа в бандл и выдает исключение, если тип не поддерживается.

fun <T> Bundle.put(key: String, value: T) {
    when (value) {
        is Boolean -> putBoolean(key, value)
        is String -> putString(key, value)
        is Int -> putInt(key, value)
        is Short -> putShort(key, value)
        is Long -> putLong(key, value)
        is Byte -> putByte(key, value)
        is ByteArray -> putByteArray(key, value)
        is Char -> putChar(key, value)
        is CharArray -> putCharArray(key, value)
        is CharSequence -> putCharSequence(key, value)
        is Float -> putFloat(key, value)
        is Bundle -> putBundle(key, value)
        is Parcelable -> putParcelable(key, value)
        is Serializable -> putSerializable(key, value)
        else -> throw IllegalStateException("Type of property $key is not supported")
    }
}

Теперь мы готовы создать сам делегат:

class FragmentArgumentDelegate<T : Any> :
    ReadWriteProperty<Fragment, T> {

    @Suppress("UNCHECKED_CAST")
    override fun getValue(
        thisRef: Fragment,
        property: KProperty<*>
    ): T {
        val key = property.name
        return thisRef.arguments
            ?.get(key) as? T
            ?: throw IllegalStateException("Property ${property.name} could not be read")
    }

    override fun setValue(
        thisRef: Fragment,
        property: KProperty<*>, value: T
    ) {
        val args = thisRef.arguments
            ?: Bundle().also(thisRef::setArguments)
        val key = property.name
        args.put(key, value)
    }
}

Делегат читает значение свойства из аргументов фрагмента и, когда значение свойства меняется, он извлекает аргументы фрагмента (или создает и устанавливает новый Bundle в качестве аргументов, если у фрагмента их не было). Затем делегат записывает новое значение в эти аргументы, используя функцию расширения Bundle.put, которую мы создали ранее.

ReadWriteProperty— это дженерик-интерфейс, который принимает параметры двух типов. Первый мы сделаем Fragment, так этот делегат будет подходить только свойствам внутри фрагмента. После этого мы сможем получить доступ к инстансу фрагмента с thisRefи управлять его аргументами.

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

Параметр второго типа ReadWriteProperty определяет, какой вид значений может быть у свойства. Мы определенно устанавливаем тип как ненулевой и выдаем исключение, если значение невозможно прочитать. Это позволяет нам работать с ненулевыми свойствами в нашем фрагменте, уберегая процесс от раздражающих проверок на null.

Но иногда нам надо, чтобы свойство было именно обнуляемое. Так что давайте создадим другой делегат, такой, чтобы он не выдавал исключение, когда аргумент не будет найден, а возвращал вместо этого null:

class FragmentNullableArgumentDelegate<T : Any?> :
    ReadWriteProperty<Fragment, T?> {

    @Suppress("UNCHECKED_CAST")
    override fun getValue(
        thisRef: Fragment,
        property: KProperty<*>
    ): T? {
        val key = property.name
        return thisRef.arguments?.get(key) as? T
    }

    override fun setValue(
        thisRef: Fragment,
        property: KProperty<*>, value: T?
    ) {
        val args = thisRef.arguments
            ?: Bundle().also(thisRef::setArguments)
        val key = property.name
        value?.let { args.put(key, it) } ?: args.remove(key)
    }
}

А теперь давайте создадим несколько функций для удобства. Это необязательно, но исключительно ради эстетических целей, почему нет:

fun <T : Any> argument(): ReadWriteProperty<Fragment, T> =
    FragmentArgumentDelegate()
fun <T : Any> argumentNullable(): ReadWriteProperty<Fragment, T?> =
    FragmentNullableArgumentDelegate()

И наконец, начнем пользоваться нашими делегатами:

class DemoFragment : Fragment() {
    private var param1: Int by argument()
    private var param2: String by argument()
    companion object {
        fun newInstance(param1: Int, param2: String): DemoFragment =
            DemoFragment().apply {
                this.param1 = param1
                this.param2 = param2
            }
    }
}

Выглядит достаточно аккуратно, да?

Делегаты SharedPreferences

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

Давайте допустим, что у нас есть некоторый класс, который отвечает за сохранение и получение трех параметров:

class Settings(context: Context) {

    private val prefs: SharedPreferences = 
        PreferenceManager.getDefaultSharedPreferences(context)

    fun getParam1(): String? {
        return prefs.getString(PrefKeys.PARAM1, null)
    }

    fun saveParam1(param1: String?) {
        prefs.edit().putString(PrefKeys.PARAM1, param1).apply()
    }

    fun getParam2(): Int {
        return prefs.getInt(PrefKeys.PARAM2, 0)
    }

    fun saveParam2(param2: Int) {
        prefs.edit().putInt(PrefKeys.PARAM2, param2).apply()
    }

    fun getParam3(): String {
        return prefs.getString(PrefKeys.PARAM3, null) 
            ?: DefaulsValues.PARAM3
    }

    fun saveParam3(param3: String) {
        prefs.edit().putString(PrefKeys.PARAM2, param3).apply()
    }

    companion object {
        private object PrefKeys {
            const val PARAM1 = "param1"
            const val PARAM2 = "param2"
            const val PARAM3 = "special_key_param3"
        }

        private object DefaulsValues {
            const val PARAM3 = "defaultParam3"
        }
    }
}

Здесь мы получаем дефолтные SharedPreferences, и даем методы для получения и сохранения значений для наших параметров. Также мы сделали param3, отличающийся тем, что он использует ключ специального предпочтения и имеет нестандартное значение по умолчанию.

И снова у нас есть повторяющиеся части кода. Конечно, мы можем переместить некоторые из них в приватные методы. И тем не менее у нас все равно останется достаточно громоздкий код. Кроме того, что, если мы захотим переиспользовать эту логику в каком-то другом классе? Давайте посмотрим, как делегаты могут сделать код намного чище.

На этот раз мы будем использовать выражения объектов и создадим функции расширения для класса SharedPreferences.

fun SharedPreferences.string(
    defaultValue: String = "",
    key: (KProperty<*>) -> String = KProperty<*>::name
): ReadWriteProperty<Any, String> =
    object : ReadWriteProperty<Any, String> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ) = getString(key(property), defaultValue)
        override fun setValue(
            thisRef: Any,
            property: KProperty<*>,
            value: String
        ) = edit().putString(key(property), value).apply()
    }

Здесь мы сделали функцию расширения SharedPreferences. Она возвращает объект анонимного подкласса ReadWriteProperty для нашего делегата.

Делегат читает значение свойства как String из предпочтений с помощью функции key для ключа предпочтений. По умолчанию ключ является именем свойства, так что нам не надо сохранять и передавать константы. В то же время у нас все еще есть возможность передать кастомный ключ, если, например, мы хотим обойти противоречие ключей внутри предпочтений или же захотим получить явный доступ к ключу. Также мы можем дать дефолтное значение для свойства в случае, если его нет в предпочтениях.

А еще делегат заботится о сохранении нового значения свойства в предпочтениях, применяя ту же функцию key.

Чтобы наш пример Settings работал, нам надо добавить еще два делегата для типов String?и Int, которые работают практически одинаково:

fun SharedPreferences.stringNullable(
    defaultValue: String? = null,
    key: (KProperty<*>) -> String = KProperty<*>::name
): ReadWriteProperty<Any, String?> =
    object : ReadWriteProperty<Any, String?> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ) = getString(key(property), defaultValue)

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

fun SharedPreferences.int(
    defaultValue: Int = 0,
    key: (KProperty<*>) -> String = KProperty<*>::name
): ReadWriteProperty<Any, Int> =
    object : ReadWriteProperty<Any, Int> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ) = getInt(key(property), defaultValue)

        override fun setValue(
            thisRef: Any,
            property: KProperty<*>,
            value: Int
        ) = edit().putInt(key(property), value).apply()
    }

А теперь мы можем сделать наш класс Settings красивым:

class Settings(context: Context) {

    private val prefs: SharedPreferences =
        PreferenceManager.getDefaultSharedPreferences(context)

    var param1 by prefs.stringNullable()
    var param2 by prefs.int()
    var param3 by prefs.string(
        key = { "KEY_PARAM3" },
        defaultValue = "default"
    )
}

И вот код выглядит намного лучше. Если нам в будущем понадобится новый параметр, его можно добавить буквально в одной строчке кода!

Делегаты представления

Предполагается, что у нас есть пользовательское представление, в котором есть три поля: заголовок, подзаголовок и описание. И все это выглядит так:

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <TextView
        android:id="@+id/tvTitle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <TextView
        android:id="@+id/tvSubtitle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <TextView
        android:id="@+id/tvDescription"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</LinearLayout>

И мы хотим, чтобы наш CustomView предоставлял методы для доступа и изменения текста в этих полях:

class CustomView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
    var title: String
        get() = tvTitle.text.toString()
        set(value) {
            tvTitle.text = value
        }
    var subtitle: String
        get() = tvSubtitle.text.toString()
        set(value) {
            tvSubtitle.text = value
        }
    var description: String
        get() = tvDescription.text.toString()
        set(value) {
            tvDescription.text = value
        }
    init {
        inflate(context, R.layout.custom_view, this)
    }
}

Здесь мы применяем привязку представлений из Kotlin Android Extensions для доступа к представлениям внутри макета.

Ясно, что у нас есть некоторый код, который можно запросто переместить в отдельную сущность. Так что давайте сделаем это с помощью делегатов! Напишем функцию расширения TextView, которая возвращает делегат для работы с его текстом:

fun TextView.text(): ReadWriteProperty<Any, String> =
    object : ReadWriteProperty<Any, String> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ): String = text.toString()

        override fun setValue(
            thisRef: Any,
            property: KProperty<*>, value: String
        ) {
            text = value
        }
    }

И используем его в нашем CustomView:

class CustomView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {

    init {
        inflate(context, R.layout.custom_view, this)
    }
    
    var title by tvTitle.text()
    var subtitle by tvSubtitle.text()
    var description by tvDescription.text()
}

Убедитесь, что свойства инициализированы после наполнения представления в блоке init, так как они должны быть ненулевыми.

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

Конечно, вы не ограничены TextView. Например, вот вам делегат для представления видимости (keepBounds определяет, занимает ли все еще представление место в макете или нет, когда его не видно):

fun View.isVisible(keepBounds: Boolean): ReadWriteProperty<Any, Boolean> =
    object : ReadWriteProperty<Any, Boolean> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ): Boolean = visibility == View.VISIBLE

        override fun setValue(
            thisRef: Any,
            property: KProperty<*>,
            value: Boolean
        ) {
            visibility = when {
                value -> View.VISIBLE
                keepBounds -> View.INVISIBLE
                else -> View.GONE
            }
        }
    }

А вот делегат для прогресса в ProgressBar как плавающее от 0 до 1:

fun ProgressBar.progress(): ReadWriteProperty<Any, Float> =
    object : ReadWriteProperty<Any, Float> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ): Float = if (max == 0) 0f else progress / max.toFloat()

        override fun setValue(
            thisRef: Any,
            property: KProperty<*>, value: Float
        ) {
            progress = (value * max).toInt()
        }
    }

А вот как нам стоит использовать их, если у нас есть progressBar в нашем CustomView:

class CustomView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {

    init {
        inflate(context, R.layout.custom_view, this)
    }
    
    var title by tvTitle.text()
    var subtitle by tvSubtitle.text()
    var description by tvDescription.text()

    var progress by progressBar.progress()
    var isProgressVisible by progressBar.isVisible(keepBounds = false)
}

Очевидно, вы можете делегировать что угодно — и пусть только небо будет вам ограничением!

Заключение

И вот мы прошлись по некоторым примерам делегирования свойств в Kotlin для случаев разработки на Android. Конечно, вы вправе думать, что есть и много других способов использовать их в своем приложении. Цель была продемонстрировать, насколько сильным инструментом является делегирование свойств, а также, что можно с ним сделать.

Специально для сайта ITWORLD.UZ. Новость взята с сайта NOP::Nuances of programming