Reactive Data Display Manager. История одного рефакторинга
В мобильной разработке редкие экраны обходятся без коллекций UITableView или UICollectionView. Эти UI-компоненты позволяют разделять экран на ячейки, настраивать их по отдельности или заменять при необходимости. Однако вместе с этим появляется много рутинной работы по настройке DataSource и Delegate коллекции.
Чтобы уменьшить рутину, мы создали библиотеку Reactive Data Display Manager (RDDM). В её основе — адаптер, имплементирующий DataSource и Delegate, и генератор, соответствующий ячейке коллекции. Подробнее о библиотеке вы можете прочитать в первой статье «Reactive Data Display Manager. Введение», написанной аж в 2019 году.
Библиотека дорабатывалась и становилась мощнее. Но чем больше мы вносили улучшений, тем больше понимали, что с архитектурой библиотеки что-то не так. Последней каплей стала попытка подружить RDDM с мощным UITableViewDiffableDataSource, которая завершилась провалом.
Я — Никита Коробейников, iOS teamlead в Surf. С недавних пор отвечаю за развитие RDDM. В статье расскажу историю рефакторинга, который помог обновить библиотеку до современного состояния и подружить с новейшими API для работы с коллекциями.
Проблемы расширения
RDDM родился из идеи вынести UITableViewDelegate и UITableViewDataSource в отдельную сущность, чтобы не имплементировать их методы. Однако во время разработки мы столкнулись с тем, что такое решение сложно модифицировать: любая модификация порождала нового наследника адаптера.
Рассмотрим ситуацию на примере экрана с пагинацией. Такой механизм часто используется, когда нет смысла загружать данные целиком: например, если с сервера приходит много однотипных данных, которые не уместить сразу на одном экране.
Backend-разработчики предоставляют нам API для загрузки данных по пачкам. Задача iOS-разработчика заключается в том, чтобы определить момент, когда нужно запросить следующую пачку. Желательно минимизировать время ожидания: предугадать намерения пользователя и запросить данные чуть раньше.
В этом поможет метод делегата tableView(_:willDisplay:forRowAt:), который срабатывает незадолго до отрисовки ячейки. По индексу можно определить, что последняя ячейка готова к отрисовке, но при использовании RDDM нельзя просто взять и переопределить метод делегата. Придётся наследоваться =(
open class PaginableBaseTableDataDisplayManager: BaseTableDataDisplayManager {
/// Called if table shows last cell
public var lastCellShowingEvent = BaseEvent<Void>()
open override func tableView(_ tableView: UITableView,
willDisplay cell: UITableViewCell,
forRowAt indexPath: IndexPath) {
super.tableView(tableView, willDisplay: cell, forRowAt: indexPath)
let lastSectionIndex = self.cellGenerators.count - 1
let lastCellInLastSectionIndex = self.cellGenerators[lastSectionIndex].count - 1
let lastCellIndexPath = IndexPath(row: lastCellInLastSectionIndex,
section: lastSectionIndex)
if indexPath == lastCellIndexPath {
self.lastCellShowingEvent.invoke(with: ())
}
}
}
Не самое дальновидное решение, но это лучше, чем копировать логику из контроллера в контроллер, не так ли? Будем использовать этот адаптер для экранов с пагинацией. Но что если мы захотим расширить возможности адаптера? Тогда появится ещё один наследник. Если понадобится сочетать возможности адаптеров, получится наследник наследника.
За годы использования в RDDM появилось слишком много наследников базового адаптера. На девяти проанализированных проектах собралось 40 различных наследников и их комбинаций. Пугающая статистика, правда? Но это не самое страшное.
С выходом iOS 13 в дополнение к стандартному UITableViewDataSource Apple создали новый тип — UITableViewDiffableDataSource. Мы столкнулись с проблемой: заменить dataSource и попробовать новый мы не могли, потому что RDDM-адаптер представлял монолитное слияние UITableViewDelegate и UITableViewDataSource.
Эти причины и натолкнули нас на мысль о рефакторинге.
Рефакторинг
Что хотели получить после рефакторинга
Возможность расширения без наследования.
Возможность подмены delegate или dataSource.
Хотя бы частичную обратную совместимость. В отдел iOS-разработки Surf работает двадцать разработчиков. Все они используют RDDM в продакшене, поэтому сохранить концепцию библиотеки и интерфейс адаптера было важно.
Архитектура библиотеки до рефакторинга
Это общая схема для UITableView и UICollectionView. Фиксация на тип коллекции и ячеек происходит через алиасы CollectionType, CellGeneratorType. Пунктиром обозначаем части, у которых нет проблем с расширяемостью или заменой. В данном случае — только с интерфейсом адаптера.
Недостатки такой архитектуры мы уже описали выше: слишком много наследников базового адаптера и невозможность использовать новый тип dataSource.
Что получилось после рефакторинга
В новой архитектуре получилось довольно много сущностей. Чтобы собрать из них конкретный адаптер, используем паттерн Builder.
Delegate и DataSource теперь — свойства адаптера. Их можно заменять — правда, только при инициализации, но этого достаточно.
Animator отвечает за обновление таблицы или коллекции. Его можно подменить, но на деле достаточно двух готовых реализаций, которые выбираются в зависимости от версии ОС. Для iOS младше 11 мы проводим вставку и удаление внутри beginUpdates/endUpdates. Для более новых iOS используем performBatchUpdates.
Для мелких модификаций ввели плагины.
PluginAction представляет реакцию на событие делегата или источника и выполняет работу с генератором или адаптером. Используем его, например, в поддержке пагинации.
FeaturePlugin нужен для более сложных фич, в которых потребуется подменить возвращаемое значение в делегате или источнике. Например, в поддержке swipeActions для таблицы.
В общем случае фича равно плагин. То есть когда нужно будет добавить адаптеру новое умение, в первую очередь подумаем, как реализовать его через плагины.
Примеры использования
Пагинация
public override func process(event: TableEvent, with manager: BaseTableManager?) {
switch event {
case .willDisplayCell(let indexPath):
guard let generators = manager?.generators else {
return
}
let lastSectionIndex = generators.count - 1
let lastCellInLastSectionIndex = generators[lastSectionIndex].count - 1
let lastCellIndexPath = IndexPath(row: lastCellInLastSectionIndex,
section: lastSectionIndex)
if indexPath == lastCellIndexPath {
action()
}
default:
break
}
}
Для пагинации теперь используется плагин. Тело главного метода плагина говорит само за себя. Алгоритм действий аналогичен тому, что был в адаптере-наследнике. TableEvent тут — всего лишь enum, описывающий события делегата или dataSource.
Подключить плагин очень просто
private let lastCellIsVisibleEvent: () -> Void = {
print("Last cell is visible")
}
private lazy var adapter = tableView.rddm.manualBuilder
.add(plugin: .lastCellIsVisible(action: lastCellIsVisibleEvent))
.build()
Бонус-трек: стресс-тест адаптер
Количество плагинов ограничено только вашей фантазией.
private lazy var adapter = tableView.rddm.manualBuilder
.add(plugin: .displayable())
.add(plugin: .direction(action: scrollDirectionEvent))
.add(plugin: .headerIsVisible(action: headerVisibleEvent))
.add(plugin: .lastCellIsVisible(action: lastCellIsVisibleEvent))
.add(plugin: .selectable())
.add(plugin: prefetcherablePlugin)
.add(plugin: .foldable())
.add(featurePlugin: .movable())
.add(plugin: .refreshable(refreshControl: UIRefreshControl(), output: self))
.add(featurePlugin: .swipeActions(swipeProvider: swipeActionProvider))
.add(featurePlugin: .sectionTitleDisplayable())
.build()
Стресс-тест адаптер проверяет сочетаемость плагинов. В одном адаптере 11 плагинов:
Displayable пробрасывает willDisplay и didEndDisplaying в генератор, соответствующий типу DisplayableItem.
Direction определяет направление скролла. Реакция идёт на ScrollEvent — событие ScrollViewDelegate.
HeaderIsVisible позволяет задать реакцию на видимость заголовка секции.
LastCellIsVisible нам уже знаком.
Selectable пробрасывает didSelect в генератор, соответствующий SelectableItem.
PrefetchablePlugin добавляет префетчинг изображений. В Example-проекте есть реализация префетчера для Nuke, но можно использовать любую другую библиотеку загрузки изображений с поддержкой префетчинга.
Foldable поддерживает разворачиваемые ячейки.
Movable поддерживает перетаскивания ячеек.
Refreshable — плагин для поддержки UIRefreshControl.
SwipeActions добавляет поддержку sipe menu для таблицы.
SectionTitleDisplayable добавляет отображение индексных заголовков.
11 плагинов в одном адаптере! Представьте, как бы страшно выглядело дерево наследования аналогичного адаптера, если бы мы делали его на старой нерасширяемой архитектуре.
Все эти плагины уже реализованы в библиотеке: можно использовать их в проектах, если обновиться до RDDM 7.x.
Как подключить DiffableDataSource
Очевидно, что UITableViewDiffableDataSource не подключить плагином. К тому же это не конкретный протокол, а generic класс.
Давайте рассмотрим преимущества UITableViewDiffableDataSource на примере изменяемой коллекции.
Чаще контент с сервера мы получаем и показываем без изменений, но иногда даём пользователю возможность:
удалить ячейку,
вставить новую ячейку после или перед другой ячейкой,
поменять ячейки местами.
Такие кейсы я буду называть изменяемой коллекцией.
Заменить ячейку можно тремя разными способами:
ручным,
с помощью сторонней библиотеки,
с помощью нового DiffableDataSource.
Ручной способ
Ручной способ подразумевает, что мы действуем по алгоритму:
определили индекс элемента;
обновили массив генераторов, переместив элемент или удалив его;
выполнили ту же операцию с индексами в коллекции, вызвав методы insertItems/deleteItems.
guard let index = self.findGenerator(oldGenerator) else { return }
generators[index.sectionIndex].remove(at: index.generatorIndex)
generators[index.sectionIndex].insert(newGenerator, at: index.generatorIndex)
let indexPath = IndexPath(row: index.generatorIndex, section: index.sectionIndex)
animator?.perform(in: view, animated: insertAnimation != .none) { [weak view] in
view?.deleteItems(at: [indexPath])
view?.insertItems(at: [indexPath])
}
Внутри аниматора в данном случае скрывается метод performBatchUpdates.
Имеем одну операцию поиска, две операции удаления и две операции вставки. Причём удаление и вставку нужно выполнить в правильном порядке, чтобы результат замены соответствовал ожиданиям и не вызывал падение приложения.
Отчасти адаптер RDDM помогает соблюсти порядок выполнения операций, потому что они скрыты внутри реализации адаптера. Но под каждую новую сложную операцию нужно будет безошибочно определить IndexPaths и вызвать специфичные для коллекции операции в правильном порядке.
Это неудобно, поэтому появились решения типа DifferenceKit. Не будем изобретать велосипед: подружим нашу библиотеку со сторонней.
DifferenceKit
С DifferenceKit алгоритм замены примерно такой:
Сделать генераторы дифференцируемыми. Для этого в библиотеке есть специальный фреймворк, напоминающий Equatable с дополнительным полем для уникального идентификатора.
Сделать слепок массива генераторов.
Обновить массив генераторов, переместив или удалив элемент.
Сделать новый слепок массива генераторов.
Сформировать changeSet из состояния контента до и после изменений, используя слепки.
Применить changeSet к коллекции, указав необходимые анимации.
Библиотека сделана через extensions к коллекции. Её можно использовать, начиная с iOS 9, — то есть практически на любом проекте.
guard let index = self.findGenerator(oldGenerator) else { return }
let oldSnapshot = makeSnapshot()
generators[index.sectionIndex].remove(at: index.generatorIndex)
generators[index.sectionIndex].insert(newGenerator, at: index.generatorIndex)
let newSnapshot = makeSnapshot()
let changeset = StagedChangeset(source: oldSnapshot, target: newSnapshot)
view.reload(using: changeset,
deleteSectionsAnimation: .automatic,
insertSectionsAnimation: .automatic)
Шагов в алгоритме больше, но код стал понятнее.
makeSnapshot преобразует массив секций и генераторов в понятный для DifferenceKit формат. Один раз снимаем слепок до изменений и ещё раз — после изменений.
StagedChangeset представляет упорядоченный набор операций типа insertItems/deleteItems. Внутри reload есть блок performBatchUpdates: в нём будут выполнены все операции из составленного сета.
У reload также есть блок-параметр interrupt, в котором можно определить, при каком наборе операций следует сделать полный reloadData коллекции. В общем, решение получилось гибкое и прекрасно ложится на концепцию RDDM.
Вносить DifferenceKit внутрь RDDM мы не стали, чтобы у библиотеки не появились созависимости.
Пример дружбы двух библиотек — в Example проекте.
DiffableDataSource
Кроме сторонних решений, есть системный подход от Apple. Нам так хотелось его использовать, но есть серьёзное ограничение: DiffableDataSource доступен, начиная с iOS 13. Из-за этого DiffableDataSource пока не принести на многие наши проекты. Но скоро мы будем стартовать проекты именно с этого таргета, поэтому подход заранее изучили и подружили с RDDM.
Как и в предыдущих примерах, покажем, что сделать для замены ячейки другой ячейкой:
Все генераторы должны быть дифференцируемы. SectionIdentifiable и ItemIdentifiable — алиасы для Hashable + Equatable.
Определить DiffableDataSource — generic DataSource, в котором уже определены основные методы типа numbersOfSections, cellForRowAt и другие. Для генерации ячеек, заголовков и футеров используется блок, который устанавливается при инициализации.
Обновить массив генераторов, переместив элемент или удалив его.
Сделать слепок NSDiffableDataSourceSnapshot массива генераторов.
Применить изменения.
В результате получаем такой код для операции замены ячейки другой ячейкой:
guard let index = self.findGenerator(oldGenerator) else { return }
generators[index.sectionIndex].remove(at: index.generatorIndex)
generators[index.sectionIndex].insert(newGenerator, at: index.generatorIndex)
guard let snapshot = makeSnapshot() else { return }
DispatchQueue.main.async { [weak dataSource] in
dataSource?.apply(snapshot, animatingDifferences: true, completion: completion)
}
Код сочетает в себе всё хорошее от двух предыдущих решений:
Ясность ручного решения.
Удобство приёма со слепком из решения с DifferenceKit.
Флаг animatingDifferences обеспечивает автоматический выбор необходимых анимаций для ячеек. Значение false будет равносильно вызову reloadData.
В отличие от решения с DifferenceKit, слепок делаем один раз: формируем его из массива генераторов после изменений.
Слепок до изменений всегда есть внутри dataSource. Мы пользуемся тем, что у нас всегда есть возможность сделать слепок из массива генераторов и не создаём конфликтующие стейты.
func appendSections([SectionIdentifierType])
func insertItems([ItemIdentifierType], afterItem: ItemIdentifierType)
func insertItems([ItemIdentifierType], beforeItem: ItemIdentifierType)
func deleteItems([ItemIdentifierType])
func deleteSections([SectionIdentifierType])
func reloadItems([ItemIdentifierType])
Все операции следует выполняеть через адаптер. Плюс RDDM-адаптера в том, что он сочетает в себе делегат и датасорс и позволяет добавлять фичи с помощью плагинов.
Использование DiffableDataSource без RDDM будет выглядеть немного иначе.
Снапшот можно использовать вместо адаптера. NSDiffableDataSourceSnapshot — не просто слепок и не массив. Это более сложный класс, к которому можно применять insert/delete операции — наподобие тех, что есть в коллекции.Однако нам эта способность не нужна. В этом случае мы:
— Либо будем дублировать операции, как и в ручном методе.
— Либо нарушим соответствие состояний адаптера и слепка, так как они независимы.
DiffableDataSource — мастхэв для экранов с поиском или формочек, где часто меняется количество ячеек или их высота и вручную рулить этим сложно.
Если требования по таргету соблюдены, обязательно попробуйте.
Если вас заинтересовал RDDM и вы хотели бы попробовать его на своём проекте, то милости прошу в репозиторий. Мы собираемся дальше развивать библиотеку и всегда рады контрибьюторам.
Больше полезного про iOS — в нашем телеграм-канале Surf iOS Team. Публикуем кейсы, лучшие практики, новости и вакансии Surf. Присоединяйтесь >>