Компонентная архитектура UI в iOS-приложении

от автора

Привет, Хабр!

Меня зовут Валера, и уже два года я разрабатываю iOS-приложение в составе команды Badoo. Один из наших приоритетов — легкосопровождаемый код. Из-за большого количества новых фич, еженедельно попадающих к нам в руки, нам нужно в первую очередь думать об архитектуре приложения, иначе будет крайне сложно добавить новую фичу в продукт, не ломая уже существующие. Очевидно, что это также относится и к реализации пользовательского интерфейса (UI) независимо от того, делается это с помощью кода, Xcode (XIB) или смешанного подхода. В этой статье я опишу некоторые методики реализации UI, которые позволяют нам упрощать разработку пользовательского интерфейса, делая её гибкой и удобной для тестирования. Также есть версия этой статьи на английском.

Прежде чем начать…

Я буду рассматривать методики реализации пользовательского интерфейса на примере приложения, написанного на Swift. Приложение по нажатию на кнопку показывает список друзей.

Оно состоит из трёх частей:

  1. Компоненты — кастомные UI-компоненты, то есть код, относящийся только к пользовательскому интерфейсу.
  2. Демоприложение — демонстрационные view models и другие сущности пользовательского интерфейса, имеющие только UI-зависимости.
  3. Реальное приложение — view models и другие сущности, которые могут содержать специфические зависимости и логику.

Почему такое разделение? На этот вопрос я отвечу ниже, а пока ознакомьтесь с пользовательским интерфейсом нашего приложения:

Это всплывающее view с содержимым поверх другого полноэкранного view. Всё просто.

Полный исходный код проекта доступен на GitHub.

Прежде чем углубиться в UI-код, хочу познакомить вас с используемым здесь вспомогательным классом Observable. Его интерфейс выглядит так:

var value: T func observe(_ closure: @escaping (_ old: T, _ new: T) -> Void) -> ObserverProtocol func observeNewAndCall(_ closure: @escaping (_ new: T) -> Void) -> ObserverProtocol

Он просто уведомляет всех ранее подписавшихся наблюдателей об изменениях, так что это своего рода альтернатива KVO (key-value observing) или, если хотите, реактивному программированию. Вот пример использования:

self.observers.append(self.viewModel.items.observe { [weak self] (_, newItems) in     self?.state = newItems.isEmpty ? .zeroCase(type: .empty) : .normal     self?.collectionView.reloadSections(IndexSet(integer: 0)) })

Контроллер подписывается на изменения свойства self.viewModel.items, и, когда происходит изменение, обработчик исполняет бизнес-логику. Например, обновляет состояние view и перезагружает данные коллекции (collection view) с новыми элементами.

Больше примеров использования вы увидите ниже.

Методики

В этом разделе я расскажу о четырёх методиках UI-разработки, которые используются в Badoo:

1. Реализация пользовательского интерфейса в коде.

2. Использование layout anchors.

3. Компоненты — разделяй и властвуй.

4. Разделение пользовательского интерфейса и логики.

#1: Реализация пользовательского интерфейса в коде

В Badoo большая часть пользовательского интереса реализуется в коде. Почему мы не используем XIB’ы или storyboards? Справедливый вопрос. Главная причина — удобство сопровождения кода для команды среднего размера, а именно:

  • хорошо видны изменения в коде, а значит, нет необходимости анализировать XML сториборда/XIB-файл для того, чтобы найти изменения, внесённые коллегой;
  • системам управления версиями (например, Git) гораздо проще работать с кодом, нежели с «тяжёлыми» XLM-файлами, особенно во время мёрж-конфликтов; также учитывается, что содержимое файлов XIB/storyboard изменяется при каждом их сохранении, даже если интерфейс не менялся (правда я слышал, что в Xcode 9 эта проблема уже пофикшена);
  • может быть трудно изменять и поддерживать некоторые свойства в Interface Builder (IB), например, свойства CALayer в процессе релайаута дочерних views (layout subviews), что может привести к нескольким источникам истины (sources of truth) для состояния view;
  • Interface Builder — не самый быстрый инструмент, и иногда намного быстрее работать непосредственно с кодом.

Взгляните на следующий контроллер (FriendsListViewController):

final class FriendsListViewController: UIViewController {     struct ViewConfig {         let backgroundColor: UIColor         let cornerRadius: CGFloat     }      private var infoView: FriendsListView!      private let viewModel: FriendsListViewModelProtocol     private let viewConfig: ViewConfig      init(viewModel: FriendsListViewModelProtocol, viewConfig: ViewConfig) {         self.viewModel = viewModel         self.viewConfig = viewConfig         super.init(nibName: nil, bundle: nil)     }      required init?(coder aDecoder: NSCoder) {         fatalError("init(coder:) has not been implemented")     }      override func viewDidLoad() {         super.viewDidLoad()         self.setupContainerView()     }      private func setupContainerView() {         self.view.backgroundColor = self.viewConfig.backgroundColor          let infoView = FriendsListView(             frame: .zero,             viewModel: self.viewModel,             viewConfig: .defaultConfig)         infoView.backgroundColor = self.viewConfig.backgroundColor          self.view.addSubview(infoView)         self.infoView = infoView          infoView.translatesAutoresizingMaskIntoConstraints = false         infoView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true         infoView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true         infoView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true         infoView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true     }      // …. }

На этом примере видно, что создать контроллер представления можно, только предоставив view model и view configuration. Подробнее о моделях представления, то есть о шаблоне проектирования MVVM (Model-View-ViewModel) можно прочитать здесь. Поскольку конфигурация view — это простая структурная сущность (struct entity), определяющая разметку (layout) и стиль view, а именно отступы, размеры, цвета, шрифты и т. д., я считаю целесообразным предоставлять стандартную конфигурацию вроде такой:

extension FriendsListViewController.ViewConfig {     static var defaultConfig: FriendsListViewController.ViewConfig {         return FriendsListViewController.ViewConfig(backgroundColor: .white,                                                     cornerRadius: 16)     } }

Вся инициализация view происходит в методе setupContainerView, который вызывается только один раз из viewDidLoad в момент, когда view уже создано и загружено, но ещё не отрисовано на экране, то есть в иерархию представления просто добавляются все необходимые элементы (subviews), а затем применяются разметка (layout) и стили.

Вот как теперь выглядит контроллер представления:

final class FriendsListPresenter: FriendsListPresenterProtocol {     // …      func presentFriendsList(from presentingViewController: UIViewController) {         let controller = Class.createFriendsListViewController(             presentingViewController: presentingViewController,             headerViewModel: self.headerViewModel,             contentViewModel: self.contentViewModel)         controller.modalPresentationStyle = .overCurrentContext         controller.modalTransitionStyle = .crossDissolve          presentingViewController.present(controller, animated: true, completion: nil)     }      private class func createFriendsListViewController(             presentingViewController: UIViewController,             headerViewModel: FriendsListHeaderViewModelProtocol,             contentViewModel: FriendsListContentViewModelProtocol)              -> FriendsListContainerViewController {                  let dismissViewControllerBlock: VoidBlock = { [weak presentingViewController] in             presentingViewController?.dismiss(animated: true, completion: nil)         }          let infoViewModel = FriendsListViewModel(             headerViewModel: headerViewModel,             contentViewModel: contentViewModel)         let containerViewModel = FriendsListContainerViewModel(onOutsideContentTapAction: dismissViewControllerBlock)          let friendsListViewController = FriendsListViewController(             viewModel: infoViewModel,             viewConfig: .defaultConfig)         let controller = FriendsListContainerViewController(             contentViewController: friendsListViewController,             viewModel: containerViewModel,             viewConfig: .defaultConfig)         return controller     } }

Можно увидеть чёткое разделение ответственности, и этот концепт не сильно сложнее, чем вызвать segue на сториборде.

Создать view controller довольно просто, учитывая, что у нас есть его модель и можно просто использовать стандартную конфигурацию представления:

 let friendsListViewController = FriendsListViewController(         viewModel: infoViewModel,         viewConfig: .defaultConfig) 

#2: Использование layout anchors

Вот код разметки (layout):

 self.view.addSubview(infoView) self.infoView = infoView  infoView.translatesAutoresizingMaskIntoConstraints = false infoView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true infoView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true infoView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true infoView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true 

Проще говоря, этот код помещает infoView внутрь родительского view (superview), в координаты (0, 0) относительно исходных размеров superview.

Почему мы используем layout anchors? Это быстро и просто. Конечно, вы можете задавать UIView.frame вручную и на лету рассчитывать все позиции и размеры, но иногда это может обернуться чересчур запутанным и/или громоздким кодом.

Можно также использовать текстовый формат для разметки, как описано здесь, но зачастую это приводит к ошибкам, поскольку нужно чётко соблюдать формат, а Xcode не делает проверок текста описания разметки на этапе написания/компиляции кода, а также нельзя использовать Safe Area Layout Guide:

 NSLayoutConstraint.constraints(     withVisualFormat: "V:|-(\(topSpace))-[headerView(headerHeight@200)]-[collectionView(collectionViewHeight@990)]|",     options: [],     metrics: metrics,     views: views) 

Довольно легко сделать ошибку или опечатку в текстовой строке, определяющей разметку, не так ли?

#3: Компоненты — разделяй и властвуй

Наш пример пользовательского интерфейса разделён на компоненты, каждый из которых выполняет одну конкретную функцию, не более.

Например:

  1. FriendsListHeaderView — отображает информацию о друзьях и кнопку «Закрыть».
  2. FriendsListContentView — отображает список друзей с кликабельными ячейками, контент динамически подгружается при достижении конца списка.
  3. FriendsListView — контейнер для двух предыдущих views.

Как говорилось ранее, мы в Badoo любим принцип единственной ответственности, когда каждый компонент отвечает за отдельную функцию. Это помогает не только в процессе багфиксинга (что, может быть, является не самой интересной частью работы iOS-разработчика), но и во время разработки нового функционала, потому что такой подход существенно расширяет возможности переиспользования кода в будущем.

#4: Разделение пользовательского интерфейса и логики

И последний, но не менее важный пункт — разделение пользовательского интерфейса и логики. Методика, которая может сэкономить время и нервы вашей команде. В прямом смысле: отдельный проект под пользовательский интерфейс и отдельный — под бизнес-логику.

Вернёмся к нашему примеру. Как вы помните, сущность презентации (presenter) выглядит вот так:

func presentFriendsList(from presentingViewController: UIViewController) {     let controller = Class.createFriendsListViewController(         presentingViewController: presentingViewController,         headerViewModel: self.headerViewModel,         contentViewModel: self.contentViewModel)     controller.modalPresentationStyle = .overCurrentContext     controller.modalTransitionStyle = .crossDissolve      presentingViewController.present(controller, animated: true, completion: nil) }

Вам нужно предоставить только view models заголовка и контента. Остальное скрыто внутри вышеописанной реализации UI-компонентов.

Протокол модели представления заголовка выглядит так:

protocol FriendsListHeaderViewModelProtocol {     var friendsCountIcon: UIImage? { get }     var closeButtonIcon: UIImage? { get }      var friendsCount: Observable<String> { get }      var onCloseAction: VoidBlock? { get set } }

Теперь представьте, что вы добавляете визуальные тесты для UI, — это так же просто, как и передача моделей-заглушек для UI-компонентов.

final class FriendsListHeaderDemoViewModel: FriendsListHeaderViewModelProtocol {     var friendsCountIcon: UIImage? = UIImage(named: "ic_friends_count")     var closeButtonIcon: UIImage? = UIImage(named: "ic_close_cross")      var friendsCount: Observable<String>     var onCloseAction: VoidBlock?      init() {         let friendsCountString = "\(Int.random(min: 1, max: 5000))"         self.friendsCount = Observable(friendsCountString)     } }

Выглядит просто, не так ли? Теперь мы хотим добавить бизнес-логику к компонентам нашего приложения, для которой могут потребоваться провайдеры данных, модели данных и т. д.:

final class FriendsListHeaderViewModel: FriendsListHeaderViewModelProtocol {     let friendsCountIcon: UIImage?     let closeButtonIcon: UIImage?     let friendsCount: Observable<String> = Observable("0")     var onCloseAction: VoidBlock?      private let dataProvider: FriendsListDataProviderProtocol     private var observers: [ObserverProtocol] = []      init(dataProvider: FriendsListDataProviderProtocol,          friendsCountIcon: UIImage?,          closeButtonIcon: UIImage?) {         self.dataProvider = dataProvider         self.friendsCountIcon = friendsCountIcon         self.closeButtonIcon = closeButtonIcon          self.setupDataObservers()     }      private func setupDataObservers() {         self.observers.append(self.dataProvider.totalItemsCount.observeNewAndCall { [weak self] (newCount) in             self?.friendsCount.value = "\(newCount)"         })     } }

Что может быть проще? Просто реализуем провайдер данных — и вперёд!

Реализация модели контента выглядит немного сложнее, но разделение ответственности всё равно сильно упрощает жизнь. Вот пример того, как можно инстанцировать и отобразить список друзей по нажатию кнопки:

private func presentRealFriendsList(sender: Any) {     let avatarPlaceholderImage = UIImage(named: "avatar-placeholder")     let itemFactory = FriendsListItemFactory(avatarPlaceholderImage: avatarPlaceholderImage)     let dataProvider = FriendsListDataProvider(itemFactory: itemFactory)     let viewModelFactory = FriendsListViewModelFactory(dataProvider: dataProvider)      var headerViewModel = viewModelFactory.makeHeaderViewModel()     headerViewModel.onCloseAction = { [weak self] in         self?.dismiss(animated: true, completion: nil)     }      let contentViewModel = viewModelFactory.makeContentViewModel()     let presenter = FriendsListPresenter(         headerViewModel: headerViewModel,         contentViewModel: contentViewModel)     presenter.presentFriendsList(from: self) }

Эта методика помогает изолировать пользовательский интерфейс от бизнес-логики. Более того, это позволяет покрыть весь UI визуальными тестами, передавая компонентам тестовые данные! Поэтому разделение пользовательского интерфейса и связанной с ним бизнес-логики имеет решающее значение для успеха проекта, будь то стартап или уже готовый продукт.

Заключение

Конечно, это только некоторые методики, используемые в Badoo, и они не являются универсальным решением для всех возможных случаев. Поэтому используйте их, предварительно оценив, подходят ли они вам и вашим проектам.

Существуют и другие методики, например, XIB-конфигурируемые UI-компоненты с использованием Interface Builder (о них рассказывается в другой нашей статье), но по разным причинам они не используются в Badoo. Помните, что у каждого есть своё мнение и видение общей картины, поэтому, чтобы разработать успешный проект, стоит прийти к консенсусу в команде и выбрать наиболее подходящий для большинства сценариев подход.

Да пребудет с вами Swift!

Источники


ссылка на оригинал статьи https://habr.com/post/421559/


Комментарии

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

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