Как iOS‑разработчикам, нам приходится очень много работать с пользовательскими интерфейсами. Понимание различных аспектов пользовательского интерфейса, таких как жизненный цикл или компоновка, имеет решающее значение для работы с UIKit и SwiftUI. Но действительно ли разработчикам так необходимо понимать механику, лежащую в основе пользовательского интерфейса, или вполне можно ограничиться поверхностным представлением о предоставляемых API? Я считаю, что понимание внутренней работы фреймворка может дать значительные преимущества, например, помочь избежать проблем с производительностью, спорадических глюков, неожиданных анимаций или ошибок в верстке. Но кроме всего этого, это попросту интересно!

Главная цель этой серии статей — предоставить вам полный обзор того, как UIKit и SwiftUI устроены под капотом. Сначала мы обсудим UIKit, а в следующей части перейдем к SwiftUI.

UIKit

Давайте рассмотрим пример, который можно встретить практически в каждом UIKit‑приложении. Допустим, в какой‑то момент мы решили добавить subview в view нашего view‑контроллера.

// Не так давно в далекой-далекой галактике жил да был view-контроллер...
override func viewDidLoad() {
     super.viewDidLoad()
     let subview = QuestionView()
     view.addSubview(subview)
     setupConstraints(for: subview)
}

Этот пример выглядит тривиально, не так ли? Но как это все работает на самом деле?

Давайте разобьем этот большой вопрос «как» на пару вопросов поменьше:

  • Что происходит в приложении перед обновлением пользовательского интерфейса в viewDidLoad?

  • Какие именно происходят изменения в пользовательском интерфейсе? Как на экране появляется новое subview?

Мы поэтапно рассмотрим рассмотрим весь этот процесс, и для начала давайте разберемся, что происходит после запуска приложения. И конечно нашим незаменимым помощником на этом пути будет отладчик Xcode. Давайте создадим пустой проект UIKit Swift в Xcode и поставим точку останова в viewDidLoad. По умолчанию вы увидите что‑то вроде этого, но вы можете развернуть стек вызовов, нажав на кнопку в правом нижнем углу Debug‑навигатора.

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

ПРИМЕЧАНИЕ: Возможно, это очевидный и известный для всех факт, но здесь мы видим, что наш код, связанный с UI, работает в главном потоке (main thread). UIKit не является потокобезопасным, вы должны работать с ним только в главном потоке.

UIApplicationMain

Давайте посмотрим на нижнюю часть стека на предыдущем скриншоте:

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

Например, если вы создадите проект Objective‑C (да, вы все еще можете это сделать) в Xcode, вы увидите что‑то вроде этого:

int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        // Здесь находится код, в рамках которого могут создаваться autorelease‑объекты.
        appDelegateClassName = NSStringFromClass([AppDelegate class]);
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

Только представьте — все, что происходит внутри вашего приложения, происходит внутри функции main. Забавно, но все ваше приложение — это не что иное, как один гигантский вызов функции main.

Но сама по себе функция main делает не так уж много. Ее основное назначение — вызов функции UIApplicationMain().

ПРИМЕЧАНИЕ: Подробнее о UIApplicationMain вы можете прочитать здесь и здесь.

UIApplicationMain, в свою очередь, делает многое для настройки нашего приложения для iOS:

  • создает общий экземпляр UIApplication и делегат приложения;

  • запускает UIApplication, который запускает создание сцены, делегата сцены и т.д;

  • [САМОЕ ВАЖНОЕ ДЛЯ НАС СЕЙЧАС] Она запускает главный цикл событий (main event loop), вызывая Runloop.run().

Если вы до сих пор не слышали о runloop, то следующая глава как раз для вас.

Runloop

Если говорить простым языком, то Runloop (цикл выполнения) — это бесконечный цикл событий, происходящих в приложении.

Runloop — это причина, по которой мы фактически зависаем между вызовом main() и UIApplicationMain() и return этих функций.

На каждой итерации runloop выполняет три основные задачи:

  • Обрабатывает события;

  • Уведомляет наблюдателей об изменении состояния runloop;

  • Погружает поток в сон, если его нечем занять.

ПРИМЕЧАНИЕ 1: Подобно многим другим вещам в высокоуровневых фреймворках, таких как UIKit или Foundation, runloop (он же NSRunloop) — это обертка для CFRunloop (CF означает Core Foundation) на стороне Foundation, созданная для сокрытия некоторых интерфейсов CFRunloop, таких как добавление источников событий, наблюдателей или нового режима runloop.

ПРИМЕЧАНИЕ 2: Подробнее о шагах итерации runloop вы можете прочитать здесь и здесь. При необходимости вы сможете найти подробные шаги итераций непосредственно в коде здесь, как только Core Foundation станет открытым исходным кодом.

ПРИМЕЧАНИЕ 3: Runloop связан с потоком, причем с каждым потоком связан только один runloop. Он автоматически запускается в главном потоке при запуске приложения, но для других потоков вы должны запускать его явно.

ПРИМЕЧАНИЕ 4: CFRunloop является потокобезопасным, а runloop (NSRunloop) — нет.

ПРИМЕЧАНИЕ 5: Вам может быть интересно, сколько итераций цикла выполнения происходит в секунду. Однако это некорректный вопрос. Частота итераций runloop не является фиксированной и не привязана к частоте обновления экрана. Runloop и обновление экрана работают независимо друг от друга. (Обычно частота итераций runloop превосходит потенциальную частоту обновления экрана, но только если главный поток не заблокирован).

Runloop-события

Runloop обрабатывает несколько различных типов событий:

  • Блоки главной очереди GCD

  • Таймеры

  • Источники (0 и 1)

  • Наблюдатели

Если вы поставите точку останова практически на любой строке кода в вашем приложении, вы, скорее всего, найдете внизу стека вызовов одну из функций, перечисленных ниже. Эти функции не служат никакой другой цели, кроме помощи в отладке в пределах стек‑трейса. RunLoop гарантирует, что весь код главного потока будет выполняться через одну из этих функций. Они также позволяют понять, какое именно событие, обработанное циклом runloop, вызвало выполнение кода, на котором вы установили точку останова.

static void __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__();

Блоки главной очереди (main queue) и таймеры обычно совершенно понятны разработчикам, которые хоть раз создавали таймер или использовали главную очередь GCD. Напротив, источники (sources) и наблюдатели (observers) таят в себе больше загадок относительно их использования и назначения. Давайте прольем на них свет.

Runloop-источники

Говоря простым языком, источник runloop — это способ сообщить runloop, что в какой‑то другой точке приложения или даже в другом процессе произошло какое‑то событие, и runloop должен обработать это событие, вызвав соответствующий коллбэк. Источники позволяют runloop отслеживать внешние события и реагировать на них соответствующим образом.

Как вы, наверное, заметили, есть два типа источников — source0 и source1. Не совсем говорящее за себя название с точки зрения назначения, верно? Они названы так потому, что поле version в их контексте принимает значение либо 0, либо 1.

Но служат они разным целям:

  • Источники версии 0 предназначены для обмена данными внутри приложения;

  • Источники версии 1 предназначены для межпроцессного взаимодействия (IPC).

Источники версии 0 (внутренняя связь в приложении)

Этот вид источников управляется самим приложением (своего рода механизм обмена сообщениями внутри приложения). Когда источник готов сработать, какая‑нибудь другая часть приложения, например, код в отдельном потоке, должна вызвать функцию CFRunLoopSourceSignal, чтобы сообщить runloop, что источник готов к срабатыванию и должен быть вызван соответствующий коллбэк. Runloop должен быть пробужден (с помощью CFRunLoopWakeUp()), чтобы источник был обработан.

Среди примеров источников версии 0 можно выделить:

  • performSelector(onMainThread:with:waitUntilDone:) из NSObject.

  • SFSocket.

  • Получение событий касания в UIKit. Если вы поставите точку останова в селекторе касания UIButton, то заметите, что UIEvent, вызвавший это действие, был получен с использованием источника версии 0.

Источники версии 1 (межпроцессное взаимодействие — IPC)

Источниками версии 1 управляет ядро. Эти источники автоматически сигнализируются ядром при поступлении сообщения на Mach‑порт источника. Mach‑порты — это прежде всего механизм межпроцессного взаимодействия (IPC) в операционных системах macOS и iOS. Они позволяют процессам (а наше приложение — один из них) отправлять сообщения и данные друг другу.

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

let displayLink = CADisplayLink(target: self, selector: #selector(update))
displayLink.add(to: .current, forMode: .common)

@objc func update() {
    print("Updating!")
}
  • CFMachPort — еще один пример источника версии 1.

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

ПРИМЕЧАНИЕ: При необходимости вы также можете создать пользовательские источники версий 0 и 1.

Runloop-наблюдатели 

Из обсуждения итераций runloop мы уже знаем, что у runloop есть наблюдатели, которых он должен уведомлять об изменениях своего состояния.

API CFRunLoopObserver позволяет наблюдать за поведением CFRunLoop и получать уведомления о его активности: когда он обрабатывает события, когда уходит в сон и т. д.

Вы можете связать runloop‑наблюдателей со следующими CFRunLoopActivity‑событиями в вашем собственном runloop:

  • Вход в runloop. (kCFRunLoopEntry)

  • Когда runloop собирается обработать таймер. (kCFRunLoopBeforeTimers)

  • Когда runloop собирается обработать источник. (kCFRunLoopBeforeSources)

  • Когда runloop собирается перейти в спящий режим. (kCFRunLoopBeforeWaiting)

  • Когда runloop пробудился, но еще не обработал событие, которое его разбудило. (kCFRunLoopAfterWaiting)

  • Выход из runloop. (kCFRunLoopExit)

Вероятно, вам никогда в жизни не понадобится создавать собственных runloop‑наблюдателей в своем приложении, но знание о их существовании и назначении очень важно для понимания работы UIKit. Но прежде чем мы попытаемся разобраться в связи между runloop‑наблюдателями и UIKit, нам нужно понять, что UIKit скрывает под капотом.

Внутри UIKit заключен более низко‑уровневый фреймворк под названием Core Animation, отвечающий за многочисленные аспекты пользовательского интерфейса, анимации и рендеринга. Давайте рассмотрим CoreAnimation более подробно.

CoreAnimation

UIKit — это хорошо известный фреймворк от Apple. Мы ежедневно используем его для создания пользовательского интерфейса в наших приложениях. UIKit содержит всевозможные компоненты. Но, как ни странно, под капотом UIKit делегирует значительную часть задач, связанных с пользовательским интерфейсом, таких как анимация, верстка и рендеринг, фреймворку Core Animation.

За каждым UIView скрывается CALayer. Когда создается UIView, он создает CALayer и устанавливает себя в качестве CALayerDelegate, который отвечает за ряд таких важных вещей, как предоставление содержимого слоя, расположение вложенных представлений и анимирование изменений свойств. Например, UIView делает так, что свойства UIView не анимируются по умолчанию (в отличие от CALayer), т.к. UIView отключает анимацию по умолчанию, будучи CALayerDelegate для своего слоя. Свойства и методы UIView, которые каким‑то образом изменяют внешний вид представления, на самом деле являются просто обертками для свойствам CALayer. Например, frame, bounds, backgroundColor и isHidden — это фактически проксированные свойства CALayer.

ПРИМЕЧАНИЕ: Среди вещей, которые действительно обрабатываются UIKit, можно выделить взаимодействие с пользователем (например, прикосновения и жесты) и доступность.

Совокупность представлений на экране вместе образует иерархию представлений или дерево представлений.

Аналогично, CALayers формируют иерархию слоев параллельно с представлениями, поэтому за деревом представлений существует дерево слоев. На самом деле Core Animation работает не с одним деревом слоев, а с тремя:

  • Дерево слоев модели;

  • Дерево презентационных слоев;

  • Дерево слоев рендеринга (недоступно из приложения).

Вы можете получить доступ к дереву модели и узлам представления с помощью методов model() и presentation() CALayer.

Каждое дерево слоев играет свою собственную роль в отображении содержимого вашего приложения на экране:

  • Дерево слоев модели (или просто «дерево слоев») — это дерево, с которым ваше приложение взаимодействует чаще всего. Объекты в этом дереве — это объекты модели, которые хранят целевые значения (конечное состояние) для любых анимаций. Всякий раз, когда вы изменяете свойство слоя, вы используете один из этих объектов.

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

  • Дерево рендеринга выполняет фактическую анимацию и является приватным для Core Animation (недоступным из приложения). Это самое близкое дерево слоев к реальному состоянию экрана.

ПОМНИТЕ: Презентационный слой (presentation layer) — это не оригинальный слой, а лишь временный «призрачный» слой. Он представляет собой приближенное состояние слоя дерева рендеринга. Может быть полезно использовать его текущие значения для создания новых анимаций или для взаимодействия со слоями во время анимации.

ПРИМЕЧАНИЕ: Изначальная структура каждого дерева в точности соответствует структуре иерархии представления (view hierarchy). Однако при необходимости приложение может добавить в иерархию слоев дополнительные объекты (слои, не связанные с представлением).

Хорошо, теперь мы знаем, что за деревом представления скрывается еще 3 дерева слоев. Если дерево рендеринга является самым близким к актуальному состоянию экрана, то как изменения дерева слоев модели фактически применяются к дереву рендеринга?

Важно понимать, что изменения пользовательского интерфейса не вызывают сиюминутное обновление экрана каждый раз, когда что‑то меняется. CoreAnimation группирует изменения в дереве слоев в CATransaction — механизм для группировки нескольких операций с деревом слоев в атомарные обновления дерева рендеринга.

CATransaction

Когда мы хотим изменить слой и свойств, требующих изменений, несколько, применение этих изменений по одному будет неэффективным по очевидным соображениям оптимизации. Даже если мы изменяем свойство несколько раз подряд, Core Animation объединяет эти обновления в одну транзакцию, отправляемую в дерево рендеринга.

Но когда и где именно происходит эта CATransaction? Помните, я говорил о связи между UIKit и runloop‑наблюдателями? Так вот, на самом деле это связь между runloop‑наблюдателями и CoreAnimation. Дело в том, что Core Animation наблюдает за событием CFRunLoopActivity.kCFRunLoopBeforeWaiting. Когда это событие срабатывает в конце каждой итерации runloop, если в дереве слоев модели произошли какие‑либо изменения (например, изменились границы или добавились анимации), CoreAnimation группирует набор изменений в одну CATransaction (называемую неявной транзакцией) и фиксирует ее, чтобы применить к дереву рендера все сразу. Неявная транзакция создается только в том случае, если были совершены изменения в дереве слоев модели.

Если поставить точку останова в методе layoutSubviews() вашего view, вы увидите, что он вызывается из функции‑коллбека runloop‑наблюдателя, обнаружив соответствующую функцию в стеке вызовов: __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__();

Давайте обновим нашу схему, добавив несколько новых шагов:

CATransaction на примерах

Чтобы лучше понять CATransaction, давайте рассмотрим несколько примеров, вдохновленных этим замечательным постом. Представим, что у нас есть кнопка, цвет фона которой изначально установлен на .red:

button.backgroundColor = .red
...
@objc func buttonTapped() {
    button.backgroundColor = .yellow // неявная транзакция начинается здесь
    sleep(3) // блокирует главный поток на 3 секунды
    button.backgroundColor = .green
}

Когда мы нажимаем на кнопку, мы:

  • изменим backgroundColor на .yellow

  • блокируем главный поток на 3 секунды с помощью функции sleep()

  • изменим backgroundColor на .green

Как вы думаете, как будет работать этот код?

Не совсем очевидно, почему мы не видим желтый цвет, верно? Это произошло потому, что после установки желтого цвета и изменения дерева слоев модели мы заблокировали главный поток, приостановив итерацию runloop. Таким образом, неявная CATransaction не была зафиксирована до истечения 3-секундного интервала. Однако сразу после этих 3 секунд мы изменили цвет на .green. Это «зеленое» изменение фиксируется вместе с неявной CATransaction в конце цикла runloop. Именно поэтому желтый цвет так и не отобразился.

Явные транзакции

Мы уже упоминали, что CoreAnimation отправляет неявную транзакцию на каждой итерации runloop, но транзакция может быть и явной, если вы сами создадите ее в своем коде следующим образом:

CATransaction.begin()
button.backgroundColor = .yellow
// здесь можно изменить еще что‑нибудь, если вам так угодно
CATransaction.commit()

Все изменения, внесенные в свойства слоя между CATransaction.begin() и CATransaction.commit(), группируются вместе и фиксируются атомарно.

ПРИМЕЧАНИЕ 1: Явные и неявные транзакции представляют из себя одну и ту же CATransaction. Единственное различие заключается в том, начинаем ли мы и фиксируем ее вручную или это делается автоматически CoreAnimation при любом изменении слоя.

ПРИМЕЧАНИЕ 2: Теперь, когда вы знаете о методах begin и commit CATransaction, мы можем рассмотреть логику неявных транзакций более подробно. Неявная транзакция запускается CoreAnimation (посредством begin), когда вы впервые изменяете дерево слоев во время текущей итерации runloop. Затем все последующие изменения во время итерации runloop группируются внутри этой транзакции. Наконец, в конце текущей итерации цикла выполнения, когда срабатывает kCFRunLoopBeforeWaiting, неявная транзакция фиксируется (посредством commit).

Давайте посмотрим, как изменится результат нашего предыдущего примера при использовании явной транзакции:

button.backgroundColor = .red
...
@objc func buttonTapped() {
    CATransaction.begin()
    button.backgroundColor = .yellow
    CATransaction.commit()
    sleep(3) // блокирует главный поток на 3 секунды
    button.backgroundColor = .green // неявная транзакция начинается здесь
}

Теперь мы видим желтый цвет. Круто, но почему?

Мы зафиксировали изменение на «желтое состояние» с помощью явной транзакции прямо перед блокировкой главного потока, поэтому оно было изменено мгновенно сразу после касания. Затем через 3 секунды мы делаем изменение на «зеленое состояние», которое происходит с помощью неявной транзакции в конце цикла runloop.

ПРИМЕЧАНИЕ 3: Вы, наверное, сейчас задаетесь вопросом, зачем нужна явная транзакция. Думаю, ее актуальность была куда выше давным‑давно, когда API анимации UIKit не был так развит, как сейчас. Например, animate(withDuration:) стала доступна только с iOS 4. До этого явная транзакция была хорошим способом зафиксировать анимацию и легко изменить длительность, функцию синхронизации или другие параметры анимации. Но на самом деле явные транзакции и сегодня могут быть полезны в редких случаях совместной анимации свойств слоя и представления.

Вложенные транзакции

Давайте еще раз обновим наш пример. Теперь мы поместим нашу явную транзакцию внутрь другой явной транзакции.

button.backgroundColor = .red
...
@objc func buttonTapped() {
    CATransaction.begin()
    CATransaction.begin()
    button.backgroundColor = .yellow
    CATransaction.commit()
    sleep(3) // блокирует главный поток на 3 секунды
    button.backgroundColor = .green
    CATransaction.commit()
}

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

Это происходит потому, что транзакции могут быть вложенными. Когда одна транзакция вложена в другую, фиксация «внутренней» (дочерней) транзакции вступает в силу только после фиксации родительской. Чтобы лучше понять, почему вложенные транзакции работают именно таким образом, вспомните, что транзакции управляются стеком транзакций (LIFO — last in, first out). Когда вы начинаете транзакцию, она добавляется в стек транзакций, а после фиксации удаляется. Если вы помещаете несколько транзакций в стек транзакций, они становятся вложенными, и эти транзакции будут фиксироваться по мере его опустошения.

ПРИМЕЧАНИЕ: Неявная транзакция не может быть вложена в явную, если неявная транзакция начинается, когда вы изменяете дерево слоев вне явной транзакции. Это также означает, что неявная транзакция может быть добавлена в стек транзакций только тогда, когда стек пуст.

Неявная транзакция

Последний пример показывает, как работает неявная транзакция.

button.backgroundColor = .red
...
@objc func buttonTapped() {
    button.backgroundColor = .white // неявная транзакция начинается здесь
    CATransaction.begin()
    button.backgroundColor = .yellow
    CATransaction.commit()
    sleep(3) // блокирует главный поток на 3 секунды
    button.backgroundColor = .green
}

Выглядит очень похоже на пример с явной транзакцией, когда желтое состояние отображалось. За исключением одной детали — мы изменили дерево слоев модели, присвоив сначала button.backgroundColor =.white, что вызвало начало неявной транзакции, поэтому наша явная транзакция была вложена в неявную и не отправлялась в дерево рендеринга до тех пор, пока не была зафиксирована неявная. Вот почему в данном случае мы увидели только зеленое состояние через 3 секунды.

ПРИМЕЧАНИЕ 1: Сколько транзакций создается за цикл runloop?
0 — если ничего не изменилось;
1 — если дерево слоев изменилось за пределами явной транзакции ИЛИ была создана 1 явная транзакция;
N+1 — если дерево слоев изменилось за пределами явной транзакции И было создано N явных транзакций.

ПРИМЕЧАНИЕ 2: Вы можете заметить, что у транзакции также есть метод flush. Он фиксирует все существующие неявные транзакции. Flush обычно вызывается автоматически в конце текущей итерации runloop, независимо от его режима. Вы должны стараться избегать явного вызова flush. Позволяя flush выполняться во время цикла runloop естественным образом, вы повысите производительность приложения, сохраните атомарность обновлений экрана, а транзакции и анимации, работающие от транзакции к транзакции, продолжат функционировать должным образом.

Следующее, что нам нужно понять, — это этапы транзакции.

Этапы транзакции

Метод фиксации CATransaction состоит из четырех этапов:

  1. Этап компоновки (Layout)

  2. Этап отображения (Display)

  3. Подготовительный этап (Prepare)

  4. Этап фиксации (Commit)

Все они происходят внутри метода CA::Transaction::commit(). Мы можем увидеть этот метод в стеке вызовов, если поставим точку останова в методе layoutSubviews() нашего view.

Давайте быстро пройдемся по всем этим этапам.

Этапы компоновки и отображения

На скриншоте выше вы можете наблюдать метод CA::Layer::layout_and_display_if_need(CA::Transaction*), отвечающий за выполнение двух этапов: компоновки и отображения. Интуитивно понятно, что этап компоновки обрабатывает обновление компоновки дерева слоев до фиксации транзакции. Аналогично, этап отображения отвечает за обновление отображаемого содержимого в слоях. Core Animation инициирует эти этапы до фиксации транзакции, чтобы убедиться, что состояние дерева слоев актуально и может быть точно применено к дереву рендеринга.

Обе эти этапа проходят практически одинаково. Core Animation обходит дерево слоев, используя поиск вглубь (DFS), начиная с верхнего superview (UIWindow) и заканчивая нижними листовыми слоями. Если соответствующий флаг слоя (возвращаемый функциями needsLayout() / needsDisplay()) активирован, то соответственно вызываются методы layoutSublayers() / display(). Эти флаги можно активировать неявно, изменяя свойства слоя (например, границы), или явно, вызывая setNeedsLayout() / setNeedsDisplay().

ПРИМЕЧАНИЕ: Вам не нужно вызывать напрямую layoutSublayers / display.

Этап компоновки

Методы компоновки в UIView, часто используемые в UIKit, такие как layoutIfNeeded, setNeedsLayout и layoutSubviews, по сути, служат обертками для соответствующих методов в CALayer. Хотя UIKit и включает дополнительную логику для обеспечения правильного функционирования ограничений системы Auto Layout, он напрямую зависит от фундаментального процесса компоновки CoreAnimation.

В рамках этапа компоновки Core Animation вызывает метод layoutSublayers() CALayer сверху вниз для каждого слоя в текущем дереве слоев, если они были помечены как невалидные (invalidated) в результате неявного или явного изменения флага needsLayout. В конечном итоге, по завершении выполнения этой цепочки каждый слой приобретает свое окончательное положение и размер.

Если все дерево слоев помечено как невалидное с помощью setNeedsLayout(), то во время обхода на этапе компоновки для каждого слоя будет вызвана функция layoutSublayers().

Чаще всего дерево помечается как невалидное только частично. В этом случае дерево слоев также будет полностью пройдено, но layoutSublayers() будет вызван только для невалидных слоев (с активированным флагом needsLayout).

Как мы уже говорили выше, UIView устанавливает себя в качестве CALayerDelegate своего базового слоя.

Реализация layoutSublayers по умолчанию вызывает метод layoutSublayers(of:) объекта‑делегата слоя, если он реализован.

UIView реализует метод layoutSublayers(of:) и вызывает метод UIView layoutSubviews(), с которым вы, вероятно, уже знакомы и часто используете в верстке ваших view.

extension UIView: CALayerDelegate {
    func layoutSublayers(of layer: CALayer) {
        layoutSubviews()
    }
}

layoutSubviews() призвана помочь UIKit в поддержке Auto Layout и добавляет один дополнительный шаг к фазе компоновки — Constraints pass (проход по ограничениям). Это шаг, который UIKit выполняет для разрешения ограничений. Он проходит по дереву представлений снизу вверх от листовых subview к корневому view и вызывает метод updateConstraints() view, если needsUpdateConstraints() возвращает true. Обычно он становится true автоматически при изменении ограничений или при явном вызове setNeedsUpdateConstraints().

ПРИМЕЧАНИЕ 1: layoutSubviews() выполняет проход по ограничениям, если это необходимо, при каждом вызове.

ПРИМЕЧАНИЕ 2: Одно из важных отличий прохода по ограничениям от логики обхода layoutSublayers заключается в том, что обход происходит в противоположном направлении — снизу вверх, от листьев к superview.

Этап отображения

На этапе отображения Core Animation вызывает метод display() каждого слоя в текущем дереве слоев, продвигаясь сверху вниз (от верхнего слоя к подслоям). Этот метод отвечает за обновление содержимого слоя. К концу этой фазы будет актуализировано содержимое каждого слоя. У подклассов есть возможность переопределить метод display(), используя его для непосредственной установки свойства содержимого слоя.

Механизм, задействующий setNeedsDisplay и needsDisplay, такой же, как и на этапе компоновки.

Давайте углубимся в логику отображения слоя:

  • Реализация отображения слоя по умолчанию вызывает метод display(_ layer:) объекта‑делегата слоя, если он реализован.

  • В противном случае этот метод создает backing store (пиксельный битмап, как изображение) и вызывает функцию CALayer's draw(in ctx: CGContext).

  • draw(in ctx: CGContext) по умолчанию ничего не делает, кроме вызова функции CALayerDelegate's draw(layer: CALayer, in ctx: CGContext), если она реализована для заполнения backing store содержимым с использованием Core Graphics для отрисовки через CGContext.

  • UIView как CALayerDelegate своего вспомогательного слоя не реализует display(_ layer:). Но он реализует draw(layer, in ctx: CGContext), где вызывает метод UIView draw(rect:).

  • Вызывается реализация по умолчанию, если функция UIView draw(rect:) ничего не делает. Но это можно переопределить в подклассах для реализации пользовательской отрисовки с помощью Core Graphics.

Понять все это за один присест может быть довольно непросто. Но самый важный аспект заключается в том, что, по сути, UIKit дает нам возможность выполнять пользовательскую отрисовку с помощью Core Graphics посредством переопределения метода draw(rect:). Давайте построим на следующую диаграмму, чтобы хоть как‑то структурировать все происходящее:

Даже если вы никогда не слышали об этапе отображения, вы наверняка использовали draw(rect:) для отрисовки пользовательской графики в пользовательских компонентах ваших приложений. Вы можете рисовать линии и фигуры в контексте Core Graphics. Например, вы можете нарисовать 100 эллипсов, чтобы получить вот такой тор:

override func draw(_ rect: CGRect) {
    guard let context = UIGraphicsGetCurrentContext() else { return }

    context.setLineWidth(1.0)
    context.setStrokeColor(UIColor.orange.cgColor)

    context.translateBy(x: frame.size.width / 2.0,
                        y: frame.size.height / 2.0)

    let numberOfEllipses = 100
    let amount = Double.pi * 2 / Double(numberOfEllipses)

    for _ in 1...numberOfEllipses {
        context.rotate(by: CGFloat(amount))
        let rect = CGRect(x: -(frame.size.width * 0.25),
                          y: -(frame.size.height / 2.0),
                          width: frame.size.width * 0.5,
                          height: frame.size.height)
        context.addEllipse(in: rect)
    }
    context.strokePath()
}

Но рисование пользовательской графики — не единственный случай использования. Например, UILabel использует этап отображения и draw(rect:) для отрисовки текста (там вызывается drawText(in rect: CGRect)). Вы можете переопределить метод draw(*rect:) для label и увидите, что никакой текст не был отображен.

class CustomLabel: UILabel {
    override func draw(_ rect: CGRect) {
        // super.draw(rect)
    }
}

ПРИМЕЧАНИЕ: Помните, что отрисовка Core Graphics выполняется центральным процессором. CoreGraphics рисует в backing store CALayer — это растровые данные, которые впоследствии будут переданы на GPU.

Теперь мы знаем, что фиксация транзакции запускает этапы компоновки и отрисовки. Это как раз те фазы, на которых обычно происходит обновление пользовательского интерфейса.

У CATransactions на самом деле есть еще 2 фазы. Обычно разработчики не взаимодействуют с ними, но они очень важны из‑за того, что они делают.

Подготовительная фаза — выполняет дополнительную работу с Core Animation, такую как декодирование (JPG, PNG) и преобразование изображений (если формат не поддерживается GPU, например, индексное растровое изображение).

Фаза фиксации — упаковывает дерево слоев и отправляет транзакцию на Render server для применения к дереву рендеринга.

Вы наверняка заметили здесь новое ключевое слово — Render server. До этого момента мы не обсуждали, где именно находится дерево рендеринга. Удивительно, но не внутри приложения. Дерево рендеринга управляется отдельным процессом под названием Render server, который выдает вызовы отрисовки для GPU с помощью Metal. Этот процесс выполняет большую часть тяжелой работы фреймворка Core Animation.

Render server

После фиксации транзакции она будет отправлена по протоколу IPC (Inter‑Process Communication) процессу Render server.

ПРИМЕЧАНИЕ: Render server для простоты можно рассматривать как бэкенд Core Animation.

Возможно, при отладке приложения вы задавались вопросом, почему индикатор выполнения продолжает вращаться, хоть приложение и приостановлено. Теперь вы можете догадаться, почему — он вращается в другом процессе — Render Server. То же самое происходит и с любой другой анимацией. Если вы зафиксируете транзакцию с добавленной анимацией до блокировки главного потока, то увидите, как она продолжает работать.

Получив транзакцию, render server декодирует ее, чтобы применить обновления к дереву рендеринга.

Для каждой итерации обновления дисплея render server необходимо предоставить следующий кадр для GPU, поэтому он делает следующее:

  • Вычисляет промежуточные значения для свойств слоев дерева рендеринга, если идет связанная анимация.

  • Использует команды Metal для выполнения рендеринга дерева слоев.

    • ПРИМЕЧАНИЕ: Как работает Metal — это отдельная большая тема для следующего поста, а пока объясним просто: у Metal есть очередь команд, и он помещает туда команды для рендеринга на GPU.

  • Результатом рендеринга на GPU является пиксельное растровое изображение (bitmap), которое отображается на экране.

Таким образом, мы можем обновить нашу схему до ее окончательного состояния:

Подытожим

  • UIApplicationMain:

    • main() — это «точка входа» приложения, вызываемая средой выполнения операционной системы при запуске приложения. Сама по себе функция main делает не так уж много. Ее основное назначение — вызов функции UIApplicationMain().

    • UIApplicationMain() управляет настройкой приложения и запускает главный цикл событий — Runloop.

  • Runloop:

    • Бесконечный цикл управления событиями приложения.

    • Управляет обработкой событий:

      • Блоки главной очереди GCD

      • Таймеры

      • Источники (версии 0 и 1)

      • Наблюдатели.

  • CoreAnimation:

    • UIKit внутренне делегирует работу, связанную с пользовательским интерфейсом, Core Animation.

    • UIViews поддерживаются CALayers, которые занимаются рендерингом, компоновкой и анимацией.

    • CoreAnimation содержит параллельно три дерева слоев: дерево модели, дерево представления и дерево рендеринга.

    • CoreAnimation рендерит иерархию представлений нашего приложения на каждой итерации runloop, а не при каждом изменении иерархии представлений. Для этого он наблюдает за событием CFRunLoopActivity.kCFRunLoopBeforeWaiting и инициирует неявную CATransaction, если дерево слоев было изменено. В конце каждой итерации runloop CoreAnimation фиксирует эту транзакцию непосредственно перед тем, как поток переходит в сон.

  • CATransaction:

    • CATransaction — это инструмент Core Animation для объединения нескольких операций с деревом слоев в атомарные обновления дерева рендеринга.

    • Явные транзакции можно создавать вручную с помощью методов begin и commit.

    • Транзакции могут быть вложенными, но если они вложены в текущую транзакцию, изменения во вложенной транзакции фиксируются только после того, как это сделает родительская транзакция.

    • Транзакции состоят из четырех фаз: компоновка, отображение, подготовка и фиксация.

    • В конце каждой итерации runloop CoreAnimation передает неявную CATransaction через IPC (Inter Process Communication) другому процессу — Render Server.

  • Render Server:

    • Render Server управляет деревом рендеринга и отвечает за анимацию и рендеринг.

    • Render Server использует команды Metal для выполнения рендеринга дерева рендеринга. Результатом рендеринга на GPU является пиксельное растровое изображение, подготовленное для отображения на экране.

БОНУС: если вы хотите увидеть полную картину, то вот вся схема:

Еще один бонус: Лучшая книга о UIKit — это... на самом деле книга о Core Animation.

Она была опубликована еще в 2013 году, но до сих пор не потеряла своей актуальности.

Спасибо за внимание и до встречи в следующем посте о SwiftUI!


В завершение приглашаем всех iOS-разработчиков на открытый урок, посвящённый знакомству с мультиплатформой KMP.

Узнаете про актуальный стек для кросс-платформенной разработки, какие нюансы нужно знать и учитывать iOS-разработчику при миграции кода. Встреча пройдёт 19 февраля на платформе Otus. Участие бесплатное, но требуется регистрация.

Список всех открытых уроков по разработке и не только можно посмотреть в календаре.

Комментарии (2)


  1. Bardakan
    18.02.2025 16:13

    почему вы перепечатываете чужие статьи и даже не проверяете работоспособность ссылок?


    1. MaxRokatansky
      18.02.2025 16:13

      Добрый день. О каком «перепечатывании» идёт речь? Это перевод статьи. Ссылок на вспомогательные материалы и документацию действительно немало, сейчас ещё раз перепроверим их работоспособность.