Кто мы? Андроид-разработчики! Чего мы хотим? Чтобы наши списочки не подлагивали, анимашечки крутились плавно, а переходы между экранами были такими, что глаз радуется. Одним словом: чтобы интерфейс был плавным и отзывчивым, а переходы экранов — быстрыми. Чтобы быть уверенным, что всё действительно плавно и чётко, надо замерять! Но что замерять? Как измерить ту самую плавность, как оценить гладкость анимаций? У кого-нибудь есть плавнометр? Или может у вас есть транзишинометр?
Google даёт нам Macrobenchmark и JunkStats — инструменты для оценки общей отзывчивости и стабильности интерфейса, наши плавнометры. Но этого недостаточно для того, чтобы понять, быстро ли у нас открываются экраны.
Мы поговорим, почему это так, и о том, как правильно оценивать время открытия экрана, ведь это один из самых заметных для пользователя моментов. Будем делать наш транзишинометр и замерять рендер экрана до первого onDraw и до последнего! И не переживайте! Мы посмотрим на то, как это делается и во Fragments, и в Compose.
Погнали!
UI-перформанс — это когда видно!
Поговорим про UI-перформанс. Я предлагаю считать оптимизацией UI-перформанса всё, что касается того, что видно пользователю. Сейчас поясню.
Существуют специфичные участки кода, которые связаны с UI напрямую. Например, этапы measure и layout, где можно из-за излишней вложенности вьюшек создать лаги. Есть этап отрисовки на Canvas — у него свои особенности и рекомендации к коду. Есть API для анимаций и многое другое. Это всё, безусловно, напрямую связано с перформансом UI.
Но что, если мы добавим неоптимальный код в главный поток где-нибудь прямо во ViewModel
или решим, что можно сходить в файл из Fragment
. Всё это может вызвать задержки, потому что главный поток просто задержит отрисовку кадров. Пользователь заметит это сразу, даже если причина не связана напрямую с кодом, специфичным для UI.
Именно поэтому давайте договоримся считать оптимизацией UI-перформанса всё, что влияет на видимую плавность и отзывчивость интерфейса. То, что остаётся за кадром и не сказывается на восприятии пользователем, — это уже другая история, просто оптимизация общего перформанса.
Есть два вида метрик
UI — это бесконечный цикл по генерации и отображению кадров. Если каждый кадр успевает отрисоваться в отведённое ему время, то наш интерфейс плавный и отзывчивый. Но если мы начинаем не успевать отрисовывать кадры, то появляются рывки, задержки, неплавные переходы и т.д. Для таких случаев нам нужны метрики, которые помогают понять, что происходит с производительностью интерфейса. Их можно условно разделить на два типа.
Первый тип — метрики по кадрам. Они помогают измерить плавность: сколько кадров успеваем отрисовать, сколько кадров потеряли, каково среднее время отрисовки и т.д. Эти метрики важны для оценки общей отзывчивости, особенно при скроллинге и анимациях.
Второй тип — линейные метрики, замеряющие время выполнения конкретной операции, например, открытия экрана. Мы просто считаем время от начала до конца операции, чтобы понять скорость и лагучесть перехода.
Таким образом, есть два ключевых подхода — это метрики по кадрам и линейные метрики.
Метрики по кадрам
Для замера плавности по кадрам можно использовать инструменты, такие как JunkStats и Macrobenchmark. Они помогают оценить, насколько стабильно и быстро отрисовываются кадры.
Macrobenchmark — это инструмент, с помощью которого можно написать UI-тест, замеряющий данные по отрисовке кадров для конкретного действия. Для этого достаточно указать метрику FrameTimingMetric
, и она посчитает вам два показателя (с перцентилями, всё как надо):
-
frameDurationCpuMs — сколько времени ушло на отрисовку кадров;
-
frameOverrunMs — сколько времени потратили на отрисовку кадров сверх отведённого.
Как правило, Macrobenchmark используют на стадии деплоя, интегрировав его в процесс CI/CD.
JunkStats — инструмент, который «ловит» junk-кадры. Это такие кадры, которые отрисовывались значительно дольше положенного. Это небольшая библиотека с одним главным листенером JankStats.OnFrameListener
, который возвращает данные о junk-кадре. С этой информацией можно делать что угодно: писать в логи, отправлять на сервер или в аналитику.
Метрики по кадрам — это наши «плавнометры». Я не буду подробно разбирать, как работают эти инструменты. В их документации всё подробно расписано. Мне интереснее перейти к линейным метрикам для замера загрузки экрана, к нашим «транзишинометрам».
Транзишинометр
Что мы хотим замерить? Время от старта рендеринга экрана до момента, когда его увидел пользователь. Всё, что произошло за это время — измерения, лейауты, рекомпозиции и прочее, — должно быть учтено. В данном случае нам важна не средняя длительность кадра на перцентиле 95, а общее время от начала до конца процесса.
Как это замерить? С помощью Firebase Performance Monitoring или любого другого инструмента, который собирает трейсы.
В Firebase Performance Monitoring это будет выглядеть так:
val trace = Firebase.performance.newTrace("MyScreen") // This is the very beginning of render my screen trace.start() // ... // My screen is ready trace.stop()
Либо вы можете самостоятельно замерить время в миллисекундах и отправить событие в вашу систему аналитики. Например, в Dodo Engineering мы шлём трейсы и в Firebase Performance Monitoring, и в собственную систему аналитики.
Я рассмотрю, как замерить такую метрику и в системе View, и в Compose. В обоих случаях легко определить начальную точку замера:
-
для фрагмента — метод
onCreate
; -
для Compose — первая композиция.
Но что взять за конечную точку? В качестве завершения замера хочется использовать какой-то этап отрисовки. Независимо от того, работаем ли мы с фрагментами на основе View или с Compose, у нас есть общий процесс отрисовки кадров, включающий вызовы метода onDraw
. В Android есть замечательный класс, который умеет засекать эти колбэки, — ViewTreeObserver
. Он удобен тем, что работает и для View, и для Compose. А, кстати, почему? Ведь мы привыкли считать ViewTreeObsrever
чем-то из мира View, а не Compose.
Когда мы работаем с Compose, будь то Compose-экран, микс из View и Compose, или полностью Compose-приложение, используется такой класс как ComposeView
. Даже если вы просто вызываете setContent
в Activity, под капотом всё равно создаётся ComposeView
. Этот класс — наша входная точка в мир Compose, это мост из мира View в Compose, и он всегда присутствует. У ComposeView
есть поле root
типа LayoutNode
, — корневой элемент всего Compose-дерева виджетов экрана.
Когда отрисовка проходит от Choreographer
через все ViewGroup
и View
, мы доходим до ComposeView
, и далее вызывается draw
у этого корневого элемента. Вызовы draw
продолжаются дальше вниз, по всему Compose-дереву. То есть цепочка вызовов onDraw — это сквозной процесс. Он не останавливается в мире View
, а продолжается в ComposeView
и дальше по композиции. Поэтому замер перерисовки, подписавшись на onDraw
у ViewTreeObserver
, подходит как для View, так и для Compose UI.
В качестве конечной точки можно использовать 2 варианта
-
первый вариант — до первого колбэка
onDraw
. Я назвал его Until First Draw. В этом случае мы замерим инициализацию всех UI-элементов, измерения, лейауты, рекомпозиции и т.д. Эта метрика нужна, чтобы понять, когда экран первый раз готов к отрисовке; -
второй вариант — до последнего колбэка
onDraw
, Until Last Draw. Эта метрика более сложная и интересная, так как первыйonDraw
— это не всегда момент, когда экран полностью «устаканился». На экране могут быть лагающие анимации или рекомпозиции, приводящие к дополнительным перерисовкам — куча всего. Поэтому отловив последнийonDraw
, мы сможем более точно оценить, когда экран действительно готов.
Рассмотрим оба варианта.
Until First Draw
Разберём, как замерить такую метрику ручками.
Напомню: нам неважно, какой именно инструмент мы будем использовать (будь то Firebase Performance Monitoring или другой). Для примера я буду использовать абстрактный класс Tracer
с методами start(traceName)
, stop(traceName)
и trace(traceName, time)
.
Рассмотрим пример для экранов на фрагментах. Эту метрику можно изобразить следующим образом:
Т.к. мы замеряем до первого onDraw
, в эту метрику войдёт настройка вёрстки экрана, этапы measure и layout.
Теперь напишем код. Будем вызывать первый колбэк в onCreate
, а второй колбэк — в первый onDraw
. Для этого сначала создадим OnFirstDrawListener
. При первом onDraw
он вызовет колбэк onFinish
и тут же отпишется от дальнейших вызовов. Этот класс пригодится нам дальше.
private class OnFirstDrawListener( private val viewTreeObserver: ViewTreeObserver, private val onFinish: (() -> Unit)? = null, ) : OnDrawListener { private var firstOnDrawHappened: Boolean = false override fun onDraw() { if (!firstOnDrawHappened) { firstOnDrawHappened = true onFinish?.invoke() Handler(Looper.getMainLooper()).post { dispose() } } } fun dispose() { if (viewTreeObserver.isAlive) { viewTreeObserver.removeOnDrawListener(this) } } }
Теперь создадим класс UntilFirstDrawTracer
. Он будет выполнять всю логику замера.
Ключевой элемент здесь — LifecycleOwner
(например, получаем его от фрагмента или активити). Далее переопределяем свой DefaultLifecycleObserver
. Он вызывает колбэк onStart()
в onCreate
, а затем подписывается через ViewTreeObserver
на наш OnFirstDrawListener
. О последнем мы написали выше.
class UntilFirstDrawTracer( private val lifecycleOwner: LifecycleOwner, private val onStart: () -> Unit, private val onFinish: () -> Unit, ) { inner class FirstDrawObserver : DefaultLifecycleObserver { private var startTime: Long = 0L private var onFirstDrawListener: OnFirstDrawListener? = null override fun onCreate(owner: LifecycleOwner) { onStart() } override fun onStart(owner: LifecycleOwner) { val viewTreeObserver: ViewTreeObserver? = when (owner) { is Fragment -> owner.view?.viewTreeObserver is Activity -> owner.window.decorView.viewTreeObserver else -> null } viewTreeObserver?.let { nonNullViewTreeObserver -> onFirstDrawListener = OnFirstDrawListener( viewTreeObserver = nonNullViewTreeObserver, onFinish = onFinish, ) nonNullViewTreeObserver.addOnDrawListener(onFirstDrawListener) } } } fun setup() { lifecycleOwner.lifecycle.addObserver(FirstDrawObserver()) } }
Теперь покажу, как использовать этот класс. Например, можно создать экстеншен traceUntilFirstDraw
, который использует UntilFirstDrawTracer
и настраивает его.
fun LifecycleOwner.traceUntilFirstDraw( onStart: () -> Unit, onFinish: () -> Unit, ) { UntilFirstDrawTracer( lifecycleOwner = this, onStart = onStart, onFinish = onFinish, ) .setup() } class MyFragment: Fragment() { override fun onCreate(savedInstanceState: Bundle?) { traceUntilFirstDraw( onStart = { Tracer.start("MyScreen") }, onFinish = { Tracer.end("MyScreen") } ) super.onCreate(savedInstanceState) } }
В итоге во фрагменте нам достаточно вызвать один метод traceUntilFirstDraw
, передав в него два колбэка.
Теперь рассмотрим вариант с одним колбэком, который сразу возвращает время выполнения. Это удобно, когда не нужно отдельно отслеживать начало и конец. Получаем уже рассчитанное время и отправляем его.
Вот что для этого нужно изменить:
private class OnFirstDrawListener( ... private val startTime: Long, private val onMeasured: ((Long) -> Unit)? = null, ) : OnDrawListener { override fun onDraw() { ... val finishTime = SystemClock.elapsedRealtime() onMeasured?.invoke(finishTime - startTime) ... } } }
Мы просто добавили подсчитанное время в метод onDraw
и вызвали onMeasured
. Теперь использовать обновленный OnFirstDrawListener
можно следующим образом:
fun LifecycleOwner.traceUntilFirstDraw( onMeasured: (Long) -> Unit, ) { UntilFirstDrawAutoTracer( lifecycleOwner = this, onMeasured = onMeasured, ) .setup() } class MyFragment: Fragment() { override fun onCreate(savedInstanceState: Bundle?) { traceUntilFirstDraw { time -> Tracer.trace("MyScreen", time) } super.onCreate(savedInstanceState) } }
Полный код этого класса можно найти здесь:
https://gist.github.com/makzimi/167f4db097ff27cb7dda87df47f2dd2a
Until First Draw в Compose
Теперь посмотрим, как реализовать такую же метрику в Jetpack Compose. Т.к. у Compose есть три стадии: Composition, Layout и Drawing, схему этой метрики можно изобразить следующим образом:
Теперь напишем код. Большой плюс здесь в том, что наш OnFirstDrawListener
остаётся таким же, как в предыдущем примере!
@Composable fun UntilFirstDrawTracer(onMeasured: (Long) -> Unit) { val startTime = remember { TimeSource.Monotonic.markNow() } val view = LocalView.current val viewTreeObserver = view.viewTreeObserver DisposableEffect(viewTreeObserver) { val listener = OnFirstDrawListener( viewTreeObserver = viewTreeObserver, onFinish = { onMeasured(startTime.elapsedNow().inWholeMilliseconds) }, ) viewTreeObserver.addOnDrawListener(listener) onDispose { viewTreeObserver.removeOnDrawListener(listener) } } }
Что происходит в этом коде? Мы используем классную штуку — LocalView.current
, которая предоставляет доступ к View
в Compose
. Именно поэтому OnFirstDrawListener
остаётся таким же, как в примере с фрагментами. Мы используем DisposableEffect
, чтобы создать и подписать слушателя на viewTreeObserver
, а затем автоматически отписаться от него при завершении DisposableEffect
.
Преимущество DisposableEffect
как раз в том, что он позволяет нам подписаться на какие-то события в момент, когда компонент появляется, и отписаться, когда он уходит из композиции — то что нам нужно.
Как пользоваться этим кодом? Всё просто:
@Composable fun TopicScreen() { UntilFirstDrawTracer { time -> Tracer.trace("TopicScreen", time) } // UI content for the TopicScreen goes here }
Нам остаётся лишь вызвать UntilFirstDrawTracer
в нужном месте и передать колбэк onMeasured
, чтобы получить время и записать его.
Полный код этого решения можно найти здесь.
Until Last Draw
Теперь рассмотрим другой подход — замер до последнего onDraw
. Этот способ чуть сложнее: нам нужно дождаться того момента, когда все перерисовки завершились и новых вызовов onDraw
больше не будет (или, скорее всего, не будет). Эту метрику можно изобразить примерно так. После onCreate
мы можем получить несколько onDraw
. Нам нужен последний из них:
Для этого мы будем использовать тот же ViewTreeObserver
, но теперь будем отслеживать каждый onDraw
и ждать таймаут, чтобы проверить, вызовется ли onDraw
снова. Если за установленный таймаут новый onDraw
не вызывается, то предполагаем, что предыдущий onDraw
был последним, и замеряем время. Если же onDraw
снова вызывается, цикл повторяется.
Здесь важно учитывать, что к моменту определения последнего onDraw
уже слишком поздно брать текущее время, поэтому мы сохраняем время последнего onDraw
и используем его для расчёта. Вот так выглядит код:
inner class LastDrawObserver : DefaultLifecycleObserver { private var startTime: Long = 0L private var lastTime: Long = 0L private var onLastDrawListener: OnLastDrawListener? = null inner class OnLastDrawListener( private val viewTreeObserver: ViewTreeObserver, ) : OnDrawListener { override fun onDraw() { val checkTime = SystemClock.elapsedRealtime() lastTime = checkTime handler.postDelayed( { if (checkTime == lastTime) { dispose() onMeasured(lastTime - startTime) } }, TIMEOUT, ) } ... } ... }
Чтобы использовать это, достаточно добавить следующий код во фрагмент:
class MyFragment: Fragment() { override fun onCreate(savedInstanceState: Bundle?) { traceUntilLastDraw { time -> Tracer.trace("MyScreen", time) } super.onCreate(savedInstanceState) } }
Полный код этого метода можно найти здесь.
Until Last Draw в Compose
Теперь давайте посмотрим, как сделать замер до последнего onDraw
в Jetpack Compose. У нас по-прежнему остаются все этапы жизненного цикла Compose, поэтому схема метрики выглядит так:
И здесь отличная новость — OnLastDrawListener
остаётся таким же, как и в предыдущем примере с View! Это позволяет легко адаптировать его для Compose. Пишем такой метод:
@Composable fun UntilLastDrawTracer(onMeasured: (Long) -> Unit) { val startTime = remember { System.currentTimeMillis() } val view = LocalView.current val viewTreeObserver = view.viewTreeObserver DisposableEffect(viewTreeObserver) { val listener = OnLastDrawListener( viewTreeObserver = viewTreeObserver, startTime = startTime, onMeasured = onMeasured ) viewTreeObserver.addOnDrawListener(listener) onDispose { viewTreeObserver.removeOnDrawListener(listener) } } }
Как видно из кода, структура почти та же, что и в примере для первого onDraw
. Мы используем LocalView.current
для доступа к View
из Compose, а DisposableEffect
позволяет автоматически отписываться, когда Composable
уходит из композиции. Единственное отличие — это использование OnLastDrawListener
, который ждёт последнего onDraw
перед замером.
Использовать это также просто:
@Composable fun MyScreen() { UntilLastDrawTracer { time -> Tracer.trace("MyScreen", time) } // UI content for MyScreen goes here }
Полный код этого решения можно найти здесь.
Важное предостережение!
Используя метод с последним onDraw
, нужно быть уверенным, что он существует и его можно поймать. В некоторых случаях onDraw
может продолжать вызываться бесконечно. Например, если на экране есть постоянная анимашка, видео, использующее TextureView
, или другой UI-элемент, обновляющийся каждый кадр. В таких случаях замер времени до последнего onDraw
будет невозможен: последний кадр просто не наступит.
Поэтому, если вы используете замеры до последнего onDraw
, лучше запускать их на этапе деплоя. Например, на CI, когда вы можете специально отключить определённые анимации, видео или другие бесконечные перерисовки. Например, мы сделали так: создали специальный build type, в котором отключаются конкретные анимации и видео на конкретных экранах, чтобы гарантировать корректный подсчёт метрики.
Выводы
Оптимизация UI-перформанса — это работа над всем, что видно пользователю. Если что-то привлекает внимание, вызывает задержки или подлагивания, значит, это часть нашей работы по улучшению UI-перформанса.
Мы разобрали два типа метрик UI-перформанса: метрики по кадрам и линейные метрики. Метрики по кадрам мы замеряем «плавнометрами» вроде JunkStats и Macrobenchmark. Эти инструменты подробно расскажут нам про отрисованные и неотрисованные кадры, но они не смогут ответить на вопрос: «сколько времени открывался этот экран?»
Чтобы ответить на этот вопрос, нужно измерить открытие экрана с помощью линейной метрики, которая просто считает время от и до. В статье мы рассмотрели, как самостоятельно написать «транзишинометр», который будет работать как в мире View, так и в мире Compose.
Until First Draw — это метрика, которая считает время от начала открытия экрана до первого кадра отрисовки. Она замеряет все процессы инициализации экрана, начальные измерения и лейаутинг. Плюс этой метрики в том, что её можно спокойно использовать в продакшене: первый onDraw
всегда существует.
Until Last Draw — метрика, которая считает время от начала открытия экрана до последнего кадра отрисовки, когда экран «стабилизируется», пройдут все обязательные анимации и рекомпозиции и т.д. Плюс этой метрики в том, что она охватывает полную картину открытия экрана, а минус — в том, что иногда её неудобно использовать в продакшене, поскольку может потребоваться отключение определённых анимаций. Эта метрика больше подойдёт для этапа деплоя и прогонов на CI.
Спасибо, что дочитали статью! Если вам интересен мой опыт, но лень читать большие тексты, подписывайтесь на Telegram-канал «Мобильное чтиво». В нём я делюсь своими мыслями про Android-разработку и не только в формате постов.
ссылка на оригинал статьи https://habr.com/ru/articles/862646/
Добавить комментарий