Android: Троттлинг перерисовок — как убрать jank с Macrobenchmark и Perfetto
от Николай Власов
Когда 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: Воркэраунд — адаптивный троттлинг перерисовок
Поскольку устранить первопричину — накладные расходы на каждый draw-вызов и конкуренцию за GPU-конвейер — без серьёзной переработки компонента сложно, мы применим прагматичный воркэраунд: троттлинг частоты перерисовок, чтобы рендер-конвейер не захлёбывался. По сути, это хак: мы не делаем каждый кадр дешевле, а просто запрашиваем меньше кадров.
Мы реализуем механизм адаптивного пропуска кадров (троттлинг). Логика такая:
- С помощью
ValueAnimatorмы всё так же вычисляем положение градиента. - Но
postInvalidateOnAnimation()вызываем не всегда, а только если с момента последней перерисовки прошло достаточно времени (например, > 16 мс). - Чтобы адаптироваться к нагрузке, используем экспоненциальное скользящее среднее (
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, но технически мы рендерим меньше уникальных кадров в секунду. На этом устройстве визуальная разница незаметна, но важно понимать: это воркэраунд, а не фундаментальная оптимизация.
Выводы
- Не доверяйте ощущениям, измеряйте. Android Macrobenchmark — мощный инструмент для получения объективных данных о производительности UI.
- Wall Time vs CPU Time — ваш первый шаг в анализе трейсов. Этот подход моментально показывает, в чем корень зла: в "тяжелом" коде или в ожиданиях.
- Perfetto SQL — это суперсила. Возможность делать точные запросы к данным трассировки позволяет быстро проверять гипотезы и не блуждать в догадках.
- Не можешь ускорить — придроссели. Иногда проблема производительности не в том, что вы рисуете, а в том, что вы перегружаете рендер-конвейер избыточными запросами. Троттлинг кадров — прагматичный хак: он ничего не ускоряет, но не даёт системе захлебнуться. Знайте компромиссы и применяйте его осознанно.
Системный подход, основанный на данных, — даже когда итогом оказывается хак, а не чистое исправление, — даёт более глубокое понимание того, как работает система рендеринга в Android. Понимать, почему ваш воркэраунд работает, не менее ценно, чем сам воркэраунд. Это знание окупится в будущих проектах, где вы, возможно, найдёте способ устранить первопричину напрямую.