Хабрахабр

Компонентная архитектура 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.

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

Компоненты — разделяй и властвуй. 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 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!

Источники

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть