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:

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

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

    Спасибо за внимание!
    Surf
    51,04
    Компания
    Поделиться публикацией

    Похожие публикации

    Комментарии 2

      0
      А других реализаций подобного подхода вы не встречали? Мне кажется они должны существовать…
        0
        Если Вы посмотрите в репозиторий, то этой библиотеке почти 3 года. Я встречал нечто похожее, но гораздо позже того как мы ее сделали)

        Она изначально была простенькой и решала небольшие проблемы, а сейчас сильно «растолстела». Собственно, поэтому одной статьей дело не ограничится.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое