Привет! Меня зовут Саша, я iOS-разработчик в команде, которая делает ленту ВКонтакте. Сейчас расскажу, как мы оптимизируем отображение интерфейса и обходим связанные с этим проблемы.
Думаю, вы представляете, что такое лента VK. Это экран, где можно просматривать разнообразный контент: тексты, статичные картинки, анимированные гифки, встраиваемые элементы (видео и музыку). Всё это должно отображаться плавно, отсюда высокие требования к производительности решений.
Теперь посмотрим, какие существуют стандартные подходы к работе с отображениями и какие ограничения или преимущества следует учитывать.
Если вы больше любите слушать, чем читать, видеозапись доклада есть вот тут.
Содержание
- Описание и вычисление layout
1.1. Auto Layout
1.2. Расчётframe
«вручную» - Вычисление размера текста
2.1. Стандартные методы вычисления размераUILabel
/UITextView
/UITextField
2.2. МетодыNSAttributedString
/NSString
2.3. TextKit
2.4. CoreText - Как работает лента ВКонтакте
- Как добиться лучшей производительности
4.1 Почему возникают проблемы с производительностью
4.2.CATransaction.commit
4.3. Rendering Pipeline
4.4. Самые уязвимые по производительности места - Инструменты измерений
5.1. Metal System Trace
5.2. Фиксируем просадки производительности в коде во время работы приложения
- Как исследовать проблемы. Рекомендации
- Заключение
- Источники информации
1. Описание и вычисление layout
Для начала вспомним, как создать визуальную структуру интерфейса (layout) штатными средствами. Ради экономии места обойдёмся без листингов — я просто перечислю решения и поясню их особенности.
1.1. Auto Layout
Пожалуй, самый популярный способ создать интерфейс в iOS — использовать систему вёрстки Auto Layout от Apple. В её основе — алгоритм Cassowary, неразрывно связанный с понятием ограничений (constraints).
Пока запомним, что интерфейс, реализованный с помощью Auto Layout, построен на ограничениях.
Особенности подхода:
- Система ограничений преобразуется в задачу линейного программирования.
- Полученную оптимизационную задачу Cassowary решает с помощью симплекс-метода. У этого метода экспоненциальная асимптотическая сложность. Что это значит? При росте количества ограничений в layout в худшем случае вычисления могут замедлиться экспоненциально.
- Итоговые значения
frame
дляUIView
— решение соответствующей оптимизационной задачи.
Преимущества использования системы Auto Layout:
- На простых отображениях возможна линейная сложность вычислений.
- Отлично ладит со всеми стандартными элементами, так как является «родной» технологией Apple.
- «Из коробки» работает с
UIView
. - Доступна в Interface Builder, что позволяет описывать layout в Storyboard или XIB.
- Гарантирует актуальное решение даже при transition. Это значит, что значение
frame
каждойUIView
всегда(!) является решением актуальной задачи layout.
Возможностей системы хватает для большинства отображений. Но она не подходит для создания ленты с огромным количеством разнородного контента. Почему?
Важно помнить, что Auto Layout:
- Работает только в Main-потоке. Предположу, что инженеры Apple выбрали Main-поток точкой синхронизации решения Auto Layout и значений frame всех
UIView
. Без этого пришлось бы в отдельном потоке вычислять Auto Layout и постоянно синхронизировать значения с Main-потоком. - Может медленно работать на сложных представлениях, так как в основе — переборный алгоритм, сложность которого в худшем случае экспоненциальна.
- Доступен с iOS 6.0. Сейчас это вряд ли проблема, но учесть стоит.
Вывод: с помощью Auto Layout удобно создавать отображения без коллекций или с ними, но без сложных связей между элементами.
1.2. Расчёт frame
«вручную»
Суть подхода: вычисляем все значения frame
сами. Например, реализуем методы layoutSubviews
, sizeThatFits
. То есть в layoutSubviews
сами располагаем все дочерние элементы, в sizeThatFits
вычисляем размер, соответствующий нужному расположению дочерних элементов и контента.
Что это даёт? Мы можем переносить сложные вычисления в Background-поток, а сравнительно простые — выполнять в Main-потоке.
В чём проблема? Придётся самим реализовывать вычисления, легко ошибиться. Также нужно самим обеспечивать соответствие расположения дочерних элементов и результатов, возвращаемых в sizeThatFits
.
Самостоятельный расчёт оправдан, если:
- мы столкнулись или предвидим, что столкнёмся, с ограничениями производительности Auto Layout;
- в приложении есть сложная коллекция, и велик шанс, что разрабатываемый элемент попадёт в одну из её ячеек;
- мы хотим вычислять размер элемента в Background-потоке;
- мы выводим на экран нестандартные элементы, размер которых нужно постоянно пересчитывать в зависимости от контента или окружения.
Пример. Рисуем подсказки, которые автоматически масштабируются под содержимое. Самое интересное в этой задаче — как вычислять визуальный размер текста в каждой подсказке.
2. Вычисление размера текста
Эту задачу можно решить минимум четырьмя способами, каждый из которых опирается на свой набор методов. И у каждого — свои особенности и ограничения.
2.1. Стандартные методы вычисления размера UILabel
/UITextView
/UITextField
Методы sizeThatFits
(используется по умолчанию в sizeToFit
) и intrinsicContentSize
(используется в Auto Layout) возвращают предпочтительный размер содержимого view. Например, с их помощью мы можем узнать, сколько места занимает текст, записанный в UILabel
.
Минус в том, что оба метода работают только в Main-потоке — из фонового их не вызвать.
Когда полезны стандартные методы?
- Если мы уже используем
sizeToFit
или Auto Layout. - Когда в отображении есть стандартные элементы, и мы хотим получить в коде их размер.
- Для любых отображений без сложных коллекций.
2.2. Методы NSAttributedString/NSString
Обратите внимание на методы boundingRect
и sizeWithAttributes
. Не советую с их помощью считать размер содержимого UILabel
/UITextView
/UITextField
. Я не нашёл нигде в документации информации о том, что методы NSString
и методы layout UIView
элементов базируются на одном коде (одних классах). Эти две группы классов принадлежат разным фреймворкам: Foundation и UIKit соответственно. Может, вам уже приходилось подгонять результат boundingRect к размерам UILabel
? Или вы сталкивались с тем, что методы NSString
не учитывают размер emoji? Вот такие проблемы можно получить.
Я ещё расскажу, какие классы отвечают за рисование текста в UILabel
/UITextView
/UITextField
, а пока вернёмся к методам.
Использовать boundingRect и sizeWithAttributes стоит, если мы:
- Рисуем нестандартные элементы интерфейса с помощью
drawInRect
,drawAtPoint
или других методов классаNSString
/NSAttributedString
. - Хотим считать размер элементов в Background-потоке. Повторюсь, это только при использовании соответствующих методов отрисовки.
- Рисуем на произвольном контексте, например, выводим строку поверх изображения.
2.3. TextKit
Этот инструмент состоит из стандартных классов NLayoutManager
, NSTextStorage
и NSTextContainer
. На них же основаны layout UILabel
/UITextView
/UITextField
.
TextKit очень удобен, когда нужно детально описать расположение текста и указать, какие фигуры он будет обтекать:
С помощью TextKit можно в фоновой очереди вычислять размер элементов интерфейса, а также количество и frame
строк/символов. Кроме того, фреймворк позволяет рисовать глифы и полностью менять вид текста в рамках существующего layout. Всё это работает в iOS 7.0 и выше.
TextKit полезен, когда нужно:
- вывести текст со сложным layout;
- рисовать текст на изображениях;
- вычислять размеры отдельных подстрок;
- считать количество строк;
- использовать результаты вычислений в
UITextView
.
Подчеркну ещё раз. Если надо заранее вычислить размер UITextView
, мы сначала настраиваем экземпляры классов NSLayoutManager
, NSTextStorage
и NSTextContainer
, а затем передаём эти экземпляры в соответствующий UITextView
, где они будут отвечать за layout. Только так мы гарантируем полное совпадение всех значений.
Не используйте TextKit с UILabel
и UITextField
! Для них (в отличие от UITextView
) вы не сможете настроить NSLayoutManager
, NSTextStorage
и NSTextContainer
.
2.4. CoreText
Это самый низкоуровневый инструмент для работы с текстом в iOS. Он даёт максимум контроля над отрисовкой шрифтов, символов, строк, отступов. А ещё он, как и TextKit, позволяет вычислять типографические параметры текста, такие как baseline и размер frame отдельных строк.
Как известно, чем больше свободы, тем выше ответственность. И чтобы с помощью CoreText получать хорошие результаты, нужно уметь пользоваться его методами.
CoreText обеспечивает потокобезопасность операций над большинством объектов. Это значит, что мы можем вызывать его методы из разных потоков. Для сравнения, при использовании TextKit придётся самим думать о последовательности вызовов методов.
CoreText стоит использовать, если:
- Нужен предельно простой низкоуровневый API для прямого доступа к параметрам текста. Сразу скажу, что для абсолютного большинства задач хватает возможностей TextKit.
- Предстоит много работать с отдельными строками (
CTLine
) и символами/элементами. - Важна поддержка в iOS 6.0.
Для ленты ВКонтакте мы использовали CoreText. Почему? Просто на момент, когда мы реализовывали основные функции работы с текстом, TextKit ещё не было.
3. Как работает лента ВКонтакте
Коротко о том, как мы получаем данные от сервера, формируем layout и отображения.
Сначала рассмотрим задачи, выполняемые в Background-очереди. Получаем данные от сервера, обрабатываем их и декларативно описываем последующее отображение. На данном этапе у нас ещё нет экземпляров UIView
, мы только задаём правила и структуру будущего интерфейса своим декларативным инструментом, в некоторой степени похожим на SwiftUI. Чтобы рассчитать layout, вычисляем все frame
с учётом текущих ограничений, например, ширины экрана. Составляем обновление текущего dataSource
(dataSourceUpdate
). Здесь же, в Background-очереди, мы готовим изображения: выполняем декомпрессию (подробнее см. раздел о производительности), рисуем тени, скругления и другие эффекты.
Теперь переходим в Main-очередь. Применяем полученный dataSourceUpdate
к UITableView
, повторно используем и обрабатываем события интерфейса, заполняем ячейки.
Чтобы описать нашу систему layout, потребовалась бы отдельная статья, а здесь я перечислю основные её особенности:
- Декларативный API — набор правил, на которых построен интерфейс.
- Базовые компоненты образуют дерево (
nodes
). - Простые вычисления в базовых компонентах. Например, в списках мы лишь вычисляем смещение
origin
с учётом ширины/высоты всех дочерних элементов. - Базовые элементы не создают лишних «контейнеров»
UIView
в иерархии. К примеру, компонент списка не образует дополнительнойUIView
и не складывает туда дочерние элементы. Вместо этого мы вычисляем смещениеorigin
дочерних элементов относительно родительского (для списка) элемента. - Низкоуровневое управление текстом с помощью CoreText.
Но даже при таком подходе просмотр ленты может быть не плавным из-за проблем производительности. Почему?
У каждой ячейки — сложная иерархия nodes
. И хотя базовые элементы не создают лишних контейнеров, в ленте всё равно отображается очень много UIView
. А при заполнении иерархии «нодами» (view binding) в Main-очереди возникает лишняя работа, от которой сложно уйти.
Мы постарались перенести как можно больше задач в Background-очередь и сейчас продолжаем это делать. Кроме того, есть ресурсоёмкие для CPU и GPU операции, которые надо учитывать и обходить.
4. Как добиться лучшей производительности
Самый простой ответ — разгрузить Main-поток, CPU и GPU. Чтобы это сделать, надо глубоко разбираться в работе iOS-приложений. И прежде всего выявить источники проблем.
4.1 Почему возникают проблемы с производительностью
Core Animation, RunLoop
и Scroll
Давайте вспомним, как строится интерфейс в iOS. На верхнем уровне есть UIKit, который отвечает за взаимодействие с пользователем: обработку жестов, вывод приложения из сна и подобные вещи. За отрисовку интерфейса отвечает более низкоуровневый инструмент — Core Animation (как и в macOS). Это фреймворк со своей системой описания интерфейса. Рассмотрим основные понятия построения интерфейса.
Для Core Animation весь интерфейс — это слои CALayer
. Они образуют древовидную структуру (Render Tree), управляемую через транзакции CATransaction
.
Транзакция — это группа изменений, точнее, информация о необходимости что-то обновить в отображаемом интерфейсе. Любое изменение frame
или других параметров слоя попадает в текущую транзакцию. Если таковой ещё нет, система сама создаёт implicit transaction.
Несколько транзакций образуют стек. Свежие обновления попадают в верхнюю транзакцию стека.
Теперь мы знаем, что для обновления экрана нужно формировать транзакции с новыми параметрами для дерева слоёв.
Когда и как создавать транзакции? В нашем приложении у потоков есть сущность под названием RunLoop
. Это, упрощённо говоря, бесконечный цикл, на каждой итерации которого обрабатывается текущая очередь событий.
В Main-потоке RunLoop
нужен для обработки событий из разных источников, таких как интерфейс (жесты), таймеры или, например, обработчики приёма данных из NSStream
и NSPort
.
Как же связаны Core Animation и RunLoop
? Выше мы выяснили, что при изменении свойств слоя в Render Tree система при необходимости создаёт implicit-транзакции (поэтому нам не нужно самим вызывать CATransaction.begin
, чтобы что-то перерисовать). Далее, на каждой итерации RunLoop
система автоматически закрывает открытые транзакции и применяет внесённые изменения (CATransaction.commit
).
Обратите внимание! Количество итераций RunLoop
не зависит от частоты обновления экрана. Цикл вообще не синхронизирован с экраном и работает как «бесконечный while()
».
Теперь посмотрим, что происходит в итерациях RunLoop
на Main-потоке во время прокрутки (scroll):
...
if (dispatchBlocks.count > 0) { // обработка блоков MainQueue
doBlocks()
}
...
if (hasPanEvent) {
handlePan() // UIScrollView change content offset -> change bounds
}
...
if (hasCATransaction) {
CATransaction.commit()
}
...
Сначала выполняются блоки, добавленные в Main-очередь через dispatch_async
/dispatch_sync
. И пока они не выполнятся, программа не переходит к следующим задачам.
Дальше UIKit начинает обрабатывать pan-жест пользователя. В рамках обработки этого жеста изменяется UIScrollView.contentOffset
, и вследствие — UIScrollView.bounds
. Изменение bounds
UIScrollView
(соответственно, и его наследников UITableView
, UICollectionView
) приводит к обновлению видимой части контента (viewport
).
В конце итерации RunLoop
, если у нас есть открытые транзакции, автоматически происходит commit
или flush
.
Чтобы проверить, как это работает, расставим точки останова в соответствующих местах.
Вот как будет выглядеть обработка жеста:
А вот — CATransaction.commit
после handlePan
:
Во время «прокрутки по инерции» (scroll decelerating) UIScrollView
создаёт таймер CADisplayLink
, чтобы синхронизировать количество изменений contentOffset
в секунду с частотой обновления экрана.
Замечаем, что CATransaction.commit
происходит не в конце итерации RunLoop
, а прямо в обработке таймера CADisplayLink
. Но это не принципиально:
4.2. CATransaction.commit
В действительности все операции внутри CATransaction.commit
выполняются над слоями CALayer
. У слоёв есть свои методы цикла обновления layout (layoutSublayers
) и изображения (drawLayer
). Реализация этих методов по умолчанию приводит к вызовам методов делегата. Добавляя новый экземпляр UIView
в иерархию UIKit, мы неявно добавляем соответствующий слой в иерархию слоёв Core Animation. При этом UIView
по умолчанию является делегатом своего слоя. Как можно заметить по стеку вызовов, UIView
в рамках реализации методов делегата CALayer
выполняет свои методы, о которых пойдёт речь:
Так как обычно мы работаем с иерархией UIView
, дальше пойдёт описание на примерах UIView
.
Во время CATransaction.commit
выполняется layout всех UIView
, помеченных setNeedsLayout
. Заметим, что лишний раз самим вызывать layoutSubviews
или layoutIfNeeded
нет смысла из-за их гарантированного отложенного выполнения в системе внутри CATransaction.commit
. Даже если за одну транзакцию (между вызовами CATransaction.begin
и CATransaction.commit
) вы несколько раз измените frame
и вызовете setNeedsLayout
, каждое изменение не применится мгновенно. Итоговые изменения вступят в силу только после вызова CATransaction.commit
. Соответствующие методы CALayer
: setNeedsLayout
, layoutIfNeeded
и layoutSublayers
.
Аналогичную связку для рисования формируют методы setNeedsDisplay
и drawRect
. Для CALayer
это setNeedsDisplay
, displayIfNeeded
и drawLayer
. В рамках CATransaction.commit
вызываются методы отрисовки у всех элементов, помеченных setNeedsDisplay
. Этот этап иногда именуется offscreen-drawing.
Пример. Для конкретики и удобства возьмём UITableView
:
...
// Layout
UITableView.layoutSubviews()
// переиспользование ячеек, заполнение и т.д.
...
// Offscreen drawing
UITableView.drawRect()
// Обновление дерева слоёв
...
UIKit переиспользует ячейки UITableView
/UICollectionView
в layoutSubviews
: вызывает метод делегата willDisplayCell
и так далее. Во время CATransaction.commit
происходит offscreen-drawing: вызываются методы drawInContext
всех слоёв или drawRect
всех UIView
, помеченных как setNeedsDisplay
. Замечу, когда мы что-то рисуем в drawRect
, это происходит на главном потоке, а нам нужно срочно изменить отображение слоёв для нового кадра. Ясно, что такое решение может оказаться очень неэффективным.
Что же происходит в CATransaction.commit
дальше? Render Tree отправляется на Render Server.
4.3. Rendering Pipeline
Вспомним весь процесс формирования кадра интерфейса в iOS (rendering pipeline [WWDC 2014 Session 419. Advanced Graphics and Animations for iOS Apps]):
За формирование кадра отвечает не только процесс нашего приложения — Core Animation работает ещё и в отдельном системном процессе, который называется Render Server.
Как формируется кадр. Мы (или система за нас) создаём в приложении новую транзакцию (CATransaction
) с описанием изменений интерфейса, «коммитим» её и передаём на Render Server. Всё, на стороне приложения работа выполнена. Дальше Render Server декодирует транзакцию (Render Tree), вызывает нужные команды на видеочипе, рисует новый кадр и выводит его на экран.
Интересно, что при создании кадра используется некая «многопоточность». Если частота обновления экрана равна 60 кадрам в секунду, новый кадр суммарно формируется не за 1/60, а за 1/30 секунды. Всё потому, что, пока приложение готовит новый кадр, Render Server ещё обрабатывает предыдущий:
Грубо говоря, общее время формирования кадра перед отображением на экране складывается из 1/60 секунды в нашем процессе для формирования транзакции и 1/60 секунды в процессе Render Server при обработке транзакции.
Хочется сделать следующее замечание. Мы можем сами распараллелить рисование слоёв и рендерить контент слоя UIImage
/CGImage
в Background-потоке. После этого в главном потоке нужно присвоить созданное изображение свойству CALayer.contents
. В плане производительности это очень хороший подход. Именно его используют разработчики Texture. Но поскольку мы можем изменить CALayer.contents
только в процессе формирования транзакции в процессе нашего приложения, на создание и подстановку нового изображения у нас всего 1/60 секунды при 60 кадрах вместо 1/30 секунды (с учётом оптимизаций и распараллеливания rendering pipeline с Render Server).
Кроме того, Render Server всё равно может взять на себя blending (см. далее) и кратковременное кеширование слоёв [iOS Core Animation: Advanced Techniques. Nick Lockwood]. И если мы не успеем нарисовать за 1/60 секунды новое изображение для свойства CALayer.contents
, соответствующий слой останется пустым или со старым закешированным контентом. Зато это хорошо распараллеливаемый подход.
Вывод: всегда нужно выбирать решение, максимально подходящее для конкретной задачи.
4.4. Самые уязвимые по производительности места
Main-thread
Проблема 1. Невозможность быстро применить изменения (CATransaction.commit
) из-за долгого выполнения UIView.layoutSubviews
или сложной иерархии отображаемых UIView
(точнее, слоёв CALayer
). Чтобы это исправить, надо максимально упростить иерархию и вычисления в рамках layoutSubviews
/cellForRow
/willDisplayCell
.
Проблема 2. Много работы в drawInContext
/drawRect
. Рисовать что-то на Main-потоке во время срочного формирования транзакции (внутри CATransaction.commit
) — непозволительная роскошь. Любые отрисовки мы можем по возможности и при необходимости попытаться провести заранее в отдельном потоке и закешировать, если это будет эффективнее.
Проблема 3. Декомпрессия изображений отнимает много ресурсов. Изображение приходит с сервера в сжатом виде и требует декомпрессии. По умолчанию система сама проводит декомпрессию в CATransaction.commit
, но эту задачу можно вынести в другой поток, предварительно отрисовав изображение в этом потоке.
Проблема 4. Лишние копии изображений. Для оперативной памяти очень накладно создавать без необходимости копии одних и тех же экземпляров картинок UIImage
/CGImage
.
Проблема 5. Лишняя работа. Не стоит тратить ресурсы Main-thread на выполнение задач, не актуальных для текущего scroll. То есть стоит попытаться отложить или вынести в другой поток какие-то служебные операции, которые не требуются для текущего изменения UI.
Проблема 6. Лишние блоки в Main-очереди. Как мы заметили ранее, RunLoop
на Main-потоке не продолжит выполнение, пока не обработает все блоки, добавленные в Main-очередь. Так что не стоит переполнять очередь без реальной необходимости.
GPU
Blending. Полупрозрачные слои в отображении увеличивают нагрузку на GPU при расчёте итогового цвета пикселя экрана (так как Render Server необходимо провести довольно непростую работу на GPU, чтобы вычислить сквозь все полупрозрачные слои итоговый цвет пикселя). Чтобы повысить производительность, надо либо избавиться от таких слоёв, либо заранее отрисовать изображение в Background-очереди.
Визуальные эффекты. Отображение, к которому применены UIBlurEffect
, UIVibrancyEffect
и другие подобные эффекты, формируется поэтапно, в несколько проходов (Render Pass). Повторные проходы буфера пикселей обходятся дорого, поэтому надо либо избавиться от лишних эффектов, либо отрисовывать их заранее.
Offscreen rendering (Render Server)
Render Server берёт на себя многие операции по формированию кадра. Если вы не хотите грузить его лишней работой, подумайте, стоит ли использовать следующие свойства:
cornerRadius
— вынуждает Render Server сначала отрисовать слой во временный буфер, обрезать края и только после этого получить итоговое изображение;shadowRadius
— перекладывает на Render Server вычисление всех актуальных теней, отбрасываемых контентом слоя;mask
— подразумевает промежуточное рисование слоя с последующим применением маски.
Изменение любого свойства CALayer
, которое требует вычислений и применения графических эффектов, может вести к Offscreen rendering. Проблема схожа с той, что возникает при использовании UIVisualEffect
(с той лишь разницей, что одни эффекты вычисляются Render Server на CPU, другие на GPU).
Теперь разберёмся, как выявить слабые места в нашем приложении.
5. Инструменты измерений
Для поиска узких мест, где приложение теряет производительность, некоторые советуют использовать Time Profiler. Я предпочитаю Metal System Trace — он даёт больше информации и включает в себя Time Profiler как один из компонентов.
5.1. Metal System Trace
Инструмент показывает, какая работа выполняется в приложении в каждый конкретный момент (такт исполнения). Мы можем следить, что происходит на видеочипе: например, какие там выполняются шейдеры.
Особенно ценно, что с Metal System Trace мы знаем, сколько заняла отрисовка каждого кадра и как долго не обновлялся экран. При возникновении проблем проще выяснить, возникли они на стороне приложения или Render Server. В первом случае мы сможем найти операцию, которая сильно грузит или блокирует Main-поток, во втором — поймём, что переборщили с визуальными эффектами.
Если видеочип не успел обработать какие-то кадры, мы это увидим:
У Metal System Trace есть пара ограничений. Система работает только на 64-битных девайсах, то есть не ниже iPhone 5s. Кроме того, анализ метрик происходит не в режиме реального времени. Мы включаем запись, что-то делаем с интерфейсом, останавливаем запись, и только после этого система формирует метрики взаимодействия с UI.
5.2. Фиксируем просадки производительности в коде во время работы приложения
Иногда проблемы с прокруткой надо искать в коде самостоятельно. Допустим, вам нужны какие-то регресс-тесты или метрики. Вы можете реализовать их с помощью такого простого инструмента, как упомянутый таймер CADisplayLink
.
У экземпляров CADisplayLink
есть свойство timestamp
— время формирования последней транзакции (и отправки её на Render Server). При каждом срабатывании таймера мы можем сравнивать новый CADisplayLink.timestamp
с предыдущим сохранённым нами timestamp
. Это позволит понять, успели мы подготовить предыдущую транзакцию за доступное время формирования (например, 1/60 секунды) или нет:
// Создаём CADisplayLink.
link = [CADisplayLink displayLinkWithTarget:target selector:selector]
[link addToRunLoop:[NSRunLoop mainRunLoop] forMode:UITrackingRunLoopMode]
// При каждом срабатывании CADisplayLink вычисляем:
diff = prevTimestamp - link.timestamp
if (diff > 1/fps) { // обрабатываем freeze }
prevTimestamp = link.timestamp
CADisplayLink
добавлен в UITrackingRunLoopMode
, чтобы таймер работал только во время прокрутки.
Визуализация алгоритма в рамках Rendering Pipeline:
Дальше можно сделать очень простой UI-инструмент, который будет замерять параметры и выводить значения на экран. Мы ключевой метрикой «здоровья» отображения считаем следующую величину freezeFrameTimeRate
:
scrollTime // суммарное время всего Scroll
freezeFrameTime // суммарное время отображения кадров, которые "зависли", то есть отображались дольше положенного времени
freezeFrameTimeRate = freezeFrameTime / scrollTime
Для наглядности, при обнаружении проблемы с производительностью мы можем на доли секунды показывать какую-нибудь яркую и заметную UIView
. Это поможет понять, все ли проблемы видны нашему «радару»:
Следует признать, что не каждую видимую проблему прокрутки можно отловить программными средствами, то есть наша «сигнальная UIView
» будет загораться не на все видимые пользователем проблемы производительности. Почему? Вернёмся к тому, как формируется кадр. Иногда, например, при использовании сложных системных визуальных эффектов, мы оказываемся в следующей ситуации: CADisplayLink
сообщает, что транзакция готова и отправлена на Render Server в момент времени link.timetamp
, но при этом Render Server всё ещё обрабатывает предыдущий кадр, и этот кадр обрабатывается довольно долго. Наше приложение 60 раз в секунду обрабатывает UI-события, генерирует новые транзакции и отправляет эти транзакции на Render Server. При этом Render Server может не успевать обрабатывать все транзакции, тогда мы видим пропуски кадров.
К сожалению, у нас пока нет инструмента, который сообщал бы, что Render Server не справился с кадром. Мы пытались использовать Metal, но не смогли получить доступ к буферу Render Server. И, в принципе, это вполне логично по соображениям защищённости iOS, так как Render Server является отдельным и при этом самым важным для рендеринга всей системы процессом.
Как исследовать проблемы. Рекомендации
Когда вы разбираетесь, почему падает производительность, обратите внимание на несколько важных моментов. При исследовании проблем производительности следует использовать только реальные устройства и только релизные сборки, где уже присутствуют все оптимизации и отключён дебаггер.
Помните: сначала измерения — потом оптимизация кода! И по возможности попросите коллег проверить результаты исследования проблем — ошибиться и прийти к неверным выводам очень легко.
Заключение
Мой совет — в поисках своего решения постоянно держите в уме текущую задачу и не увлекайтесь преждевременной оптимизацией. Когда пишете новый код, всегда обращайте внимание на то, как он скажется на производительности приложения.
Источники информации
Благодарю коллег, которые работают или работали в команде ВКонтакте — они очень помогли с поиском и анализом информации. Мы вместе обсуждали плюсы и минусы разных подходов, чтобы найти лучшие.
Основные источники, на которые я опирался в работе над статьёй:
- Документация Apple.
- Доклад моего коллеги по Auto Layout.
- The Cassowary Linear Arithmetic Constraint Solving Algorithm.
- iOS Core Animation: Advanced Techniques. Nick Lockwood.
- WWDC 2014 Session 419. Advanced Graphics and Animations for iOS Apps.