company_banner

Как подружиться с UIKit


    Привет, Хабр! Меня зовут Богдан, в Badoo я работаю в мобильной команде iOS-разработчиком. Мы достаточно редко рассказываем что-либо о нашей мобильной разработке, хотя статьи – один из лучших способов документировать хорошие практики. Эта статья статья расскажет о нескольких полезных подходах которые мы используем в нашей работе.


    Уже на протяжении нескольких лет iOS-сообщество сражается с UIKit. Кто-то придумывает сложные способы «погребения» внутренностей UIKit под слоями абстракций в своих выдуманных архитектурах, другие команды переписывают его, теша своё эго, но оставляя за собой дикое количество кода, который нужно поддерживать.


    Заставьте UIKit работать на себя


    Я ленив, поэтому стараюсь писать только тот код, который необходим. Я хочу писать код, соответствующий требованиям продукта и принятым в команде стандартам качества, но свожу к минимуму объём кода для поддержки инфраструктуры и стандартных кусков архитектурных шаблонов. Поэтому я верю, что вместо борьбы с UIKit мы должны принять его и использовать как можно шире.


    Выбор архитектуры, подходящей для UIKit


    Любую проблему можно решить, добавив ещё один уровень абстракции. Поэтому многие выбирают VIPER – в нём много уровней/ сущностей, которые можно использовать в работе. Писать приложение в VIPER не сложно – гораздо сложнее написать обладающее теми же достоинствами MVC-приложение с поддержкой меньшего объёма шаблонного кода.


    Если начинать проект с нуля, то можно выбрать архитектурный шаблон и всё делать «правильно» с самого начала. Но в большинстве случаев такая роскошь нам недоступна – приходится работать с уже имеющейся кодовой базой.


    Проведём мысленный эксперимент.


    Вы присоединяетесь к команде, которая наработала большую кодовую базу. Какой подход вы надеетесь в ней увидеть? Чистый MVC? Какой-нибудь MVVM/ MVP с flow-контроллерами? Может быть, VIPER-подход или подход на основе Redux в каком-нибудь FRP-фреймворке? Лично я рассчитываю увидеть простейший и работающий подход. Более того, я хочу оставить после себя такой код, который кто угодно сможет читать и исправлять.


    Короче, давайте посмотрим, как можно что-то делать на основе контроллеров представлений, а не пытаться их заменять или прятать.


    Допустим, у вас есть набор экранов, каждый из которых представлен одним контроллером. Эти контроллеры представлений извлекают из интернета какие-то данные и выводят на экран. С точки зрения продукта всё работает идеально, но вы понятия не имеете, как тестировать код контроллеров, а попытки переиспользования заканчиваются копипастингом, из-за чего контроллеры представлений увеличиваются в размерах.



    Очевидно, что нужно начать разделять код. Но как сделать это без лишних хлопот? Если вытащить код, извлекающий данные, в отдельный объект, то контроллер будет только выводить информацию на экран. Так и сделаем:



    Теперь всё выглядит очень похоже на MVVM, поэтому будем пользоваться его терминологией. Итак, у нас есть представление и модель представления. Эту модель мы легко можем протестировать. Давайте теперь перенесём в сервисы повторяющиеся задачи вроде работы с сетью и хранения данных.



    В результате:


    1. Вы сможете переиспользовать свой код.
    2. Получите источник истины, не привязанный к уровню пользовательского интерфейса.

    Какое отношение всё это имеет к UIKit? Позвольте объяснить.


    Модель представления сохраняется контроллером представления, и её вообще не интересует, существует ли контроллер. Так что если мы удалим из памяти контроллер, то и соответствующая модель тоже будет удалена.


    С другой стороны, если контроллер сохраняется другим объектом (например, презентером) в MVP, то, если по какой-то причине контроллер будет выгружен, связь между ним и презентером нарушится. И если вы думаете, что трудно случайно выгрузить не тот контроллер, то внимательно почитайте описание UIViewController.dismiss(animated:completion:).



    Так что я считаю, что безопаснее всего будет признать контроллер представления королём, и, следовательно, объекты, не относящиеся к UI, разделить на две категории:


    1. Объекты с жизненным циклом, равным циклу UI-элементов (например, модель представления).
    2. Объекты с жизненным циклом, равным циклу приложения (например, сервис).

    Использование жизненного цикла контроллера представления


    Почему так велик соблазн засунуть весь код в контроллер представления? Да потому что в контроллере у нас есть доступ ко всем данным и текущему состоянию представления. Если в модели или презентере нужно иметь доступ к жизненному циклу представления, то придётся передавать его вручную, и это нормально, но придётся писать больше кода.


    Но есть и другое решение. Поскольку контроллеры представлений способны работать друг с другом, Соруш Ханлоу предложил воспользоваться этим для распределения работы между маленькими контроллерами представлений.


    Можно пойти ещё дальше и применить универсальный способ подключения к жизненному циклу контроллера представлений – ViewControllerLifecycleBehaviour.


    public protocol ViewControllerLifecycleBehaviour {
        func afterLoading(_ viewController: UIViewController)
        func beforeAppearing(_ viewController: UIViewController)
        func afterAppearing(_ viewController: UIViewController)
        func beforeDisappearing(_ viewController: UIViewController)
        func afterDisappearing(_ viewController: UIViewController)
        func beforeLayingOutSubviews(_ viewController: UIViewController)
        func afterLayingOutSubviews(_ viewController: UIViewController)
    }

    Объясню на примере. Допустим, нам нужно определить скриншоты в котроллере представления чата, но только когда тот выведен на экран. Если вынести эту задачу в VCLBehaviour, то всё становится проще простого:


    open override func viewDidLoad() {
        let screenshotDetector =  ScreenshotDetector(notificationCenter:
                                      NotificationCenter.default) {
        // Screenshot was detected
    }
    self.add(behaviours: [screenshotDetector])}

    В реализации поведения тоже ничего сложного:


    public final class ScreenshotDetector: NSObject,  
        ViewControllerLifecycleBehaviour {
        public init(notificationCenter: NotificationCenter,    
                   didDetectScreenshot: @escaping () -> Void) {     
           self.didDetectScreenshot = didDetectScreenshot                
           self.notificationCenter = notificationCenter
        }
        deinit {
           self.notificationCenter.removeObserver(self)
        }
        public func afterAppearing(_ viewController: UIViewController) {
           self.notificationCenter.addObserver(self, selector:
               #selector(userDidTakeScreenshot), 
               name: .UIApplicationUserDidTakeScreenshot, object: nil)
        }
        public func afterDisappearing(_ viewController:
            UIViewController) {    
           self.notificationCenter.removeObserver(self)
        }
        @objc private func userDidTakeScreenshot() {
            self.didDetectScreenshot()
        }
        private let didDetectScreenshot: () -> Void
        private let notificationCenter: NotificationCenter
    }

    Поведение также можно тестировать изолированно, поскольку оно закрыто нашим протоколом ViewControllerLifecycleBehaviour.


    Подробности реализации: здесь.


    Поведение можно использовать в задачах, зависящих от VCL, например, в аналитике.


    Использование цепочки ответчиков


    Допустим, глубоко в иерархии представлений у вас есть кнопка, и вам нужно всего лишь сделать презентацию нового контроллера. Обычно для этого внедряют контроллер представления, из которого делается презентация. Это правильный подход. Но иногда из-за этого появляется переходная зависимость, используемая теми, кто находится не в середине, а в глубине иерархии.



    Как вы уже, наверное, догадались, есть другой способ решения. Для поиска контроллера, способного презентовать другой контроллер представления, можно использовать цепочку ответчиков.


    Например:


    public extension UIView {
        public func viewControllerForPresentation() 
            -> UIViewController? {
            var next = self.next
            while let nextResponder = next {
                if let viewController = next as? UIViewController,
                       viewController.presentedViewController == nil,
                       !viewController.isDetached {
                    return viewController
                }
            next = nextResponder.next
            }
            return nil
        }
    }
    public extension UIViewController {
        public var isDetached: Bool {
            if self.viewIfLoaded?.window?.rootViewController == self
                return false
            }
            return self.parent == nil && 
                self.presentingViewController == nil
        }
    }

    Использование иерархии представлений


    Шаблон Entity–component–system (сущность–компонент–система) – это прекрасный способ внедрения аналитики в приложение. Мой коллега реализовал такую систему и это оказалось очень удобно.


    Здесь «сущность» – это UIView, «компонент» – часть данных отслеживания, «система» – сервис отслеживания аналитики.


    Идея в том, чтобы дополнить UI-представления соответствующими данными отслеживания. Затем сервис отслеживания аналитики сканирует N раз/ секунд видимую часть иерархии представлений и записывает данные отслеживания, которые ещё не были записаны.



    При использовании такой системы от разработчика требуется только добавить данные отслеживания вроде имён экранов и элементов:


    class EditProfileViewController: UIViewController {
       override func viewDidLoad() {
           ...
           self.trackingScreen =   
               TrackingScreen(screenName:.screenNameMyProfile)
       }
    }
    class SparkUIButton: UIButton {
        public override func awakeFromNib() {
            ...
            self.trackingElement = 
                TrackingElement(elementType: .elementSparkButton)
        }
    }

    Обход иерархии представлений – это BFS, при котором игнорируются представления, которые не видны:


    let visibleElements = Class.visibleElements(inView: window)
    for view in visibleElements {
        guard let trackingElement = view.trackingElement else { 
            continue 
        }
        self.trackViewElement(view)
    }

    Очевидно, что у этой системы есть ограничения производительности, которые нельзя игнорировать. Избежать перегрузки основного потока выполнения можно разными способами:


    1. Не слишком часто сканировать иерархию представлений.
    2. Не сканировать иерархию представлений при прокрутке (используйте более подходящий режим цикла исполнения (run loop mode)).
    3. Сканируйте иерархию только тогда, когда уведомление публикуется в NSNotificationQueue с помощью NSPostWhenIdle.

    P. S.


    Надеюсь, мне удалось показать, как можно «ужиться» с UIKit, и вы нашли что-то полезное для своей повседневной работы. Или по крайней мере получили пищу для размышлений.

    • +38
    • 8,5k
    • 5
    Badoo 257,51
    Big Dating
    Поделиться публикацией
    Похожие публикации
    Комментарии 5
    • 0

      А можно для тех, у кого не очень большой опыт в разработке под iOS, пояснить – какие именно проблемы с UIKit?
      Делал пару приложений и всё казалось логичным и удобным, особенно если есть возможность на Swift писать и Storyboard юзать. Но и с Obj-c и созданием элементов в коде тоже особо проблем не замечал...

      • 0
        Если для вас все логично и удобно то наверно вы не пытались покрывать код тестами. Иначе вы зразу бы столкнулись с тем что непонятно как организовывать передачу даних между экранами, кто на кого должен держать сильные ссылки, как обеспечить изолированость слоев (привет clean architecture) и собсвенно независимость экранов друг от дружки.
        • 0

          Тесты я писал, но не для UI, тут я согласен.


          Передача данных между экранами прекрасно организуется через Segue (либо я что-то не так понял, делал не для тестов, а просто чтобы передать данные, например, в экран детализации о том, какой именно элемент был нажат).


          Сильные ссылки у меня хранятся в том контроллере, который их создаёт...


          Что такое изолированность слоёв я не понял, а экраны сами собой получаются изолированными, если не пытаться сделать что-то странное.

        • 0
          Возникает большой соблазн превратить UIContoller в анти-паттерн «god class». Как вам правильно заметили выше, это сильно усложняет покрытие кода тестами, и ухудшает такой важный для последующей поддержки кода параметр, как связность (coupling)

          И дело не в передаче данных между экранами, а в том, что постепенно на UIController навешиваются обязанности заниматься чем угодно, что является грубым нарушением single responsibility principle.

          Что такое изолированность слоёв я не понял


          Я бы посоветовал поискать материалы на тему «луковой» архитектуры (onion architecture). Она хоть и придумывалась изначально для бэкенда, но даёт общее представление о том, какие бывают слои, и как их правильно изолировать.
          • 0

            Так god class практически с любой архитектурой можно накрутить, если захотеть. Мне показалось, что всё наоборот – подход Apple провоцирует деление кода на небольшие классы. Но – ещё раз – у меня мало опыта, возможно это справедливо только для небольших приложений, а в больших и сложных всё сложнее.

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

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