淺談 Android Dex 文件

概述

爲何要了解 Dex 文件

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

什麼是 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 運行時環境執行。java

Dex 文件是怎麼生成的

java 代碼轉化爲 dex 文件的流程如圖所示,固然真的處理流程不會這麼簡單,這裏只是一個形象的顯示:
android

注:圖片來源於網絡

如今來經過一個簡單的例子實現 java 代碼到 dex 文件的轉化。git

從 .java 到 .class

先來建立一個 Hello.java 文件,爲了便於分析,這裏寫一些簡單的代碼。代碼以下:github

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 虛擬機上直接執行。這裏使用使用命令執行該文件。api

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 文件,而後解決問題。最後,若是你們有任何的建議或意見,歡迎反饋。

參考資源

圖片描述

相關文章
相關標籤/搜索