Android: Плавные анимации с Macrobenchmark и Perfetto
2025-10-26 by Николай Власов
Когда UI начинает тормозить, простым взглядом причину не найти — нужны мощные инструменты. В этой статье мы покажем, как использовать профессиональный арсенал Android-разработчика — Macrobenchmark и Perfetto — для охоты на "janky frames" в view-анимации. Вы пройдете весь путь от измерения проблемы до ее решения и увидите, как анализ данных превращает лагающий интерфейс в идеально плавный.
Привет! В моей предыдущей статье мы интегрировали LeakCanary для отлова утечек памяти, используя в качестве подопытного ShimmerView. Этот компонент отлично справлялся со своей задачей, но на некоторых устройствах я начал замечать подтормаживания анимации — те самые "janky frames", которые портят пользовательский опыт.
Просто смотреть на лагающий UI — не наш метод. Мы, как инженеры, должны подходить к проблеме системно: измерить, проанализировать, найти причину и исправить, а затем снова измерить, чтобы убедиться в успехе.
В этой статье я покажу весь путь исследования производительности ShimmerView:
- Написание Macrobenchmark для получения объективных и воспроизводимых метрик производительности.
- Анализ трейсов в Perfetto UI с помощью SQL-запросов для поиска узкого места.
- Формулирование и проверка гипотезы о причине просадок FPS.
- Реализация оптимизации на основе полученных данных.
- Проверка результата и подведение итогов.
Глава 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. Анимация стала плавной.
Выводы
- Не доверяйте ощущениям, измеряйте. Android Macrobenchmark — мощный инструмент для получения объективных данных о производительности UI.
- Wall Time vs CPU Time — ваш первый шаг в анализе трейсов. Этот подход моментально показывает, в чем корень зла: в "тяжелом" коде или в ожиданиях.
- Perfetto SQL — это суперсила. Возможность делать точные запросы к данным трассировки позволяет быстро проверять гипотезы и не блуждать в догадках.
- Частота важнее сложности. Иногда проблема производительности не в том, что вы рисуете, а в том, как часто вы это делаете. Интеллектуальное управление частотой кадров может дать колоссальный прирост производительности.
Системный подход к оптимизации, основанный на данных, позволяет не только решать конкретные проблемы, но и глубже понимать, как работает система рендеринга в Android. Это знание окупится в будущих проектах.