Привет! Меня зовут Саша, я iOS-разработчик в команде, которая делает ленту ВКонтакте. Сейчас расскажу, как мы оптимизируем отображение интерфейса и обходим связанные с этим проблемы.
Думаю, вы представляете, что такое лента VK. Это экран, где можно просматривать разнообразный контент: тексты, статичные картинки, анимированные гифки, встраиваемые элементы (видео и музыку). Всё это должно отображаться плавно, отсюда высокие требования к производительности решений. Теперь посмотрим, какие существуют стандартные подходы к работе с отображениями и какие ограничения или преимущества следует учитывать. Если вы больше любите слушать, чем читать, видеозапись доклада есть вот тут.  Для начала вспомним, как создать визуальную структуру интерфейса (layout) штатными средствами. Ради экономии места обойдёмся без листингов — я просто перечислю решения и поясню их особенности. Пожалуй, самый популярный способ создать интерфейс в iOS — использовать систему вёрстки Auto Layout от Apple. В её основе — алгоритм Cassowary, неразрывно связанный с понятием ограничений (constraints).  Пока запомним, что интерфейс, реализованный с помощью Auto Layout, построен на ограничениях. Особенности подхода: Преимущества использования системы Auto Layout: Возможностей системы хватает для большинства отображений. Но она не подходит для создания ленты с огромным количеством разнородного контента. Почему?  Важно помнить, что Auto Layout: Вывод: с помощью Auto Layout удобно создавать отображения без коллекций или с ними, но без сложных связей между элементами. Суть подхода: вычисляем все значения frame сами. Например, реализуем методы layoutSubviews, sizeThatFits. То есть в layoutSubviews сами располагаем все дочерние элементы, в sizeThatFits вычисляем размер, соответствующий нужному расположению дочерних элементов и контента. Что это даёт? Мы можем переносить сложные вычисления в Background-поток, а сравнительно простые — выполнять в Main-потоке. В чём проблема? Придётся самим реализовывать вычисления, легко ошибиться. Также нужно самим обеспечивать соответствие расположения дочерних элементов и результатов, возвращаемых в sizeThatFits. Самостоятельный расчёт оправдан, если: Пример. Рисуем подсказки, которые автоматически масштабируются под содержимое. Самое интересное в этой задаче — как вычислять визуальный размер текста в каждой подсказке. Эту задачу можно решить минимум четырьмя способами, каждый из которых опирается на свой набор методов. И у каждого — свои особенности и ограничения. Методы sizeThatFits (используется по умолчанию в sizeToFit) и intrinsicContentSize (используется в Auto Layout) возвращают предпочтительный размер содержимого view. Например, с их помощью мы можем узнать, сколько места занимает текст, записанный в UILabel. Минус в том, что оба метода работают только в Main-потоке — из фонового их не вызвать.  Когда полезны стандартные методы? Обратите внимание на методы boundingRect и sizeWithAttributes. Не советую с их помощью считать размер содержимого UILabel/UITextView/UITextField. Я не нашёл нигде в документации информации о том, что методы NSString и методы layout UIView элементов базируются на одном коде (одних классах). Эти две группы классов принадлежат разным фреймворкам: Foundation и UIKit соответственно. Может, вам уже приходилось подгонять результат boundingRect к размерам UILabel? Или вы сталкивались с тем, что методы NSString не учитывают размер emoji? Вот такие проблемы можно получить.  Я ещё расскажу, какие классы отвечают за рисование текста в UILabel/UITextView/UITextField, а пока вернёмся к методам. Использовать boundingRect и sizeWithAttributes стоит, если мы: Этот инструмент состоит из стандартных классов NLayoutManager, NSTextStorage и NSTextContainer. На них же основаны layout UILabel/UITextView/UITextField. TextKit очень удобен, когда нужно детально описать расположение текста и указать, какие фигуры он будет обтекать:  С помощью TextKit можно в фоновой очереди вычислять размер элементов интерфейса, а также количество и frame строк/символов. Кроме того, фреймворк позволяет рисовать глифы и полностью менять вид текста в рамках существующего layout. Всё это работает в iOS 7.0 и выше. TextKit полезен, когда нужно: Подчеркну ещё раз. Если надо заранее вычислить размер UITextView, мы сначала настраиваем экземпляры классов NSLayoutManager, NSTextStorage и NSTextContainer, а затем передаём эти экземпляры в соответствующий UITextView, где они будут отвечать за layout. Только так мы гарантируем полное совпадение всех значений.  Не используйте TextKit с UILabel и UITextField! Для них (в отличие от UITextView) вы не сможете настроить NSLayoutManager, NSTextStorage и NSTextContainer. Это самый низкоуровневый инструмент для работы с текстом в iOS. Он даёт максимум контроля над отрисовкой шрифтов, символов, строк, отступов. А ещё он, как и TextKit, позволяет вычислять типографические параметры текста, такие как baseline и размер frame отдельных строк. Как известно, чем больше свободы, тем выше ответственность. И чтобы с помощью CoreText получать хорошие результаты, нужно уметь пользоваться его методами. CoreText обеспечивает потокобезопасность операций над большинством объектов. Это значит, что мы можем вызывать его методы из разных потоков. Для сравнения, при использовании TextKit придётся самим думать о последовательности вызовов методов. CoreText стоит использовать, если: Для ленты ВКонтакте мы использовали CoreText. Почему? Просто на момент, когда мы реализовывали основные функции работы с текстом, TextKit ещё не было. Коротко о том, как мы получаем данные от сервера, формируем layout и отображения.  Сначала рассмотрим задачи, выполняемые в Background-очереди. Получаем данные от сервера, обрабатываем их и декларативно описываем последующее отображение. На данном этапе у нас ещё нет экземпляров UIView, мы только задаём правила и структуру будущего интерфейса своим декларативным инструментом, в некоторой степени похожим на SwiftUI. Чтобы рассчитать layout, вычисляем все frame с учётом текущих ограничений, например, ширины экрана. Составляем обновление текущего dataSource (dataSourceUpdate). Здесь же, в Background-очереди, мы готовим изображения: выполняем декомпрессию (подробнее см. раздел о производительности), рисуем тени, скругления и другие эффекты. Теперь переходим в Main-очередь. Применяем полученный dataSourceUpdate к UITableView, повторно используем и обрабатываем события интерфейса, заполняем ячейки. Чтобы описать нашу систему layout, потребовалась бы отдельная статья, а здесь я перечислю основные её особенности: Но даже при таком подходе просмотр ленты может быть не плавным из-за проблем производительности. Почему? У каждой ячейки — сложная иерархия nodes. И хотя базовые элементы не создают лишних контейнеров, в ленте всё равно отображается очень много UIView. А при заполнении иерархии «нодами» (view binding) в Main-очереди возникает лишняя работа, от которой сложно уйти.  Мы постарались перенести как можно больше задач в Background-очередь и сейчас продолжаем это делать. Кроме того, есть ресурсоёмкие для CPU и GPU операции, которые надо учитывать и обходить. Самый простой ответ — разгрузить Main-поток, CPU и GPU. Чтобы это сделать, надо глубоко разбираться в работе iOS-приложений. И прежде всего выявить источники проблем. 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): Сначала выполняются блоки, добавленные в 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. Но это не принципиально:  В действительности все операции внутри 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: UIKit переиспользует ячейки UITableView/UICollectionView в layoutSubviews: вызывает метод делегата willDisplayCell и так далее. Во время CATransaction.commit происходит offscreen-drawing: вызываются методы drawInContext всех слоёв или drawRect всех UIView, помеченных как setNeedsDisplay. Замечу, когда мы что-то рисуем в drawRect, это происходит на главном потоке, а нам нужно срочно изменить отображение слоёв для нового кадра. Ясно, что такое решение может оказаться очень неэффективным. Что же происходит в CATransaction.commit дальше? Render Tree отправляется на Render Server. Вспомним весь процесс формирования кадра интерфейса в 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, соответствующий слой останется пустым или со старым закешированным контентом. Зато это хорошо распараллеливаемый подход.  Вывод: всегда нужно выбирать решение, максимально подходящее для конкретной задачи. 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 берёт на себя многие операции по формированию кадра. Если вы не хотите грузить его лишней работой, подумайте, стоит ли использовать следующие свойства: Изменение любого свойства CALayer, которое требует вычислений и применения графических эффектов, может вести к Offscreen rendering. Проблема схожа с той, что возникает при использовании UIVisualEffect (с той лишь разницей, что одни эффекты вычисляются Render Server на CPU, другие на GPU). Теперь разберёмся, как выявить слабые места в нашем приложении. Для поиска узких мест, где приложение теряет производительность, некоторые советуют использовать Time Profiler. Я предпочитаю Metal System Trace — он даёт больше информации и включает в себя Time Profiler как один из компонентов. Инструмент показывает, какая работа выполняется в приложении в каждый конкретный момент (такт исполнения). Мы можем следить, что происходит на видеочипе: например, какие там выполняются шейдеры.  Особенно ценно, что с Metal System Trace мы знаем, сколько заняла отрисовка каждого кадра и как долго не обновлялся экран. При возникновении проблем проще выяснить, возникли они на стороне приложения или Render Server. В первом случае мы сможем найти операцию, которая сильно грузит или блокирует Main-поток, во втором — поймём, что переборщили с визуальными эффектами.  Если видеочип не успел обработать какие-то кадры, мы это увидим:  У Metal System Trace есть пара ограничений. Система работает только на 64-битных девайсах, то есть не ниже iPhone 5s. Кроме того, анализ метрик происходит не в режиме реального времени. Мы включаем запись, что-то делаем с интерфейсом, останавливаем запись, и только после этого система формирует метрики взаимодействия с UI. Иногда проблемы с прокруткой надо искать в коде самостоятельно. Допустим, вам нужны какие-то регресс-тесты или метрики. Вы можете реализовать их с помощью такого простого инструмента, как упомянутый таймер CADisplayLink.  У экземпляров CADisplayLink есть свойство timestamp — время формирования последней транзакции (и отправки её на Render Server). При каждом срабатывании таймера мы можем сравнивать новый CADisplayLink.timestamp с предыдущим сохранённым нами timestamp. Это позволит понять, успели мы подготовить предыдущую транзакцию за доступное время формирования (например, 1/60 секунды) или нет: CADisplayLink добавлен в UITrackingRunLoopMode, чтобы таймер работал только во время прокрутки. Визуализация алгоритма в рамках Rendering Pipeline:
 Дальше можно сделать очень простой UI-инструмент, который будет замерять параметры и выводить значения на экран. Мы ключевой метрикой «здоровья» отображения считаем следующую величину freezeFrameTimeRate: Для наглядности, при обнаружении проблемы с производительностью мы можем на доли секунды показывать какую-нибудь яркую и заметную UIView. Это поможет понять, все ли проблемы видны нашему «радару»:  Следует признать, что не каждую видимую проблему прокрутки можно отловить программными средствами, то есть наша «сигнальная UIView» будет загораться не на все видимые пользователем проблемы производительности. Почему? Вернёмся к тому, как формируется кадр. Иногда, например, при использовании сложных системных визуальных эффектов, мы оказываемся в следующей ситуации: CADisplayLink сообщает, что транзакция готова и отправлена на Render Server в момент времени link.timetamp, но при этом Render Server всё ещё обрабатывает предыдущий кадр, и этот кадр обрабатывается довольно долго. Наше приложение 60 раз в секунду обрабатывает UI-события, генерирует новые транзакции и отправляет эти транзакции на Render Server. При этом Render Server может не успевать обрабатывать все транзакции, тогда мы видим пропуски кадров. К сожалению, у нас пока нет инструмента, который сообщал бы, что Render Server не справился с кадром. Мы пытались использовать Metal, но не смогли получить доступ к буферу Render Server. И, в принципе, это вполне логично по соображениям защищённости iOS, так как Render Server является отдельным и при этом самым важным для рендеринга всей системы процессом. Когда вы разбираетесь, почему падает производительность, обратите внимание на несколько важных моментов. При исследовании проблем производительности следует использовать только реальные устройства и только релизные сборки, где уже присутствуют все оптимизации и отключён дебаггер. Помните: сначала измерения — потом оптимизация кода! И по возможности попросите коллег проверить результаты исследования проблем — ошибиться и прийти к неверным выводам очень легко. Мой совет — в поисках своего решения постоянно держите в уме текущую задачу и не увлекайтесь преждевременной оптимизацией. Когда пишете новый код, всегда обращайте внимание на то, как он скажется на производительности приложения. Благодарю коллег, которые работают или работали в команде ВКонтакте — они очень помогли с поиском и анализом информации. Мы вместе обсуждали плюсы и минусы разных подходов, чтобы найти лучшие.  Основные источники, на которые я опирался в работе над статьёй:  Инженер