Меню
Android: Троттлинг перерисовок — как убрать jank с Macrobenchmark и Perfetto

Android: Троттлинг перерисовок — как убрать jank с Macrobenchmark и Perfetto

от Николай Власов

Когда UI начинает тормозить, простым взглядом причину не найти — нужны мощные инструменты. В этой статье мы покажем, как использовать профессиональный арсенал Android-разработчика — Macrobenchmark и Perfetto — для охоты на "janky frames" в view-анимации. Вы пройдете весь путь: от измерения проблемы до выявления её причины и применения прицельного воркэраунда — троттлинга перерисовок — чтобы убрать видимые подтормаживания.

Привет! В моей предыдущей статье мы интегрировали LeakCanary для отлова утечек памяти, используя в качестве подопытного ShimmerView. Этот компонент отлично справлялся со своей задачей, но на некоторых устройствах я начал замечать подтормаживания анимации — те самые "janky frames", которые портят пользовательский опыт.

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

В этой статье я покажу весь путь исследования производительности ShimmerView:

  1. Написание Macrobenchmark для получения объективных и воспроизводимых метрик производительности.
  2. Анализ трейсов в Perfetto UI с помощью SQL-запросов для поиска узкого места.
  3. Формулирование и проверка гипотезы о причине просадок FPS.
  4. Реализация воркэраунда (троттлинг перерисовок) на основе полученных данных.
  5. Проверка результата и подведение итогов.

Глава 1: Измерение проблемы с помощью Macrobenchmark

Чтобы понять, насколько все плохо, нам нужны конкретные цифры. Идеальным инструментом для этого является Android Macrobenchmark. Он позволяет запускать UI-сценарии на реальных устройствах и собирать метрики производительности, например, FrameTimingMetric, который отслеживает время отрисовки каждого кадра.

В идеальном мире для получения полной картины производительности тесты нужно проводить на широком спектре устройств: от бюджетных моделей до флагманов. Это помогает понять, как приложение ведет себя при различных мощностях процессора, объеме оперативной памяти и разрешении экрана.

Однако для первоначальной оценки и выявления самых очевидных проблем достаточно взять одно, но показательное устройство. В моем случае выбор пал на Huawei Honor 10i под управлением Android 9. Этот смартфон, выпущенный в 2019 году, оснащен процессором Kirin 710 и 4 ГБ оперативной памяти, что на сегодняшний день можно считать достаточно скромными характеристиками. Если анимация "тормозит" на таком устройстве, проблема, скорее всего, будет заметна и на более мощных моделях, хоть и в меньшей степени. Таким образом, тестирование на заведомо слабом аппарате позволяет эффективно выявлять узкие места в производительности.

Вот так выглядит мой тест для базовой, неоптимизированной версии ShimmerView:

@RunWith(AndroidJUnit4::class)
class AnimationBenchmark {

    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun shimmerAnimationBasic() {
        measureAnimation(
            testName = "shimmer_basic",
            stringResName = "basic_shimmer",
            viewToWaitForId = "basic_shimmer_view"
        )
    }

    // ...

    private fun measureAnimation(
        testName: String,
        stringResName: String,
        viewToWaitForId: String
    ) {
        benchmarkRule.measureRepeated(
            packageName = "com.ndev.android.ui.sample",
            metrics = listOf(FrameTimingMetric()),
            iterations = 3,
            startupMode = StartupMode.WARM, // Имитируем запуск из памяти
            setupBlock = {
                // Подготовка: запускаем Activity, переходим на нужный экран
                startActivityAndWait()
                navigateToScreen(stringResName, viewToWaitForId)
                ensureAnimationStopped()
            }
        ) {
            // Измерение: запускаем анимацию и ждем
            startAnimation()
            Thread.sleep(35_000L) // ANIMATION_DURATION_MS
            stopAnimation()
        }
    }
    // ... остальной код теста
}

Почему анимация длится 35 секунд?

Многие проблемы с производительностью носят накопительный характер. Короткий тест (1-2 секунды) может не выявить проблемы, связанные с троттлингом процессора из-за нагрева, давлением на Garbage Collector или другими эффектами, которые проявляются со временем. Длительное измерение дает нам более полную и честную картину.

После запуска shimmerAnimationBasic мы получаем следующие результаты:

frameCount           min 2,179.0,   median 2,184.0,   max 2,187.0
frameDurationCpuMs   P50     12.2,   P90     27.1,   P95     28.6,   P99     29.2
Traces: Iteration 0 1 2

Анализ результатов:

  • P50 (медиана) в 12.2 мс выглядит неплохо. Это меньше 16.6 мс, необходимых для 60 FPS.
  • Но P90 (90% кадров рендерятся медленнее этого значения), P95 и P99 — это катастрофа. 29.2 мс на 99-м перцентиле означает, что в худшие моменты наш FPS падает до ~34 (1000 / 29.2). Это и есть те самые "jank"-и.

Теперь у нас есть не просто ощущение, а конкретные цифры и трейс для глубокого анализа.

Глава 2: Погружение в трейс с Perfetto

Открываем один из трейсов, сгенерированных бенчмарком, в Perfetto UI. Перед нами — огромный объем данных. Чтобы найти в нем смысл, нужно задавать правильные вопросы.

Навигатор расследования: Wall Time vs. CPU Time

Первый и самый важный вопрос: поток занят работой или чего-то ждет?

  • Wall Time (или Wall Duration) — это общее время, прошедшее от начала до конца выполнения функции. "Время на настенных часах".
  • CPU Time (или CPU Duration) — это время, которое процессор реально тратил на выполнение инструкций этого потока.

Если Wall Time ≈ CPU Time, значит, процессор был полностью загружен вычислениями. Проблема в нашем коде — он слишком "тяжелый". Нужно искать, какие конкретно методы (onMeasure, onDraw, inflate) занимают много времени, и оптимизировать их.

Если Wall Time >> CPU Time, значит, большую часть времени поток просто простаивал в ожидании. Ожидать можно ответа от диска (I/O), сети, другого процесса (через Binder) или, что часто бывает в UI, от GPU.

Чтобы проверить это, используем SQL-запрос. Сначала найдем все "проблемные" кадры (те, что длились дольше 16.6 мс), а затем посмотрим, чем в это время занимался главный поток.

-- Шаг 1: Находим все "janky" кадры в UI потоке нашего приложения
WITH janky_frames AS (
  SELECT
    ts, -- timestamp начала
    dur -- длительность
  FROM slice
  JOIN thread_track ON slice.track_id = thread_track.id
  JOIN thread ON thread_track.utid = thread.utid
  JOIN process ON thread.upid = process.upid
  WHERE
    process.name = 'com.ndev.android.ui.sample'
    AND slice.name = 'Choreographer#doFrame'
    AND slice.dur > 16666666 -- Длительность > 16.6 мс (в наносекундах)
)
-- Шаг 2: Агрегируем все операции в главном потоке, которые происходили ВНУТРИ этих janky-кадров
SELECT
  slice.name AS operation_name,
  SUM(slice.dur) / 1000000 AS total_wall_duration_ms,
  SUM(
    (SELECT SUM(sched.dur)
     FROM sched
     WHERE
       sched.utid = thread.utid
       AND sched.ts >= slice.ts AND sched.ts < slice.ts + slice.dur
    )
  ) / 1000000 AS total_cpu_duration_ms,
  COUNT(*) AS frequency
FROM slice
JOIN thread_track ON slice.track_id = thread_track.id
JOIN thread ON thread_track.utid = thread.utid
JOIN janky_frames ON
  slice.ts >= janky_frames.ts AND slice.ts < janky_frames.ts + janky_frames.dur
WHERE
  thread.is_main_thread = 1
  AND slice.dur > 1000000 -- Игнорируем слишком короткие операции
GROUP BY
  slice.name
ORDER BY
  total_wall_duration_ms DESC
LIMIT 25;

Результат выполнения этого запроса говорит о многом:

name total_wall_duration_ms total_cpu_duration_ms frequency
Choreographer#doFrame 619 67 37
traversal 591 65 32
draw 575 51 32
onMessageReceived 545 189 113
handleMessageRefresh 251 87 51
... ... ... ...

Смотрим на первую строку: Choreographer#doFrame, корневая операция отрисовки кадра. Wall Time (619 мс) почти в 10 раз больше, чем CPU Time (67 мс)!

Наша гипотеза: UI поток не загружен работой, он ждет. Учитывая, что ShimmerView — это чисто визуальный компонент, который постоянно перерисовывается, самый вероятный виновник — GPU. UI поток слишком часто отправляет команды на отрисовку и ждет, пока RenderThread и GPU с ними справятся.

Глава 3: Проверка гипотезы о загрузке GPU

Проверим эту гипотезу. Напишем запрос, который посмотрит, чем был занят RenderThread в моменты, когда UI поток ловил jank-и.

WITH janky_frames AS (
  -- (такой же, как в предыдущем запросе)
  SELECT ts, dur FROM slice
  JOIN thread_track ON slice.track_id = thread_track.id
  JOIN thread ON thread_track.utid = thread.utid
  JOIN process ON thread.upid = process.upid
  WHERE
    process.name = 'com.ndev.android.ui.sample'
    AND slice.name = 'Choreographer#doFrame'
    AND slice.dur > 16666666
),
render_thread AS (
  -- Находим RenderThread нашего приложения
  SELECT utid
  FROM thread
  WHERE name = 'RenderThread' AND upid = (
    SELECT upid FROM process WHERE name = 'com.ndev.android.ui.sample' LIMIT 1
  )
)
SELECT
  slice.name,
  SUM(slice.dur) / 1000000 AS total_duration_ms,
  COUNT(*) as frequency
FROM slice
JOIN janky_frames ON
  -- Ищем операции, которые ПЕРЕСЕКАЮТСЯ по времени с janky-кадрами
  slice.ts < janky_frames.ts + janky_frames.dur AND slice.ts + slice.dur > janky_frames.ts
WHERE
  -- Нас интересуют срезы только из RenderThread
  slice.track_id = (SELECT id FROM thread_track WHERE utid = (SELECT utid FROM render_thread))
GROUP BY
  slice.name
ORDER BY
  total_duration_ms DESC
LIMIT 20;

Результаты подтверждают нашу теорию:

name total_duration_ms frequency
DrawFrame 1021 58
binder transaction 409 188
dequeueBuffer 382 57
eglSwapBuffersWithDamageKHR 226 29
queueBuffer 97 29
... ... ...

Здесь мы видим полный набор "тяжелых" операций, связанных с графикой: DrawFrame, dequeueBuffer, eglSwapBuffers. Это прямые доказательства того, что RenderThread активно работает с GPU, пытаясь отрисовать наши кадры.

Вывод: Проблема не в сложности самого ShimmerView (CPU Time был низким), а в частоте его перерисовки. Наш ValueAnimator на каждом обновлении значения вызывает postInvalidateOnAnimation(), заставляя систему рендеринга работать на износ.

Глава 4: Воркэраунд — адаптивный троттлинг перерисовок

Поскольку устранить первопричину — накладные расходы на каждый draw-вызов и конкуренцию за GPU-конвейер — без серьёзной переработки компонента сложно, мы применим прагматичный воркэраунд: троттлинг частоты перерисовок, чтобы рендер-конвейер не захлёбывался. По сути, это хак: мы не делаем каждый кадр дешевле, а просто запрашиваем меньше кадров.

Мы реализуем механизм адаптивного пропуска кадров (троттлинг). Логика такая:

  1. С помощью ValueAnimator мы всё так же вычисляем положение градиента.
  2. Но postInvalidateOnAnimation() вызываем не всегда, а только если с момента последней перерисовки прошло достаточно времени (например, > 16 мс).
  3. Чтобы адаптироваться к нагрузке, используем экспоненциальное скользящее среднее (EMA) для расчёта реального времени между кадрами. Если система под нагрузкой и кадры рендерятся медленно, мы перестаём пытаться «протолкнуть» лишние обновления — это только усугубило бы ситуацию.

Вот ключевой фрагмент кода троттлинга в ValueAnimator.addUpdateListener:

addUpdateListener { animation ->
    shimmerTranslate = animation.animatedValue as Float

    val now = System.nanoTime()
    // Обновляем EMA для расчета среднего времени кадра
    if (lastAnimatorUpdateNs != 0L) {
        val delta = (now - lastAnimatorUpdateNs).toDouble()
        emaFrameNs = emaFrameNs * (1.0 - emaAlpha) + delta * emaAlpha
    }
    lastAnimatorUpdateNs = now

    // Определяем, сколько нужно ждать до следующего кадра
    // Либо наше адаптивное значение, либо целевое (16.6 мс)
    val adaptiveFrameNs = if (useAdaptiveThrottling) {
        emaFrameNs.coerceAtLeast(targetFrameNs.toDouble()).toLong()
    } else {
        targetFrameNs
    }
    // Применяем жесткий лимит, чтобы не останавливаться совсем
    val allowedFrameNs = adaptiveFrameNs.coerceAtLeast(minFrameNsHardLimit)

    // Вызываем invalidate, только если прошло достаточно времени
    if (now - lastInvalidateNs >= allowedFrameNs) {
        lastInvalidateNs = now
        postInvalidateOnAnimation()
    }
}

Глава 5: Проверка результатов

С новым кодом запускаем бенчмарк shimmerAnimationOpt. Результаты говорят сами за себя:

AnimationBenchmark_shimmerAnimationOpt
frameCount           min 1,446.0,   median 1,447.0,   max 1,449.0
frameDurationCpuMs   P50     11.0,   P90     12.2,   P95     13.3,   P99     15.4

Сравним "до" и "после":

Метрика shimmerAnimationBasic (До) shimmerAnimationOpt (После) Изменение
frameCount (median) 2,184 1,447 -33.7%
frameDurationCpuMs P95 28.6 мс 13.3 мс -53.5%
frameDurationCpuMs P99 29.2 мс 15.4 мс -47.3%

Троттлинг перерисовок сократил количество отрисованных кадров (frameCount) на треть, что сняло давление с GPU-конвейера. 99-й перцентиль времени кадра упал до 15.4 мс — теперь укладывается в бюджет 16.6 мс для 60 FPS.

Важная оговорка: это компромисс, а не бесплатный выигрыш. Мы не ускорили отрисовку каждого кадра — мы просто перестали запрашивать больше кадров, чем конвейер способен переварить. Анимация выглядит плавнее, потому что исчезли пики jank, но технически мы рендерим меньше уникальных кадров в секунду. На этом устройстве визуальная разница незаметна, но важно понимать: это воркэраунд, а не фундаментальная оптимизация.

Выводы

  1. Не доверяйте ощущениям, измеряйте. Android Macrobenchmark — мощный инструмент для получения объективных данных о производительности UI.
  2. Wall Time vs CPU Time — ваш первый шаг в анализе трейсов. Этот подход моментально показывает, в чем корень зла: в "тяжелом" коде или в ожиданиях.
  3. Perfetto SQL — это суперсила. Возможность делать точные запросы к данным трассировки позволяет быстро проверять гипотезы и не блуждать в догадках.
  4. Не можешь ускорить — придроссели. Иногда проблема производительности не в том, что вы рисуете, а в том, что вы перегружаете рендер-конвейер избыточными запросами. Троттлинг кадров — прагматичный хак: он ничего не ускоряет, но не даёт системе захлебнуться. Знайте компромиссы и применяйте его осознанно.

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