От Kotlin до машинного кода
Привет! Я Александр, Android-разработчик, автор телеграм-канала «Записки Инженера». В этой небольшой статье разберем большой путь, который проходит код Android-приложения от написания в IDE до выполнения на устройстве. Разберем, какие трансформации проходит код на каждом этапе, как можно посмотреть их результат, и для чего это может пригодиться.
Виртуальная машина
Как мы знаем, Kotlin компилируется в Java Bytecode. Его можно посмотреть в Android Studio, нажав Kotlin → Show Bytecode. Это то, что выполняет JVM (Java Virtual Machine) при запуске Java-программы. Сама Android Studio, например, работает именно на таком байткоде внутри JVM.
Но Android вообще не использует JVM. Вместо этого Android-приложения выполняются в виртуальных машинах ART (Android Runtime) или Dalvik (до Android 5.0). Они, в отличие от JVM, оптимизированны для мобильных устройств. Поэтому, например, коллекции в JVM и в Android могут работать совершенно по-разному (подробнее в этом докладе с Joker 2024).
JVM и Dalvik
DEX и OAT
Соответственно, Java Bytecode для нас не является последней инстанцией. При сборке APK он компилируется в DEX — специальный байткод для Android Runtime. Также тут отрабатывает R8 — он сокращает код, инлайнит вызовы, удаляет неиспользуемые (как ему кажется:)) классы… В общем, ведет себя довольно агрессивно (поэтому иногда приходится использовать аннотацию @Keep в коде).
Но и это не все! На самом устройстве байткод DEX может идти тремя путями:
1) Интерпретация. При интерпретации байткод DEX выполняется непосредственно виртуальной машиной, без преобразования в машинный код. Среда выполнения читает каждую инструкцию байткода и в реальном времени выполняет соответствующие операции.
2) Компиляция до запуска приложения (ahead of time). AOT компилирует байткод DEX в нативный машинный код заранее, обычно во время процесса установки приложения. Полученный код (в формате OAT) сохраняется на устройстве и выполняется напрямую, минуя этапы интерпретации или компиляции во время выполнения.
3) Компилиция во время выполнения приложения (just in time). При использовании JIT байткод DEX компилируется в нативный машинный код во время выполнения приложения. При этом используется подход Profile-guided compilation: среда выполнения определяет часто используемые участки кода и компилирует их для оптимизации производительности. Скомпилированный код кэшируется для повторного использования при последующих запусках приложения.
Архитектура JIT
Почему все это происходит только при установке на устройство, а не во время сборки APK? Потому что оптимизации, которые тут применяются, и ассемблерные инструкции в OAT, которые получаются на выходе, зависят от конкретного устройства — точнее, от его процессора.
Kotlin Explorer
Окей, где же посмотреть, как выглядит код после всех этих компиляций? Тут нам поможет Kotlin Explorer. Он дизассемблит код на Kotlin одновременно в Java Bytecode, DEX и OAT. Также позволяет посмотреть результат работы R8.
Kotlin Explorer
Как и зачем работать с тулзой показывал сам автор, Romain Guy, в своем докладе «Practical Optimizations». Там он, в том числе, рассказывает, как оптимизировали класс Offset в Jetpack Compose. Этот класс используется очень часто в ui коде, поэтому должен работать максимально быстро и содержать по минимуму инструкций ассемблера. Так, например, вынос утилитной функции из companion object в top-level дал 40% улучшение скорости выполнения.
В продуктовой разработке такой подход вряд ли применим — в приоритете простота и чистота кода, а не его производительность. Но вдруг вы когда-нибудь задумаете написать свой ui фреймворк:) Да и в любом случае, полезно помнить, что ни о каких массивах, классах, циклах и прочих хэшмапах процессор ничего не знает. Только арифметические операции, чтение, запись памяти и суровый goto.