Про байт‑код написано уже немало. Он везде, и никого этим не удивить: его генерирует компилятор, переупаковывает система сборки, «портит» обфускатор и изредка читают программисты. Естественно, для работы с байт‑кодом есть немало инструментов, которые используются в разных областях и на разных платформах. Среди них и 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.
Байт-код не так сложен, как машинный код. Вот, для понимания, как выглядит «было и стало» на примере кода небольшого класса:
class Example {
fun execute(runnable: Runnable): Int {
try {
println("Going to run")
runnable.run()
} catch (ex: Throwable) {
println("What a Terrible Failure")
}
return 0
}
}
При этом в «было и стало» отчасти сохраняется соответствие — например, тоже есть заголовок функции, вызовы и инструкции.
Отдельно от всего существует обработка исключений — она выполнена не в виде инструкций, а в виде метаданных метода, о перехвате исключения и передачи его по нужной метке позаботится виртуальная машина.
Схожая ситуация и при работе с 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. Чтобы работать с ним, надо выполнить некоторые операции.
Подключаем плагин, выполняя следующую команду:
plugins {
id 'ru.ok.byteweaver'
}
Конфигурируем плагин. Указываем, какие варианты сборки есть, какие инструменты будут подключены:
byteweaver {
debug {
srcFiles += 'byteweaver/notification-log.conf'
srcFiles += 'byteweaver/proxy-toast-for-tests.conf'
srcFiles += 'byteweaver/rx-npe.conf'
}
profile {
srcFiles += 'byteweaver/auto-trace.conf'
}
release {
srcFiles += 'byteweaver/notification-log.conf'
srcFiles += 'byteweaver/auto-trace.conf'
srcFiles += 'byteweaver/rx-npe.conf'
}
}
При этом надо указать, какое именно будет инструментирование в debug
, profile
и release
. Надо отметить, что файлы конфигурации (и все последующие команды) пишутся на языке ByteWeaver.
-
Определяем классы, на которые будем воздействовать. Например:
любой класс, который расширяет
view
;любой класс, который реализует
runnable
;любой класс из пакета ru.ok.android с помеченными аннотациями.
class io.reactivex.rxjava3.internal.operators.single.SingleFromCallable {
class * extends android.view.View {
class * extends java.lang.Runnable {
class * {
@SomeAnnotation
class ru.ok.android.* {
При этом мы также можем использовать import
, что позволяет отказаться от дублирования.
import ru.ok.android.app.NotificationsLogger;
import java.lang.String;
-
Определяем методы, которые будем инструментировать. Например:
все наследники
Activity
, методonCreate
;все
Runnable
, методRun
;все классы, все методы и так далее.
class * extends android.app.Activity {
void onCreate(android.os.Bundle) {
class * extends java.lang.Runnable {
void run() {
class * {
@ru.ok.android.commons.os.AutoTraceCompat
* *(***) {
class * {
* *(***) {
Вставляем код в начало/конец. Например, во все методы, аннотированные
AutoTraceCompat
, в любом классе мы в начале ставим вызовTraceCompat.beginTraceSection(trace)
, а в конце —TraceCompat.endSection
.
class * {
@ru.ok.android.commons.os.AutoTraceCompat
* *(***) {
before void TraceCompat.beginTraceSection(trace);
after void TraceCompat.endSection();
}
}
Для примера, «до и после добавления кода» будет выглядеть так:
public class Main {
@AutoTraceCompat
public static void main(String[] args) {
System.out.println("Hellow World");
}
}
После:
public class Main {
@AutoTraceCompat
public static void main(String[] args) {
TraceCompat.beginTraceSection("Main.main(String[])");
System.out.println("Hellow World");
TraceCompat.endSection();
}
}
-
Заменяем вызовы. Например, в методе
subscribeActual
классаSingleFromCallable
вызовыcallable.call()
, которые возвращаютObject
, заменим на вызовыRxNpeChecker.checkCallableCall(self)
.
Примеры реальных преобразований в проде
Мы активно используем патчинг байт-кода у себя в production-среде. Для наглядности разберём несколько примеров.
«Поимка» тостов
Один из вариантов использования ByteWeaver — отлавливание тостов. Тосты (Toast) — системные уведомления, носящие исключительно информирующий характер и не требующие каких‑либо действий от пользователя. Один из распространённых примеров тост‑уведомлений — уведомление о получении прав разработчика.
Чтобы отлавливать тосты, в любом классе и в любом методе вызовы Toast.show()
меняем на ToastWatcher.show(self)
.
class * {
* *(***) {
void Toast.show() {
replace void ToastWatcher.show(self);
}
}
}
object ToastWatcher {
var listener: (tpast: Toast) -> Unit = {}
fun show(toast: Toast) {
listener(toast)
toast.show()
}
}
После этого мы пишем ToastWatcher
с методом show
. То есть в итоге мы не влияем на основную функциональность, но дополнительно подвешиваем listener(toast)
. Важно, что это статический метод (@JvmStatic
), как и все методы, которые мы планируем добавлять.
Логирование нотификаций
Здесь речь не о пушах, а о том, что разные библиотеки могут показывать уведомления. Мы хотим отслеживать всех, кто пытается что‑то отображать в шторке уведомлений Android — с этой задачей мы столкнулись, когда нотификаций в нашем приложении стало слишком много и это начало негативно влиять на пользовательский опыт.
Чтобы отловить все нотификации, мы сделали следующее. В любом классе вызовы NotificationManager.notify
мы заменили на NotificationsLogger
.
class * {
* *(***) {
void NotificationManager.notify(int, Notification) {
replace void NotificationsLogger.logNotify(self, 0, 1);
}
}
}
Далее NotificationsLogger
всё переправляет в LogNotificationsUtil
, благодаря чему журналирует функциональность, не влияя на неё.
public class NotificationsLogger {
@KeepName
public static void logNotify(NotificationManager manager, String tag, int id, Notification notification) {
LogNotificationsUtil.logNotification(notification, trace());
manager.notify(tag, id, notification);
}
}
Затем LogNotificationsUtil
, в зависимости от флажка, отслеживает и собирает всю информацию об уведомлении и его отправителе.
public final class LogNotificationsUtil {
public static void logNotification(Notification notification, String codeSrc) {
if (!Env.get(PushEnv.class).PUSH_LOG_NOTIFICATIONS_ENABLED()) {
return;
}
long ltime = System.currentTimeMillis();
final OneLogItem.Builder builder = OneLogItem.builder()
.setCollector(Collectors.OK_MOBILE_APPS_OPERATIONS)
.setType(Type.OPERATIONS_SUCCESS)
.setDatum(1, codeSrc)
.setOperation("notification_notify")
.setCustom("ltime", ltime);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
builder.setDatum(0, notification.getChannelId());
}
builder.build().log();
}
}
Поиск багов
Не так давно мы столкнулись со следующей ситуацией — в 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
.
public final class SingleFromCallable<T> extends Single<T> {
final Callable<? extends T> callable;
public SingleFromCallable(Callable<? extends T> callable) {
this.callable = callable;
}
@Override
protected void subscribeActual(SingleObserver<? super T> observer) {
try {
value = Objects.requireNonNull(callable.call(), "The callable returned a null value");
} catch (Throwable ex) {
Exceptions.throwIfFatal(ex);
RxJavaPlugins.onError(ex);
return;
}
}
}
Чтобы сделать это сообщение полезным и интерпретируемым, мы решили обогатить его, добавив дополнительную информацию. Для этого заменили вызовы Callable.call
на RxNpeChecker
.
class io.reactivex.rxjava3.internal.operators.single.SingleFromCallable {
* subscribeActual(***) {
java.lang.Object java.util.concurrent.Callable.call() {
replace java.lang.Object ru.ok.android.utils.RxNpeChecker.checkCallableCall(self);
}
}
}
RxNpeChecker
, в свою очередь, делает вызов Callable
, но с другим Exception
, в котором значительно больше полезной информации.
public class RxNpeChecker {
@SuppressWarnings("unused") // used from generated bytecode
public static Object checkCallableCall(Callable callable) throws Exception {
final Object result = callable.call();
if (result == null) {
throw new NullPointerException("The callable returned a null value: " + callable);
}
return result;
}
}
Благодаря этому мы смогли идентифицировать, что null value вернул callable l90.b
.
Далее уже можно локализовать источник ошибки без ByteWeaver. Для этого мы смотрим, кто такой l90.b
, и видим, что это некая ExternalSyntheticLambda1
в RxApiClient
. А в RxApiClient
видно, что null возвращает один из методов API.
@Singleton
public final class RxApiClient {
private final ApiClient delegate;
private final Scheduler scheduler;
@Inject
public RxApiClient(@NonNull ApiClient delegate) {
this.delegate = delegate;
this.scheduler = Schedulers.io();
}
@NonNull
public <T> Single<T> execute(@NonNull ApiExecutableRequest<T> request) {
return Single.fromCallable(() -> delegate.execute(request))
.subscribeOn(scheduler);
}
}
Чтобы найти конкретный метод, используя код на Java, дополнительно журналируем и начинаем добавлять больше информации об API-шном методе.
@Singleton
public final class RxApiClient {
private final ApiClient delegate;
private final Scheduler scheduler;
@Inject
public RxApiClient(@NonNull ApiClient delegate) {
this.delegate = delegate;
this.scheduler = Schedulers.io();
}
@NonNull
public <T> Single<T> execute(@NonNull ApiExecutableRequest<T> request) {
return Single.fromCallable(() -> delegate.execute(request))
.subscribeOn(scheduler);
}
@NonNull
private <T> T executeNonNull(@NonNull ApiExecutableRequest<T> request) throws IOException, ApiException {
final T result = delegate.execute(request);
if (result == null) {
final String msg = "Parsed api value was null."
+ " Request: " + request
+ ", method: " + ApiRequests.extractLogTag(request)
+ ", parser: " + request.getOkParser();
throw new NullPointerException(msg);
}
return result;
}
}
В итоге после простых манипуляций мы смогли точно локализовать источник наших «проблем»:
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 — можете протестировать инструмент и начать работу с ним в своих проектах прямо сейчас.
И да, если вы ещё не работаете с байт-кодом — самое время начать погружение в тему. Это может оказаться сложно, но точно будет увлекательно и полезно.
yrub
выглядит так, словно было лень разобраться с aspectj и вы за это потратили в 100+ раз больше времени, чтобы сделать то же самое. Или я что-то не уловил?