В мобильных приложениях табличные экраны занимают значительное место в общем объёме интерфейса. Это происходит благодаря их возможности отображать большое количество контента. Но есть и обратный эффект — программирование таких экранов порождает много однотипного кода.
В прошлых своих статьях мы начали решать проблему шаблонного кода и его размножения путём введения нового подхода, а также поговорили об универсальном источнике данных для реализованных экранов. В этом тексте мы рассмотрим очередную подчасть нашего решения — переиснользуемый провайдер данных. Подробно и в деталях покажем, как реализовывать View-слой, придерживаясь принципов SOLID, так, чтобы он не зависел от типа хранения данных.
Вне зависимости от того, какую архитектуру (MVC, MVVM, VIPER и др.) вы используете, компоненты из этой статьи помогут сократить время разработки, поиска и исправления ошибок и добавления нового функционала.
Цикл статей:
Карта соответствия
Обзервер
Коллекции
...
Секционный список
Предположим, по мере развития приложения отображения данных плоским списком оказалось недостаточно и теперь требуется разбиение на группы. Для примера воспользуемся данными из предыдущих статей и разделим их на группы по типу ViewModel`ей, что является одним из самых частых сценариев:
let firstSectionObjects = [
TextViewModel(text: "First Cell"),
TextViewModel(text: "Cell #2"),
TextViewModel(text: "This is also a text cell"),
]
let secondSectionObjects = [
ValueSettingViewModel(parameter: "Size", value: 25),
ValueSettingViewModel(parameter: "Opacity", value: 37),
ValueSettingViewModel(parameter: "Blur", value: 13),
]
let thirdSectionObjects = [
SwitchedSettingViewModel(parameter: "Push notifications enabled", enabled: true),
SwitchedSettingViewModel(parameter: "Camera access enabled", enabled: false),
]
Предыдущий плоский массив можно представить просто как сумму указанных массивов:
lazy var plainArray = firstSectionObjects +
secondSectionObjects +
thirdSectionObjects
Передать три отдельных массива вместо одного в реализованный в предыдущих статьях ArrayDataProvider невозможно. Для того чтобы работать с тремя массивами, каждый из которых представляет свою секцию, нужно описать отдельный тип данных и реализовать новый провайдер, работающий с этими данными. Попробуем это сделать в соответствии с принципами SOLID, что потребует предварительной подготовки.
Второй ячейке FirstViewController`а задаём текст «Section Divided Data», стиль — Basic и привязываем сегвей выделения ячейки к уже созданному ранее визуальному представлению SimpleArchTableViewController — это делается по принципу DRY. Визуальное представление для отображения наших данных уже реализовано, зачем его повторять? Созданному в прошлой статье сегвею задаём идентификатор plainListDataSegue, а вновь добавленному — sectionDevidedDataSegue.
Однако, если мы запустим приложение и попробуем тапнуть по созданной ячейке, мы увидим, что контроллер откроется без разделения на секции. Это произошло, потому что не был заменён провайдер данных, статично создаваемый в нашем FirstTableViewController.
По гайдам Apple известно, что вновь открываемые контроллеры необходимо настраивать в функции prepare(for:sender:). Создадим контроллер FirstTableViewController, укажем его в storyboard вместо дефолтного UITableViewController и реализуем указанную функцию:
class FirstTableViewController: UITableViewController {
let dataSourceFabric: FirstDataSourceFibricProtocol = FirstDataSourceFabric()
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let destinationTableViewController =
segue.destination as? ConfigurableTableViewController
else {
return
}
switch segue.identifier {
case "plainListDataSegue":
destinationTableViewController.dataSource =
dataSourceFabric.makePlainListDataSource(array: plainArray)
case "sectionDevidedDataSegue":
destinationTableViewController.dataSource =
dataSourceFabric.makeSectionDevidedDataSource(sections: sectionArray)
default:
break
}
}
}
Данный код реализует всё то, что и советует Apple, — создаёт и настраивает открываемый viewController, который скрыт за протоколом ConfigurableTableViewController. Последний объявлен по аналогии с протоколом Configurable ячеек. Он лишь определяет, что табличный контроллер может быть сконфигурирован указанием ему соответствующего табличного источника данных:
protocol ConfigurableTableViewController where Self: UITableViewController {
var dataSource: UITableViewDataSource? { get set }
}
Обратим внимание, что хоть использование фабрики для создания открываемых контроллеров и скрыто за протоколом FirstDataSourceFabricProtocol, однако конкретный экземпляр фабрики FirstDataSourceFabric создаётся в конструкторах контроллера. Это грубое нарушение принципа инверсии зависимостей, но временно оставим это за скобками и вернёмся к теме в следующих статьях.
Фабрика
Создание двух похожих контроллеров, различающихся лишь способом отображения данных, требует введения фабрики. В соответствии с принципом единой ответственности она как раз и будет заниматься созданием и настройкой контроллеров. Код для неё взят целиком из FirstViewController, слегка видоизменен, чтобы избежать дублирования кода по принципу DRY, и выглядит следующим образом:
class FirstDataSourceFabric: FirstDataSourceFibricProtocol {
let firstSectionObjects = [
TextViewModel(text: "First Cell"),
TextViewModel(text: "Cell #2"),
TextViewModel(text: "This is also a text cell"),
]
let secondSectionObjects = [
ValueSettingViewModel(parameter: "Size", value: 25),
ValueSettingViewModel(parameter: "Opacity", value: 37),
ValueSettingViewModel(parameter: "Blur", value: 13),
]
let thirdSectionObjects = [
SwitchedSettingViewModel(parameter: "Push notifications enabled", enabled: true),
SwitchedSettingViewModel(parameter: "Camera access enabled", enabled: false),
]
func makePlainListDataSource() -> UITableViewDataSource? {
let plainArray = firstSectionObjects +
secondSectionObjects + thirdSectionObjects
let dataProvider = ArrayDataProvider(array: plainArray)
return makeDataSource(with: dataProvider)
}
func makeSectionDevidedDataSource() -> UITableViewDataSource? {
let sectionArray = [
Section(objects: firstSectionObjects, name: "Text Cells", indexTitle: "T"),
Section(objects: secondSectionObjects, name: "Int Cells", indexTitle: "V"),
Section(objects: thirdSectionObjects, name: "Bool Cells", indexTitle: "B"),
]
let dataProvider = SectionDataProvider(sections: sectionArray)
return makeDataSource(with: dataProvider)
}
func makeDataSource(with dataProvider: ViewModelDataProvider) -> UITableViewDataSource? {
let dataSource = TableViewDataSource(dataProvider: dataProvider)
dataSource.registerCell(class: TextTableViewCell.self,
identifier: "TextTableViewCell",
for: TextViewModel.self)
dataSource.registerCell(class: DetailedTextTableViewCell.self,
identifier: "DetailedTextTableViewCell",
for: ValueSettingViewModel.self)
dataSource.registerCell(class: DetailedTextTableViewCell.self,
identifier: "SwitchedSettingTableViewCell",
for: SwitchedSettingViewModel.self)
return dataSource
}
}
Функции makePlainListDataSource() и makeSectionDevidedDataSource() создают источник данных для плоского и секционного списков соответственно.
Инициализируется частный экземпляр провайдера данных и передаётся в функцию makeDataSource(with dataProvider:), которая завершает создание источника данных.
Отметим, что константные данные для указанных функций ради упрощения данного примера задаются прямо в фабрике. В будущих статьях мы обязательно покажем правильную работу с данными в соответствии с принципами многоуровневых или слоистых архитектур.
Протокол SectionInfo задан полностью по аналогии с системным NSFetchedResultsSectionInfo, служит для описания секции данных, её заголовка и содержащихся в ней элементов и выглядит следующим образом:
protocol SectionInfo {
var numberOfObjects: Int { get }
var objects: [ItemViewModel]? { get }
var name: String { get }
var indexTitle: String? { get }
}
И соответственно, структура, реализующая данный протокол и используемая для описания секции данных, выглядит так:
struct Section: SectionInfo {
var numberOfObjects: Int { return objects!.count }
var objects: [ItemViewModel]?
var name: String
var indexTitle: String?
}
Провайдер данных
Приступим к реализации нового провайдера данных:
class SectionDataProvider {
let sections: [SectionInfo]
public init(sections: [SectionInfo]) {
self.sections = sections
}
func numberOfRows(inSection section: Int) -> Int {
let section = sections[section]
return section.numberOfObjects
}
func itemForRow(atIndexPath indexPath: IndexPath) -> ItemViewModel? {
let section = sections[indexPath.section]
return section.objects![indexPath.row]
}
}
Данный провайдер работает с массивом секций, каждая из которых содержит число элементов в ней, сами элементы, заголовок и индекс, отображаемый справа в таблице и используемый для быстрого перехода между секциями таблицы.
Свойство dataSource в SimpleArchTableViewController перестало быть ленивым и переехало в родительский класс TableViewController, реализующий протокол ConfigurableTableViewController. Все конфигурируемые табличные контроллеры должны быть унаследованы от данного класса аналогично тому, как все ячейки или viewModel`и должны реализовывать соответствующие протоколы.
class TableViewController: UITableViewController,
ConfigurableTableViewController {
var dataSource: UITableViewDataSource? {
didSet {
guard isViewLoaded else { return }
tableView.dataSource = dataSource
tableView.reloadData()
}
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = datSource
}
}
Вышеописанный класс просто хранит заданный источник данных и проксирует его в свою таблицу.
Обратим внимание, что в данном случае нельзя было обойтись дефолтной реализацией экстеншена, т. к. необходимо переопределить функцию viewDidLoad, которая должна устанавливать dataSource после прогрузки таблички контроллера, что было бы невозможно сделать с помощью дефолтной реализации протокола.
В результате всех изменений SimpleArchTableViewController остался полностью пустым, но теперь он унаследован от TableViewController, следовательно можно полностью избавиться от него, удалив исходный код, и в storyboard`е заменить на базовый класс. Таким образом мы получили возможность реализовывать различные представления табличных контроллеров без какого-либо наследования. Обе ячейки открывают контроллер одного и того же базового класса TableViewController, с одним и тем же представлением, описанным в storyboard-е, однако данные они отображают по-разному. Запустим приложение, откроем контроллер, спрятанный за ячейкой Section Divided Data, и посмотрим на результат:
На скриншоте видно, что контроллер выглядит точно так же, как и SimpleArchTableViewController, их внешний вид абсолютно одинаков. Разберёмся, почему так произошло.
Заголовки секций
Объясним причину. Был реализован секционный список, и ячейки в этом списке действительно лежат теперь в разных секциях, а не в одной, как это было в предыдущей статье. Однако ничего не сделано для заголовков секций, вследствие чего они не отображаются, и наш секционный список выглядит как плоский.
Чтобы это исправить, требуется расширить TableViewDataSource из первой статьи ещё парой методов, что не противоречит принципу открытости-закрытости SOLID:
func tableView(_ tableView: UITableView,
titleForHeaderInSection section: Int) -> String? {
return dataProvider.title(forSection: section)
}
func sectionIndexTitles(for tableView: UITableView) -> [String]? {
return dataProvider.sectionIndexTitles()
}
Так как функции требуют, чтобы провайдер данных имел метод, возвращающий заголовок для указанной секции, и массив строк для индексов, отображаемых в правой части таблицы, то необходимо расширить протокол провайдера данных ViewModelDataProvider следующим образом:
protocol ViewModelDataProvider {
...
func title(forSection section: Int) -> String?
func sectionIndexTitles() -> [String]?
}
Уже на этапе компиляции станет понятно, что классы ArrayDataProvider и SectionDataProvider не конформят полностью только что расширенный протокол. Реализуем вновь добавленные методы:
extension ArrayDataProvider: ViewModelDataProvider {
...
func title(forSection section: Int) -> String? {
return sectionTitle
}
func sectionIndexTitles() -> [String]? {
guard
let count = sectionTitle?.count,
count > 0,
let substring = sectionTitle?.prefix(1)
else {
return nil
}
return [String(substring)]
}
}
extension SectionDataProvider: ViewModelDataProvider {
...
func title(forSection section: Int) -> String? {
let section = sections[section]
return section.name
}
func sectionIndexTitles() -> [String]? {
return section.compactMap { $0.indexTitle }
}
}
А также расширим инициализатор ArrayDataProvider и добавим свойство:
class ArrayDataProvider<T: ItemViewModel> {
let array: [T]
let sectionTitle: String?
init(array: [T], sectionTitle: String? = nil) {
self.array = array
self.sectionTitle = sectionTitle
}
}
Можно было бы реализовать и дефолтную имплементацию протокола ViewModelDataProvider в его расширении, чтобы все сущности, реализующие данный протокол, сразу получили данный функционал. Однако в данном конкретном случае есть всего два класса, реализующих данный протокол, и оба имеют различные реализации. При запуске получается следующий результат:
Отображаются заголовки секций и их индексы справа. При этом внешний вид контроллера, реализованного в прошлой статье, не изменился, но мы получили дополнительный функционал: для плоских списков мы теперь также можем задать заголовок единственной секции, если захотим.
Стоит обратить внимание, что с помощью выделения отдельной сущности провайдера данных мы смогли полностью отделить логику отображения от типа данных и преобразования типа данных к виду отображения. В описанном выше примере плоская таблица может отображаться как с провайдером плоских данных, так и с провайдером данных, разбитых по секциям, что полностью соответствует принципам инверсии зависимостей и подстановки Лисков.
Заключение
Представим вышеописанные архитектурные решения графически:
Мы видим, что:
появилась фабрика, создающая источники данных, используемые для настройки открываемых контроллеров;
системный контроллер UITableViewController был заменён на базовую реализацию конфигурируемого TableViewController.
В этой статье мы показали, как реализовывать View-слой, придерживаясь принципов SOLID. В частности, мы реализовали базовый конфигурируемый табличный контроллер TableViewController, логика отображения данных которого не зависит от провайдера данных. Мы также реализовали два провайдера данных, ArrayDataProvider и SectionDataProvider, для отображения соответственно плоских массивов и разбитых по секциям данных. При этом никакие другие классы архитектуры менять не пришлось, представления в storyboard`е или NIB-файле не подверглись корректировке. В соответствии с принципом открытости-закрытости в SOLID мы не изменили ни одного класса, реализованного в прошлой статье.
Из минусов — осталась жёсткая связь между FirstViewController и фабрикой FirstDataSourceFabric, но в одной из следующих статей мы обязательно разберём, что делать в таких случаях. В следующей статье мы попробуем реализовать переиспользуемую абстрактную реализацию делегата.