Кто мы? Андроид-разработчики! Чего мы хотим? Чтобы наши списочки не подлагивали, анимашечки крутились плавно, а переходы между экранами были такими, что глаз радуется. Одним словом: чтобы интерфейс был плавным и отзывчивым, а переходы экранов — быстрыми. Чтобы быть уверенным, что всё действительно плавно и чётко, надо замерять! Но что замерять? Как измерить ту самую плавность, как оценить гладкость анимаций? У кого-нибудь есть плавнометр? Или может у вас есть транзишинометр?
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-разработку и не только в формате постов.