Меню
Android: Плавные анимации с Macrobenchmark и Perfetto

Android: Плавные анимации с Macrobenchmark и Perfetto

2025-10-26 by Николай Власов

Когда 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: Решение — адаптивное управление частотой отрисовки

Раз проблема в избыточных перерисовках, решение — сократить их количество до разумного предела. Нам не нужно обновлять кадр чаще, чем это позволяет дисплей (обычно 60 Гц, или 16.6 мс на кадр).

Мы реализуем механизм адаптивного пропуска кадров. Логика такая: 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. Анимация стала плавной.

Выводы

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

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