В предыдущей статье мы начали разбирать, как избавиться от шаблонного многострочного кода в iOS-приложении. В результате сформировали первоначальное представление о том, какие основные архитектурные сущности, классы и протоколы будут каркасом разработанного подхода. В этой статье поговорим о том, каким образом будем получать данные, и покажем провайдер. Он доступен к использованию в таблицах и в коллекциях.
Подробно расскажем про переиспользуемый провайдер табличного источника данных
Покажем использование на конкретном примере
Опишем результат с позиции SOLID
Обсудим достоинства и недостатки подхода
В основе решения лежат принципы SOLID. Цель состоит в том, чтобы составляющие элементы нашего подхода были независимыми, не влияющими друг на друга.
Для начала
Создаём проект приложения типа Tabbed App, в котором сразу удаляем 2 UIViewController’а (First, Second) — и файлы, и из storyboard’ов, заменяя их UINavigationController’ами во втором случае
В автоматически созданных Xcode’ом табличных контроллерах заменяем UIViewController на static cells
Первую ячейку табличного контроллера именуем SimpleArchTableViewController, задаём basic-стиль
Создаём новый UIViewController с тем же названием, класс для него, а также от переименованной ячейки протягиваем к нему outlet.
В чём состоит задача
Реализовать табличный источник данных, с помощью которого можно легко добавлять новые ячейки с любым типом данных в табличку. Обратимся к протоколу UITableViewDataSource, откуда используем 3 метода, необходимых для конфигурации внешнего вида таблицы.
class TableViewDataSource: NSObject {
}
extension TableViewDataSource: UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
<#code#>
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
<#code#>
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
<#code#>
}
}
Класс TableViewDataSource реализовывает протокол UITableViewDataSource в соответствии с принципом единственной ответственности. Ответственность данного класса будет заключаться в возвращении массива ячеек, разбитых по секциям (секция пока может быть всего лишь одна). При этом, чтобы данный класс был переиспользуемым, он не должен знать ничего ни о типе ячеек, которые он возвращает, ни об их идентификаторах, классах и nib’ах. Также он не должен быть завязан на конкретную реализацию view model ячейки — это выполнение принципа инверсии зависимостей.
Источник данных
Для переиспользуемого табличного источника данных нужен провайдер. Он будет скрывать за собой логику преобразования данных, хранящихся в любом возможном виде, к той самой структуре массива ячеек, разбитых по секциям. Это необходимо, чтобы наш источник данных не зависел от входящей коллекции данных. Также не будет необходимости переписывать его при изменении типа коллекции, хранящей данные. Ещё одним плюсом станет возможность использования данного провайдера при работе с UICollectionView (об этом поговорим в одной из будущих статей).
Очевидно, что по принципу инверсии зависимостей провайдер данных должен быть закрыт протоколом, определённым на уровне TableViewDataSource.
class TableViewDataSource: NSObject {
let dataProvider: ViewModelDataProvider
override init(dataProvider: ViewModelDataProvider) {
self.dataProvider = dataProvider
}
}
protocol ViewModelDataProvider {
func numberOfSections() -> Int
func numberOfRows(inSection section: Int) -> Int
func itemForRow(atIndexPath indexPath: IndexPath) -> ItemViewModel
}
protocol ItemViewModel {
}
Протокол ItemViewModel здесь нужен с целью скрытия конкретной реализации данных для ячейки. Отсутствие в нём методов и свойств станет понятным чуть позже. Важно — метод itemForRow(atIndexPath:) возвращает не опциональное значение, поскольку в нашей компании мы предпочитаем диагностировать ошибки на ранней стадии, нежели иметь дело с явно себя не проявляющими проблемами, цена которых будет лишь расти.
Реализуем методы табличного источника данных.
func numberOfSections(in tableView: UITableView) -> Int {
return dataProvider.numberOfSections()
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return dataProvider.numberOfRows(inSection: section)
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard
let viewModel = dataProvider.itemForRow(atIndexPath: indexPath),
let fabric = cellFabric(viewModel: viewModel)
else {
return UITableViewCell()
}
let cell = fabric.makeCell(for: tableView, at: indexPath, with: viewModel)
return cell
}
Подробнее рассмотрим последнюю функцию.
Она:
получает от провайдера данных view model для нужной ячейки;
затем выбирает подходящую для view model фабрику ячейки, которая и создаёт соответствующую ячейку, связывая её с view model;
результат возвращает системе.
Данный код позволяет нам полностью абстрагироваться от реализации ячейки, её класса, идентификатора и прочего. При этом мы получаем все преимущества статической типизации при связывании ячейки с её view model.
За идею и реализацию такого гибкого и удобного механизма хотел бы поблагодарить моего коллегу @Antonmaster.
Функция, выбирающая фабрику, выглядит следующим образом:
func cellFabric(viewModel: ItemViewModel) -> TableViewCellFabric? {
let viewModelType = type(of: viewModel)
let viewModelTypeString = "\(viewModelType)"
return itemViewModelClassToFabricMapping[viewModelTypeString]
}
В вышеприведенном коде видно, что в свою очередь фабрика для заданной view model берется по ее классу из словаря соответствия. Сам словарь представлен ниже.
private lazy var itemViewModelClassToFabricMapping = [String: TableViewCellFabric]()
public func registerCell<Cell>(class: Cell.Type,
identifier: String,
for itemViewModelClass: ItemViewModel.Type)
where Cell: UITableViewCell & Configurable {
let cellFabric = GenericTableViewCellFabric<Cell>(cellIdentifier: identifier)
let itemViewModelTypeString = "\(itemViewModelClass)"
itemViewModelClassToFabricMapping[itemViewModelTypeString] = cellFabric
}
Функция registerCell отвечает за его наполнение.
Generic-тип регистрируемой ячейки Cell является потомком системной UITableViewCell, реализующим generic-протокол Configurable.
public protocol Configurable where Self: UIView {
associatedtype ItemViewModel
var viewModel: ItemViewModel? { get set }
}
Протокол просто указывает, что view может быть конфигурируема с помощью view model. В нашем случае generic-тип Cell указывает конфигурируемую ячейку таблицы.
Фабрики ячеек закрыты следующим протоколом, определяющим их поведение таким образом, что каждая фабрика умеет работать лишь с ячейками с одним-единственным, заранее заданным идентификатором.
protocol TableViewCellFabric {
var cellIdentifier: String { get }
func makeCell(for tableView: UITableView,
at indexPath: IndexPath,
with viewModel: ItemViewModel) -> UITableViewCell
}
Рассмотрим реализацию протокола на примере класса.
class GenericTableViewCellFabric<Cell>: TableViewCellFabric
where Cell: UITableViewCell & Configurable {
let cellIdentifier: String
func makeCell(for tableView: UITableView,
at indexPath: IndexPath,
with viewModel: ItemViewModel) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier,
for: indexPath)
guard
let configurableCell = cell as? Cell,
let viewModel = viewModel as? Cell.ItemViewModel
else {
return cell
}
configurableCell.viewModel = viewModel
return configurableCell
}
init(cellIdentifier: String) {
self.cellIdentifier = cellIdentifier
}
}
Данная фабрика в соответствии с рекомендованным Apple алгоритмом работы с таблицами сначала запрашивает у таблицы ячейку с ранее заданным идентификатором. Она сперва приводится к generic-типу Cell, описанному выше, а тип view model проверяется на соответствие типу, используемому для конфигурации ячейки. Если проверки прошли успешно, то происходит связывание ячейки с переданной view model.
Пример использования источника данных
Рассмотрим использование источника данных на конкретном примере. Для этого описываем класс ячейки и с помощью протокола задаём её view model.
protocol TextViewModelProtocol: ItemViewModel {
var text: String { get }
}
class TextTableViewCell: UITableViewCell, Configurable {
var viewModel: TextViewModelProtocol? {
didSet {
textLabel?.text = viewModel?.text
}
}
}
class TextViewModel: TextViewModelProtocol {
var text: String
init(text: String) {
self.text = text
}
}
Здесь didSet-свойства viewModel используются для обновления внешнего вида ячейки. Протокол Configurable реализует взаимодействие между ячейкой и её view model, и тип последней оказывается скрыт, что помогает при мокировании данных во время тестирования, а также соответствует принципу инверсии зависимостей SOLID.
Конкретная реализация протокола ViewModelDataProvider - ArrayDataProvider. Она работает с одномерными массивами и используется, чтобы отображать данные в одну секцию UITableView или UICollectionView.
class ArrayDataProvider<T: ItemViewModel> {
let array: [T]
init(array: [T]) {
self.array = array
}
}
extension ArrayDataProvider: ViewModelDataProvider {
func numberOfSections() -> Int {
return 1
}
func numberOfRows(inSection section: Int) -> Int {
return array.count
}
func itemForRow(atIndexPath indexPath: IndexPath) -> ItemViewModel? {
guard
indexPath.row >= 0,
indexPath.row < array.count
else {
return nil
}
return array[indexPath.row]
}
}
Собираем всё вместе и инициализируем в FirstViewController.
private let viewModels = [
TextViewModel(text: "First Cell"),
TextViewModel(text: "Cell #2"),
TextViewModel(text: "This is also a text cell"),
]
private lazy var dataSource: TableViewDataSource = {
let dataProvider = ArrayDataProvider(array: viewModels)
let dataSource = TableViewDataSource(dataProvider: dataProvider)
dataSource.registerCell(class: TextTableViewCell.self,
identifier: "TextTableViewCell",
for: TextViewModel.self)
return dataSource
}()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
tableView.dataSource = dataSource
}
Последним шагом задаём класс и идентификатор у единственной ячейки-прототипа таблицы FirstViewController в storyboard.
Результатом запуска является UITabbarViewController c двумя вкладками. Пока нас интересует только первая ячейка первой вкладки, при нажатии на которую открывается табличка с тремя ячейками, содержащими различный текст.
Графическое представление
Опишем результат с позиции SOLID:
у каждого блока на данной диаграмме есть одна-единственная ответственность;
интерфейсы узкоспециализированные;
число связей между блоками минимально;
каждый блок, кроме TextViewModel, держится за счёт лишь одной сильной ссылки;
зависимости нижних компонентов от конкретных реализаций верхних компонентов отсутствуют;
нижележащие компоненты зависят от абстракций-протоколов, описанных уровнем выше.
Представим реализованный подход графически (сильные связи изображены сплошной линией, а слабые — пунктирной).
Возможности подхода
Для демонстрации возможностей подхода сделаем следующее:
через storyboard добавляем ещё один прототип ячейки в наш UITableViewController;
задаём ему соответственно идентификатор и класс DetailedTextTableViewCell;
меняем стиль с Basic на Right Detail;
по аналогии создадим новый класс ячейки и протокола view model для неё;
protocol DetailedTextViewModelProtocol: TextViewModel { var detailedText: String { get } } class DetailedTextTableViewCell: UITableViewCell, Configurable { var viewModel: DetailedTextViewModelProtocol? { didSet { textLabel?.text = viewModel?.text detailTextLabel?.text = viewModel?.detailedText } } }
реализуем протокол view model на примере ячейки параметра настроек. Данная ячейка будет отображать название некоего параметра и его числовое значение:
class ValueSettingViewModel: TextViewModel, DetailedTextViewModelProtocol { var detailedText: String { return String(value) } var value: Int init(parameter: String, value: Int) { self.value = value super.init(text: parameter) } }
добавляем наши данные в массив данных и регистрируем соответствие идентификатора вновь созданной ячейки с классом вновь созданной view model в контроллере.
var array = [
...
ValueSettingViewModel(parameter: "Size", value: 25),
ValueSettingViewModel(parameter: "Opacity", value: 37),
ValueSettingViewModel(parameter: "Blur", value: 13),
]
dataSource.registerCell(class: DetailedTextTableViewCell.self,
identifier: "DetailedTextTableViewCell",
for: ValueSettingViewModel.self)
Это все действия, необходимые для добавления в имеющийся контроллер ячейки с новым типом представления, работающей с другим типом данных. При этом ни одна строчка ранее написанного кода не изменилась — принцип открытости-закрытости. Был добавлен класс, описывающий логику представления данных в ячейке, и класс, представляющий сами данные, — принцип единой ответственности.
Недостатки подхода
Одно из преимуществ показанного подхода — его простота и понятность. Минимальный порог входа для начала использования данного решения с ходу — middle. Разработчику уровня junior придётся подтягивать базовые знания по основным архитектурным паттернам и языку, чтобы включиться в разработку быстро.
Подход, описанный в статье, хорошо подойдёт как для разработки с нуля, так и для поддержки крупных, долгоиграющих проектов, позволяя сократить кодобазу. Для лёгких приложений, не обладающих объёмным функционалом, это не лучший вариант.
Из недостатков данного подхода стоит отметить невозможность использования нескольких различных типов представления для одной и той же view model в рамках одной таблицы.
Как можно это решить: заменить текущую реализацию соответствия класса вьюмодели и фабрики по созданию соответствующих ячеек на более гибкую реализацию карты соответствий, о которой мы подробно поговорим в одной из будущих статей.
Код используемый в статье можно посмотреть тут.