[Из песочницы] Обобщаем анимацию таблиц в iOS приложениях
Пользователи хотят видеть изменения
Анимированное обновление списков всегда было непростой задачей в iOS. Что неприятно, это всегда было рутинной задачей.
Приложения крупных компаний, таких как Facebook, Twitter, Instagram, VK, используют таблицы. Более того, почти каждое iOS приложение написано с использованием UITableView или UICollectionView и пользователи хотят видеть, что изменяется у них на экранах, по этой причине reloadData не подходит для обновления экрана. Посмотрев несколько уже имеющихся фреймворков для данной задачи, я удивился, как много они в себе обобщают, помимо расчета анимаций. Некоторые же вообще при вставке одного элемента в начало, радостно сообщали о перемещениях всех остальных элементов.
Начав решать проблему обобщения построения и запуска анимаций, я ещё не понимал такого количества наличия подводных камней в дебрях UIKit. Но обо всём по порядку.
Рассчёт изменений
Чтобы попытаться анимировать таблицу, нужно для начала вычислить, что изменилось в двух списках. Есть 4 типа изменений ячейки: добавление, удаление, обновление и перемещение. Добавление и удаление рассчитать довольно просто, можно взять два вычитания списков. Если элемента нет в исходном списке, но есть в новом, то он был добавлен, а если же он есть в исходном, но нет в новом, то его удалили.
var initialList: Set = [1,2,3,5,6,7]
var resultList: Set = [1,3,4,5,6]
let insertList = resultList.subtracting(initialList) // {4}
let deleteList = initialList.subtracting(resultList) // {7, 2}
С обновлением ячейки немного сложнее. Сравнения ячеек оказывается недостаточно для определения обновления ячейки и приходится возлагать это на пользователя. Для этого заводится поле updateField, в которое пользователь отдаёт признак актуальности ячейки. Это может быть Date (Timestamp), какой-то целочисленный или строковый хэш (К примеру, новый текст изменённого сообщения). В общем, это сумма полей, которые отрисовываются на экране.
С перемещением ситуация схожа, но немного другая. Теоретически мы можем, сравнивая поля, узнать, что одно переместилось относительно другого, но на практике происходит следующее:
Например, есть два массива,
let a = [1,2,3,4]
let b = [4,1,2,3]
Быстро бросив взгляд на список, сразу видно, что »4» поменяла свою позицию и передвинулась влево, но на самом деле могло произойти так, что »1»,»2» и »3» передвинулись вправо, а »4» осталась на месте. Результат один и тот же, а способы совершенно разные, причём разные не только логически, но и визуально анимация для пользователя будет разной. Однако, нельзя, имея на руках только способ сравнения элементов, стопроцентно сказать, что именно передвинулось.
Но что если мы введём утверждение, что элементы перемещаются только вверх? Тогда становится понятным, что именно переместилось. Поэтому предоставлена возможность выбирать приоритетное направление расчета перемещений. К примеру, при написании мессенджера, какой-то определённый чат скорее всего переедет снизу вверх при добавлении нового сообщения. Однако, можно предоставить функцию для индикации о перемещении ячейки. Предположим, во вью модели есть поле lastMessageDate, которое изменяется при новом сообщении и, соответственно, изменяется порядок сортировки данной вью модели относительно других.
В итоге мы вычислили все 4 типа изменений. Дело за малым — применить их.
Применение изменений к таблицу
Для того, чтобы запустить изменения в таблице, в UITableView и UICollectionView предусмотрены специальные механизмы изменений, поэтому просто воспользуемся стандартными функциями.
tableView.beginUpdates()
self.currentList = newList
tableView.reloadRows(animations.toUpdate, with: .fade)
tableView.insertRows(at: animations.toInsert, with: .fade)
tableView.deleteRows(at: animations.toDelete, with: .fade)
tableView.reloadRows(at: animations.toUpdate, with: .fade)
for (from, to) in animations.cells.toMove {
tableView.moveRow(at: from, to: to)
}
tableView.endUpdates()
Запускаем, проверяем, всё отлично, всё работает. Но только до поры до времени…
Когда мы пытаемся обновить и переместить ячейку, мы падаем с ошибкой: attempt to delete and reload the same index path
Первая мысль, которая приходит в голову: «Но я же не пытаюсь удалить ячейку!». На самом деле move и update является ни чем иным как delete + insert, а таблица очень не любит таких действий и бросает ошибку (Всегда удивлялся, почему бы try-catch не сделать уже). Лечится она просто, выносим обновления в следующий цикл.
tableView.beginUpdates()
// insertions, deletions, moves…
tableView.endUpdates()
tableView.beginUpdates()
tableView.reloadRows(animations.cells.toDeferredUpdate, with: .fade)
tableView.endUpdates()
Теперь переходим к одной из самых сложных проблем, которую пришлось решить.
Всё вроде бы работает прекрасно, но при обновлении ячейки видно странное «моргание», причём не зависимо от того, передаётся стиль анимации .fade или .none, хоть это и не логично.
Вроде бы мелочь, но при наличии приличного количества обновлений в таблице, начинает отвратительно «перемаргивать», чего очень не хочется. Чтобы всё это обойти, приходится синхронизировать между собой анимации вставки-удаления-перемещения и обновления. То есть, пока не закончится первый .endUpdates (), нельзя начинать новый .beginUpdates (). Из-за этой, вроде бы незначительной проблемы пришлось написать класс синхронизации анимаций, который всё это дело обрабатывает. Единственным минусом стало то, что теперь изменения применяются не синхронно, а отложенно, то есть они ставятся в последовательную очередь.
let operation = BlockOperation()
// 1. Синхронизируем анимации. Нельзя использовать семафоры на главном потоке, так что ожидаем завершение анимации в специальной стерилизованной очереди
operation.addExecutionBlock {
// 2. Получаем текущий список. Он не передаётся явно, а получается непосредственно перед рассчётом анимаций
// потому что может быть изменён предыдущей задачей в очереди
guard let currentList = DispatchQueue.main.sync(execute: getCurrentListBlock) else { return }
do {
// 3. Просим рассчитать анимации
let animations = try animator.buildAnimations(from: currentList, to: newList)
var didSetNewList = false
DispatchQueue.main.sync {
// 4. Применяем анимации вставки, удаления и перемещения
mainPerform(self.semaphore, animations)
}
// 5. Ждём завершения анимации
_ = self.semaphore.wait()
if !animations.cells.toDeferredUpdate.isEmpty {
// 6. Происходит то же самое, только для отложенной update операции
DispatchQueue.main.sync {
deferredPerform(self.semaphore, animations.cells.toDeferredUpdate)
}
_ = self.semaphore.wait()
}
} catch {
DispatchQueue.main.sync {
onAnimationsError(error)
}
}
}
self.applyQueue.addOperation(operation)
Внутри mainPerform и deferredPerform происходит следующее:
table.performBatchUpdates({
// insert, delete, move...
}, completion: { _ in
semaphore.signal()
})
Наконец, завершив задумку, я поверил, что знаю всё об аномалиях, пока не наткнулся на странный баг, повторяющийся не всегда, но на определённых наборах изменений, когда применяешь обновления вместе с перемещениями. Даже если обновление и перемещение абсолютно никак не пересекаются, таблица может бросить фатальное исключение, и я окончательно удостоверился, что это никак не разрешить, кроме выноса reload в следующий цикл применения анимаций. «Но ведь можно просить ячейку у таблицы и насильно обновлять у неё данные», скажете вы. Можно, но только в том случае, когда высота ячеек статична, ведь её пересчёт нельзя просто так вызвать.
Позже возникла другая проблема. С AppStore стали часто прилетать ошибки падения таблицы. Благодаря логам, определить проблему было нетрудно. В функцию расчёта анимаций передавали невалидные списки вида:
let a = [1,2,3]
let b = [1,2,3,3,4,5]
То есть, одинаковые элементы задублировались. Лечится это довольно просто, аниматор стал бросать ошибку при расчете (Обратите внимание на листинг выше, там как раз расчет обёрнут в try-catch блок, как раз по этой причине). Определяя неконсистентность, с помощью сравнения количества элементов в исходном массиве (Array) и наборе элементов (Set). При добавлении элемента в Set, в котором он уже есть, он заменяется, и поэтому элементов в Set отказывается меньше, чем в массиве. Данную проверку можно отключить, но делать это очень не рекомендуется. Поверьте, в очень многих местах это спасло от ошибки, несмотря на уверенность разработчиков в правильности передаваемых аргументов.
Заключение
Анимировать таблицы в iOS не так просто. Большу́ю часть сложности добавляют закрытые исходники UIKit, в которых невозможно всегда понять, в каких случаях он бросает ошибку. Документация Apple по данному вопросу крайне скудна и говорит лишь, относительно индексов какого списка (старого или нового) необходимо передавать изменения. Способ работы с секциями таблиц ничем не отличается от работы с ячейками, поэтому примеры показаны только на ячейках. В статье код упрощён для более лёгкого понимания и уменьшения размера.
Исходный код находятся на GitHub, можно подцепить с помощью cocoapods или с помощью исходного кода. Код протестирован на множестве кейсов и в текущий момент живёт в продакшене некоторых приложений.
Совместим с iOS 8+ и Swift 4
Используемый материал
Документация Apple по .endUpdates
Применение изменений в iOS 11