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

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

Время на прочтение7 мин
Количество просмотров2.7K
Это первая часть из цикла статей о библиотеке ReactiveDataDisplayManager (RDDM) от команды iOS разработки Surf. В этой статье я опишу частые проблемы, с которыми приходится сталкиваться при работе с «обычными» таблицами, а также дам описание 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:

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

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

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

Кейсы, лучшие практики, новости и вакансии Surf — в телеграм-канале Surf iOS Team. Присоединяйтесь >>
Теги:
Хабы:
+3
Комментарии2

Публикации

Информация

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