company_banner

Сложные отображения коллекций в iOS: проблемы и решения на примере ленты ВКонтакте

    Привет! Меня зовут Саша, я iOS-разработчик в команде, которая делает ленту ВКонтакте. Сейчас расскажу, как мы оптимизируем отображение интерфейса и обходим связанные с этим проблемы.
    Думаю, вы представляете, что такое лента VK. Это экран, где можно просматривать разнообразный контент: тексты, статичные картинки, анимированные гифки, встраиваемые элементы (видео и музыку). Всё это должно отображаться плавно, отсюда высокие требования к производительности решений.


    Теперь посмотрим, какие существуют стандартные подходы к работе с отображениями и какие ограничения или преимущества следует учитывать.


    Если вы больше любите слушать, чем читать, видеозапись доклада есть вот тут.



    Содержание


    1. Описание и вычисление layout
      1.1. Auto Layout
      1.2. Расчёт frame «вручную»
    2. Вычисление размера текста
      2.1. Стандартные методы вычисления размера UILabel/UITextView/UITextField
      2.2. Методы NSAttributedString/NSString
      2.3. TextKit
      2.4. CoreText
    3. Как работает лента ВКонтакте
    4. Как добиться лучшей производительности
      4.1 Почему возникают проблемы с производительностью
      4.2. CATransaction.commit
      4.3. Rendering Pipeline
      4.4. Самые уязвимые по производительности места
    5. Инструменты измерений
      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 является отдельным и при этом самым важным для рендеринга всей системы процессом.


    Как исследовать проблемы. Рекомендации


    Когда вы разбираетесь, почему падает производительность, обратите внимание на несколько важных моментов. При исследовании проблем производительности следует использовать только реальные устройства и только релизные сборки, где уже присутствуют все оптимизации и отключён дебаггер.


    Помните: сначала измерения — потом оптимизация кода! И по возможности попросите коллег проверить результаты исследования проблем — ошибиться и прийти к неверным выводам очень легко.




    Заключение


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




    Источники информации


    Благодарю коллег, которые работают или работали в команде ВКонтакте — они очень помогли с поиском и анализом информации. Мы вместе обсуждали плюсы и минусы разных подходов, чтобы найти лучшие.


    Основные источники, на которые я опирался в работе над статьёй:


    1. Документация Apple.
    2. Доклад моего коллеги по Auto Layout.
    3. The Cassowary Linear Arithmetic Constraint Solving Algorithm.
    4. iOS Core Animation: Advanced Techniques. Nick Lockwood.
    5. WWDC 2014 Session 419. Advanced Graphics and Animations for iOS Apps.

    ВКонтакте
    Компания

    Похожие публикации

    Комментарии 10

      +3
      Спасибо, оч круто
        0
        спасибо)
        0
        Спасибо, очень интересно!
        И хорошо показано что и в мобильной разработке можно до бесконечности копать в глубину и ширину.
        Судя по примерам вы используете Obj-C, но правильно ли я понимаю, что все то же самое можно написать на swift?
        Ещё интересно не проводили ли вы сравнительного тестирования похожих UI на классике и SwiftUI?
          0
          спасибо) всё так, в основном код на Obj-C, хотя уже есть отдельные модули на Swift, но описанные подходы не зависят от языка. В плане SwiftUI не подскажу, не изучал его, только слегка посмотрел в документации, что он похож на наш декларативный layout. Но в плане исследования производительности, думаю, там применимы те же подходы, так как система рендеринга должна быть общая.
          0

          “ При росте количества ограничений в layout в худшем случае вычисления могут замедлиться экспоненциально.”


          Если не ошибаюсь, с iOS 12 SDK(или рантайма, нужно проверить) это уже не актуально, зависимость стала линейной.

            +1
            Я немного по-другому понял доклады инженеров Apple. На сколько я понимаю, они обещают линейную производительность на отображениях без сложных связей между «соседями ветвями иерархии». То есть, если ограничения выставлены, например, только не далее связи «родитель-потомок». Не видел информации, чтобы появился более эффективный алгоритм решения системы ограничений. Но, возможно, я ошибаюсь.
          0
          Обратите внимание на методы boundingRect и sizeWithAttributes. Не советую с их помощью считать размер содержимого UILabel/UITextView/UITextField.

          В достаточно серьезном докладе Как мы делаем лейаут пользовательского интерфейса — Андрей Мишанин в ответах на вопросы слушателей автор как раз упоминает, что они кэшируют размеры строчек через boundingRect...

          Ваше мнение, насколько оправдано использование этих методов? Может в какой-то цифре вы сможете выразить негативные последствия их использования, т.е., предположим, вы имеете опыт, который показывает, что лишь ХХ% времени эти функции ведут себя не так адекватно как мы ожидаем?
            +1

            1) Моё мнение такое, что нужно использовать связку UILabel/UITextView и методов boundingRect с предосторожностью, так как я нигде в документации не нашёл подтверждения, что в основе layout NSString/NSAttributedString лежит используемый UILabel/UITextView TextKit. То есть, тот факт, что boundingRect в каких-то случаях (в случае UILabel) совпадает с результатами работы TextKit — недокументированное поведение. Но если будет подтверждение, что в основе лежат абсолютно одни и те же методы, и в эти методы передаются абсолютно идентичные аргументы — конечно, можно использовать boundingRect + UILabel. Для примера код playground. Здесь можно заметить, что результаты UILabel.sizeThatFits и NSAttributedString совпадают, если округлить в бОльшую сторону результат boundingRect с учётом screen scale, то есть до ближайшего бОльшего количества пикселей. Для UITextView значения уже не совпадают, хотя согласно документации TextKit в UITextView тоже используется TextKit, но, явно, с другими параметрами (причём, этими параметрами мы сами можем управлять). Конечно, можно и для UITextView попробовать подобрать параметры boundingRect, но насколько это корректно и детерминировано?
            2) Мы сейчас в Ленте не используем boundingRect но из прошлого опыта смутно помню, что раньше приходилось подбирать параметры и константы отступов, чтобы результат boundingRect совпадал с тем, как UILabel располагает текст внутри себя.

              0
              Спасибо за ответ и за публикацию, делитесь чаще подобным бесценным опытом!

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое