Demeter: реактивное профилирование Android-приложений

53ac08dad4a95f244e8638c438a91659.png

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

Наша команда часто сталкивалась с проблемами при поиске «узких мест» в производительности приложения. Мы пробовали различные инструменты профилирования, но все они требовали слишком много времени на сбор метрик, анализ и фильтрацию нужных данных. Чтобы решить эту проблему, мы разработали собственное решение, которое сочетает скорость анализа и простоту поиска проблем. Мы создали библиотеку Demeter, которую легко интегрировать в отладочную версию Android‑приложения. Она позволяет получать отчёты о производительности во время использования приложения и переходов между экранами. Такую сборку можно передать команде тестирования, а затем проанализировать отчёты и изучить изменения.

Для чего нужно измерять производительность

Итак, первое, что нужно сделать, — определить цель, с которой мы хотим что‑то измерить. В нашем случае, когда пользователь открывает Яндекс Go, ему нужно быстро сделать заказ, сесть в машину и поехать. Долгий экран загрузки — потеря времени пользователя, у которого могут сорваться планы, и его уход в другое приложение. Это потери и для пользователя, и для бизнеса — нам такое точно не нужно.

Чтобы измерить производительность, обратимся к истокам: Google предлагает нам разделить на три части все существующие проблемы с производительностью: Slow Frames, Frozen Frames и ANRs.

Выдержка из документации Android. Источник 
Выдержка из документации Android. Источник 

В Slow Frames попадают проблемы от 16 мс, потому что они привязаны к времени отрисовки одного кадра. Например, для того чтобы обеспечить 60 FPS (60 кадров в секунду), время отрисовки одного кадра должно составлять не больше чем 11 мс. Всё, что выше, — это проблема. Для 90 и 120 FPS мы должны обеспечить и того меньшее время работы каждой функции. Мы будем фокусироваться на таких местах и исправлять их.

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

Прежде всего надо посмотреть, какими инструментами пользуются другие. В автоматике это Firebase Performance Metrics и сочетание Pulse и PerfTests (мы их тоже используем и дополняем кастомными метриками). По инструментам рассмотрим Profiler, Perfecto и YAMP — они принадлежат к одному семейству и помогают анализировать stack trace, а также нашу собственную библиотеку Demeter.

Инструменты для измерения производительности

Firebase. Этот инструмент автоматически замеряет время старта приложения — можно просто подключить библиотеку и получить готовые отчёты форграунда и бэкграунда. Firebase предоставляет данные о Slow‑ и Frozen Frames, позволяя классифицировать их по группам, а также визуализировать процент проблем, включая ANRs, на графиках в отчёте.

Firebase Performance Monitoring
Firebase Performance Monitoring

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

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

f9b8d4d3d005ca54e16a87735b7f43fe.png

PerfTests удобны тем, что мы можем понять, где конкретно «затаилась» проблема, запустить Bisect, найти нужный коммит и всё исправить. Но из‑за того, что писать PerfTests непросто, создаётся много false positive‑информации и, соответственно, задач. Чтобы разобрать их, уходит очень много времени.

74f39e688e280f1f5b37d314ab8021da.png

Хотя тесты автоматически находят аномалию и автора PR (Pull Request), команда должна регулярно выделять время на то, чтобы разбирать эти инциденты. К тому же писать тесты довольно непросто: каждый из них должен быть изолирован, нужно уметь сохранять все сетевые ответы, поддерживать работоспособность этих тестов при написании нового функционала. В общем, работы очень много.

Далее рассмотрим способ сбора Custom Metrics (собственных метрик) через AppMetrica и их самостоятельный анализ.

1eea89f39bfe5a2a926a4dfba30ec113.png

Для отображения данных мы используем DataLens, куда собираем различные события приложения, включая события, связанные с производительностью. Для сбора такой информации можно в начале функции замерить время её выполнения, а в конце — отправить данные о времени в AppMetrica.

Например, для измерений мы отправляем параметр timeSinceAppLaunch. Здесь обозначается время с момента запуска приложения и продолжительность (duration) для отображения времени этого события. Это позволяет точно понимать, в какой момент мы начали измерение и сколько оно длилось.

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

Изменение скорости выполнения работы функциональности с течением времени. По X — версии приложения, по Y — время работы
Изменение скорости выполнения работы функциональности с течением времени. По X — версии приложения, по Y — время работы

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

Какие проблемы могут возникать с измерением производительности

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

Для этого мы можем воспользоваться Android Profiler или Perfetto, которые позволяют анализировать stacktrace и детально изучать происходящее. Эти инструменты помогают выявить задержки, визуализировать проблемные участки и точно определить, на каком этапе что‑то идёт не так.

Анализ стека вызовов в Android Profiler
Анализ стека вызовов в Android Profiler

Также есть инструмент YAMP. Это модифицированный Profiler, его тоже используют мобильные разработчики разных команд в Яндексе. Он удобен тем, что в нём можно фильтровать, подсвечивать и модифицировать все события.

9660b80b01db84202bff3f1b4e485639.png

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

5a73e5a5bb6333da15f8c27f544ae527.png

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

При этом не хотим отказываться от имеющихся инструментов: наша цель — дополнить и ускорить исследования. Так появилась идея сделать библиотеку, которая будет автоматически показывать время выполнения методов во время работы приложения, добавлять информацию для профайлера для более детального stacktrace, а также отображать время инициализации зависимостей для Dagger и количество рекомпозиций для Compose.

Как мы сделали Demeter

Мы хотим понять, почему какая‑то функция выполняется очень долго. Хочется видеть, что привело к длительной загрузке, какая функция выполнялась долго, а главное — получить возможность видеть сразу такие функции в одном месте. Для этого мы начали делать Demeter.

Рассмотрим какую‑то абстрактную функцию heavyOperation. Как понять, что именно она долго загружается?

25649e49291e0ce191322627a2aa10fe.png

А теперь заменим логирование вызовом отдельной функции, например TraceMetricsHolder.log, чтобы собирать информацию в одном месте.

4f9760d432e998a0cadf504ac3eacbbf.png9aaac115e0a8f517649a7b4d1f110410.png

Но такой подход неудобен и трудозатратен, поэтому мы автоматизировали процесс (о деталях расскажу чуть ниже). В результате мы получили систему, которая анализирует все инжектированные объекты: фиксирует время выполнения методов, время создания классов и потоки исполнения, в которых они работают. Данные также можно экспортировать в CSV‑формат, чтобы было удобно загрузить в трекер и сравнить с предыдущими результатами.

Нам было интересно проанализировать время конструирования dagger‑зависимостей, поэтому мы написали такой анализатор, который работает с аннотациями @Inject.

Замер времени инициализации объектов Inject
Замер времени инициализации объектов Inject

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

Особенно полезно это оказалось в нашем приложении Яндекс Go, где используется множество SDK. Мы сразу видели, если новая библиотека замедляет работу, и могли быстро разобраться в причинах. Также мы оперативно выявляли медленные методы у коллег, заводили задачи и оперативно их исправляли.

Исследование всех вызовов приложения
Исследование всех вызовов приложения

Также мы разработали небольшой Compose‑анализатор, чтобы отслеживать частоту рекомпозиций и выявлять то, что вызвало эти рекомпозиции.

Compose-анализатор
Compose-анализатор

Чтобы добиться такой автоматики, мы использовали два подхода: один основан на ASM Visitor Factory, а второй — на Kotlin Compiler Plugin.

ASM Visitor Factory

4359a2b729a6c9170496cae2e8c755ad.png

Перед тем как приступить к написанию ASM Class Visitor, важно понять общий процесс сборки приложения: как исходный код превращается в исполняемый файл.

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

806c0e9f8ec37e7962ca93a9e89256cf.png

Наша цель — добавить вызов специального метода во все методы приложения, чтобы замерять их время выполнения. Для этого ASM позволяет переопределить ClassVisitor, где мы создаём собственный визитор и указываем, с какими пакетами будем работать, какие методы переопределять и какую логику применять.

179dea6e8c7340a4a1eed0feed60fb2f.png

В переопределённом методе isInstrumentable определяются места, с которыми будет работать наш Visitor, и в данном случаем мы указали пакет com.mypackage.project. Если метод isInstrumentable будет возвращать true, то значит, мы будем анализировать абсолютно весь код, который есть в нашем приложении, в т. ч. все подключённые библиотеки. Так как нам нужно переопределить всего лишь методы, то мы возьмём из Class Visitor метод visitMethod и воспользуемся вспомогательным классом AdviceAdapter.

Переопределяем visitMethod, воспользовавшись помощью AdviceAdapter. В данном случаем мы создаём новую реализацию ExampleMethodAdapter, унаследованную от AdviceAdapter. Это поможет использовать более высокий уровень абстракции, чтобы совсем глубоко не погружаться в работу Opcodes и использовать готовые реализации.

36eb0e0657c82321677c58a5d28e7029.png

Из AdviceAdapter возьмём и переопределим два метода: onMethodEnter и onMethodExit. В onMethodEnter нам необходимо вызвать метод для замера текущего времени и сохранить результат. Однако, в отличие от Kotlin и Java, здесь мы не можем просто сохранить значение в переменную. Нам нужно определить смещение в стеке, куда оно будет записано. Для этого AdviceAdapter предоставляет метод newLocal, который автоматически находит подходящее смещение и резервирует необходимое место в локальных переменных.

e7008efc21a8d506cbe5b96542132bae.png

После этого мы должны сохранить переменную при помощи Opcode LSTORE с указанием смещения. Вызванный метод SystemClock.elapsedRealtime запишет текущее время в эту переменную, а дальше на выходе из функции эту переменную будем считывать.

На onMethodExit нас интересует только Opcode RETURN, поэтому ограничимся только им и получим данные из ранее сохранённой переменной через LLOAD. Осталось только вызвать статическую функцию TraceMetricsHolder, куда передадим значение начала замера.

6d7d400b3cb8bc6a93bd99737dc8a90b.png

Теперь нам осталось настроить плагин для Gradle, чтобы он выполнялся при компиляции после упаковки.dex. Тут нам достаточно указать наш Visitor Class и scope, где мы будем исследовать и трансформировать наш код. Если, например, указан all, то это значит, что мы будем трансформировать вообще все подключённые библиотеки. Но можно ограничиться только своим проектом, указав PROJECT.

f54f20edad0443e0a42084fc32e92248.png

Kotlin Compiler Plugin

2864a7bb5314221e19c897af18f86ef0.png

Второй подход, который мы использовали, — Kotlin Compiler Plugin. Возвращаясь к той же схеме преобразования кода, можно обратить внимание, что он работает на преобразование именно исходного кода. Это значит, он не умеет взаимодействовать с подключёнными библиотеками. Посмотрим, как это происходит, на примере исследования Compose‑рекомпозиций в Demeter.

Схема фронтенд-и бэкенд-частей Kotlin Compiler Plugin
Схема фронтенд-и бэкенд-частей Kotlin Compiler Plugin

Фронтенд‑часть Kotlin Compiler Plugin отвечает за построение синтаксического дерева и анализ исходного кода, но в моём примере это неважно. Меня интересует генерация и модификация кода, поэтому я буду работать с бэкенд‑частью, а именно с JVM.

Допустим, у нас есть простая @Composable‑функция, отвечающая за ввод текста. Чтобы замерить количество рекомпозиций, посмотрим, как эта функция транслируется в java‑код.

Изначальная функция:

d5eaf028ae7f0e75bcd7ed8c0a8be408.png

То, как компилятор преобразует Compose‑функцию в java‑код:

8c99b4efae309890beaa67e2a60729f5.png

Как видно, сгенерированный код стал более объёмным. Чтобы определить, когда функция начинает рекомпозицию, нужно найти и залогировать метод updateScope. Для этого нужно добавить логирование сразу после вызова updateScope, и как раз для этого нам понадобится Kotlin Compiler Plugin, а именно transform с переопределением visitCall.

Добавим свой метод notifyRecomposition после updateScope:

0e0fef971f0eab7d1c57a3381c8d4c7c.png

Чтобы сделать это, создадим Transformer, унаследовав от класса IrElemetTransformerVoidWithContext из Kotlin Compiler Plugin и переопределив метод visitCall. В нём найдём вызов updateScope и попробуем сразу же его преобразовать — в коде это transformRecomposeCall.

a35201696c4570c3c4313502bccd400c.png

transformRecomposeCall ищет по названию целевую функцию и при помощи irBlockBodyBuilder добавляет необходимый вызов для логирования. Не стоит забывать добавить в visitCall текущую функцию, иначе она потеряется и просто «вырежется».

bb315bb9496159d3e28a11f9f0a357e3.png

irRecompose — extension‑функция для преобразования, которую рассмотрим ниже.

10c50e2ce83a2e130f4ac927efa030bc.png

Если преобразование удалось, то сначала нужно добавить «модифицированную» функцию, а после — свой метод.

828a19ba54256cf79d46a9086fea2b10.png

Чтобы добавить в проект свой вызов, нужно извлечь из текущего контекста нашу функцию — в данном случае notifyRecomposition из Demeter. Мы уверены, что этот метод есть, поэтому вызываем его без дополнительных проверок. Так получаем callable, с которым будем работать.

d07f04c30155f0eadb39b49c5a082894.png

Возвращаемся к реализации extension‑функции irRecompose. Так как анализируемая функция должна быть @Composable, то значит, её первым аргументом должен быть composer. Если этот аргумент присутствует — отлично, мы можем модифицировать код и добавлять нужную нам функцию.

9ac79de8d042ce6f227d17da6567affd.png

Создаём новый call, который добавим в функцию, существующий RecomposeNotifyFunction и передаём ей нужный аргумент.

c068f6e85d507b44de7f948d727dc6e8.png

Итак, в нашем проекте используется два подхода: ASM и Kotlin Compiler Plugin. Их плюсы и минусы:

35266c063fc5f054fbf967f1320abfad.jpeg

Мы использовали два подхода ещё и потому, что, когда разработчики видят код на ASM, это обычно не вызывает у них радости: мало кто горит желанием с ним разбираться. Зато есть удобный и дружелюбный Kotlin Compiler Plugin, с которым работать гораздо приятнее. Благодаря ему мы можем вносить нужные изменения без лишних сложностей, и при этом не всегда приходится залезать в сторонние библиотеки.

Вывод

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

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

Кроме того, мы сможем выявлять просадки производительности ещё на этапе пул‑реквеста, не дожидаясь выхода в прод. А с помощью Demeter можно анализировать и сторонние библиотеки. Для этого мы разработали систему расширения функциональности, которая позволяет легко добавлять новые плагины, что значительно упростило жизнь разработчикам.

Мы выложили библиотеку в опенсорс — она доступна на GitHub — и будем рады предложениям, как её улучшить для ещё большего ускорения анализа.

© Habrahabr.ru