Привет, Хабр! Меня зовут Виталий Барабанов, я 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 утра по Москве (когда все ещё спят и нагрузки минимальные). Благодаря этому графики стали загружаться мгновенно.

Сложности и инсайты

Конечно, не всё получилось гладко.

  1. Система может срендерить кадр быстрее ожидаемого, поэтому его нужно ограничить максимумом значения targetTimestamp.

  2. Apple измеряет Hitch Rate только при скроллинге, а мы считаем шире, при этом динамика совпадает.

  3. Есть два подхода к расчётам FPS: единица на продолжительность кадра (бывают выбросы как те, что описаны выше), либо количество кадров в единицу времени (надо хранить, писать и чистить целый массив данных).

  4. Количество данных огромно – у нас оно достигало 40 млн записей в день! Так очень легко положить БД.

  5. На первый взгляд кажется удобным строить графики напрямую из базы: не надо писать скрипты и дополнительную логику. Но при больших объёмах данных и высокой нагрузке они грузятся до 15 минут.

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

Новые метрики и их интерпретация

Что мы получили в итоге? Всё, что и планировали: теперь в режиме реального времени мы можем наблюдать за динамикой, фильтровать данные на доске, по экранам, по самому приложению, по типу приложения, по версии ОС. 

В результате:

  • Удалось снизить долю временно пропущенных кадров в 5 раз

  • Выделили самые проблемные экраны благодаря детализации

  • Реализовали в качестве бонуса утилитку наблюдения в реальном времени в дебаг-панели

Что же мы узнали и как интерпретировали результаты?

  • Про частоту кадров. Тут мы молодцы: средний показатель по FPS на всех экранах близок к максимальному. Референсным выступает iPhone 11, а там 60 кадров – это всегда максималка.

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

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

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

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

Итого от идеи до реализации прошло несколько месяцев: мы разработали инфраструктуру, выделили мощности, согласовали, разработали со стороны приложения, собрали нужные данные и уже после – провели оптимизацию.

Выводы

А выводы простые: 

  • Оптимизировать работу UI важно. Это напрямую влияет на пользовательский опыт и удержание

  • Собственные метрики позволяют получить больше информации

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

В общем, тратьте время и деньги на инфраструктуру и сбор метрик – это ощутимо скажется на фидбэке пользователей.

А как вы оптимизировали UI в вашей компании? Какие данные собираете и как их анализируете? Буду рад, если поделитесь вашим опытом в комментариях!