Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Девять из десяти экранов любого iOS-приложения имеют табличный вид. Неважно, как реализовано это представление — на UITableView или UICollectionView, но для его реализации необходимо каждый раз писать шаблонный код:
реализация табличного источника данных (UITableViewDataSource);
реализация табличного делегата (UITableViewDelegate);
реализация обратных уведомлений вью об изменениях данных;
типичный код по работе с различными коллекциями (плоские, секционные списки на основе массивов, упорядоченных множеств и прочих коллекций) и преобразование их к табличным структурам для источника данных коллекции;
все предыдущие пункты придётся повторить, если вы вдруг решите использовать UICollectionView.
Такое большое количество шаблонного кода значительно увеличивает время разработки, тестирования и ревью. Для уменьшения time-to-market мы в ПСБ создали микромодуль, который скрывает в себе весь шаблонный код. Новый модуль представляет собой набор абстрактных реализаций, лёгких в переиспользовании и достаточно универсальных для использования в 90% общих задач. В этой статье расскажем подробности.
Автогенераторы кода и их недостатки
iOS-сообщество давно заметило проблему и предложило использовать генераторы кода из шаблонов, например generamba. Однако этот путь имеет следующие недостатки.
Генерируемый код может достигать довольно больших размеров, при этом число варьируемых параметров в нём может быть совсем невелико, что приводит к большому количеству дублей кода и увеличению размера выполняемого файла
Также разработчики очень быстро устают от проверок больших однотипных блоков кода. Это приводит к тому, что они просто не смотрят такие блоки кода на ревью. Отчего качество ревью сильно снижается, т.к. реализатор функционала мог внести существенные изменения в автогенерированные куски, и никто этого не заметит
Даже автогенерированный код обязан быть протестирован. Даже если он сильно похож, всё равно на каждую копию должны быть написаны практически идентичные тесты. Что приводит к дублям не только кода, но и тестов. Сотням и даже тысячам одинаковых тестов
Автогенерируемый код плохо поддаётся одинаковым изменениям, т.к. их необходимо провести во многих сотнях копий классов, сгенерированных ранее
Можно задать любой шаблон для автогенерированного класса или наборов классов. В том числе и с ошибками. Особенно опасны архитектурные ошибки, исправлять которые будет нереально сложно, поскольку любая архитектурная ошибка приводит к огромному рефакторингу. А представьте, если у вас эта ошибка ещё и в нескольких сотнях копий?
В редких случаях UITableView не подходит для решения задачи, и тогда на помощь приходит UICollectionView, который также содержит много шаблонного кода, поэтому при автогенерации вы получите ещё и удвоение всех вышеперечисленных проблем с коллекциями
Данный подход активно используется в таком архитектурном паттерне, как VIPER, известном своей излишней многословностью.
Предлагаемое решение — универсальные переиспользуемые классы
Перечисление, как это решение превращает все минусы генерации в плюсы
Дополнительный плюс — возможность переиспользовать классы в любой архитектуре и за счёт этого быстрее и проще менять архитектуры на любом экране в приложении
Минусы решения и пути их нейтрализации
Наше решение: переиспользуемые абстрактные реализации
Учитывая все вышеперечисленные недостатки генерации кода, мы задумались, как же можно решить изначальную задачу по уменьшению time-to-market.
В результате мы в ПСБ разработали и продолжаем улучшать микромодуль переиспользуемых абстрактных реализаций для построения табличных представлений, в котором и содержится весь шаблонный код. Модуль написан на языке Swift от компании Apple. Подход, используемый при его реализации, существует уже на протяжении 3–4 лет. Пока использование его ограничено внутри банка и не планируется к переводу в опенсорс из-за юридических особенностей и необходимости переписать некоторую часть кода.
Просьба не считать это паттерном или, тем более, архитектурой. Это набор классов, лёгких в переиспользовании и достаточно универсальных, чтобы вы могли использовать их в 90% своих задач. Если они вам не подходят, смело их отметайте и работайте так, как вы привыкли.
Преимущества описываемого подхода
Все вышеперечисленные недостатки превращаются в плюсы при использовании абстрактных реализаций. Абстрактная реализация реализуется всего один раз, и в этом есть преимущества:
Он не увеличивает размер выполняемого файла, т.к. у нас всего одна реализация функционала с зависимостями, инжектируемыми снаружи
Ревью проводится один раз. Значит, разработчики не устают от ревью, а его качество повышается
Тесты пишутся один раз. В них легко разобраться. Их легко поддерживать
В абстрактную реализацию довольно просто внести изменения (при условии ее хорошего покрытия тестами), тем самым мы получим новое поведение по всему приложению за один раз
Абстрактную реализацию очень сложно написать неправильно. Думаю, каждый из вас сталкивался с тем, как сложно перевести конкретную реализацию в полную абстракцию. Это происходит из-за того, что реализации на абстракциях обязаны удовлетворять принципам хорошего проектирования: SOLID, KISS, DRY и прочим. Без выполнения этих принципов абстрактную реализацию просто не получится создать. Таким образом, мы ещё на этапе разработки получаем хорошо структурированный код
Из-за микромодульной организации кода, вызванной предыдущим пунктом, мы получаем возможность переиспользовать большую часть кода между таблицами и коллекциями без каких-либо изменений. (На самом деле, в наших проектах коллекции используются нечасто, поэтому универсальная реализация ещё не до конца отполирована для использования с коллекциями, но работа на этом не останавливается)
В результате шаблонный код сведён к минимуму таким образом, что контроллер представления может быть почти пустым. А для создания нового экрана достаточно лишь написать фабрику, связывающую абстрактные компоненты друг с другом и инициализирующую их конкретными параметрами, такими как бизнес-логика и получение данных. Тесты пишутся только на вновь создаваемые компоненты, но не на переиспользуемые. Всё это приводит к существенному увеличению time-to-market не только при первой разработке фичи, но и при её последующей поддержке и модификации.
Дополнительные плюсы использования абстрактных реализаций табличных представлений
Помимо вышеперечисленных плюсов, такая реализация имеет дополнительное преимущество. Поскольку каждый класс реализован на абстракциях и построен по всем принципам SOLID, то их довольно просто перемещать и перегруппировывать между собой, получая различные из существующих архитектур. На сегодняшний день описываемые абстрактные реализации очень легко применяются в MVVM. Не сомневаемся, что любой экран очень просто перегруппировать в паттерн MVC. Мы уже проверили возможность использования классов в VIP и скоро представим на Хабре схему использования.
Есть основания полагать, что при использовании классов из описываемого микромодуля в архитектуре VIPER необходимость в кодогенарации полностью отпадает.
Таким образом, описываемый в этой статье подход с переиспользованием базовых ответственностей позволяет гораздо быстрее и проще строить любую из известных архитектур. При необходимости вы также сможете менять архитектуру на лету, нивелируя тем самым возможные ошибки, допущенные на стадии выбора наиболее подходящей архитектуры под ваш проект. Даже если вы выбрали неверную архитектуру и она вас больше тормозит, чем помогает, то с помощью использования наших универсальных классов вы в будущем всегда легко сможете поменять архитектуру в уже готовом коде. Не правда ли, заманчиво?
Минусы решения и пути их нейтрализации
К недостаткам решения можно отнести необходимость ознакомления с каждым переиспользуемым классом. Однако, этот минус должен нивелироваться полной независимостью классов друг от друга и возможностью их использования по отдельности. Например, рекомендуем начать с использования в вашем любимом подходе и любимой архитектуре переиспользуемого источника данных (DataSource). Затем после успешного использования класса в нескольких экранах необходимо подключить переиспользуемый делегат (Delegate), и уже после — учиться использовать обзервер для простого и лёгкого мониторинга изменений данных. Минусом это будет являться лишь для тех, кто плохо знаком с документацией Apple по работе с таблицами и коллекциями.
Bus-фактор не является проблемой, поскольку в любой момент каждый из компонентов может быть реализован любым другим способом. Просто это отбросит нас к непомерно большому time-to-market реализации вашей задачи. Несмотря на это, можно прекрасно выполнить задачу даже без понимания подхода, описываемого в данной статье. И вновь хотим посоветовать документацию Apple.
Основные типы для вынесения всего шаблонного кода в отдельный модуль и комфортного переиспользования компонентов дизайн-системы:
ViewModel, UI-компонент
Описание протоколов, необходимых для визуальной составляющей приложения
DiffableObserver
Карта соответствия
Описание принципа работы элементов модуля с конкретными ячейками
DataProvider
DataSource
Описывается общий источник данных с уточнением того, что для коллекций и таблиц есть свои реализации
Delegate — обработка событий и схема работы
Описание переиспользуемых абстракций и реализаций
В этом разделе рассмотрим основные типы, используемые для решения задачи вынесения всего шаблонного кода в отдельный модуль, а также для комфортного переиспользования компонентов дизайн-системы.
Вью-модель компонента
Все вью-модели компонента подписываются под пустой протокол NAItemViewModel.
public protocol NAItemViewModel: IdentifiableViewModelType {
}
Протокол IdentifiableViewModelType используется для реализации DifferenceKit и будет рассмотрен ниже.
UI-компонент
Все компоненты дизайн-системы подписываются под протокол NAConfigurable, который требует объявления типа вью-модели этого компонента.
public protocol NAConfigurable where Self: UIView {
associatedtype ItemViewModel
var viewModel: ItemViewModel? { get set }
}
Свойство viewModel мутабельно для установки в него вью-модели, во вью это свойство реализуется как наблюдатель.
DiffableObserver
Зачастую необходимо обновлять коллекцию таким образом, чтобы это сопровождалось анимированным удалением, вставкой, перемещением и обновлением элементов коллекции. Для того чтобы каждый раз не писать эту логику в проекте, мы внедрили библиотеку DifferenceKit, которая под капотом сравнивает новый массив данных с предыдущим и возвращает позиции ячеек и секции, над которыми необходимо выполнить те или иные действия.
Все модели ячеек подписаны под общий протокол IdentifiableViewModelType, который требует от модели строковый идентификатор и метод проверки эквивалентности с другой моделью, посредствам протокола EquatableViewModelType.
Строковый идентификатор используется для выполнения операций вставки, удаления и перемещения элементов коллекции. Для каждой модели в DataSource он должен быть уникальным. В противном случае возникнут проблемы с выполняемыми операциями, и в конечном итоге приложение экстренно закроется.
public protocol IdentifiableViewModelType: EquatableViewModelType {
var identifier: String { get }
}
Листинг протокола IdentifiableViewModelType:
Метод проверки эквивалентности с другой моделью используется для операции обновления элементов коллекции. Для каждой модели необходимо реализовать удовлетворение протоколу EquatableViewModelType:
public protocol EquatableViewModelType {
func isEqual(_ other: EquatableViewModelType) -> Bool
}
Также необходимо создать объект класса DiffableObserver, который имеет слабую ссылку на коллекцию и контейнер с наблюдателем за данными в провайдере данных. DiffableObserver следит за изменениями через SectionChangingObserver и обновляет TargetReloading (коллекция). DiffableObserver будет совершать необходимые операции над элементами коллекции каждый раз, когда в провайдере данных обновятся данные:
Карта соответствия
Для корректной работы универсального источника данных он должен понимать, какой тип ячейки работает с текущей вью-моделью. Карта соответствия представляет собой словарь, где ключом почти во всех случаях является идентификатор ячейки (по умолчанию строковое представление названия типа ячейки). Все карты соответствуют протоколу Map, где требуется указать, с какими типами работает карта:
public protocol NAMap: Mapping {
associatedtype Value
associatedtype ResolvingKey
associatedtype RegistrationKey
var storage: MapStorage<Value> { get }
func resolve(for key: ResolvingKey) -> Value?
func register(_ value: Value, for key: RegistrationKey)
}
Value — тип значения в словаре
ResolvingKey — тип, по которому осуществляется доступ к значению
RegisteringKey — тип, по которому осуществляется регистрация элемента в карте
Для регистрации и получения значения в карте соответствия бывает удобно использовать разные типы ключей, протокол это предусматривает. Также протоколом предусмотрены два метода для взаимодействия, а именно для регистрации и получения значения по ключу.
DataProvider
Следуя принципам SOLID, а именно single responsibility, мы выделили абстрактный провайдер данных. Ответственность провайдера данных заключается в поставке данных для DataSource. Провайдер абстрагирован от UI таким образом, что в сигнатурах определённых методов нет UI-типов:
public protocol NAViewModelDataProvider {
func numberOfSections() -> Int
func numberOfRows(inSection section: Int) -> Int
func itemForRow(atIndexPath indexPath: IndexPath) -> NAItemViewModel?
func title(forSection section: Int) -> String?
func sectionIndexTitles() -> [String]?
}
На основе протокола уже предоставляется возможность делать различные провайдеры, в частности для простых и более сложных коллекций — с секциями.
DataSource
Для уменьшения количества повторяющегося кода был выделен общий источник данных, в котором заключена логика формирования коллекционного представления.
Переиспользуемый источник данных (DataSource) хранит абстрактный тип провайдера данных (DataProvider), а также карту соответствия ячеек на тип/идетификатор вьюмодели (CellMap). Все хранимые объекты имеют абстрактный тип и инжектируются извне.
На основе базового источника данных мы выделили ещё два: источники данных для таблиц и коллекций реализующие протоколы NATableViewDataSource и NACollectionViewDataSource соответственно. В них реализована логика формирования соответствующей коллекции, где предусмотрена своя карта соответствия.
Это позволяет на лету менять представление, не затрагивая данные. Достаточно только заменить табличный источник данных на коллекционный или обратно.
Delegate
Для уменьшения количества повторяющего кода был выделен общий делегат, в котором заключена логика обработки событий коллекций. Основной функционал — обработка нажатий на элемент таблицы. Делегат реализует протокол UITableViewDelegate. Таким же образом реализуется и для коллекции.
Для обработки нажатий на элементы секций в делегат инжектируется карта соответствия (ActionMap), где ключ — идентификатор ячейки, значение — Selector, который выполняется в соответствующем методе делегата. Чтобы правильно определить Selector в карте, в делегат инжектируется провайдер данных.
Реализация MVVM на выделенных абстрактных ответственностях
В заключение представим блок-схемы переиспользования вышеперечисленных абстрактных реализаций для архитектуры MVVM и VIP-cycle. При желании читатель может сам перегруппировать данные ответственности, чтобы отрисовать блок-схемы табличных экранов для архитектур MVC, VIPER или любой другой любимой архитектуры.
За интеграцию переиспользуемых абстрактных реализаций в архитектуру VIP-cycle и схему, представленную ниже, благодарим @denizztret.
Мы рассмотрели в общих чертах все единые ответственности, необходимые для построения экранов в виде табличных представлений. В следующих статьях мы на конкретных примерах разберём, как переиспользовать данные классы для решения конкретных задач.
Наше решение позволяет добиться реализации стандартных механизмов работы за счёт меньшего объёма кода, нежели его нативные аналоги. Комбинация данных инструментов и известных архитектурных паттернов во многом помогает упростить жизнь себе и команде из-за избавления от тонны однообразного кода. Он становится обузой в поддержке. Особенно он критичен в мобильных приложениях, которые борются за место на устройстве пользователя. Табличные экраны как раз являются источником рассматриваемых нами проблем, так что в следующей статье рассмотрим на конкретном примере применение переиспользуемого источника данных (DataSource).