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

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

Комментарии (3)


  1. madflux
    13.12.2021 10:42
    -3

    SOLID это не манна небесная, не нужно его запихивать везде где-только можно. Нет ничего страшного в дублировании кода.

    В статье решая выдуманные проблемы, усложнили код, ну и его поддержку соответственно. Не нужно так делать.


    1. ws233 Автор
      13.12.2021 13:39

      В чем на Ваш взгляд заключается усложнение?


      1. madflux
        14.12.2021 11:18

        Собственно фабрики и дата провайдеры понятны в этом коде будут только вам, всем остальным нужно с этим разбираться.

        Keep it simple, stupid