Как стать автором
Обновить
70.28
Surf
Создаём веб- и мобильные приложения

Reactive Data Display Manager. История одного рефакторинга

Время на прочтение10 мин
Количество просмотров1.2K

В мобильной разработке редкие экраны обходятся без коллекций 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.   

Эти причины и натолкнули нас на мысль о рефакторинге.

Рефакторинг 

Что хотели получить после рефакторинга

  1. Возможность расширения без наследования.

  2. Возможность подмены delegate или dataSource.

  3. Хотя бы частичную обратную совместимость. В отдел 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 плагинов:

  1. Displayable пробрасывает willDisplay и didEndDisplaying в генератор, соответствующий типу DisplayableItem.

  2. Direction определяет направление скролла. Реакция идёт на ScrollEvent  — событие ScrollViewDelegate.

  3. HeaderIsVisible позволяет задать реакцию на видимость заголовка секции.

  4. LastCellIsVisible нам уже знаком.

  5. Selectable пробрасывает didSelect в генератор, соответствующий SelectableItem.

  6. PrefetchablePlugin добавляет префетчинг изображений. В Example-проекте есть реализация префетчера для Nuke, но можно использовать любую другую библиотеку загрузки изображений с поддержкой префетчинга.

  7. Foldable поддерживает разворачиваемые ячейки.

  8. Movable поддерживает перетаскивания ячеек.

  9. Refreshable —  плагин для поддержки UIRefreshControl.

  10. SwipeActions добавляет поддержку sipe menu для таблицы.

  11. SectionTitleDisplayable добавляет отображение индексных заголовков.

11 плагинов в одном адаптере! Представьте, как бы страшно выглядело дерево наследования аналогичного адаптера, если бы мы делали его на старой нерасширяемой архитектуре.

Все эти плагины уже реализованы в библиотеке: можно использовать их в проектах, если обновиться до RDDM 7.x.

Как подключить DiffableDataSource

Очевидно, что UITableViewDiffableDataSource не подключить плагином. К тому же это не конкретный протокол, а generic класс.

Давайте рассмотрим преимущества UITableViewDiffableDataSource на примере изменяемой коллекции.

Чаще контент с сервера мы получаем и показываем без изменений, но иногда даём пользователю возможность: 

  • удалить ячейку,

  • вставить новую ячейку после или перед другой ячейкой,

  • поменять ячейки местами.

Такие кейсы я буду называть изменяемой коллекцией. 

Заменить ячейку можно тремя разными способами: 

  • ручным, 

  • с помощью сторонней библиотеки, 

  • с помощью нового DiffableDataSource.

Ручной способ

Ручной способ подразумевает, что мы действуем по алгоритму:

  1. определили индекс элемента;

  2. обновили массив генераторов, переместив элемент или удалив его;

  3. выполнили ту же операцию с индексами в коллекции, вызвав методы 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 алгоритм замены примерно такой:

  1. Сделать генераторы дифференцируемыми. Для этого в библиотеке есть специальный фреймворк, напоминающий Equatable с дополнительным полем для уникального идентификатора.

  2. Сделать слепок массива генераторов.

  3. Обновить массив генераторов, переместив или удалив элемент.

  4. Сделать новый слепок массива генераторов.

  5. Сформировать changeSet из состояния контента до и после изменений, используя слепки.

  6. Применить 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.

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

  1. Все генераторы должны быть дифференцируемы. SectionIdentifiable и ItemIdentifiable —  алиасы для Hashable + Equatable.

  2. Определить DiffableDataSource — generic DataSource, в котором уже определены основные методы типа numbersOfSections, cellForRowAt и другие. Для генерации ячеек, заголовков и футеров используется блок, который устанавливается при инициализации.

  3. Обновить массив генераторов, переместив элемент или удалив его.

  4. Сделать слепок NSDiffableDataSourceSnapshot массива генераторов.

  5. Применить изменения. 

В результате получаем такой код для операции замены ячейки другой ячейкой:

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. Присоединяйтесь >>

Теги:
Хабы:
+5
Комментарии0

Публикации

Информация

Сайт
surf.ru
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия