company_banner

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

    Источники

    Badoo
    371,00
    Big Dating
    Поделиться публикацией

    Комментарии 9

      0
      почему не loadView?
        0
        обычно мы добавляем вьюхи поверх дефолтового вью, и делаем это после того, как это вью создано (viewDidLoad). В общем-то, никто не запрещает делать иначе, т.е. провайдить свою кастомную вьюху контроллеру внутри loadView().
          0

          но зачем все эти сложности с растягиванием своей вьюхи, с поддержкой UIScrollView и тд, если Apple придумал простой механизм специально для этого?

            0
            не понял вопроса, сори :) а в примере используется UICollectionView
              0
              что сделано сейчас:
              friendsListView создаётся в viewDidLoad. Нужно создать инстанс, добавить его в self.view, «растянуть» от края до края

              при этом:
              * есть ненулевая вероятность обратиться к friendsListView до её создания, например где-то между init и viewDidLoad, вместо порождения процесса загрузки вьюхи будет падение
              * образуется никому не нужный «контейнер» из пустой self.view. Это может порождать проблемы в случае, если friendsListView это UIScrollView. При глубокой вложенности таких контроллеров, система может перестать понимать, что этой UIScrollView нужно автоматически добавлять верхние/нижние инсеты для navigationBar/tabBar/etc.

              Для этого был создан метод loadView.

              Я обычно делаю так:
              class FriendsListViewController: UIViewController {
                  private var infoView: FriendsListView!
              
                  var infoView: FriendsListView {
                      return view as! FriendsListView
                  }
              
                  override func loadView() {
                      view = FriendsListView()
                  }
              }


              при этом и self.view, и self.infoView будут порождать загрузку view, не нужно никуда ничего руками добавлять и растягивать, нет проблем с перегруженностью иерархии view.
        0
        Спасибо за статью. А как вы реализуете UI который должен показываться/скрываться в зависимости от модели? Если у нас модель состоит из 100 полей (не однородных как для таблицы, а разных), то каким образом View должна обновлять представление? Строить все 100 индивидуальных компонентов (иконка, текст, кнопка или что-то еще) для 100 полей ViewModel и скрывать/показывать их в зависимости от ViewModel?
          0
          привет! можно оперировать абстракциями, например, как мы делаем это в Chatto: github.com/badoo/Chatto/tree/master/ChattoApp/ChattoApp/Source/Chat%20Items, где есть пример похожей реализации ЮИ с разными типами элементов в коллекции: Text Message, Photo Message,…
          0
          Хотел задать вопрос почему вы не пользуетесь NSNotifications и NSNotificationCenter а определяете свой протокол?
            0
            имхо, реактивное программирование более удобно и понятно в данном случае :) как вариант, можно использовать RxSwift или ReactiveCocoa вместо своей имплементации.

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

          Самое читаемое