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

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-разработку и не только в формате постов.

Комментарии (0)