Проблема 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
Время для классных слов все еще не пришло.
Давайте рассмотрим как происходит работа приложения, а точнее, как данные появляются на экране. Мы всегда представляем этот процесс последовательно. Ну, более-менее:
- Получить данные из сети;
- Обработать;
- Вывести это данные на экран.
Но так ли это на самом деле? Нет! На самом деле мы делаем так:
- Получить данные из сети;
- Обработать;
- Сохранить внутри ViewController модель;
- Что-то вызывает обновление экрана;
- Сохраненная модель преобразуется в ячейки;
- Данные выводятся на экран.
Кроме количества здесь есть еще отличия. Во-первых, мы больше не выводим данные, они выводятся. Во-вторых, возникает логический разрыв в процессе обработки данных, модель сохраняется и на этом процесс заканчивается. Далее происходит что-то и запускается другой процесс. Таким образом, мы явно не добавляем элементы на экран, а лишь сохраняем их (что кстати, тоже чревато) до востребования.
А еще вспомним про UITableViewDelegate, он, в том числе, содержит методы для определения высоты ячеек. Обычно хватает automaticDimension, но иногда этого недостаточно и нужно задавать высоту самостоятельно (например, в случае анимаций или для хедеров)
Тогда мы вообще разделяем настройки ячейки, часть с конфигурацией высоты находится в другом методе.
Проблемы:
- Теряется явная связь между обработкой данных и их отображением на UI;
- Конфигурирование ячейки разрывается на разные части.
Идея
Перечисленные проблемы на сложных экранах вызывают головную боль и резкое желание пойти попить чай.
Во-первых не хочется постоянно имплементировать методы делегата. Очевидное решение — создать объект, который будет его имплементировать. Дальше будем делать что-то вроде:
let displayManager = DisplayManager(self.tableView)
Отлично. Теперь нужно, чтобы объект умел работать с любыми ячейками, при этом конфигурирование этих ячеек нужно вынести куда-то в другое место.
Если вынести конфигурацию в отдельный объект, то мы инкапсулируем (самое время для умных слов) конфигурацию в одном месте. В это же самое место, мы можем вынести логику по форматированию данных (например, изменение формата даты, конкатенации строк и т.п.). Через этот же объект можем подписываться на события в ячейке.
В таком случае у нас будет объект, у которого есть два разных интерфейса:
- Интерфейс порождения экземпляров
UITableView— для нашего DisplayManager-а. - Интерфейс инициаллизации, подписки и конфигурации — для 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/

Добавить комментарий