Reactive Data Display Manager. Введение

от автора

Это первая часть из цикла статей о библиотеке ReactiveDataDisplayManager (RDDM). В этой статье я опишу частые проблемы, с которыми приходится сталкиваться при работе с «обычными» таблицами, а также дам описание RDDM.


Проблема 1. UITableViewDataSource

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

class ViewController: UIViewController {     ... } extension ViewController: UITableViewDelegate {    ... } extension ViewController: UITableViewDataSource {     ... }

Разберем самый обычный вариант. Что нам нужно имплементировать? Правильно, обычно имплементируются 3 метода UITableViewDataSource:

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int func numberOfSections(in tableView: UITableView) -> Int func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)

Пока не будем обращать внимание на вспомогательные методы (numberOfSection и проч.) и рассмотрим самый интересный — func tableView(tableView: UITableView, indexPath: IndexPath)

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

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) {     let anyCell = tableView.dequeueReusableCell(withIdentifier: ProductCell.self, for: indexPath)     guard let cell = anyCell as? ProductCell else {         return UITableViewCell()     }       cell.configure(for: self.products[indexPath.row])     return cell }

Отлично, вроде не сложно. А теперь, предположим, что у нас несколько типов ячеек, например, три:

  • Продукты;
  • Список акций;
  • Реклама.

Для простоты примера вынесем получение ячейки в метод getCell:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) {     switch indexPath.row {     case 0:         guard let cell: PromoCell = self.getCell() else {             return UITableViewCell()         }         cell.configure(self.promo)         return cell     case 1:         guard let cell: AdCell = self.getCell() else {             return UITableViewCell()         }         cell.configure(self.ad)         return cell     default:         guard let cell: AdCell = self.getCell() else {             return UITableViewCell()         }                   cell.configure(self.products[indexPath.row - 2])         return cell     } }

Как-то много кода. Представим, что хотим сверстать экран настроек. Что там будет?

  • Ячейка-шапка с аватаром;
  • Набор ячеек с переходами «вглубь»;
  • Ячейки со свитчерами (например, включить/выключить вход по пин-коду);
  • Ячейки с информацией (напримерБ ячейка на которой будет телефон, email, whatever);
  • Персональные предложения.

Причем, порядок задан. Большой метод получится…

А теперь другая ситуация — есть форма ввода. На форме ввода куча одинаковых ячеек, каждая из которых отвечает за определенное поле в модели данных. Например, ячейка для ввода телефона отвечает за phone и так далее.
Все просто, но есть одно «НО». В этом случае все равно придется расписывать разные кейсы, потому что необходимо обновлять нужные поля.

Можно продолжить фантазировать и представить Backend Driven Design, в котором нам приходят 6 разных типов полей ввода, причем в зависимости от состояния полей (видимость, тип ввода, наличие валидации, наличие значения по-умолчанию и так далее) ячейки меняются настолько сильно, что их нельзя привести к одному интерфейсу. В таком случае, этот метод будет выглядеть очень неприятно. Даже если декомпозировать конфигурацию на разные методы.

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

Проблемы:

  • Если есть ячейки разных видов, то код становится лапшеобразным;
  • Возникает много проблем с обработкой событий из ячеек;
  • Некрасивый код в случае, если нужно изменять состояние таблицы.

Проблема 2. MindSet

Время для классных слов все еще не пришло.
Давайте рассмотрим как происходит работа приложения, а точнее, как данные появляются на экране. Мы всегда представляем этот процесс последовательно. Ну, более-менее:

  1. Получить данные из сети;
  2. Обработать;
  3. Вывести это данные на экран.

Но так ли это на самом деле? Нет! На самом деле мы делаем так:

  1. Получить данные из сети;
  2. Обработать;
  3. Сохранить внутри ViewController модель;
  4. Что-то вызывает обновление экрана;
  5. Сохраненная модель преобразуется в ячейки;
  6. Данные выводятся на экран.

Кроме количества здесь есть еще отличия. Во-первых, мы больше не выводим данные, они выводятся. Во-вторых, возникает логический разрыв в процессе обработки данных, модель сохраняется и на этом процесс заканчивается. Далее происходит что-то и запускается другой процесс. Таким образом, мы явно не добавляем элементы на экран, а лишь сохраняем их (что кстати, тоже чревато) до востребования.

А еще вспомним про UITableViewDelegate, он, в том числе, содержит методы для определения высоты ячеек. Обычно хватает automaticDimension, но иногда этого недостаточно и нужно задавать высоту самостоятельно (например, в случае анимаций или для хедеров)
Тогда мы вообще разделяем настройки ячейки, часть с конфигурацией высоты находится в другом методе.

Проблемы:

  • Теряется явная связь между обработкой данных и их отображением на UI;
  • Конфигурирование ячейки разрывается на разные части.

Идея

Перечисленные проблемы на сложных экранах вызывают головную боль и резкое желание пойти попить чай.

Во-первых не хочется постоянно имплементировать методы делегата. Очевидное решение — создать объект, который будет его имплементировать. Дальше будем делать что-то вроде:

let displayManager = DisplayManager(self.tableView)

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

Если вынести конфигурацию в отдельный объект, то мы инкапсулируем (самое время для умных слов) конфигурацию в одном месте. В это же самое место, мы можем вынести логику по форматированию данных (например, изменение формата даты, конкатенации строк и т.п.). Через этот же объект можем подписываться на события в ячейке.

В таком случае у нас будет объект, у которого есть два разных интерфейса:

  1. Интерфейс порождения экземпляров UITableView — для нашего DisplayManager-а.
  2. Интерфейс инициаллизации, подписки и конфигурации — для Presenter-а или ViewController-а.

Назовем этот объект генератором. Тогда наш генератор для таблицы — ячейка, а для всего остального — способ представить данные на UI и обработать события.

А так как теперь конфигурация инкапсулирована генератором, и при этом сам генератор является ячейкой, то можем решить кучу проблем. В том числе и перечисленные выше.

Реализация

public protocol TableCellGenerator: class {       var identifier: UITableViewCell.Type { get }     var cellHeight: CGFloat { get }     var estimatedCellHeight: CGFloat? { get }       func generate(tableView: UITableView, for indexPath: IndexPath) -> UITableViewCell     func registerCell(in tableView: UITableView) }   public protocol ViewBuilder {       associatedtype ViewType: UIView       func build(view: ViewType) }

С такой реализаций мы можем сделать реализацию по-умолчанию:

public extension TableCellGenerator where Self: ViewBuilder {       func generate(tableView: UITableView, for indexPath: IndexPath) -> UITableViewCell {         guard let cell = tableView.dequeueReusableCell(withIdentifier: self.identifier.nameOfClass, for: indexPath) as? Self.ViewType else {             return UITableViewCell()         }           self.build(view: cell)           return cell as? UITableViewCell ?? UITableViewCell()     }       func registerCell(in tableView: UITableView) {         tableView.registerNib(self.identifier)     } }<source lang="swift">

Приведу пример небольшого генератора:

final class FamilyCellGenerator {       private var cell: FamilyCell?     private var family: Family?       var didTapPerson: ((Person) -> Void)?       func show(family: Family) {         self.family = family         cell?.fill(with: family)     }       func showLoading() {         self.family = nil         cell?.showLoading()     } }   extension FamilyCellGenerator: TableCellGenerator {     var identifier: UITableViewCell.Type {         return FamilyCell.self     } }   extension FamilyCellGenerator: ViewBuilder {     func build(view: FamilyCell) {         self.cell = view         view.selectionStyle = .none         view.didTapPerson = { [weak self] person in             self?.didTapPerson?(person)         }         if let family = self.family {             view.fill(with: family)         } else {             view.showLoading()         }     } }

Здесь мы спрятали и конфигурацию и подписки. Обратите внимание, что теперь мы получили место, в котором можем инкапсулировать состояние (потому что инкапсулировать состояние в ячейке нельзя из-за того, что она переиспользуется таблицей). А еще получили возможность менять данные в ячейке «на лету».

Обратите внимание на self.cell = view. Мы запомнили ячейку и теперь можем обновлять данные без перезагрузки этой ячейки. Это полезное свойство.

Но я отвлекся. Так как у нас любая ячейка может быть представлена генератором, то мы можем сделать интерфейс нашего DisplayManager-а немного красивее.

public protocol DataDisplayManager: class {       associatedtype CollectionType     associatedtype CellGeneratorType     associatedtype HeaderGeneratorType       init(collection: CollectionType)       func forceRefill()     func addSectionHeaderGenerator(_ generator: HeaderGeneratorType)     func addCellGenerator(_ generator: CellGeneratorType)     func addCellGenerators(_ generators: [CellGeneratorType], after: CellGeneratorType)     func addCellGenerator(_ generator: CellGeneratorType, after: CellGeneratorType)     func addCellGenerators(_ generators: [CellGeneratorType])     func update(generators: [CellGeneratorType])     func clearHeaderGenerators()     func clearCellGenerators() }

На самом деле это не все. Мы можем вставлять генераторы в нужные места или удалять их.

Кстати, вставить ячейку после какой-то определенной ячейки бывает чертовски полезно. Особенно, если мы постепенно подгружаем данные (допустим пользователь ввел ИНН, мы подгрузили информацию по ИНН и вывели ее, добавив несколько новых ячеек после поля с ИНН).

Итог

Как теперь будет выглядеть работа с ячейками:

class ViewController: UIViewController {       func update(data: [Products]) {         let gens = data.map { ProductCellGenerator($0) }         self.ddm.addGenerators(gens)     } } 

Или вот:

class ViewController: UIViewController {     func update(fields: [Field]) {         let gens = fields.map { field             switch field.type {             case .phone:                 let gen = PhoneCellGenerator(item)                 gen.didUpdate = { self.updatePhone($0) }                 return gen             case .date:                 let gen = DateInputCellGenerator(item)                 gen.didTap = { self.showPicker() }                 return gen             case .dropdown:                 let gen = DropdownCellGenerator(item)                 gen.didTap = { self.showDropdown(item) }                 return gen             }         }           let splitter = SplitterGenerator()         self.ddm.addGenerator(splitter)           self.ddm.addGenerators(gens)           self.ddm.addGenerator(splitter)     } }

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

Плюсы использования RDDM:

  • Инкапсуляция конфигурирования ячеек;
  • Уменьшение дублирования кода за счет инкапсуляции работы с коллекций в адаптер;
  • Выделение объекта-адаптера, который инкапсулирует конкретную логику работы с коллекций;
  • Код становится очевиднее и проще для чтения;
  • Сокращается количество кода, которое надо написать, чтобы добавить таблицу;
  • Упрощается процесс обработки событий из ячеек.

Исходники тут.

Спасибо за внимание!


ссылка на оригинал статьи https://habr.com/ru/company/surfstudio/blog/466147/