Формат файла DEX
от Askalite
JAMIE LYNCH
January 4, 2018
Оригинал:
https://www.bugsnag.com/blog/dex-and-d8
Задумывались ли вы, что происходит с кодом приложения Android, когда тот компилируется и упаковывается в APK? В этой статье подробно рассматривается формат исполняемого файла Dalvik с практическим примером структуры простейшего файла Dex.
Что такое файл DEX?
Файл Dex содержит код для исполнения в виртуальной машине Android Runtime. Каждый APK имеет отдельный файл classes.dex, который ссылается на все классы или методы, используемые в приложении. По сути, любая активность, объект или фрагмент, используемые в вашем коде, будут преобразованы в байты, в файл Dex, который можно запустить как приложение Android.
Будет очень полезно понять структуру Dex файла. Например, ссылки могут занимать слишком много места. Использование многих сторонних библиотек может увеличить размер APK на мегабайты или, что еще хуже, привести к печально известному ограничению размера метода в 64 КБ. И, конечно, может наступить день, когда знание стуктуры Dex поможет отследить неожиданное поведение в вашем приложении.
Процесс дексирования
Все исходные файлы Java в проекте Android сначала компилируются в файлы .class, которые состоят из инструкций байт-кода. В традиционном приложении Java эти инструкции будут выполняться JVM. Однако приложения Android выполняются в Android Runtime, которая использует несовместимые коды операций, и поэтому требуется дополнительный шаг дексирования, когда файлы .class преобразуются в один файл .dex.
Поскольку большинство мобильных устройств имеют жёсткие ограничения по объему памяти, вычислительной мощности и времени автономной работы, ART предлагает производительность превосходящую JVM. Одна из ключевых особенностей, которая помогает достичь этого, заключается в том, что ART выполняет компиляцию как до исполнения, так и во время исполнения. Это позволяет избежать среде исполнения некоторых накладных расходов JIT, но при этом с течением времени позволяет повысить производительность при профилировании приложения.
Как создать файл Dex
Практика на примере файла Dex делает понимание намного проще. Давайте создадим минимальный APK, который содержит только один класс Application, поскольку это позволит нам понять формат файла, не перегруженный тысячами методов, присутствующих в типичном приложении.
Мы будем использовать Hexfiend для просмотра файла Dex в шестнадцатеричном формате, поскольку Dex использует некоторые необычные типы данных для экономии места. Мы скрыли все нулевые байты, поэтому пустые места на приведенном выше снимке экрана фактически представляют 00.
Структура файла Dex
Полная структура нашего 480-байтового файла Dex показана в шестнадцатеричном и UTF-8 ниже. Некоторые секции мгновенно распознаются при интерпретации как UTF-8, например, один класс BugsnagApp, который мы определили в исходном коде, а других секций не так много:
- 6465780A 30333800 7A44CBBB FB4AE841 0286C06A 8DF19000
- 3C5DE024 D07326A2 E0010000 70000000 78563412 00000000
- 00000000 64010000 05000000 70000000 03000000 84000000
- 01000000 90000000 00000000 00000000 02000000 9C000000
- 01000000 AC000000 14010000 CC000000 E4000000 EC000000
- 07010000 2C010000 2F010000 01000000 02000000 03000000
- 03000000 02000000 00000000 00000000 00000000 01000000
- 00000000 01000000 01000000 00000000 00000000 FFFFFFFF
- 00000000 57010000 00000000 01000100 01000000 00000000
- 04000000 70100000 00000E00 063C696E 69743E00 194C616E
- 64726F69 642F6170 702F4170 706C6963 6174696F 6E3B0023
- 4C636F6D 2F627567 736E6167 2F646578 6578616D 706C652F
- 42756773 6E616741 70703B00 01560026 7E7E4438 7B226D69
- 6E2D6170 69223A32 362C2276 65727369 6F6E223A 2276302E
- 312E3134 227D0000 00010001 818004CC 01000000 0A000000
- 00000000 01000000 00000000 01000000 05000000 70000000
- 02000000 03000000 84000000 03000000 01000000 90000000
- 05000000 02000000 9C000000 06000000 01000000 AC000000
- 01200000 01000000 CC000000 02200000 05000000 E4000000
- 00200000 01000000 57010000 00100000 01000000 64010000
- dex
- 038zDÀª˚JËAÜ¿jçÒê<]‡$–s&¢‡pxV4dpÑêú¨Ã‰Ï,/ˇˇˇˇWp<init>Landroid/app/Application;
- #Lcom/bugsnag/dexexample/BugsnagApp;
- V&~~D8{"min-api":26,"version":"v0.1.14"}ÅÄÃ
- pÑêú¨ Ã ‰ Wd
Интерпретация заголовка Dex файла
На очень высоком уровне файлы Dex можно разделить на две отдельные части. Заголовок файла, который содержит метаданные, и тело, которое содержит большинство данных. Схема структуры заголовка файла показана ниже.
Давайте пройдемся по каждому пункту в заголовке последовательно.
Dex File Magic
Магическое число Dex файла.
Многие форматы файлов начинаются с фиксированной последовательности байтов, которые однозначно идентифицируют приложение, использующее их, и Dex не является исключением.
- 6465 780A 3033 3800
- dex
- 038
Мы можем видеть, что первые 8 байтов должны содержать "dex", а номер версии - в настоящее время 38, когда targetSdkVersion является API 26.
Возможно, вы также заметили, что 4-й байт кодирует символ новой строки, а 8-й байт равен нулю. Они проверяются Android Framework на наличие повреждений файлов - APK не должен установиться, если нет точно такой последовательности байт.
Checksum
Контрольная сумма.
- 7A44CBBB
Следующее значение представляет собой контрольную сумму, которая вычисляется путем применения функции к содержимому всего файла, исключая любые байты, предшествующие контрольной сумме. Если байт в файле был поврежден во время загрузки или хранения на диске, вычисленная контрольная сумма не будет совпадать, и Android Framework откажется установить APK.
SHA1 сигнатура
- FB4AE841 0286C06A 8DF19000 3C5DE024 D07326A2
Заголовок также включает в себя хэш файла SHA-1 (исключая любые предыдущие байты). Это используется для уникальной идентификации файлов Dex, что может быть полезно в таких сценариях, как Multidex.
File size
Размер файла.
- E0010000
- 480
Это значение соответствует размеру файла в байтах и может также использоваться для проверки при чтении файла Dex.
Header size
Размер заголовка.
- 7000 0000
- 112
Размер заголовка должен быть 112 байтов.
Благодаря этому теперь мы можем выделить все оставшиеся поля в header_item.
Endian constant
Константа порядка байтов.
- 78563512
Файлы Dex поддерживают кодирование как с обратным, так и с прямым порядком байтов. Это значение равно REVERSE_ENDIAN_CONSTANT, что указывает на то, что данный конкретный файл Dex кодируется с прямым порядком байтов, что является поведением по умолчанию.
IDs and Offsets
Идентификаторы и смещения.
Остальные значения в заголовке файла определяют размер и расположение других структур данных, которые содержат идентификаторы для методов, строк и других элементов.
- 00000000 00000000 64010000 05000000
- 70000000 03000000 84000000 01000000
- 90000000 00000000 00000000 02000000
- 9C000000 01000000 AC000000 14010000
- CC000000
Эти значения приведены в таблице ниже, где размер равен длине массива, а смещение - это число байтов от начала файла, в котором можно найти эту информацию.
Type Size Offset
link_size 0 0
map_off N/A 356
string_ids 5 112
type_ids 3 132
proto_ids 1 144
field_ids 0 0
method_ids 2 156
class_defs 1 172
data 276 204
Стоит отметить, что link_size и field_ids оба равны 0, потому что наше приложение не связывает статически какие-либо библиотеки и не содержит какие-либо поля. Структура map_off в секции данных в значительной степени дублирует эту информацию в более простом формате для анализа файла Dex.
В качестве примера мы можем видеть, что в файле Dex есть 5 идентификаторов строк, закодированных между байтами 112-132. Каждый идентификатор в этой позиции также указывает на смещение в секции данных, которое фактически кодирует значение строки.
Map List
Отображающий Список (?)
Map_list - это секция в теле данных, которая содержит информацию, аналогичную заголовку файла.
Обладая этими знаниями, мы можем использовать смещения для получения фактической информации и определения того, что кодирует файл Dex.
Strings
Строки.
Достаточно разговоров - давайте посмотрим что-то конкретное. Давайте выясним, на что указывает структура string_ids.
- E4000000 EC000000 07010000 2C010000 2F010000
- 228, 236, 263, 300, 303
Массив кодирует 5 целочисленных смещений, которые указывают на секцию данных.
- •<init>
- •Landroid/app/Application;
- Lcom/bugsnag/dexexample/BugsnagApp;
- •V
- ~~D8{"min-api":26,"version":"v0.1.14"}
Если мы прочитаем эти значения как UTF-8, увидим несколько символов Java, которые покажутся знакомыми всем, кто ранее использовал JNI, а также JSON, который указывает, что D8 создал файл Dex. Всё это дело, идентификаторы, смещения и несколько заголовков может показаться немного бесполезным на этом этапе. Почему бы просто не кодировать строковое значение прямо в заголовке?
Причина заключается в том, что на эти строки ссылаются из нескольких точек в файле Dex. Предоставление идентификатора для каждого из них предотвращает дублирование информации и уменьшает общий размер файла, упрощает синтаксический анализ, поскольку идентификатор всегда будет иметь фиксированную длину и означает, что значения доступны только при необходимости.
Types
Типы.
- 01000000 02000000 03000000
- 1, 2, 3
Наш Dex-файл определяет 3 типа Java. Каждое значение здесь является индексом в предыдущем массиве string_id - поэтому мы можем определить, что типы в нашем файле следующие:
- •Landroid/app/Application;
- Lcom/bugsnag/dexexample/BugsnagApp;
- •V
Синтаксис TypeDescriptor может выглядеть несколько незнакомым, но L просто ссылается на полное имя класса, а V - это тип void. Наши типы включают в себя созданный нами класс BugsnagApp и класс Application из платформы Android.
Prototypes
Прототипы.
- 03000000 02000000 00000000
- 3, 2, 0
- "V", V
Прототип метода состоит из информации о типе возвращаемого значения и количестве параметров, которые он принимает. Секция proto_id использует индексы для получения информации о типе и смещении, которое в этом случае не работает, поскольку метод не принимает никаких параметров.
Methods
Методы.
В секции методов также используются индексы. Каждый метод ищет идентификатор класса, в котором он был определен, прототип метода и имя метода из таблицы строк.
- 00000000 00000000 01000000 00000000
- 0, 0, 0, 1, 0, 0
- •Landroid/app/Application "V" •<init>
- Lcom/bugsnag/dexexample/BugsnagApp; "V" •<init>
Как и ожидалось, единственные методы в нашем файле Dex относятся к конструктору для BugsnagApp.
Class Defs
Определения класса.
Эта секция содержит тип, иерархию наследования, метаданные доступа и другие метаданные класса, такие как аннотации и индексы исходного файла.
- 01000000 01000000 00000000 00000000 FFFFFFFF 00000000 57010000 00000000
- 1, 1, 0, 0, NO_INDEX,0, 343, 0
Это расшифровывается так: открытый класс Lcom/bugsnag/dexexample/BugsnagApp наследуется от Landroid/app/Application, данные класса которого хранятся с 343 байта. Модификатор открытого доступа определяется из битового поля. Далее посмотрим на данные класса.
Class Data
Данные класса.
Первые 4 байта данных нашего класса BugsnagApp определяют количество статических полей и полей экземпляров, а также любые прямые или виртуальные методы.
- 00 00 01 00
- 0, 0, 1, 0,
- 01 81 80 04 CC 01 00 00 00
- 1, 460
В классе определен только один прямой метод. Он имеет идентификатор 1, что соответствует Lcom/bugsnag/dexexample/BugsnagApp; «V» • <init> и смещение кодовых данных 460. Если бы наш метод был абстрактным или нативным, то смещения кодовых данных не было бы.
Если в нашем классе определены поля и другая информация, то в секции будет закодировано больше данных. Между прочим, если бы идентификатор метода имел значение больше 65 536, мы бы столкнулись с печально известным пределом в 64 КБ.
Code structure
Структура кода.
Переходим к анализу метода конструктора, определенного в нашем классе, который имеет следующую структуру со смещением 460:
- 0100 0000 5701 0000 0010, 0000 01000000 64010000
- 1, 0, 343, 0, 16, 0 1, 64,1
Это соответствует размеру регистра 1, 0 входящим аргументам, 343 исходящим аргументам и смещению 16, где хранится отладочная информация.
Однако самая важная часть - это последние несколько байтов. У нас есть размер списка команд 1, что означает, что наш метод скомпилирован в один код операции: 64010000.
Таблица байт-кода Dalvik предполагает, что 64 соответствует операции sget-byte в регистре с использованием индекса ссылки на поле 1. Это соответствует нашему ожиданию, что для нашего приложения будет создано одноэлементное поле BugsnagApp - но углубляться в Dalvik дальше мы не будем!
Новый компилятор Android - D8
Мы не слишком затронули процесс компиляции, но минимальный файл Dex был создан с использованием D8, нового компилятора, который будет развёрнут по умолчанию в Android Studio 3.1. Утверждают, что он обеспечивает выигрыш в производительности по общему размеру файла и скорости сборки, поэтому давайте это проверим.
Тест производительности D8
Давайте создадим новое приложение в Android Studio 3.0.1. Мы добавим поддержку Kotlin и Navigation Drawer, однако оставим все параметры по умолчанию, создадим подписанный APK и просмотрим его с помощью APK Analyzer.
Мы можем извлечь classes.dex из APK, разархивировав APK с помощью unzip app-release.apk -d app, а затем измерив размер файла в байтах: stat -f% z app/classes.dex.
Лучше быстрее меньше сильнее
Metric DX D8
Uncompressed File Size (Mb) 4.23 3.73
Class count 2790 2790
Method count 22038 22038
Total Method references 28653 28651
Наш файл Dex составляет приблизительно 88% от его предыдущего размера при компиляции с D8. Ваш тест может варьироваться, так как это очень простой пример проекта. Еще одна интересная вещь, которую стоит отметить, это то, что при использовании D8 мы потеряли следующие две ссылки на методы:
- android.view.View#isInEditMode
- java.lang.Class#desiredAssertionStatus
Похоже, что они не используются во время выполнения, и может быть оптимизировали. Пожалуйста, отпишитесь, если вы знаете, почему они отсутствуют!
Почему минификация приводит к улучшению
Включение минимизации и запутывания - это единственное, что вы можете сделать с приложением, и теперь, когда вы являетесь экспертом в формате Dex, вы можете понять почему.
Во-первых, удаление неиспользуемых классов Java с помощью Proguard уменьшит размер APK, поскольку сгенерированный файл Dex не будет содержать неиспользуемые определения классов и все связанные с ними данные, которые занимают место.
Обфускация также уменьшит размер файла Dex, поскольку, если вы не тот тип разработчика, который называет свои классы a.a.A и z.z.Z, для каждого символа потребуется меньше символов, что в целом сэкономит место. Существуют решения для отображения обфусцированных трассировок стека, которые позволяют легко диагностировать сбои в вашем приложении.
Наконец, файл Dex меньшего размера приводит к уменьшению APK, что означает, что пользователи тратят меньше средств на мобильный траффик и с меньшей вероятностью отказываются от загрузки. Если вы создаёте Instant App, то должны уменьшить APK, чтобы вписаться в жесткое ограничение в 4 МБ.
Хотите узнать больше?
Надеюсь, эта статья помогла немного понять структуру файлов Dex, которые станут намного меньше с появлением D8. Если у вас есть какие-либо вопросы или пожелания, пожалуйста, свяжитесь.
———
Дальнейшее читайте в оригинальной статье.