Your browser is not supported anymore. Please update to a more recent one.


Download Chrome

Download Firefox

Download
Internet Explorer

Download Safari

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

29 августа | Валерий Чевтаев


Меня зовут Валера, и уже два года я разрабатываю 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!

Источники