Посмотрите на анимацию ниже. Это группа разноцветных элементов, которые при нажатии кнопки начинают перемещаться. С точки зрения разработчика, эти элементы размещаются внутри RecyclerView с добавлением соответствующего адаптера и LayoutManager.
При генерировании нового списка, он заменяет старый, а Adapter уведомляется об изменениях. Если нам ничего не известно о различиях этих списков, то для реализации уведомления можно воспользоваться функцией notifyDataSetChanged(). Если же у нас есть какая-то информация, то можно прибегнуть к дополнительным функциям уведомления: notifyItemInserted(position: Int), notifyItemRemoved(fromPosition: Int, toPosition: Int) и т.д.
Теперь давайте рассмотрим системы контроля версий, вроде Git, в которых всегда видны различия в коде: что-то добавляется, что-то удаляется, а что-то изменяется. Заметили ли вы сходство этого поведения с уведомлением Adapter? Задавались ли вы когда-нибудь вопросом, как именно анализируются строки для нахождения наиболее точных различий между старыми и новыми версиями содержимого?
Разностный алгоритм Майерса
Давайте возьмем две последовательности символов. Первая последовательность — уже существующая (старая) версия. Вторая — выходной результат с какими-то изменениями (новая версия).
S1 = C B A B A
S2 = A C A B B
Наша цель — высчитать наиболее точную разницу между версиями, т.е. найти последовательность изменений, которая превращает строку S1 в S2. Это можно сделать, сыграв в простую игру. Давайте нарисуем доску (игровое поле).
Для начала, разместим на ней строки в правильном порядке. Строка S1 находится в верхней части доски, а S2 располагается слева. Поделим игровое поле на квадраты, нарисовав вертикальные и горизонтальные линии, проходящие между символами строк. Теперь присвоим каждой строке порядковый номер (начиная с 0) — так мы узнаем точное положение объекта в ходе игры. Например, (2,4) означает то, что мы находимся на пересечении вертикальной линии 2 (по оси х) и горизонтальной линии 4 (по оси y). В данном случае, доска принимает следующий вид:
Далее создадим «специальные» квадраты с диагональными линиями, идущими из верхнего левого угла в правый нижний. «Специальный» квадрат — это тот, который содержит соответствующие символы (одинаковые буквы сверху и сбоку). Нарисуем все диагональные линии на доске.
Основная цель нашей игры — найти кратчайший путь из левого верхнего угла (0,0) в правый нижний (5,5). В каждом раунде мы можем продвигаться только на один шаг вдоль границы квадратов. Как только достигнута «специальная» точка, в которой начинается диагональ квадрата, мы «перешагиваем» через квадрат, и этот шаг прибавляется к текущему ходу. Давайте начнем с раунда 0:
Начальная точка — (0,0). Оттуда мы можем двигаться вправо (1,0) или вниз (0,1) с «бонусным» шагом в (1,2). Отрисуем наш путь после первого раунда.
В раунде 2 можно двигаться следующим образом:
- из (1,0) в (1,1);
- из (1,0) в (2,0) до (3,1);
- из (1,2) в (1,3) до (2,4);
- из (1,2) в (2,2), (3,3) до (4,4);
После раунда 2 состояние доски принимает следующий вид:
Давайте рассмотрим картинку шагов всех раундов. После достижения точки (5,5), пути, связывающие квадрат с (0,0), выделяются зеленым. Их всего два, поэтому у нас будет два корректных разностных результата игры.
После нахождения корректных путей, ими можно воспользоваться для выделения различий между строками. Существуют правила, которых необходимо придерживаться:
- Каждый шаг по вертикали считается добавлением соответствующего символа в квадрат, в который мы движемся из «новой» строки (S2). Например, перемещение от (0,0) к (0,1) указывает на добавление буквы «А» из S2.
- Каждый шаг по горизонтали предполагает удаление соответствующего символа из квадрата, в который мы движемся из «старой» строки (S1) . Например: переход от (0,0) к (1,0) означает удаление буквы «С» из строки S1.
- Каждый шаг по диагонали считается сохранением соответствующего символа в квадрате, к которому мы движемся (вспомните, что диагонали находятся в квадратах с одинаковыми буквами).
Давайте запишем последовательность изменений для каждого из корректных путей (верхний и нижний зеленый).
Как вы видите, каждая разностная последовательность состоит из двух добавлений и двух удалений. Обе последовательности могут использоваться для обозначения точного различия между строками. Давайте пропустим этап реализации алгоритма Майерса (эту информацию легко найти в интернете) и посмотрим, как воспользоваться им для улучшения Android-приложений, написанных на Kotlin.
DiffUtil
DiffUtil — это служебный класс в Android. Он является реализацией алгоритма Майерса с дополнительным вторым прогоном для обнаружения возможного перемещения заданных элементов. Этот класс автоматически отправляет обновления в RecyclerView Adapter, поэтому элементы списка в нем изменяются плавно, по мере своего фактического обновления.
Предположим, что у нас есть реализация RecyclerView.Adapter со списком типов SampleItem. Тогда мы можем воспользоваться классом DiffUtil для вычисления различий между новым и существующим состоянием элемента списка.
class SampleAdapter : RecyclerView.Adapter<SampleViewHolder>() {
private var itemList: List<SampleItem> = listOf()
fun setItems(newList: List<SampleItem>) {
val diffItemCallback = object : DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
this.itemList[oldItemPosition].getItemId() == newList[newItemPosition].getItemId()
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
this.itemList[oldItemPosition].getDiff() == newList[newItemPosition].getDiff()
override fun getOldListSize() = itemList.size
override fun getNewListSize() = newList.size
}
val diffResult = DiffUtil.calculateDiff(diffItemCallback)
diffResult.dispatchUpdatesTo(this@SampleAdapter)
}
(...)
}
Как вы видите, совсем не обязательно обращаться к функциям уведомления для обновления представления. Мы можем просто указать различие между элементами списка, создав объект с реализацией интерфейса DiffUtil.Callback и отправив DiffResult в адаптер. В таком случае, DiffUtil сделает всю работу за нас. Давайте внесем некоторые изменения через специфические элементы Kotlin для получения удобного средства разработчика — RecyclerView.Adapter.
Функции-расширения Kotlin и делегированные свойства
Kotlin позволяет расширять класс новым функционалом без необходимости наследования. Это называется функцией-расширением. Давайте создадим функцию-расширение RecyclerView.Adapter, которая будет отправлять обновления во View для двух списков, передаваемых в виде параметров функции (старого и нового).
fun <T : DiffItem, R : RecyclerView.ViewHolder> RecyclerView.Adapter<R>.autoNotify(oldList: List<T>, newList: List<T>) {
val diffItemCallback = object : DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
oldList[oldItemPosition].getItemId() == newList[newItemPosition].getItemId()
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
oldList[oldItemPosition].getDiff() == newList[newItemPosition].getDiff()
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
}
DiffUtil.calculateDiff(diffItemCallback).dispatchUpdatesTo(this)
}
interface DiffItem {
fun getItemId(): String
fun getDiff(): String
}
Обратите внимание, что этот тип списка должен реализовывать интерфейс DiffItem, чтобы разработчик мог указать, какие элементы его пользовательского класса нужны для правильного выполнения операций обновления.
После реализации функции-расширения RecyclerView.Adapter можно воспользоваться еще одним специфическим для Kotlin элементом — наблюдаемым свойством. Наблюдаемое свойство — это свойство с присоединенным делегированным слушателем. Оно получает уведомления обо всех изменениях своего значения.
class SampleAdapter : RecyclerView.Adapter<SampleViewHolder>() {
private var itemList: List<SampleItem> by Delegates.observable(initialValue = listOf(), onChange = { property, oldValue, newValue ->
autoNotify(oldValue, newValue)
})
fun setItems(itemList: List<SampleItem>) {
this.itemList = itemList
}
(...)
}
Присоединение наблюдаемого делегата к свойству позволяет получать уведомления о любом изменении в функции onChange(), в которой содержатся как старые, так и новые значения. Мы можем воспользоваться нашим адаптером autoNotify() функции-расширения. В таком случае, его представление автоматически обновится сразу после изменения списка.
Несмотря на создание более удобной для разработчиков реализации класса DiffUtil, мы все еще вынуждены прописывать сложные элементы кода для добавления наблюдаемого делегата и функции-расширения. Давайте добавим последнее изменение — наше собственное расширение-делегата для элемента адаптера.
fun <T : List<DiffItem>, R : RecyclerView.ViewHolder> autoNotifyDelegate(adapter: RecyclerView.Adapter<R>, initialValue: T): ReadWriteProperty<Any?, T> =
object : ObservableProperty<T>(initialValue) {
override fun afterChange(property: KProperty<*>, oldValue: T, newValue: T) {
adapter.autoNotify(oldValue, newValue)
}
}
Задав свойство типа List<DiffItem>, мы можем с легкостью пользоваться недавно созданным делегатом и забыть о лишних и ненужных вызовах функций уведомлений.
class SampleAdapter : RecyclerView.Adapter<SampleViewHolder>() {
private var itemList: List<SampleItem> by autoNotifyDelegate(adapter = this, initialValue = listOf())
fun setItems(itemList: List<SampleItem>) {
this.itemList = itemList
}
(...)
}
Вот и все. Добавьте строку ниже в файл build.gradle, чтобы скачать эти расширения как Android-библиотеку. Удачной разработки!
implementation 'com.adid.adapterdelegate:autonotifyadapterdelegate:1.0.2'
Перевод статьи Adrian Defus: The Myers Diff Algorithm and Kotlin Observable Properties — how to connect them to make a developer’s life easier