文 | 趙恒雨 on 移動
概述
為什麼要了解 Dex 檔案
了解了 Dex 檔案以後,對日常開發中遇到一些問題能有更深的了解。如:APK 的瘦身、熱修複、插件化、應用加強、Android 逆向工程、64K 方法數限制。
什麼是 Dex 檔案
在明白什麼是 Dex 檔案之前,要先了解一下 JVM,Dalvik 和 ART。JVM 是 JAVA 虛拟機,用來運作JAVA位元組碼程式。Dalvik 是 Google 設計的用于 Android 平台的運作時環境,适合移動環境下記憶體和處理器速度有限的系統。ART 即 Android Runtime,是 Google 為了替換 Dalvik 設計的新 Android 運作時環境,在 Android4.4 推出。ART 比 Dalvik 的性能更好。Android 程式一般使用 Java 語言開發,但是 Dalvik 虛拟機并不支援直接執行 JAVA 位元組碼,是以會對編譯生成的 .class 檔案進行翻譯、重構、解釋、壓縮等處理,這個處理過程是由 dx 進行處理,處理完成後生成的産物會以 .dex 結尾,稱為 Dex 檔案。Dex 檔案格式是專為 Dalvik 設計的一種壓縮格式。是以可以簡單的了解為:Dex 檔案是很多 .class 檔案處理後的産物,最終可以在 Android 運作時環境執行。
Dex檔案是怎麼生成的
Java 代碼轉化為 Dex 檔案的流程如圖所示,當然真的處理流程不會這麼簡單,這裡隻是一個形象的顯示:
注:圖檔來源于網絡
現在來通過一個簡單的例子實作 Java 代碼到 Dex 檔案的轉化。
從.java到.class
先來建立一個 Hello.java 檔案,為了便于分析,這裡寫一些簡單的代碼。代碼如下:
public class Hello {
private String helloString = "hello! youzan";
public static void main(String[] args) {
Hello hello = new Hello();
hello.fun(hello.helloString);
}
public void fun(String a) {
System.out.println(a);
}
}
在該檔案的同級目錄下面使用 JDK 的 javac 編譯這個 java 檔案。
javac Hello
javac 指令執行後會在目前目錄生成 Hello.class 檔案,Hello.class 檔案已經可以直接在 JVM 虛拟機上直接執行。這裡使用使用指令執行該檔案。
java Hello
執行後應該會在控制台列印出“hello! youzan”
這裡也可以對 Hello.class 檔案執行 javap 指令,進行反彙編。
javap -c Hello
執行結果如下:
public class Hello {
public Hello();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: ldc #2 // String hello! youzan
7: putfield #3 // Field helloString:Ljava/lang/String;
10: return
public static void main(java.lang.String[]);
Code:
0: new #4 // class Hello
3: dup
4: invokespecial #5 // Method "<init>":()V
7: astore_1
8: aload_1
9: aload_1
10: getfield #3 // Field helloString:Ljava/lang/String;
13: invokevirtual #6 // Method fun:(Ljava/lang/String;)V
16: return
public void fun(java.lang.String);
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: aload_1
4: invokevirtual #8 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
7: return
}
其中 Code 之後都是具體的指令,供 JVM 虛拟機執行。指令的具體含義可以參考 JAVA 官方文檔。
從.class到.dex
上面生成的 .class 檔案雖然已經可以在 JVM 環境中運作,但是如果要在 Android 運作時環境中執行還需要特殊的處理,那就是 dx 處理,它會對 .class 檔案進行翻譯、重構、解釋、壓縮等操作。
dx 處理會使用到一個工具 dx.jar,這個檔案位于 SDK 中,具體的目錄大緻為 你的 SDK 根目錄/build-tools/任意版本 裡面。使用dx工具處理上面生成的 Hello.class 檔案,在 Hello.class 的目錄下使用下面的指令:
dx --dex --output=Hello.dex Hello.class
執行完成後,會在目前目錄下生成一個 Hello.dex 檔案。這個 .dex 檔案就可以直接在 Android 運作時環境執行,一般可以通過 PathClassLoader 去加載 dex 檔案。現在在目前目錄下執行 dexdump 命名來反編譯:
dexdump -d Hello.dex
執行結果如下(部分區域的含義已經在下面描述):
Processing 'Hello.dex'...
Opened 'Hello.dex', DEX version '035'
------ 這裡是編寫的Hello.java的類的資訊 ------
Class #0 -
Class descriptor : 'LHello;'
Access flags : 0x0001 (PUBLIC)
Superclass : 'Ljava/lang/Object;'
Interfaces -
Static fields -
Instance fields -
#0 : (in LHello;)
name : 'helloString'
type : 'Ljava/lang/String;'
access : 0x0002 (PRIVATE)
------ 下面區域描述的是構造方法的資訊。7010 0400 0100 1a00 0b00 之類的數字就是方法中的代碼翻譯成的指令。Dalvik使用的是16位代碼單元,是以這裡就是4個數字為一組,每個數字是16進制。invoke-direct 這些是前面指令對應的助記符,也代表着這些指令的真正操作。如果對這些指令轉化感興趣可以去https://source.android.com/devices/tech/dalvik/instruction-formats 檢視 ------
Direct methods -
#0 : (in LHello;)
name : '<init>' --- 方法名稱:這個很明顯就是構造方法 ---
type : '()V' --- 方法原型,()裡面表示入參,()後面表示傳回值,V代表void---
access : 0x10001 (PUBLIC CONSTRUCTOR) --- 方法通路類型 ---
code -
registers : 2 --- 方法使用的寄存器數量 ---
ins : 1 --- 方法入參,方法除了我們定義的參數以外,系統還會預設帶一個特殊參數 ---
outs : 1
insns size : 8 16-bit code units --- 指令大小 ---
000148: |[000148] Hello.<init>:()V
000158: 7010 0400 0100 |0000: invoke-direct {v1}, Ljava/lang/Object;.<init>:()V // method@0004
00015e: 1a00 0b00 |0003: const-string v0, "hello! youzan" // string@000b
000162: 5b10 0000 |0005: iput-object v0, v1, LHello;.helloString:Ljava/lang/String; // field@0000
000166: 0e00 |0007: return-void
catches : (none)
positions :
0x0000 line=1
0x0003 line=2
locals :
0x0000 - 0x0008 reg=1 this LHello;
#1 : (in LHello;)
name : 'main'
type : '([Ljava/lang/String;)V'
access : 0x0009 (PUBLIC STATIC)
code -
registers : 3
ins : 1
outs : 2
insns size : 11 16-bit code units
000168: |[000168] Hello.main:([Ljava/lang/String;)V
000178: 2200 0000 |0000: new-instance v0, LHello; // type@0000
00017c: 7010 0000 0000 |0002: invoke-direct {v0}, LHello;.<init>:()V // method@0000
000182: 5401 0000 |0005: iget-object v1, v0, LHello;.helloString:Ljava/lang/String; // field@0000
000186: 6e20 0100 1000 |0007: invoke-virtual {v0, v1}, LHello;.fun:(Ljava/lang/String;)V // method@0001
00018c: 0e00 |000a: return-void
catches : (none)
positions :
0x0000 line=5
0x0005 line=6
0x000a line=7
locals :
Virtual methods -
#0 : (in LHello;)
name : 'fun'
type : '(Ljava/lang/String;)V'
access : 0x0001 (PUBLIC)
code -
registers : 3
ins : 2
outs : 2
insns size : 6 16-bit code units
000190: |[000190] Hello.fun:(Ljava/lang/String;)V
0001a0: 6200 0100 |0000: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream; // field@0001
0001a4: 6e20 0300 2000 |0002: invoke-virtual {v0, v2}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V // method@0003
0001aa: 0e00 |0005: return-void
catches : (none)
positions :
0x0000 line=10
0x0005 line=11
locals :
0x0000 - 0x0006 reg=1 this LHello;
source_file_idx : 1 (Hello.java)
到此為止,已經完成了将 Java 代碼轉變成 Dalvik 可執行的檔案,即 dex。
Dex檔案的具體格式
現在來分析一下 Dex 檔案的具體格式,就像 MP3,MP4,JPG,PNG 檔案一樣,Dex 檔案也有它自己的格式,隻有遵守了這些格式,才能被 Android 運作時環境正确識别。
Dex 檔案整體布局如下圖所示:
這些區域的資料互相關聯,互相引用。由于篇幅原因,這裡隻是顯示部分區域的關聯,完整的請去官網自行檢視相關資料整理。下圖中的各字段都在後面的各區域的詳細介紹中有具體介紹。
下面将分别對檔案頭、索引區、類定義區域進行簡單的介紹。其它區域可以去 Android 官網了解。
檔案頭
檔案頭區域決定了該怎樣來讀取這個檔案。具體的格式如下表(在檔案中排列的順序就是下面表格中的順序):
id 區
id 區存儲着字元串,type,prototype,field, method 資源的真正資料在檔案中的偏移量,我們可以根據 id 區的偏移量去找到該 id 對應的真實資料。
字元串 id 區域
這個區塊是一個偏移量清單,每個偏移量對應了一個真正的字元串資源,每個偏移量占 32 位。我們可以通過偏移量找到對應的實際字元串資料。具體格式如下:
最終這個偏移的位置應該是落在資料區的。找到這個偏移量的位置後,根據下面的格式就可以讀取出這個字元串資源的具體資料:
類型 id 區
這個區塊是一個索引清單,索引的值對應字元串id區域偏移量清單中的某一項。資料格式如下:
如果我們要找到某個類型的值,需要先根據類型 id 清單中的索引值去字元串 id 清單中找到對應的項,這一項存儲的偏移量對應的字元串資源就是這個類型的字元串描述。
方法原型 id 區
這個區塊是一個方法原型 id 清單,資料格式為:
成員 id 區
這個區塊存儲着原型 id 清單,資料格式為:
方法 id 區
這個區塊存儲着方法 id 清單,資料格式為: 這個區塊存儲着原型 id 清單,資料格式為:
類定義區
這個區域存儲的是類定義的清單,具體的資料結構如下:
解析 dex 檔案的工具
這裡推薦一個可以解析 dex 檔案的工具 010 Editor。它可以通過預置的模闆讓我們更清晰的了解 dex 檔案的格式。
Dex 檔案在 Android Tinker 熱修複中的應用
在目前的主流的 Android 熱修複方案中,Tinker 有免費、開源、使用者量大等優點,是以在有贊也是基于 Tinker 搭建 Android 熱修複服務。Tinker 熱修複的主要原理就是通過對比舊 APK 的 dex 檔案與新 APK 的 dex 檔案,生成更新檔包,然後在 APP 中通過更新檔包與舊 APK 的 dex 檔案合成新的 dex 檔案。流程如下圖所示:
注:圖檔來源于 Tinker 官網
更新檔包的生成
Tinker 官方使用自研一套合成方案,就是 DexDiff。它基于 Dex 檔案格式的特性,具有更新檔包小,消耗記憶體小等優點。在 DexDiff 算法中,會根據 Dex 檔案的格式,将 Dex 檔案劃分為不同的區塊類,如下圖:
這些區塊有一個統一的資料結構,主要的資料有區塊對應的實際資料類型及在檔案中的偏移量。如下圖:
有了區塊資料中的實際資料類型與偏移量,再根據實際資料類型對應的資料結構就可以從檔案中讀出這個區塊包含的實際資料。這裡以 header 區域為例,讀取代碼如下(删除了部分無關代碼,代碼可以參照上面的 Dex 檔案格式的檔案頭的介紹):
private void readHeader(Dex.Section headerIn) throws UnsupportedEncodingException {
byte[] magic = headerIn.readByteArray(8);
int apiTarget = DexFormat.magicToApi(magic);
checksum = headerIn.readInt();
signature = headerIn.readByteArray(20);
fileSize = headerIn.readInt();
int headerSize = headerIn.readInt();
int endianTag = headerIn.readInt();
linkSize = headerIn.readInt();
linkOff = headerIn.readInt();
mapList.off = headerIn.readInt();
stringIds.size = headerIn.readInt();
stringIds.off = headerIn.readInt();
typeIds.size = headerIn.readInt();
typeIds.off = headerIn.readInt();
protoIds.size = headerIn.readInt();
protoIds.off = headerIn.readInt();
fieldIds.size = headerIn.readInt();
fieldIds.off = headerIn.readInt();
methodIds.size = headerIn.readInt();
methodIds.off = headerIn.readInt();
classDefs.size = headerIn.readInt();
classDefs.off = headerIn.readInt();
dataSize = headerIn.readInt();
dataOff = headerIn.readInt();
}
從檔案中讀取到新舊 Dex 檔案各區塊的具體的資料後,就可以進行對比生成更新檔包了。因為各區塊的資料結構不一緻,是以各區塊有着相應的 diff 算法來處理各區塊更新檔生成與合成。算法清單如圖:
這些算法會對比新舊 Dex 檔案轉化成資料結構以後資料的差異,然後生成相關的操作指令,存儲到更新檔檔案,下發到用戶端。
更新檔的合成
用戶端收到更新檔檔案後,會使用相同的讀取方式,将舊 Dex 檔案轉換為相關的資料結構,然後使用更新檔包中的操作指令,對舊 Dex 資料進行修改,生成新 Dex 資料,最後資料寫入檔案,生成新 Dex 檔案,這樣就完成了更新檔的合成。
寫在最後
本文并沒有寫什麼特别深入的東西,對dex的檔案格式也沒有完全描述完全。主要是給大家分享一個dex檔案的大緻結構,還有一些在實際中的應用。讓大家在以後遇到相關問題的時候,可以有一些方向去了解dex檔案,然後解決問題。最後,如果大家有任何的建議或意見,歡迎回報。
參考資源
- Android 官方資料(https://source.android.com/devices/tech/dalvik)
- Tinker 介紹(https://www.zybuluo.com/dodola/note/554061)
- Dalvik 和 Java 位元組碼的對比(http://www.importnew.com/596.html)
作者:趙恒雨
來源:微信公衆号:有贊coder
出處:https://mp.weixin.qq.com/s/PCc8P9cqVEo9CyY2JBFaZQ