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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

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

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

Источник: https://habr.com/ru/company/psb/blog/594829/


Интересные статьи

Интересные статьи

Привет, Хабр,Меня зовут Татьяна, я Team Coach R&D компании Plesk, и большую часть конфликтных ситуаций в командах мы проживаем и решаем с тимлидами вместе. Перио...
Решения для больших компаний обычно должны выдерживать высокие нагрузки. Когда в штате много десятков тысяч человек, и значительная доля из них ежедневно пользуются ...
Наша подборка материалов о старых и новых стандартах: от IPv6 до New IP, а также факторах, влияющих на трафик в сетях мобильных операторов и интернет-провайдеров. Чи...
В прошлом году в Австралии открыли акустическую обсерваторию. Она собирает звуковые ландшафты дикой природы Зеленого континента. Такие аудиозаписи могут дать не меньше информации о животном мире,...
Предыстория Так произошло, что сервере был атакован вирусом шифровальщиком, который по "счастливой случайности", частично отставил не тронутыми файлы .ibd (файлы сырых данных innodb ...