- PVSM.RU - https://www.pvsm.ru -
Привет!
Меня зовут Валера, и уже два года я разрабатываю iOS-приложение в составе команды Badoo. Один из наших приоритетов — легкосопровождаемый код. Из-за большого количества новых фич, еженедельно попадающих к нам в руки, нам нужно в первую очередь думать об архитектуре приложения, иначе будет крайне сложно добавить новую фичу в продукт, не ломая уже существующие. Очевидно, что это также относится и к реализации пользовательского интерфейса (UI) независимо от того, делается это с помощью кода, Xcode (XIB) или смешанного подхода. В этой статье я опишу некоторые методики реализации UI, которые позволяют нам упрощать разработку пользовательского интерфейса, делая её гибкой и удобной для тестирования. Также есть версия этой статьи на английском [1].
Я буду рассматривать методики реализации пользовательского интерфейса на примере приложения, написанного на Swift. Приложение по нажатию на кнопку показывает список друзей.
Оно состоит из трёх частей:
Почему такое разделение? На этот вопрос я отвечу ниже, а пока ознакомьтесь с пользовательским интерфейсом нашего приложения:
Это всплывающее view с содержимым поверх другого полноэкранного view. Всё просто.
Полный исходный код проекта доступен на GitHub [2].
Прежде чем углубиться в 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. Разделение пользовательского интерфейса и логики.
В Badoo большая часть пользовательского интереса реализуется в коде. Почему мы не используем XIB’ы или storyboards? Справедливый вопрос. Главная причина — удобство сопровождения кода для команды среднего размера, а именно:
Взгляните на следующий контроллер (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) можно прочитать здесь [3]. Поскольку конфигурация 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
}
}
Можно увидеть чёткое разделение ответственности [4], и этот концепт не сильно сложнее, чем вызвать segue на сториборде.
Создать view controller довольно просто, учитывая, что у нас есть его модель и можно просто использовать стандартную конфигурацию представления:
let friendsListViewController = FriendsListViewController(
viewModel: infoViewModel,
viewConfig: .defaultConfig)
Вот код разметки (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 вручную и на лету рассчитывать все позиции и размеры, но иногда это может обернуться чересчур запутанным и/или громоздким кодом.
Можно также использовать текстовый формат для разметки, как описано здесь [5], но зачастую это приводит к ошибкам, поскольку нужно чётко соблюдать формат, а Xcode не делает проверок текста описания разметки на этапе написания/компиляции кода, а также нельзя использовать Safe Area Layout Guide:
NSLayoutConstraint.constraints(
withVisualFormat: "V:|-((topSpace))-[headerView(headerHeight@200)]-[collectionView(collectionViewHeight@990)]|",
options: [],
metrics: metrics,
views: views)
Довольно легко сделать ошибку или опечатку в текстовой строке, определяющей разметку, не так ли?
Наш пример пользовательского интерфейса разделён на компоненты, каждый из которых выполняет одну конкретную функцию, не более.
Например:
FriendsListHeaderView
— отображает информацию о друзьях и кнопку «Закрыть». FriendsListContentView
— отображает список друзей с кликабельными ячейками, контент динамически подгружается при достижении конца списка.FriendsListView
— контейнер для двух предыдущих views.Как говорилось ранее, мы в Badoo любим принцип единственной ответственности [6], когда каждый компонент отвечает за отдельную функцию. Это помогает не только в процессе багфиксинга (что, может быть, является не самой интересной частью работы iOS-разработчика), но и во время разработки нового функционала, потому что такой подход существенно расширяет возможности переиспользования кода в будущем.
И последний, но не менее важный пункт — разделение пользовательского интерфейса и логики. Методика, которая может сэкономить время и нервы вашей команде. В прямом смысле: отдельный проект под пользовательский интерфейс и отдельный — под бизнес-логику.
Вернёмся к нашему примеру. Как вы помните, сущность презентации (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 (о них рассказывается в другой нашей статье [7]), но по разным причинам они не используются в Badoo. Помните, что у каждого есть своё мнение и видение общей картины, поэтому, чтобы разработать успешный проект, стоит прийти к консенсусу в команде и выбрать наиболее подходящий для большинства сценариев подход.
Да пребудет с вами Swift!
Источники
Автор: Valeron
Источник [11]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/ui/290802
Ссылки в тексте:
[1] на английском: https://badootech.badoo.com/ios-ui-development-in-badoo-592d76dc5b37
[2] GitHub: https://github.com/chupakabr/ios-ui-techniques-example
[3] здесь: https://medium.com/ios-os-x-development/ios-architecture-patterns-ecba4c38de52
[4] разделение ответственности: https://ru.wikipedia.org/wiki/%D0%A0%D0%B0%D0%B7%D0%B4%D0%B5%D0%BB%D0%B5%D0%BD%D0%B8%D0%B5_%D0%BE%D1%82%D0%B2%D0%B5%D1%82%D1%81%D1%82%D0%B2%D0%B5%D0%BD%D0%BD%D0%BE%D1%81%D1%82%D0%B8
[5] здесь: https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/AutolayoutPG/ProgrammaticallyCreatingConstraints.html#//apple_ref/doc/uid/TP40010853-CH16-SW1
[6] принцип единственной ответственности: https://ru.wikipedia.org/wiki/%D0%9F%D1%80%D0%B8%D0%BD%D1%86%D0%B8%D0%BF_%D0%B5%D0%B4%D0%B8%D0%BD%D1%81%D1%82%D0%B2%D0%B5%D0%BD%D0%BD%D0%BE%D0%B9_%D0%BE%D1%82%D0%B2%D0%B5%D1%82%D1%81%D1%82%D0%B2%D0%B5%D0%BD%D0%BD%D0%BE%D1%81%D1%82%D0%B8
[7] нашей статье: https://medium.com/@chevtaev/2527be1a0c19
[8] Моделирование состояния в Swift: https://medium.com/@johnsundell/modelling-state-in-swift-3751a4acb8f3
[9] Принципы SOLID в Swift: https://medium.com/@vinodhswamy/solid-principles-in-swift-7dc2b793fd68
[10] NSLayoutAnchor: https://developer.apple.com/documentation/uikit/nslayoutanchor
[11] Источник: https://habr.com/post/421559/?utm_source=habrahabr&utm_medium=rss&utm_campaign=421559
Нажмите здесь для печати.