Привет!
Меня зовут Валера, и уже два года я разрабатываю iOS-приложение в составе команды Badoo. Один из наших приоритетов — легкосопровождаемый код. Из-за большого количества новых фич, еженедельно попадающих к нам в руки, нам нужно в первую очередь думать об архитектуре приложения, иначе будет крайне сложно добавить новую фичу в продукт, не ломая уже существующие. Очевидно, что это также относится и к реализации пользовательского интерфейса (UI) независимо от того, делается это с помощью кода, Xcode (XIB) или смешанного подхода. В этой статье я опишу некоторые методики реализации UI, которые позволяют нам упрощать разработку пользовательского интерфейса, делая её гибкой и удобной для тестирования. Также есть версия этой статьи на английском.
Прежде чем начать…
Я буду рассматривать методики реализации пользовательского интерфейса на примере приложения, написанного на Swift. Приложение по нажатию на кнопку показывает список друзей.
Оно состоит из трёх частей:
- Компоненты — кастомные UI-компоненты, то есть код, относящийся только к пользовательскому интерфейсу.
- Демоприложение — демонстрационные view models и другие сущности пользовательского интерфейса, имеющие только UI-зависимости.
- Реальное приложение — 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: Компоненты — разделяй и властвуй
Наш пример пользовательского интерфейса разделён на компоненты, каждый из которых выполняет одну конкретную функцию, не более.
Например:
FriendsListHeaderView
— отображает информацию о друзьях и кнопку «Закрыть».FriendsListContentView
— отображает список друзей с кликабельными ячейками, контент динамически подгружается при достижении конца списка.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!
Источники
- Полный код проекта на GitHub
- Англоязычный вариант этой статьи
- Создание кастомных UI-компонентов
- Архитектурные iOS-шаблоны
- Моделирование состояния в Swift
- Принципы SOLID в Swift
- NSLayoutAnchor
- Программное создание ограничений
Автор: Valeron