Render Loop крутится — кадры мутятся.
Доброго времени суток, уважаемые читатели. Здесь я начинаю свой цикл статей о работе с графикой в iOS.
В моих планах разобраться с работой базовых механик отрисовки и углубиться к таким вещам как AVFoundation, Metal.
Но а пока хочется понять как работает из коробки отрисовка наших любимых кнопок, которые мы не устаем красить. Как достичь 60 кадров в секунду. Магические слова, что заставят возжелать наш интерфейс любого.
FPS
Render Loop
Проблемы с производительностью
Оптимизации
Что такое FPS?
Как говорит вики — это “Ка́дровая частота́, частота́ кадросме́н — количество сменяемых кадров за единицу времени в кинематографе, телевидении, компьютерной графике”
Для начала запомним информацию, что дисплеи iPhone и iPad обновляются с 60 Гц. Новый iPhone 13 может и 120 Гц. Дисплеи новейших iPad Pro с частотой 120 Гц. Apple TV может соответствовать частоте обновления телевизора или воспроизводимого фильма
Дисплей с частотой обновления 60 Гц будет обновляться 60 раз в секунду. Это постоянное число.
Приложение должно иметь возможность отображать кадры с частотой отображения. Частота 60 Гц означает 60 кадров в секунду для приложения, ~16,67 мс для рендеринга кадра.
Обратите внимание, это не обязательно означает, что приложение выполняет рендеринг 60 раз в секунду. Когда содержимое не меняется, нет необходимости его перерисовывать.
Render Loop — куда же все крутится?
Render loop — это цикл отрисовки в системе iOS.
Жизненный цикл у него такой:
Получаем событие
Создаем render tree
Отправляем на Render Server
Меняем кадр
Event
Сначала происходит какое-то событие (Тач, колбэк с сети, действие с клавиатуры, таймеры)
События вызываются из любого места в иерархии.
Допустим мы хотим изменить bounds нашей вьюхи, то Core Animation вызывает метод setNeedsLayout. Система понимает, что нужно вызвать апдейт лайут реквеста.
Commit Transaction
В основе всех обновлений слоев и анимаций лежит Core Animation. Этот фреймворк работает не только внутри самого приложения, но и между другими приложениями. Когда мы переключаемся из приложения в приложение.
Сама анимация происходит в другом этапе, за рамками нашего приложения. Этот этап называется render server.
На этапе же commit transaction происходит подготовка layer tree и его неявная транзакция для обновления.
Транзакции — это механизм, который Core Animation использует для обновления свойств. Любые свойства наших слоев не изменяются мгновенно, а вместо этого подготавливаются в транзакцию и ждут своего коммита
Когда мы хотим выполнить анимацию, то сначала проходим 4 этапа:
Layout — На этом этапе мы подготавливаем вьюхи, их свойства (frame, background color, border и другие)
Как только лайаут расчитался, система вызывает метод setNeedsDisplay.Display — обновляет CGContext. Это рисование может включать вызов функций drawRect, drawLayer каждых subviews
Prepare — На этом этапе Core Animation готовится отправить данные анимации на render server. Здесь происходит подготовка и декодинг картинок
Commit — Это заключительный этап, когда Core Animation упаковывает слои и свойства анимации и отправляет их через Interprocess communication (IPC) на render server для отображения.
Core Animation объединяет изменения в транзакцию, кодирует их и фиксирует на render server.
Окей, мы подготовили наш render tree и отдали их следующему этапу.
Render Server
Теперь мы на рендер сервере — это отдельный процесс, который вызывает методы отрисовки для GPU с использованием OpenGL или Metal. Он отвечает за рендер наших слоев в изображение
Render Prepare
На этом этапе происходит пробег про layer tree и мы подготавливаем layer pipeline для выполнения его на GPU. Рекурсивно пробегаясь от родительского слоя к дочернему.
Render Execute
После layers pipeline передан на отрисовку. Где каждый слой будет собран в финальную текстуру. До этого момента все вычисления происходили в CPU, но дальше работа перешла в руки GPU.
Некоторые слои будут отображаться дольше, чем обычные. И это чаще всего то самое бутылочное горлышко для оптимизаций.
Как только GPU выполняет отрисовку изображений, то это готово к отображению для следующего VSYNC
VSYNC — это дедлайн для каждой фазы нашего рендер лупа. Каждый VSYNC — меняет нам следующий кадр
Для достижения лучшей оптимизации каждый фрейм распараллеривается. Пока CPU читает кадр номер N, в это время GPU рендерит предыдущий кадр N-1
Мы определили что такое render loop. Теперь определим что же влияет на лаги и просадки кадров.
Проблемы с производительностью
Если мы перегрузим наше приложение или будем плохо следить за ресурсами, то можем столкнуться с такими проблемами:
Потеря кадров
Быстрая трата батареи
Долгая отзывчивость
Поэтому стоит ознакомиться с советами, которые помогут решить проблему.
Как мы помним для render loop’a у нас есть операции на CPU и на GPU.
Мы уже знаем, что здесь работа устроена так, что CPU и GPU работают параллельно друг с другом. Пока CPU читает кадр номер N, в это время GPU рендерит предыдущий кадр N-1, и так далее
Перейдем к основным проблемам и узким горлышкам, которые могут повлиять на производительность
На main thread выполняется код, который отвечает за ивенты типа касания и работу с UI. Он же рендерит экран. В большинстве современных смартфонов рендеринг происходит с частотой 60 кадров в секунду. Это значит, что задачи должны выполняться за 16,67 миллисекунд (1000 миллисекунд/ 60 кадров). Поэтому ускорение работы в Main Thread — важно.
Если какая-то операция занимает больше 16,67 миллисекунд, автоматически происходит потеря кадров, и пользователи приложения заметят это при воспроизведении анимаций. На некоторых устройствах рендеринг происходит ещё быстрее, например, на iPad Pro 2017 частота обновления экрана составляет 120 Гц, поэтому на выполнение операций за один кадр есть всего 8 миллисекунд.
Offscreen Rendering
Что же такое offscreen rendering? По своей сути — это какие-то внеэкранный расчеты.
Под капотом это выглядит следующим образом: во время прорисовки слоя, которому необходима внеэкранные расчеты, GPU останавливает процесс визуализации и передает управление CPU. В свою очередь, CPU выполняет все необходимые операции (например, cоздает тень) и возвращает управление GPU с уже прорисованным слоем. GPU визуализирует его и процесс прорисовки продолжается.
Кроме того, offscreen rendering требует выделения дополнительной памяти, для так называемого резервного хранилища. В то же время, она не нужна для прорисовки слоев, где используется аппаратное ускорение.
Нашему GPU потребуется дополнительная обратка в случае, если мы изменим свойства ниже:
Тени
Тут все просто. Рендеру не хватает информации для отрисовки тени, поэтому тут расчет тени происходит отдельно. Будет добавлен дополнительный слой. Этот слой был нарисован первым
Маски для CALayer
Рендерер должен отобразить поддерево слоев под маской. Но также нужно избежать перезаписи пикселей за пределами маски.
Поэтому мы будем хранить всю информацию об изображение, пока пиксели под маской не будут рассчитаны и не помещены в финальную текстуру.
Эти закадровые вычисления могут хранить множество пикселей, которые юзер никогда не увидит
Радиус закругления углов
Этот тип связан с маской. Закругление углов у слоя также может рассчитываются заэкранно.
Если рендерингу не хватает инфы, то он может отрисовать вью полностью
и скопировать инфу о пикселях внутри
Кто-то пишет не использовать параметр cornerRadius
. Применение viewLayer.cornerRadius
может привести к offscreen rendering. Вместо этого можно использовать класс UIBezierPath
.
Визуальные эффекты
Эта работа связана с двумя эффектами:
Яркостью
Блюр
Чтобы применить эффект рендер должен скопировать контент под визуальным эффектом в другую текстуру, что хранится в закадровом буфере. Затем применить визуальный эффект к результату и скопировать обратно в рендер буфер
Эти 4 типа закадровых расчетов сильно замедляют рендер.
Слишком большое изображение
Если вы пытаетесь нарисовать изображение, размер которого превышает максимальный размер текстуры, поддерживаемый GPU (обычно 2048 × 2048 или 4096 × 4096, в зависимости от устройства), для предварительной обработки изображения необходимо использовать CPU. Что может повлиять на перфоманс каждый раз при отрисовке множества таких изображений
Смешивание цветов
Смешивание — это операция кадровой визуализации, которая определяет конечный цвет пикселя. Каждый UIView (честно говоря, CALayer) влияет на цвет конечного пикселя, на пример, в случае объединения набора таких свойств, как alpha, backgroundColor, opaque всех вышележащих вью
Начнем с наиболее используемых свойств UIView, таких как UIView.alpha, UIView.opaque и UIView.backgroundColor.
Непрозрачность vs Прозрачность
UIView.opaque — это подсказка для визуализатора, что позволяет рассмотреть изображения в качестве полностью непрозрачной поверхности, тем самым улучшая качество отрисовки. Непрозрачность означает: "Ничего не рисуй под поверхностью». UIView.opaque позволяет пропускать отрисовку нижних слоев изображения и тем самым смешивание цветов не происходит. Будет использоваться самый верхний цвет для вью.
Alpha
Если значение alpha меньше 1, то значение opaque будет игнорироваться, даже если оно равно YES.
Несмотря на то, что значение непрозрачности по умолчанию — YES, в результате мы получаем смешивание цветов, поскольку мы сделали наше изображение прозрачным, установив значение Alpha меньше 1.
Делайте слои непрозрачными, когда это возможно — при условии, что у них одинаковые цвета, которые накладываются друг на друга. У слоя есть свойство opacity
, которому и надо выставить единицу. Всегда проверяйте, что фоновый цвет задан и он непрозрачный.
Переопределение метода drawRect
Если мы переопределили метод drawRect, то мы вносим значительную нагрузку до того, как мы что-то внутри нарисовали.
Чтобы поддерживать произвольное рисование в содержимом слоя, Core Animation должен создать фоновое изображение в памяти, равное по размеру размерам вьюхи.
Затем, как только рисунок будет завершен, он должен передать эти данные через IPC на render server. Вдобавок к этим накладным расходам отрисовка Core Graphics в любом случае очень медленная, и это точно не тот случай, когда вы захотите делать в критической ситуации с производительностью.
Декодинг изображений и даунсамлинг изображений
В общем случае, декодирование jpeg-изображений стоит делать в фоне. Большинство сторонних библиотек (AsyncDisplayKit, SDWebImage и т.д.) могут делать это по умолчанию. Если вы не хотите использовать фреймворки, то можно сделать декодирование вручную. Для этого вы можете написать расширение над UIImage
, в котором создадите контекст и вручную отрисуете изображение.
shouldRasterize( Rasterize )
В настоящее время Apple в значительной степени поддерживает свойство shouldRasterize. Включение этого свойства вызывает заэкранный рендеринг, но визуализированный контент будет кэшироваться, его можно повторно использовать при определенных условиях. Если это свойство используется правильно, потребление производительности может быть сведено к минимуму. Если эти условия соблюдены, вы можете использовать shouldRasterize:
Слой и его дочерние слои должны быть статичными, потому что, как только что-то изменится, кеш становится недействительным. Если это случается часто, мы возвращаемся то каждый кадр будет нагружать систему. Это то, чего разработчики должны избегать.
На этом основные моменты работы рендер лупа и его оптимизации я описал. В дальнешем планирую статьи о фреймворках, которые работают с графикой, видео и аудио.
Подписывайтесь на мой телеграм канал: https://t.me/iosmakemecry
Используемый материал
https://dmytro-anokhin.medium.com/rendering-performance-of-ios-apps-4d09a9228930
https://gist.github.com/SheldonWangRJT/765f50f20d06c320d9c69eb1bf17124f
https://developer.apple.com/documentation/metal/synchronization/synchronizing_cpu_and_gpu_work
https://medium.com/@joncardasis/better-ios-animations-with-catransaction-72a7425673a6
https://github.com/jrasmusson/swift-arcade/tree/master/Animation/CoreAnimation/Intro