Меню
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. Розуміти, чому ваш воркєраунд працює, не менш цінно, ніж сам воркєраунд. Це знання окупиться в майбутніх проектах, де ви, можливо, знайдете спосіб усунути першопричину безпосередньо.