Как мы отслеживаем просадки FPS в Альфа-Мобайл?

от автора

Всем привет ? я Максим Кузнецов a.k.a. Android-developer из Альфа-Мобайл. В этой статье хочу поделиться нашим опытом внедрения механизмов мониторинга производительности в продукты компании. Почему это важно? Потому что производительность напрямую влияет на опыт пользователей, рейтинги приложений и конверсии. Мы рассмотрим статистику, проблемы, наш горький опыт и планы на будущее. Давайте начнем! ?

Зачем тратить время на перформанс?

Как уже отметил выше, перформанс мобильных приложений влияет на UX, количество звезд в Play Market и App Store, продажи и репутацию компании. Есть множество подтверждающих исследований (ссылки приложил в конце статьи).

Статистика исследований:

  • 53% посещений, вероятно, будут прекращены, если экраны загружаются дольше 3 секунд.

  • Каждый второй человек ожидает, что экран загрузится быстрее, чем за две секунды.

  • 46% людей говорят, что ожидание загрузки это то, что им больше всего не нравится.

  • 36 % пользователей согласны с тем, что медленное приложение вызывает «пониженное мнение о компании».

  • Пользователи оставляют негативные отзывы и ставят низкие оценки медленным и глючным приложениям.

  • Если приложение тормозит, пользователи быстрее теряют интерес и меньше времени проводят в нём. Это снижает показатели вовлеченности и конверсии в покупки, что ударяет по монетизации.

С необходимостью поддержания высокой производительности мобильных приложений разобрались, двигаемся вперед.

Быстрое отступление или как понять, что экран действительно лагает?

Мы давно ходим не с кинеографом в кармане, а оптимальной частотой обновления экрана мобильных устройств считается 60 FPS.

При такой частоте обновления скорость отрисовки одного кадра должна быть равна: 1000ms / 60 = 16.6666667 миллисекундам.

Так это выглядит, когда просадок нет.

А так выглядит, когда просадка есть.

Какие есть способы измерения перформанса? Наиболее часто используемые из них:

  • CPU, GPU, Memory, Energy профайлеры.

  • Микро- и макро-бенчмарки.

  • System tracing.

  • Что-то еще.

Профайлинг CPU позволяет выявить узкие места, связанные с высокой загрузкой процессора. Это важно для плавной работа интерфейса и быстрого отклика на действия пользователя. 

Например, источником просадки может быть тяжелая верстка в RecyclerView.Adapter onCreateViewHolder()

Профайлинг GPU важен для приложений с интенсивной графикой, таких как игры, видеоредакторы или для работы с анимациями в Android-приложении. Он позволяет выявить проблемы с рендерингом, текстурами, шейдерами и другими аспектами, связанными с загрузкой видеокарты. 

Инструменты профайлинга GPU дают детальную информацию о том, сколько времени занимает отрисовка каждого кадра, какие операции с графикой являются самыми медленными. 

Это помогает оптимизировать использование видеопамяти, уменьшить количество вызовов OpenGL, избавиться от лишних вычислений в шейдерах и обеспечить стабильные 60 FPS.

Профайлинг памяти критичен для предотвращения утечек памяти и оптимизации использования ОЗУ.

Инструменты профайлинга помогают найти и устранить утечки памяти, оптимизировать алгоритмы, избавиться от ненужных копий данных и уложиться в лимиты ОЗУ даже на слабых устройствах.

Профайлинг энергопотребления важен для увеличения времени автономной работы устройства.

Инструменты профайлинга энергопотребления показывают, какие компоненты устройства (CPU, GPU, экран, сеть) потребляют больше всего энергии в каждый момент времени.

Это позволяет выявить и оптимизировать наиболее прожорливые участки кода, избавиться от ненужных вычислений, уменьшить яркость экрана, отключать неиспользуемые сервисы и добиться максимального времени работы от батареи.

Микро- и макро-бенчмарки — это инструменты для измерения производительности приложения на разных уровнях.

Микробенчмарки тестируют отдельные методы и алгоритмы, замеряя время их выполнения, позволяя выявить узкие места в коде и оптимизировать наиболее ресурсоемкие операции.

Макробенчмарки, в свою очередь, измеряют производительность приложения в целом при выполнении реальных сценариев использования. Они дают представление о том, как приложение будет работать в реальных условиях на разных устройствах

System Trace позволяет записывать активность системы в течение короткого периода времени в файл трассировки.

Основные возможности System Trace:

  • Записывает события и метки времени из ядра Linux, библиотек и приложений Android.

  • Отображает точную временную информацию о работе всех процессов на устройстве в момент записи трассировки.

  • Показывает, где приложение тратит время и что происходит внутри системы в конкретные моменты.

  • Позволяет добавлять пользовательские метки в код приложения для более детального анализа.

Для захвата трассировки System Trace можно использовать библиотеку Perfetto. Файл трассировки можно расшифровать в веб-интерфейсе Perfetto. 

Choreographer

Перечисленные выше способы измерения перформанса требуют особых знаний и дополнительного времени инженера, которым они не всегда располагают. Также бывает важным находить неочевидные просадки FPS и при ручном тестировании. Поэтому мы хотели инструмент, который мог использовать любой член кросс-функциональной команды без погружения в библиотеки и документацию. Как мы этого достигли рассмотрим немного позже, а сейчас обратим внимание на Choreographer.

Наиболее внимательные разработчики замечали в консоли следующий текст:

I/Choreographer: Skipped 146 frames! The application may be doing too much work on its main thread.

Choreographer контролирует анимации и другие графические события на экране устройства. Его задача — обеспечить плавное выполнение анимаций и событий, чтобы приложение выглядело плавным для пользователя. Он планирует работу в рамках рендеринга следующего кадра.

Взглянем на последовательность вызовов методов, которые ведут к отрисовке первого кадра: 

  • ActivityThread.handleResumeActivity() 

  • WindowManagerImpl.addView() 

  • WindowManagerGlobal.addView() 

  • ViewRootImpl.setView() 

  • ViewRootImpl.requestLayout() 

  • ViewRootImpl.scheduleTraversals() 

  • Choreographer.postCallback() 

  • Choreographer.scheduleFrameLocked()

Метод Choreographer.scheduleFrameLocked() ставит в очередь сообщение MSG_DO_FRAME. При обработке сообщения MSG_DO_FRAME происходит вызов метода Choreographer.doFrame(), который как раз нас и интересует.

/**  * Implement this interface to receive a callback when a new display frame is  * being rendered. The callback is invoked on the {@link Looper} thread to  * which the {@link Choreographer} is attached.  */ public interface FrameCallback {     /**      * Called when a new display frame is being rendered.      * <p>      * This method provides the time in nanoseconds when the frame started being rendered.      * The frame time provides a stable time base for synchronizing animations      * and drawing. It should be used instead of {@link SystemClock#UptimeMillis()}      * or {@link System#nanoTime()} for animations and drawing in the UI. Using the frame      * time helps to reduce inter-frame jitter because the frame time is fixed at the time      * the frame was scheduled to start, regardless of when the animations or drawing      * callback actually runs. All callbacks that run as part of rendering a frame will      * observe the same frame time so using the frame time also helps to syncronize effects      * that are performed by different callbacks.      * </p><p>      * Please note that the framework already takes care to process animations and      * drawing using the frame time as a stable time base. Most applications should      * not need to use the frame time information directly.      * </p>      *      * @param frameTimeNanos The time in nanoseconds when the frame started being rendered,      * in the {@link System#nanoTime()}  timebase. Divide this value by {@code 1000000}      * to convert it to the {@link SystemClock#uptimeMillis()} time base.      */     public void doFrame(long frameTimeNanos); }

Choreographer предоставляет возможность зарегистрировать обратный вызов, который может быть запущен при любом вызове doFrame, эти колбэки будут выполняться синхронно один за другим.

Choreographer получает уведомление о начале построения следующего кадра и синхронно вызывает doFrame колбэки, которые в конечном итоге могут вызвать ваш код.

Таким образом мы можем реализовать любую механику в doFrame, например:

№1. Послать push-нотификацию с именем Activity/Fragment, где произошла просадка.

Пример кода:

private const val SLOW_FRAME_RENDER_TIME_MS = 120L  class NotificationFrameListener : Choreographer.FrameCallback {      private val choreographer = Choreographer.getInstance()      private var lastTimeMillis = 0L     private var lastScreenOpenTimeMillis = 0L      override fun doFrame(frameTimeNanos: Long) {         val currentTimeMillis = TimeUnit.NANOSECONDS.toMillis(frameTimeNanos)         val frameRenderTime = currentTimeMillis - lastTimeMillis         if (frameRenderTime > SLOW_FRAME_RENDER_TIME_MS && lastTimeMillis > 0) {             showNotification()         }         lastTimeMillis = currentTimeMillis         choreographer.postFrameCallback(this)     } }

Пример отображения.

№2. Сохранить информацию о просадке в базу данных.

№3. Отрисовать FPS в оверлее приложения.

Пример кода:

private const val TICK_INTERVAL_MS = 500L  class OverlayFrameListener : Choreographer.FrameCallback {      private val choreographer = Choreographer.getInstance()      private var startTimeMillis = 0L     private var lastTimeMillis = 0L     private var framesRendered = 0      override fun doFrame(frameTimeNanos: Long) {         val currentTimeMillis = TimeUnit.NANOSECONDS.toMillis(frameTimeNanos)         lastTimeMillis = currentTimeMillis          if (startTimeMillis > 0) {             val timeSpan = lastTimeMillis - startTimeMillis             framesRendered++              if (timeSpan > TICK_INTERVAL_MS) {                 val fps = framesRendered * 1000 / timeSpan.toDouble()                 showFps(fps.roundToInt())                  startTimeMillis = lastTimeMillis                 framesRendered = 0             }         } else {             startTimeMillis = lastTimeMillis         }          choreographer.postFrameCallback(this)     } }

Пример отображения.

Открытие Activity может вызывать просадку FPS и это нормально

Одна из первых проблем, с которыми мы столкнулись — спам уведомлений при просадке. Это происходит из-за того, что при запуске Activity загружаются и инициализируются её компоненты: разметка, ресурсы, данные, выполнение различных жизненных циклов компонентов и т. д. Всё это может привести к блокировке главного потока (UI thread), что в свою очередь может сказаться на плавности анимаций и общей производительности приложения.

  • Единичные просадки до 30-40 FPS длительностью до 100-200 мс — это нормально, они, как правило, незаметны для пользователя.

  • Множественные просадки до 20-30 FPS длительностью до 500 мс могут быть заметны, но не критичны.

  • Длительные просадки ниже 20 FPS более 1 секунды — уже проблема.

Фиксится это завязкой на методы жизненного цикла активити и регулировкой ключевых значений того, что мы считаем просадкой. 

Внутри класса Application есть интерфейс ActivityLifecycleCallbacks. Один из его методов — onActivityPreCreated. Именно здесь мы паузируем показ нотификаций, пока активити не перейдет в состояние Resumed

class CurrentActivityProvider: Application.ActivityLifecycleCallbacks {      var currentActivity: Activity? = null         private set      var onActivityPreCreatedCallback: (() -> Unit)? = null       override fun onActivityPreCreated(activity: Activity, savedInstanceState: Bundle?) {         onActivityPreCreatedCallback?.invoke()     }       override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit      override fun onActivityStarted(activity: Activity) = Unit       override fun onActivityResumed(activity: Activity) {         currentActivity = activity     }       override fun onActivityPaused(activity: Activity) {         if (activity == currentActivity) {             currentActivity = null         }     }       override fun onActivityStopped(activity: Activity) = Unit      override fun onActivitySaveInstaceState(activity: Activity, outState: Bundle) = Unit      override fun onActivityDestroyed(activity: Activity) = Unit }

P.S. Медленным мы считаем кадр, который отрисовывается более 80 мс на устройствах с частотой обновления экрана в 120 Гц, и более 30 мс на устройствах с 60 Гц. 

P.P.S. Вычислено эмпирически.

// TODO или что мы сделали не так?

Со спамом нотификаций при открытии активити мы разобрались, настроили нужные трешхолды, добавили возможность временного отключения и вроде бы все хорошо. 

Но результат первого a/b теста показал, что почти все разработчики выключали фичу почти сразу, так как информация о просадке не была комплексной и полезной. Очевидные просадки и так бросались в глаза, а остальные не были критичными. QA-инженеры особым вниманием новую фичу так же не потчевали.

В будущем мы планируем собирать информацию в базу данных и батчами отправлять на сервер для анализа производительности от версии к версии, на определенных девайсах, фичах и тд.

Спасибо за внимание, по любым вопросам велкам в комменты ?

Источники


ссылка на оригинал статьи https://habr.com/ru/articles/812161/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *