Про байт‑код написано уже немало. Он везде, и никого этим не удивить: его генерирует компилятор, переупаковывает система сборки, «портит» обфускатор и изредка читают программисты. Естественно, для работы с байт‑кодом есть немало инструментов, которые используются в разных областях и на разных платформах. Среди них и 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 — можете протестировать инструмент и начать работу с ним в своих проектах прямо сейчас. 

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

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


  1. yrub
    28.09.2024 17:21

    выглядит так, словно было лень разобраться с aspectj и вы за это потратили в 100+ раз больше времени, чтобы сделать то же самое. Или я что-то не уловил?