Всем привет ? я Максим Кузнецов 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. Медленным мы считаем кадр, который отрисовывается более 10 мс на устройствах с частотой обновления экрана в 120 Гц, и более 20 мс на устройствах с 60 Гц.
P.P.S. Вычислено эмпирически.
// TODO или что мы сделали не так?
Со спамом нотификаций при открытии активити мы разобрались, настроили нужные трешхолды, добавили возможность временного отключения и вроде бы все хорошо.
Но результат первого a/b теста показал, что почти все разработчики выключали фичу почти сразу, так как информация о просадке не была комплексной и полезной. Очевидные просадки и так бросались в глаза, а остальные не были критичными. QA-инженеры особым вниманием новую фичу так же не потчевали.
В будущем мы планируем собирать информацию в базу данных и батчами отправлять на сервер для анализа производительности от версии к версии, на определенных девайсах, фичах и тд.
Спасибо за внимание, по любым вопросам велкам в комменты ?

Источники
Статьи, которые могут быть интересны:
Эксперименты на 3,5 квадратах: качнул сетап от «бомж-уровня» до «мини-студии»
Как у нас почти получилось сделать автономного робота для «Битвы Роботов»
Как дизайнеру с помощью макетов оптимизировать процессы и сэкономить время
База об организации процесса разметки: команда, онбординг, метрики
Подписывайтесь на блог и Телеграм-канал Alfa Digital — там мы постим новости, опросы, видео с митапов, краткие выжимки из статей, иногда шутим.
