Про байт‑код написано уже немало. Он везде, и никого этим не удивить: его генерирует компилятор, переупаковывает система сборки, «портит» обфускатор и изредка читают программисты. Естественно, для работы с байт‑кодом есть немало инструментов, которые используются в разных областях и на разных платформах. Среди них и ByteWeaver — инструмент для патчинга байт‑кода во время сборки, который может быть полезен разработчикам под Android.

Меня зовут Александр Асанов. Я Android‑разработчик в OK, Tracer, ByteWeaver. В этой статье я разберу, что такое байт‑код, как и зачем с ним работать, расскажу о ByteWeaver и покажу примеры работы с байт‑кодом.

Что такое байт-код

Байт-код  —  промежуточное представление Java-кода, которое выполняется виртуальной машиной Java (JVM). При компиляции программы компилятор Java преобразует её в байт-код, представляющий собой набор инструкций, которые виртуальная машина может понять и выполнить. Этот принцип справедлив не только для Java, но и для многих других современных систем, в том числе LLVM.

Алгоритм появления и использования байт-кода следующий:

  • Разработчик пишет исходный код.

  • Исходный код Java компилируется в байт-код. В зависимости от языка и платформы это могут быть, например, файлы типа .class, .dex, .ll.

  • Байт-код преобразуется в машинный код. Стратегии тут могут быть разными: интерпретация байт-кода, just in time, ahead of time.

В дальнейшем мы сосредоточимся на разработке под Android, а значит, нам интересны только байткод JVM и Dalvik.

Байт-код не так сложен, как машинный код. Вот, для понимания, как выглядит «было и стало» на примере кода небольшого класса:

При этом в «было и стало» отчасти сохраняется соответствие — например, тоже есть заголовок функции, вызовы и инструкции.

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

Схожая ситуация и при работе с Dalvik — средой для выполнения компонентов операционной системы Android и пользовательских приложений. Вместе с тем, поскольку Dalvik отличается от Java, байт‑код тоже отличается, но незначительно — в нём всё так же можно увидеть функции, вызовы и инструкции.

На самом деле того, что мы уже видели, достаточно для работы с ByteWeaver, потому что он как раз [SPOILER ALERT] и позволяет вставлять вызовы в начало и конец метода или заменять одни вызовы методов на другие.

Зачем править байт-код

Есть много сценариев, когда манипулирование байт‑кодом будет полезно. Так, патчинг байт‑кода может понадобиться для:

  • добавления журналов — например, чтобы в последующем передавать их в logcat, tracer или другие системы сбора журналов;

  • добавления трассировок — например, systrace и через него в тот же tracer;

  • добавления другого мониторинга;

  • поиска, а иногда и правки багов;

  • определения живого и мёртвого кода;

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

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

  • Оптимизация производительности. Инструменты профилирования и оптимизации производительности часто модифицируют байт‑код, чтобы внедрить код для мониторинга «горячих» участков кода.

  • Тестирование и отладка. Инструменты могут динамически вставлять средства журналирования и отладки во время выполнения программы.

  • Аспектно‑ориентированное программирование. Патчинг байт‑кода позволяет реализовать сквозные задачи, такие как журналирование, управление транзакциями и проверка безопасности.

  • Генерация кода во время выполнения. Отдельные инструменты умеют создавать новые классы во время выполнения программы на основе динамических условий. Это даёт больше гибкости и уменьшает количество дублируемого кода.

Но для патчинга, естественно, нужны соответствующие инструменты.

Инструменты для работы с байт-кодом

Для работы с байт‑кодом есть несколько решений:

  • ASM — библиотека, которая предоставляет API для манипуляции существующим байт‑кодом и/или генерации нового.

  • Javassist — фреймворк, который фактически скрывает в себе операции манипулирования байт‑кодом. Разработчик пишет код, который средствами библиотеки транслируется в байт‑код и внедряется в существующие классы.

  • AspectJ — расширение Java с собственным синтаксисом, которое предназначено для расширения возможностей среды выполнения Java с помощью концепций аспектно‑ориентированного программирования. AspectJ имеет компилятор, который может работать как во время компиляции, так и во время выполнения.

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

  • ASM — низкоуровневое решение;

  • Javassist — не может работать в Android runtime;

  • AspectJ — мощный и многофункциональный инструмент, но он может замедлять сборку и требует большого опыта.

ByteWeaver и история его становления

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

  • В 1997 года появился первый байт‑код на Java.

  • В 2003 году появился ASM, который фактически стал стандартом индустрии. Даже сейчас большинство манипуляций с байт‑кодом во многом базируются именно на ASM. Принцип работы ASM прост: на вход — байт‑код, на выход — байт‑код и паттерн visitor, который отлично подходит для преобразования данных. Это позволяет работать с байт‑кодом как с данными.

  • В 2016 году мы в ОК начали активно прорабатывать и улучшать механизмы работы с журналированием. Ставили перед собой цель прийти к ситуации, при которой, например, в ответ на простейшую команду log x можно будет узнать, чему равен X и в каких единицах измерения. Идея была отличной и жизнеспособной, у нас даже появился проработанный прототип. Но из‑за некоторых внутренних обстоятельств от идеи пришлось временно отказаться.

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

  • В 2022 году проект отделили от Tracer и переработали в ByteWeaver. В обновлённой реализации появился новый язык конфигурации, отдельный publishing, новые сценарии использования и не только.

  • В 2023 году ядро ByteWeaver перевели на новый AGP transformClassesWith, и также появились новые сценарии.

  • Сейчас (в 2024 году) доработка инструмента продолжается, поэтому сценариев работы с ним становится ещё больше.

Какой байт-код мы можем править

Возможность правки кода зависит от того, в какой момент выполняется патчинг. Файлы .java и .kt с исходным кодом переводятся в формат .class ещё на самых ранних этапах с помощью компилятора. На этом же этапе gradle добавляет к этим файлам .class зависимости. Таким образом, на вход ByteWeaver попадают файлы уже с зависимостями. То есть, ByteWeaver тоже появляется на ранних этапах сборки и преобразовывает классы в .class.

Далее по циклу динамической сборки обработку выполняет ряд механизмов:

  • Proguard (R8);

  • Dex (R8) (получаются файлы .dex);

  • AGP, который упаковывает файлы в архив и добавляет ресурсы.

Часть цикла выполняется на стороне маркета приложений (преобразование .aab в .apk), но в рамках обзора работы с байт‑кодом её можно опустить.

Если представить процесс сборки статически, то dexclassloader (загрузчик классов в Android, который загружает классы из файлов .jar и .apk, содержащих запись classes.dex) работает с тремя группами сущностей:

  • классами приложения (модуль приложения, библиотечные модули);

  • классами из зависимостей (прямые, транзитивные);

  • системными классами.

При этом системные классы не относятся к .apk приложения. Соответственно, инструментированы могут быть только классы приложения и классы из зависимостей. Важно, что мы не влияем на ресурсы приложения, а работаем только с вызовами функций.

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

Как можно править байт-код: пример работы с ByteWeaver 

ByteWeaver реализован в виде плагина для Gradle. Чтобы работать с ним, надо выполнить некоторые операции.

  • Подключаем плагин, выполняя следующую команду:

  • Конфигурируем плагин. Указываем, какие варианты сборки есть, какие инструменты будут подключены:

    При этом надо указать, какое именно будет инструментирование в debug, profile и release. Надо отметить, что файлы конфигурации (и все последующие команды) пишутся на языке ByteWeaver.

  • Определяем классы, на которые будем воздействовать. Например:

    • любой класс, который расширяет view;

    • любой класс, который реализует runnable;

    • любой класс из пакета ru.ok.android с помеченными аннотациями. 

    При этом мы также можем использовать import, что позволяет отказаться от дублирования.

  • Определяем методы, которые будем инструментировать. Например:

    • все наследники Activity, метод onCreate;

    • все Runnable, метод Run;

    • все классы, все методы и так далее.

  • Вставляем код в начало/конец. Например, во все методы, аннотированные AutoTraceCompat, в любом классе мы в начале ставим вызов TraceCompat.beginTraceSection(trace), а в конце — TraceCompat.endSection.

  • Для примера, «до и после добавления кода» будет выглядеть так:

  • Заменяем вызовы. Например, в методе subscribeActual класса SingleFromCallable вызовы callable.call(), которые возвращают Object, заменим на вызовы RxNpeChecker.checkCallableCall(self).

Примеры реальных преобразований в проде

Мы активно используем патчинг байт-кода у себя в production-среде. Для наглядности разберём несколько примеров.

«Поимка» тостов

Один из вариантов использования ByteWeaver — отлавливание тостов. Тосты (Toast) — системные уведомления, носящие исключительно информирующий характер и не требующие каких‑либо действий от пользователя. Один из распространённых примеров тост‑уведомлений — уведомление о получении прав разработчика.

Чтобы отлавливать тосты, в любом классе и в любом методе вызовы Toast.show() меняем на ToastWatcher.show(self).

После этого мы пишем ToastWatcher с методом show. То есть в итоге мы не влияем на основную функциональность, но дополнительно подвешиваем listener(toast). Важно, что это статический метод (@JvmStatic), как и все методы, которые мы планируем добавлять.

Журналирование уведомлений

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

Чтобы отловить все нотификации, мы сделали следующее. В любом классе вызовы NotificationManager.notify мы заменили на NotificationsLogger.

Далее NotificationsLogger всё переправляет в LogNotificationsUtil, благодаря чему журналирует функциональность, не влияя на неё.

Затем LogNotificationsUtil, в зависимости от флажка, отслеживает и собирает всю информацию об уведомлении и его отправителе. 

Поиск багов

Не так давно мы столкнулись со следующей ситуацией — в Tracer нет ни одной строчки нашего кода, но отображается NullPointerException. Кто-то вернул в RxJava 3 null, на что RxJava 3 выдала уведомление «The callable returned a null value».

При этом абсолютно не понятно, какой callable когда и почему вернул null — нет никакой информации.

Изначально мы планировали форкать RxJava 3, но после решили воспользоваться ByteWeaver. При изучении кода мы увидели, что сообщение «The callable returned a null value» просто прописано в классе SingleFromCallable.

Чтобы сделать это сообщение полезным и интерпретируемым, мы решили обогатить его, добавив дополнительную информацию. Для этого заменили вызовы Callable.call на RxNpeChecker.

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

Благодаря этому мы смогли идентифицировать, что null value вернул callable l90.b

Далее уже можно локализовать источник ошибки без ByteWeaver. Для этого мы смотрим, кто такой l90.b, и видим, что это некая ExternalSyntheticLambda1 в RxApiClient. А в RxApiClient видно, что null возвращает один из методов API.

Чтобы найти конкретный метод, используя код на Java, дополнительно журналируем и начинаем добавлять больше информации об API-шном методе. 

В итоге после простых манипуляций мы смогли точно локализовать источник наших «проблем»:

Parsed api value was null. Request: UserInfoRequest{uids=780917803396}, method: users.getInfo, parser: b80.t@43beec0

Это позволило точечно поправить баги без лишних рисков и глобальных переработок.

Таким образом, мы:

  • Поймали RxApiClient, метод users.getInfo и сразу три метода из группы Friends: friends.getOnlineV2, friends.getOutgoingFriendRequests, friends.invite (все по разным причинам возвращали null). Всё починили и обложили проверками.

  • Поймали и поправили класс LocalPhotoEditorFragment.

При этом нам даже не потребовалось форкать RxJava — в этом сильно помог ByteWeaver.

Обогащение SysTrace для Tracer

Когда мы начали собирать трассировки, то увидели, что в них недостаточно данных и не все из них мы можем добавить вручную (да и не слишком это рационально). Поэтому нам требовалась автоматизация.

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

Также покрываем трассировками методы жизненного цикла во всех классах Activity. Аналогично покрываем методы жизненного цикла во всех классах Fragment. Помимо этого, покрываем трассировками классы:

  • Service;

  • ContentProvider;

  • View;

  • Handler;

  • Handler.Callback;

  • JobIntentService;

  • Runnable.

Также помечаем сигнатуры методов inject(Activity) и inject(Fragment). Это нужно для dagger. Для всех методов в начало мы добавляем TraceCompat.beginTraceSection(trace), а в конец — TraceCompat.endSection().

Такой патчинг существенно расширяет массив собираемой информации и делает её более полной/интерпретируемой. Для сравнения, достаточно посмотреть на Java Flame Graphs до и после обогащения.

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

Что мы сделали и что делать не стоит

Итого мы:

  • добавляли логи (logcat, tracer);

  • добавляли трассировки (systrace, tracer);

  • приоткрыли чёрный ящик для тестов;

  • искали и находили баги.

Внедрение изменений и использование ByteWeaver фактически позволило работать с кодом прозрачно и удобно, быстро выявлять события и локализовать источники ошибок. Важно, что «цена» таких нововведений для нас оказалась незначительной — время сборки приложения ОК выросло всего на 5 секунд, что в масштабе нашего продукта вполне допустимо.

При этом есть вещи, которые мы делать не стали и другим не советуем:

  • Правка багов. С ByteWeaver не надо править баги. Это неочевидно, порождает нежелательные артефакты в stacktrace и при отладке, а также увеличивает риски bus factor.

  • Генерирование «продуктового» кода. ByteWeaver лучше использовать для работы с «побочным кодом», причём важно не препятствовать его выполнению. Код самого продукта и продуктовую логику затрагивать не стоит — это чревато рисками и ненужными трудностями.

Планы на будущее

Мы не останавливаемся на достигнутом и планируем активно развивать работу с байт-кодом и ByteWeaver.

  • Сейчас вставка вызова в начало метода позволяет только принимать трассировки, но не позволяет работать с аргументами метода. Мы хотим прийти к ситуации, при которой со вставкой вызова в начало метода будем получать аргументы и даже сможем на них влиять (read-only/read-write).

  • Также мы хотим, чтобы вызовы в конце метода могли получать результат или exception. В идеале, также хотим получить возможность влиять на эти результаты.

  • Наряду с этим мы хотим реализовать возможность замены целиком тела методов (с аргументами и результатами), то есть получить возможность использования replace body.

  • Для поиска методов, которых быть не должно, мы планируем добавить stopship. Так мы хотим ограничить работу с функциями, которые содержат баг или удалены, но продолжают где-то ещё вызываться.

  • Также хотим добавить немного декомпиляции. Например, чтобы в ответ на log(x) получать log("x = $x")

Выводы на основе нашего опыта

Пройдя довольно долгий путь работы с Android-приложениями, мы смогли сделать несколько ключевых выводов:

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

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

  • ByteWeaver — удобный и функциональный инструмент для патчинга байт-кода. Его можно использовать в разных сценариях, в том числе для сбора статистики, поиска и устранения багов, решения специфических задач. Важно, что ByteWeaver уже доступен в Open Source — можете протестировать инструмент и начать работу с ним в своих проектах прямо сейчас. 

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

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