Как подружиться с UIKit
Привет, Хабр! Меня зовут Богдан, в Badoo я работаю в мобильной команде iOS-разработчиком. Мы достаточно редко рассказываем что-либо о нашей мобильной разработке, хотя статьи — один из лучших способов документировать хорошие практики. Эта статья статья расскажет о нескольких полезных подходах которые мы используем в нашей работе.
Уже на протяжении нескольких лет iOS-сообщество сражается с UIKit. Кто-то придумывает сложные способы «погребения» внутренностей UIKit под слоями абстракций в своих выдуманных архитектурах, другие команды переписывают его, теша своё эго, но оставляя за собой дикое количество кода, который нужно поддерживать.
Заставьте UIKit работать на себя
Я ленив, поэтому стараюсь писать только тот код, который необходим. Я хочу писать код, соответствующий требованиям продукта и принятым в команде стандартам качества, но свожу к минимуму объём кода для поддержки инфраструктуры и стандартных кусков архитектурных шаблонов. Поэтому я верю, что вместо борьбы с UIKit мы должны принять его и использовать как можно шире.
Выбор архитектуры, подходящей для UIKit
Любую проблему можно решить, добавив ещё один уровень абстракции. Поэтому многие выбирают VIPER — в нём много уровней/ сущностей, которые можно использовать в работе. Писать приложение в VIPER не сложно — гораздо сложнее написать обладающее теми же достоинствами MVC-приложение с поддержкой меньшего объёма шаблонного кода.
Если начинать проект с нуля, то можно выбрать архитектурный шаблон и всё делать «правильно» с самого начала. Но в большинстве случаев такая роскошь нам недоступна — приходится работать с уже имеющейся кодовой базой.
Проведём мысленный эксперимент.
Вы присоединяетесь к команде, которая наработала большую кодовую базу. Какой подход вы надеетесь в ней увидеть? Чистый MVC? Какой-нибудь MVVM/ MVP с flow-контроллерами? Может быть, VIPER-подход или подход на основе Redux в каком-нибудь FRP-фреймворке? Лично я рассчитываю увидеть простейший и работающий подход. Более того, я хочу оставить после себя такой код, который кто угодно сможет читать и исправлять.
Короче, давайте посмотрим, как можно что-то делать на основе контроллеров представлений, а не пытаться их заменять или прятать.
Допустим, у вас есть набор экранов, каждый из которых представлен одним контроллером. Эти контроллеры представлений извлекают из интернета какие-то данные и выводят на экран. С точки зрения продукта всё работает идеально, но вы понятия не имеете, как тестировать код контроллеров, а попытки переиспользования заканчиваются копипастингом, из-за чего контроллеры представлений увеличиваются в размерах.
Очевидно, что нужно начать разделять код. Но как сделать это без лишних хлопот? Если вытащить код, извлекающий данные, в отдельный объект, то контроллер будет только выводить информацию на экран. Так и сделаем:
Теперь всё выглядит очень похоже на MVVM, поэтому будем пользоваться его терминологией. Итак, у нас есть представление и модель представления. Эту модель мы легко можем протестировать. Давайте теперь перенесём в сервисы повторяющиеся задачи вроде работы с сетью и хранения данных.
В результате:
- Вы сможете переиспользовать свой код.
- Получите источник истины, не привязанный к уровню пользовательского интерфейса.
Какое отношение всё это имеет к UIKit? Позвольте объяснить.
Модель представления сохраняется контроллером представления, и её вообще не интересует, существует ли контроллер. Так что если мы удалим из памяти контроллер, то и соответствующая модель тоже будет удалена.
С другой стороны, если контроллер сохраняется другим объектом (например, презентером) в MVP, то, если по какой-то причине контроллер будет выгружен, связь между ним и презентером нарушится. И если вы думаете, что трудно случайно выгрузить не тот контроллер, то внимательно почитайте описание UIViewController.dismiss(animated:completion:)
.
Так что я считаю, что безопаснее всего будет признать контроллер представления королём, и, следовательно, объекты, не относящиеся к UI, разделить на две категории:
- Объекты с жизненным циклом, равным циклу UI-элементов (например, модель представления).
- Объекты с жизненным циклом, равным циклу приложения (например, сервис).
Использование жизненного цикла контроллера представления
Почему так велик соблазн засунуть весь код в контроллер представления? Да потому что в контроллере у нас есть доступ ко всем данным и текущему состоянию представления. Если в модели или презентере нужно иметь доступ к жизненному циклу представления, то придётся передавать его вручную, и это нормально, но придётся писать больше кода.
Но есть и другое решение. Поскольку контроллеры представлений способны работать друг с другом, Соруш Ханлоу предложил воспользоваться этим для распределения работы между маленькими контроллерами представлений.
Можно пойти ещё дальше и применить универсальный способ подключения к жизненному циклу контроллера представлений — 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)
}
Объясню на примере. Допустим, нам нужно определить скриншоты в котроллере представления чата, но только когда тот выведен на экран. Если вынести эту задачу в VLCBehviour, то всё становится проще простого:
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
.
Подробности реализации: здесь.
Поведение можно использовать в задачах, зависящих от VLC, например, в аналитике.
Использование цепочки ответчиков
Допустим, глубоко в иерархии представлений у вас есть кнопка, и вам нужно всего лишь сделать презентацию нового контроллера. Обычно для этого внедряют контроллер представления, из которого делается презентация. Это правильный подход. Но иногда из-за этого появляется переходная зависимость, используемая теми, кто находится не в середине, а в глубине иерархии.
Как вы уже, наверное, догадались, есть другой способ решения. Для поиска контроллера, способного презентовать другой контроллер представления, можно использовать цепочку ответчиков.
Например:
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)
}
Очевидно, что у этой системы есть ограничения производительности, которые нельзя игнорировать. Избежать перегрузки основного потока выполнения можно разными способами:
- Не слишком часто сканировать иерархию представлений.
- Не сканировать иерархию представлений при прокрутке (используйте более подходящий режим цикла исполнения (run loop mode)).
- Сканируйте иерархию только тогда, когда уведомление публикуется в
NSNotificationQueue
с помощьюNSPostWhenIdle
.
P. S.
Надеюсь, мне удалось показать, как можно «ужиться» с UIKit, и вы нашли что-то полезное для своей повседневной работы. Или по крайней мере получили пищу для размышлений.