淺談 Android Dex 檔案

有贊技術發表於2018-11-19

概述

為什麼要了解 Dex 檔案

瞭解了 Dex 檔案以後,對日常開發中遇到一些問題能有更深的理解。如:APK 的瘦身、熱修復、外掛化、應用加固、Android 逆向工程、64 K 方法數限制。

什麼是 Dex 檔案

在明白什麼是 Dex 檔案之前,要先了解一下 JVM,Dalvik 和 ART。JVM 是 JAVA 虛擬機器,用來執行 JAVA 位元組碼程式。Dalvik 是 Google 設計的用於 Android平臺的執行時環境,適合移動環境下記憶體和處理器速度有限的系統。ART 即 Android Runtime,是 Google 為了替換 Dalvik 設計的新 Android 執行時環境,在Android 4.4推出。ART 比 Dalvik 的效能更好。Android 程式一般使用 Java 語言開發,但是 Dalvik 虛擬機器並不支援直接執行 JAVA 位元組碼,所以會對編譯生成的 .class 檔案進行翻譯、重構、解釋、壓縮等處理,這個處理過程是由 dx 進行處理,處理完成後生成的產物會以 .dex 結尾,稱為 Dex 檔案。Dex 檔案格式是專為 Dalvik 設計的一種壓縮格式。所以可以簡單的理解為:Dex 檔案是很多 .class 檔案處理後的產物,最終可以在 Android 執行時環境執行。

Dex 檔案是怎麼生成的

java 程式碼轉化為 dex 檔案的流程如圖所示,當然真的處理流程不會這麼簡單,這裡只是一個形象的顯示:

淺談 Android 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 Dex 檔案
這些區域的資料互相關聯,互相引用。由於篇幅原因,這裡只是顯示部分割槽域的關聯,完整的請去官網自行檢視相關資料整理。下圖中的各欄位都在後面的各區域的詳細介紹中有具體介紹。
淺談 Android Dex 檔案

下面將分別對檔案頭、索引區、類定義區域進行簡單的介紹。其它區域可以去 Android 官網瞭解。

檔案頭

檔案頭區域決定了該怎樣來讀取這個檔案。具體的格式如下表(在檔案中排列的順序就是下面表格中的順序):

淺談 Android Dex 檔案

id 區

id 區儲存著字串,type,prototype,field, method 資源的真正資料在檔案中的偏移量,我們可以根據 id 區的偏移量去找到該 id 對應的真實資料。

字串 id 區域

這個區塊是一個偏移量列表,每個偏移量對應了一個真正的字串資源,每個偏移量佔32位。我們可以通過偏移量找到對應的實際字串資料。具體格式如下:

淺談 Android Dex 檔案
最終這個偏移的位置應該是落在資料區的。找到這個偏移量的位置後,根據下面的格式就可以讀取出這個字串資源的具體資料:
淺談 Android Dex 檔案

型別 id 區

這個區塊是一個索引列表,索引的值對應字串id區域偏移量列表中的某一項。資料格式如下:

淺談 Android Dex 檔案
如果我們要找到某個型別的值,需要先根據型別id列表中的索引值去字串id列表中找到對應的項,這一項儲存的偏移量對應的字串資源就是這個型別的字串描述。

方法原型 id 區

這個區塊是一個方法原型 id 列表,資料格式為:

淺談 Android Dex 檔案

成員 id 區

這個區塊儲存著原型 id 列表,資料格式為:

淺談 Android Dex 檔案

方法 id 區

這個區塊儲存著方法 id 列表,資料格式為: 這個區塊儲存著原型 id 列表,資料格式為:

淺談 Android Dex 檔案

類定義區

這個區域儲存的是類定義的列表,具體的資料結構如下:

淺談 Android Dex 檔案

解析 dex 檔案的工具

這裡推薦一個可以解析 dex 檔案的工具 010 Editor。它可以通過預置的模板讓我們更清晰的瞭解 dex 檔案的格式。

淺談 Android Dex 檔案

Dex 檔案在 Android Tinker 熱修復中的應用

在目前的主流的 Android 熱修復方案中,Tinker有免費、開源、使用者量大等優點,因此在有贊也是基於 Tinker 搭建 Android 熱修復服務。Tinker 熱修復的主要原理就是通過對比舊 APK 的 dex 檔案與新 APK 的 dex 檔案,生成補丁包,然後在 APP 中通過補丁包與舊 APK 的 dex 檔案合成新的 dex 檔案。流程如下圖所示:

淺談 Android Dex 檔案

注:圖片來源於 Tinker 官網

補丁包的生成

Tinker 官方使用自研一套合成方案,就是 DexDiff。它基於 Dex檔案格式的特性,具有補丁包小,消耗記憶體小等優點。在 DexDiff 演算法中,會根據 Dex檔案的格式,將 Dex 檔案劃分為不同的區塊類,如下圖:

淺談 Android Dex 檔案
這些區塊有一個統一的資料結構,主要的資料有區塊對應的實際資料型別及在檔案中的偏移量。如下圖:
淺談 Android 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 演算法來處理各區塊補丁生成與合成。演算法列表如圖:

淺談 Android Dex 檔案
這些演算法會對比新舊 Dex 檔案轉化成資料結構以後資料的差異,然後生成相關的操作指令,儲存到補丁檔案,下發到客戶端。

補丁的合成

客戶端收到補丁檔案後,會使用相同的讀取方式,將舊 Dex 檔案轉換為相關的資料結構,然後使用補丁包中的操作指令,對舊 Dex 資料進行修改,生成新 Dex 資料,最後資料寫入檔案,生成新 Dex 檔案,這樣就完成了補丁的合成。

寫在最後

本文並沒有寫什麼特別深入的東西,對 dex 的檔案格式也沒有完全描述完全。主要是給大家分享一個 dex 檔案的大致結構,還有一些在實際中的應用。讓大家在以後遇到相關問題的時候,可以有一些方向去了解 dex 檔案,然後解決問題。最後,如果大家有任何的建議或意見,歡迎反饋。

參考資源

淺談 Android Dex 檔案

相關文章