Как переиспользуемый провайдер данных помогает сократить код в iOS-приложении

—

от автора

В мобильных приложениях табличные экраны занимают значительное место в общем объёме интерфейса. Это происходит благодаря их возможности отображать большое количество контента. Но есть и обратный эффект — программирование таких экранов порождает много однотипного кода.

В прошлых своих статьях мы начали решать проблему шаблонного кода и его размножения путём введения нового подхода, а также поговорили об универсальном источнике данных для реализованных экранов. В этом тексте мы рассмотрим очередную подчасть нашего решения — переиснользуемый провайдер данных. Подробно и в деталях покажем, как реализовывать View-слой, придерживаясь принципов SOLID, так, чтобы он не зависел от типа хранения данных.

Вне зависимости от того, какую архитектуру (MVC, MVVM, VIPER и др.) вы используете, компоненты из этой статьи помогут сократить время разработки, поиска и исправления ошибок и добавления нового функционала.

Цикл статей:

  1. Общее описание всей схемы

  2. Источник данных

  3. Провайдер данных

  4. Делегат

  5. Карта соответствия

  6. Обзервер

  7. Коллекции

  8. …

Секционный список

Предположим, по мере развития приложения отображения данных плоским списком оказалось недостаточно и теперь требуется разбиение на группы. Для примера воспользуемся данными из предыдущих статей и разделим их на группы по типу 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, но в одной из следующих статей мы обязательно разберём, что делать в таких случаях. В следующей статье мы попробуем реализовать переиспользуемую абстрактную реализацию делегата.


ссылка на оригинал статьи https://habr.com/ru/articles/594829/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *