Это первая часть из цикла статей о библиотеке ReactiveDataDisplayManager (RDDM) от команды iOS разработки Surf. В этой статье я опишу частые проблемы, с которыми приходится сталкиваться при работе с «обычными» таблицами, а также дам описание RDDM.
Для начала забудем о выделении ответственностей, переиспользовании и прочих классных словах. Рассмотрим как выглядит обычная работа с таблицами:
Разберем самый обычный вариант. Что нам нужно имплементировать? Правильно, обычно имплементируются 3 метода
Пока не будем обращать внимание на вспомогательные методы (
Допустим, мы хотим заполнить таблицу ячейками с описанием продуктов, тогда наш метод будет выглядеть вот так:
Отлично, вроде не сложно. А теперь, предположим, что у нас несколько типов ячеек, например, три:
Для простоты примера вынесем получение ячейки в метод
Как-то много кода. Представим, что хотим сверстать экран настроек. Что там будет?
Причем, порядок задан. Большой метод получится…
А теперь другая ситуация — есть форма ввода. На форме ввода куча одинаковых ячеек, каждая из которых отвечает за определенное поле в модели данных. Например, ячейка для ввода телефона отвечает за phone и так далее.
Все просто, но есть одно «НО». В этом случае все равно придется расписывать разные кейсы, потому что необходимо обновлять нужные поля.
Можно продолжить фантазировать и представить Backend Driven Design, в котором нам приходят 6 разных типов полей ввода, причем в зависимости от состояния полей (видимость, тип ввода, наличие валидации, наличие значения по-умолчанию и так далее) ячейки меняются настолько сильно, что их нельзя привести к одному интерфейсу. В таком случае, этот метод будет выглядеть очень неприятно. Даже если декомпозировать конфигурацию на разные методы.
Кстати, после этого представьте как будет выглядеть ваш код, если хотите добавлять/удалять ячейки по ходу работы. Выглядеть будет не очень приятно из-за того, что мы будем вынуждены самостоятельно следить за консистентностью сохраненных во
Проблемы:
Время для классных слов все еще не пришло.
Давайте рассмотрим как происходит работа приложения, а точнее, как данные появляются на экране. Мы всегда представляем этот процесс последовательно. Ну, более-менее:
Но так ли это на самом деле? Нет! На самом деле мы делаем так:
Кроме количества здесь есть еще отличия. Во-первых, мы больше не выводим данные, они выводятся. Во-вторых, возникает логический разрыв в процессе обработки данных, модель сохраняется и на этом процесс заканчивается. Далее происходит что-то и запускается другой процесс. Таким образом, мы явно не добавляем элементы на экран, а лишь сохраняем их (что кстати, тоже чревато) до востребования.
А еще вспомним про
Тогда мы вообще разделяем настройки ячейки, часть с конфигурацией высоты находится в другом методе.
Проблемы:
Перечисленные проблемы на сложных экранах вызывают головную боль и резкое желание пойти попить чай.
Во-первых не хочется постоянно имплементировать методы делегата. Очевидное решение — создать объект, который будет его имплементировать. Дальше будем делать что-то вроде:
Отлично. Теперь нужно, чтобы объект умел работать с любыми ячейками, при этом конфигурирование этих ячеек нужно вынести куда-то в другое место.
Если вынести конфигурацию в отдельный объект, то мы инкапсулируем (самое время для умных слов) конфигурацию в одном месте. В это же самое место, мы можем вынести логику по форматированию данных (например, изменение формата даты, конкатенации строк и т.п.). Через этот же объект можем подписываться на события в ячейке.
В таком случае у нас будет объект, у которого есть два разных интерфейса:
Назовем этот объект генератором. Тогда наш генератор для таблицы — ячейка, а для всего остального — способ представить данные на UI и обработать события.
А так как теперь конфигурация инкапсулирована генератором, и при этом сам генератор является ячейкой, то можем решить кучу проблем. В том числе и перечисленные выше.
С такой реализаций мы можем сделать реализацию по-умолчанию:
Приведу пример небольшого генератора:
Здесь мы спрятали и конфигурацию и подписки. Обратите внимание, что теперь мы получили место, в котором можем инкапсулировать состояние (потому что инкапсулировать состояние в ячейке нельзя из-за того, что она переиспользуется таблицей). А еще получили возможность менять данные в ячейке «на лету».
Обратите внимание на
Но я отвлекся. Так как у нас любая ячейка может быть представлена генератором, то мы можем сделать интерфейс нашего DisplayManager-а немного красивее.
На самом деле это не все. Мы можем вставлять генераторы в нужные места или удалять их.
Кстати, вставить ячейку после какой-то определенной ячейки бывает чертовски полезно. Особенно, если мы постепенно подгружаем данные (допустим пользователь ввел ИНН, мы подгрузили информацию по ИНН и вывели ее, добавив несколько новых ячеек после поля с ИНН).
Как теперь будет выглядеть работа с ячейками:
Или вот:
Мы можем контролировать порядок добавления элементов и, при этом, не теряется связь между обработкой данных и добавлением их на UI. Таким образом, в простых случаях у нас простой код. В сложных случаях код не превращается в макароны и при этом сносно выглядит. Еще появился декларативный интерфейс для работы с таблицами и теперь мы инкапсулируем конфигурацию ячеек, что само по себе позволяет переиспользовать ячейки вместе с конфигураций между разными экранами.
Плюсы использования 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
Время для классных слов все еще не пришло.
Давайте рассмотрим как происходит работа приложения, а точнее, как данные появляются на экране. Мы всегда представляем этот процесс последовательно. Ну, более-менее:
- Получить данные из сети;
- Обработать;
- Вывести это данные на экран.
Но так ли это на самом деле? Нет! На самом деле мы делаем так:
- Получить данные из сети;
- Обработать;
- Сохранить внутри 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:
- Инкапсуляция конфигурирования ячеек;
- Уменьшение дублирования кода за счет инкапсуляции работы с коллекций в адаптер;
- Выделение объекта-адаптера, который инкапсулирует конкретную логику работы с коллекций;
- Код становится очевиднее и проще для чтения;
- Сокращается количество кода, которое надо написать, чтобы добавить таблицу;
- Упрощается процесс обработки событий из ячеек.
Исходники тут.
Спасибо за внимание!
Кейсы, лучшие практики, новости и вакансии Surf — в телеграм-канале Surf iOS Team. Присоединяйтесь >>