Привет, Хабр! Меня зовут Виталий Барабанов, я iOS-разработчик в hh.ru. Недавно мы столкнулись с проблемой: пришёл фидбэк от пользователей о тормозах и фризах в мобильном приложении. Но чтобы установить их причину, нам не хватило данных, которые по дефолту собирает Apple.
Что делать в такой ситуации? А запилить собственный сбор метрик: с какой угодно фильтрацией, сбором любой информации, интеграцией со своей аналитикой и наблюдением в дебаг-панели! В этой статье я хочу поделиться с Хабром, как мы это сделали, сколько времени потратили и как интерпретировали результаты.
Наши приложения и дизайн-система
В hh.ru есть два приложения – для работодателей и для соискателей. Но разновидностей приложений под разные страны с учётом местного законодательства ещё больше – пять. Это суммарно 537 экранов разной сложности для iOS-платформы. Масштабы отличаются: от маленьких и простых до огромных, при этом каждую платформу посещает до миллиона пользователей в день.

Для вёрстки мы используем собственную дизайн-систему Magritte, реализованную с помощью SwiftUI, для ��авигации – Nivelir, его можно посмотреть и попробовать по ссылке.
Также мы используем собственный UI Toolkit Levitan для создания приложений удобным декларативным способом на базе SwiftUI и UIKit.
Как мы столкнулись с фризами и недостатком данных
При переходе на новые компоненты мы стали получать комментарии о фризах. Пользователи писали, что экран тормозит и подвисает. Мы задумались: а что значит тормозит, как это определить? Если действительно тормозит, то где? Стоит ли с этим вообще возиться?
По дефолту Apple выделяет два показателя:
Hitch Rate (пропуск кадров) – если кадр не успевает отрисоваться за выделенное ему время, то он пропускается. Для пользователей это выглядит как задержка в анимации и неприятное подёргивание.
Hang Rate (зависание) – когда UI не отвечает на действия пользователя долгое время, по документации Apple от 250 миллисекунд.
Чтобы дополнить контекст, приведу статистику из актуальных UI-исследований (первое, второе, третье, четвёртое): задержки больше 300 миллисекунд при взаимодействии с UI негативно влияют на удержание пользователей. При таких зависаниях они могут уйти на платформу конкурентов.
Ещё одно важное для нас значение – FPS (частота кадров в секунду). Для эффективной адаптации частоты кадров Apple использует технологию ProMotion. Она автоматически регулирует скорость обновления экрана в зависимости от контента, который отображается. Для экономии заряда батареи и снижении нагрузки на ЦП при статичных кадрах система снижает частоту обновления. При скроллинге или анимации система может поднять частоту обновления до 120 Гц.
Согласно исследованию, если FPS при работе с приложением падает более чем на 20%, треть пользователей уходит.
Как Apple предлагает с этим работать, и как мы сами взялись за дело
Apple предлагает использовать встроенную в Xcode утилиту Organizer, которая собирает визуальные показатели. Также можно использовать MetricKit, которая требует отдельного внедрения в проект и позволяет отслеживать производительность на уровне процессора и памяти.
Что в них хорошего:
Обе системы встроены в экосистему Apple
Минимально нагружают систему, поэтому наблюдение за данными не влияет на сами данные
На хранение и агрегацию данных не нужны дополнительные ресурсы
Есть и минусы (и для нас они оказались решающими):
Отсутствует возможность получить данные по конкретному экрану, только по всему приложению в целом
Сбор данных занимает от двух дней… до четырёх… или до никогда – всё зависит от объёма информации, полученной для версии приложения. Если релизы частые, а данных недостаточно – есть риск вообще не получить агрегации и данных на графике
Работа возможна только из Organizer
Нет доступа к сырым данным
Собственные пороги метрик нельзя закастомить
А ещё можно вообще не получить статистику, если пользователь отключит сбор данных со своего устройства
Поэтому мы решили делать собственное решение, которое позволит:
Фильтровать данные по экранам и версиям iOS
Снимать дополнительную статистику: статус соединения с сетью, включено или выключено энергосбережение (оно сильно влияет на UI)
Интегрироваться с собственной аналитикой
Реализация проекта
Как мы реализовали собственное решение? Разложу процесс по пунктам.
// Прежде всего, создаём в приложении перфоманс-трекер,
// в котором определяем вычисляемые свойства и свойства-помощники
public final class ScreenPerformanceTracker {
private enum Thresholds {
static let hang: TimeInterval = 0.25
}
public var averageFPS: Double {
duration > .leastNonzeroMagnitude
? Double(frameCount) / duration
: .zero
}
public var hitchRate: Double {
duration > .leastNonzeroMagnitude
? 1000.0 * hitchDuration / duration
: .zero
}
public var hangRate: Double {
duration > .leastNonzeroMagnitude
? 3600.0 * hangDuration / duration
: .zero
}
private var lastFrameTimestamp: TimeInterval = .zero
private var lastTargetFrameTimestamp: TimeInterval = .zero
public private(set) var duration: TimeInterval = .zero
public private(set) var frameCount: Int = .zero
public private(set) var firstFPS: Double?
public private(set) var minFPS = Double(UIScreen.main.maximumFramesPerSecond)
public private(set) var maxFPS: Double = .zero
public private(set) var hitchDuration: TimeInterval = .zero
public private(set) var hangDuration: TimeInterval = .zero
// Настраиваем механизм обновления экрана в трекере.
// Подписываемся на уведомления жизненного цикла приложения и устанавливаем DisplayLink в инициализаторе
init(
...
) {
...
setupDisplayLink()
subscribeToAppNotifications()
}
// Сетим DisplayLink
private func setupDisplayLink() {
// Выходим если DisplayLink уже создан
guard displayLink == nil else {
return
}
// Если ещё не создан, то создаём CADisplayLink, привязанный к методу onDisplayLinkUpdate
// Он будет вызываться при каждом обновлении экрана
let displayLink = CADisplayLink(
target: self,
selector: #selector(onDisplayLinkUpdate)
)
// Добавляем созданный CADisplayLink в run loop
displayLink.add(
to: .main,
forMode: .common
)
self.displayLink = displayLink
// Сохраняем метки текущего времени кадра и целевого времени обновления,
// чтобы начать отслеживание с корректной точки
lastFrameTimestamp = displayLink.timestamp
lastTargetFrameTimestamp = displayLink.targetTimestamp
}
// Деинитим DisplayLink
private func resetDisplayLink() {
displayLink?.remove(
from: .main,
forMode: .common
)
displayLink = nil
}
// Подписываемся на методы жизненного цикла.
private func subscribeToAppNotifications() {
// При возврате приложения из фона запускается CADisplayLink (setupDisplayLink())
NotificationCenter
.default
.publisher(for: UIApplication.didBecomeActiveNotification)
.sink { [weak self] _ in
self?.setupDisplayLink()
}
.store(in: cancellableBag)
// При уходе в фон или блокировке останавливается CADisplayLink (resetDisplayLink())
NotificationCenter
.default
.publisher(for: UIApplication.willResignActiveNotification)
.sink { [weak self] _ in
self?.resetDisplayLink()
}
.store(in: cancellableBag)
}
// После этого добавляем основной метод расчётов.
// На выходе из метода сохраняем метки времени текущего кадра.
@objc
private func onDisplayLinkUpdate(_ displayLink: CADisplayLink) {
defer {
lastFrameTimestamp = displayLink.timestamp
lastTargetFrameTimestamp = displayLink.targetTimestamp
}
guard lastFrameTimestamp > .leastNonzeroMagnitude else {
return
}
// Считаем длительность кадра
let frameTimestamp = max(displayLink.timestamp, lastTargetFrameTimestamp)
let frameDuration = frameTimestamp - lastFrameTimestamp
// Пропускаем нулевую длительность
guard frameDuration > .leastNonzeroMagnitude else {
return
}
// Сохраняем первый FPS
if firstFPS == nil {
return firstFPS = 1.0 / frameDuration
}
// Обновляем счётчики
duration += frameDuration
frameCount += 1
let currentFPS = 1.0 / frameDuration
if minFPS > currentFPS {
minFPS = currentFPS
}
if maxFPS < currentFPS {
maxFPS = currentFPS
}
// Обновляем hangDuration и hitchDuration
if frameDuration > Thresholds.hang {
hangDuration += frameDuration - Thresholds.hang
}
hitchDuration += max(displayLink.timestamp - lastTargetFrameTimestamp, .zero)
}
// Сбрасываем статистику при смене экрана
public func reset() {
duration = .zero
frameCount = .zero
firstFPS = nil
minFPS = Double(UIScreen.main.maximumFramesPerSecond)
maxFPS = .zero
hitchDuration = .zero
hangDuration = .zero
}
// И, наконец, один из самых важных поинтов – устанавливаем точку входа,
// которая вызывается при смене контекста (экрана) и передаёт событие в аналитику.
// В нашем проекте этот метод вызывается автоматически сервисом, отслеживающим переходы между экранами.
public func track() {
guard let firstFPS, frameCount > 1 else {
return reset()
}
ScreenPerformanceEvent.track(
// ... event params
)
reset()
}
}Инфраструктурная реализация
Изначально мы построили маршрут передачи данных с девайса пользователя на БД, а оттуда – визуализацию в Grafana. Такой подход казался самым простым в реализации, однако имел важный недостаток: данные графиков грузились очень долго из-за нагрузки на БД. В базу пишутся все события, и параллельно ею пользуются аналитики.

Поэтому мы расширили маршрут, добавив промежуточное звено: скрипт на питоне. Он обращается в БД через библиотеку Trino, формирует набор готовых данных из нужных нам комбинаций за выбранный диапазон времени и передаёт в легковесную базу InfluxBD. Так мы из миллиарда (!) записей за тридцать дней получили всего-то 40 тысяч. Скрипт вызывается в 7 утра по Москве (когда все ещё спят и нагрузки минимальные). Благодаря этому графики стали загружаться мгновенно.

Сложности и инсайты
Конечно, не всё получилось гладко.
Система может срендерить кадр быстрее ожидаемого, поэтому его нужно ограничить максимумом значения targetTimestamp.
Apple измеряет Hitch Rate только при скроллинге, а мы считаем шире, при этом динамика совпадает.
Есть два подхода к расчётам FPS: единица на продолжительность кадра (бывают выбросы как те, что описаны выше), либо количество кадров в единицу времени (надо хранить, писать и чистить целый массив данных).
Количество данных огромно – у нас оно достигало 40 млн записей в день! Так очень легко положить БД.
На первый взгляд кажется удобным строить графики напрямую из базы: не надо писать скрипты и дополнительную логику. Но при больших объёмах данных и высокой нагрузке они грузятся до 15 минут.
Выделение ресурсов для сбора метрик бывает непросто аргументировать бизнесу, пусть даже техническая необходимость нам очевидна.
Новые метрики и их интерпретация
Что мы получили в итоге? Всё, что и планировали: теперь в режиме реального времени мы можем наблюдать за динамикой, фильтровать данные на доске, по экранам, по самому приложению, по типу приложения, по версии ОС.

В результате:
Удалось снизить долю временно пропущенных кадров в 5 раз
Выделили самые проблемные экраны благодаря детализации
Реализовали в качестве бонуса утилитку наблюдения в реальном времени в дебаг-панели
Что же мы узнали и как интерпретировали результаты?
Про частоту кадров. Тут мы молодцы: средний показатель по FPS на всех экранах близок к максимальному. Референсным выступает iPhone 11, а там 60 кадров – это всегда максималка.

Про просадки. Видим дропы до 13 кадров в секунду на загрузках! С этим придётся работать – нашли задачку ребятам из дизайн-системы и продуктовых команд, то-то они обрадуются…

Про количество кадров при открытии. Очень низкий показатель! Но вот на него можно не обращать внимания – он закрыт анимашками, загрузками данных и т.д. – пользователь просто его не видит.

Про Hang Rate (зависание). Тут всё стабильно, без зависаний. Держим уровень.

Про Hitch Rate (пропуск кадров). Показательный прогресс! От версии 7.117 к версии 7.129 смогли сократить задержки анимации в пять раз. Показатель в 23.1 мс может выглядеть пугающим, потому что в Organizer даже 5 мс – уже много. Но тут важен контекст: Organizer отслеживает Hitch только в момент скролла контента. В нашем случае Hitch отслеживается на период жизни экрана. Как и писал выше: динамика сопоставима, но данных по «производительности» экрана больше.

Итого от идеи до реализации прошло несколько месяцев: мы разработали инфраструктуру, выделили мощности, согласовали, разработали со стороны приложения, собрали нужные данные и уже после – провели оптимизацию.
Выводы
А выводы простые:
Оптимизировать работу UI важно. Это напрямую влияет на пользовательский опыт и удержание
Собственные метрики позволяют получить больше информации
Метрики позволяют не только находить проблемы, но и подтверждать улучшения после оптимизаций
В общем, тратьте время и деньги на инфраструктуру и сбор метрик – это ощутимо скажется на фидбэке пользователей.
А как вы оптимизировали UI в вашей компании? Какие данные собираете и как их анализируете? Буду рад, если поделитесь вашим опытом в комментариях!
