Привет, Хабр! Меня зовут Виталий Барабанов, я 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 в вашей компании? Какие данные собираете и как их анализируете? Буду рад, если поделитесь вашим опытом в комментариях!
