天天看點

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

在前一篇文章 深入探索Android熱修複技術原理讀書筆記 —— 熱修複技術介紹中,對熱修複技術進行了介紹,下面将詳細介紹其中的代碼修複技術。

1 底層熱替換原理

在各種 Android 熱修複方案中,Andfix 的即時生效令人印象深刻,它稍顯另類, 并不需要重新啟動,而是在加載更新檔後直接對方法進行替換就可以完成修複,然而它的使用限制也遭遇到更多的質疑。

1.1 Andfix 回顧

我們先來看一下,為何唯獨 Andfix 能夠做到即時生效呢?

原因是這樣的,在 app 運作到一半的時候,所有需要發生變更的分類已經被加載過了,在 Android 上是無法對一個分類進行解除安裝的。而騰訊系的方案,都是讓 Classloader 去加載新的類。如果不重新開機,原來的類還在虛拟機中,就無法加載新類。是以,隻有在下次重新開機的時候,在還沒走到業務邏輯之前搶先加載更新檔中的新類,這樣後續通路這個類時,就會 Resolve 為新的類。進而達到熱修複的目的。

Andfix 采用的方法是,在已經加載了的類中直接在 native 層替換掉原有方法, 是在原來類的基礎上進行修改的。對于不同 Android 版本的 art,底層 Java 對象的資料結構是不同的,因而會進一步區分不同的替換函數。每一個 Java 方法在 art 中都對應着一個 ArtMethod,ArtMethod 記錄了這個 Java 方法的所有資訊,包括所屬類、通路權限、代碼執行位址等等。

通過 env->FromReflectedMethod,可以由 Method 對象得到這個方法對應的 ArtMethod 的真正起始位址。然後就可以把它強轉為 ArtMethod 指針,進而對其所有成員進行修改。 

這樣全部替換完之後就完成了熱修複邏輯。以後調用這個方法時就會直接走到新 方法的實作中了。

1.2 虛拟機調用方法的原理

為什麼這樣替換完就可以實作熱修複呢?這需要從虛拟機調用方法的原理說起。

在 Android 6.0, art 虛拟機中 ArtMethod 的結構是這個樣子的:

class ArtMethod FINAL {
...
 protected:
  // Field order required by test "ValidateFieldOrderOfJavaCppUnionClasses".
  // The class we are a part of.
  GcRoot<mirror::Class> declaring_class_;

  // Short cuts to declaring_class_->dex_cache_ member for fast compiled code access.
  GcRoot<mirror::PointerArray> dex_cache_resolved_methods_;

  // Short cuts to declaring_class_->dex_cache_ member for fast compiled code access.
  GcRoot<mirror::ObjectArray<mirror::Class>> dex_cache_resolved_types_;

  // Access flags; low 16 bits are defined by spec.
  uint32_t access_flags_;

  /* Dex file fields. The defining dex file is available via declaring_class_->dex_cache_ */

  // Offset to the CodeItem.
  uint32_t dex_code_item_offset_;

  // Index into method_ids of the dex file associated with this method.
  uint32_t dex_method_index_;

  /* End of dex file fields. */

  // Entry within a dispatch table for this method. For static/direct methods the index is into
  // the declaringClass.directMethods, for virtual methods the vtable and for interface methods the
  // ifTable.
  uint32_t method_index_;

  // Fake padding field gets inserted here.

  // Must be the last fields in the method.
  // PACKED(4) is necessary for the correctness of
  // RoundUp(OFFSETOF_MEMBER(ArtMethod, ptr_sized_fields_), pointer_size).
  struct PACKED(4) PtrSizedFields {
    // Method dispatch from the interpreter invokes this pointer which may cause a bridge into
    // compiled code.
    void* entry_point_from_interpreter_;

    // Pointer to JNI function registered to this method, or a function to resolve the JNI function.
    void* entry_point_from_jni_;

    // Method dispatch from quick compiled code invokes this pointer which may cause bridging into
    // the interpreter.
    void* entry_point_from_quick_compiled_code_;
  } ptr_sized_fields_;
...
}      

這其中最重要的字段就是 entry_point_from_interprete_ 和 entry_point_ from_quick_compiled_code_ 了,從名字可以看出來,他們就是方法的執行入口。 我們知道,Java 代碼在 Android 中會被編譯為 Dex Code。

art 中可以采用解釋模式或者 AOT 機器碼模式執行。

  • 解釋模式,就是取出 Dex Code,逐條解釋執行就行了。如果方法的調用者是以解釋模式運作的,在調用這個方法時,就會取得這個方法的 entry_point_fronn_ interpreter,然後跳轉過去執行。
  • AOT方式,就會先預編譯好 Dex Code 對應的機器碼,然後運作期直接執行機器碼就行了,不需要一條條地解釋執行 Dex Code。如果方法的調用者 是以AOT機器碼方式執行的,在調用這個方法時,就是跳轉到 entry_point_from_ quick_compiled_code_ 執行。

那我們是不是隻需要替換這幾個 entry_point_* 入口位址就能夠實作方法替換了呢?

并沒有這麼簡單。在實際代碼中,有許多更為複雜的調用情況。很多情況下還需要用到 dex_code_item_offset_ 等字段。由此可以看出,AOT 機器碼的執行過程,還是會有對于虛拟機以及ArtMethod 其他成員字段的依賴。

是以,當把一個舊方法的所有成員字段都換成新方法後,執行時所有資料就可以 保持和新方法的一緻。這樣在所有執行到舊方法的地方,會取得新方法的執行入口、 所屬class、方法索引号以及所屬 dex 資訊,然後像調用舊方法一樣順滑地執行到新 方法的邏輯。 

1.3 相容性問題的根源

然而,目前市面上幾乎所有的 native 替換方案,比如 Andfix 和其他安全界的 Hook 方案,都是寫死了 ArtMethod 結構體,這會帶來巨大的相容性問題。

由于Android是開源的,各個手機廠商都可以對代碼進行改造,而 Andfix 裡 ArtMethod 的結構是根據公開的 Android 源碼中的結構寫死的。如果某個廠商對這個 ArtMethod 結構體進行了修改,就和原先開源代碼裡的結構不一緻,那 麼在這個修改過了的裝置上,替換機制就會出問題。

這也正是 Andfix 不支援很多機型的原因,很大的可能,就是因為這些機型修改了底層的虛拟機結構。

1.4 突破底層結構差異

知道了 native 替換方式相容性問題的原因,我們是否有辦法尋求一種新的方式,不依賴于 ROM 底層方法結構的實作而達到替換效果呢?

我們發現,這樣 native 層面替換思路,其實就是替換 ArtMethod 的所有成員。 那麼,我們并不需要構造出 ArtMethod 具體的各個成員字段,隻要把 ArtMethod 的作為整體進行替換,這樣不就可以了嗎?

也就是把原先這樣的逐一替換:

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

變成了這樣的整體替換:

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術
// %%把舊函數的所有成員變量都替換為新函數的。
smeth->declaring_class_ = dmeth->declaring_class_;
smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
smeth->dex_cache_resolved_types_ = dmeth->dex_cache_reso1ved_types_; smeth->access_flags_ = dmeth->access_flags_;
smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
smeth->dex_method_index_ = dmeth->dex_method_index_;
smeth->method_index_ = dmeth->method_index_;      

其實可以濃縮為:

memcpy(smeth, dmeth, sizeof(ArtMethod));      

就是這樣,一句話就能取代上面一堆代碼,這正是我們深入了解替換機制的本質之後研發出的新替換方案。

但這其中最關鍵的地方,在于sizeof(ArtMethod)□如果sizeit算有偏差, 導緻部分成員沒有被替換,或者替換區域超出了邊界,都會導緻嚴重的問題。

對于ROM開發者而言,是在art源代碼裡面,是以一個簡單的sizeof (Art- Method)就行了,因為這是在編譯期就可以決定的。

但我們是上層開發者,app會被下發給各式各樣的Android裝置,是以我們是 需要在運作時動态地得到app所運作裝置上面的底層ArtMethod大小的,這就沒那 麼簡單了。

在 art 裡面,初始化一個類的時候會給這個類的所有方法配置設定空間,類的方法有 direct 方法和 virtual 方法。direct 方法包含 static 方法和所有不可 繼承的對象方法。而 virtual 方法就是所有可以繼承的對象方法了。需要對兩中類型方法都進行配置設定空間。

方法是一個接一個緊密地new出來排列在 ArtMethod Array  中的。這時隻是配置設定出空間,還沒填入真正的 ArtMethod 的各個 成員值:

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

正是這裡給了我們啟示,ArtMethod 們是緊密排列的,是以一個 ArtMethod 的大小,不就是相鄰兩個方法所對應的 ArtMethod 的起始位址的內插補點嗎?

正是如此。我們就從這個排列特點入手,自己構造一個類,以一種巧妙的方式獲 取到這個內插補點。

public class NativeStructsModel {
    final public static void fl 0 {}
    final public static void f2() {}
}       

由于 f1 和 f2 都是 static 方法,是以都屬于 direct ArtMethod Array。由于 NativeStructsModel 類中隻存在這兩個方法,是以它們肯定是相鄰的。

那麼我們就可以在JNI層取得它們位址的內插補點:

size_t firMid = (size_t) env->GetStaticMethodID(nativeStructsModelClazzf
"fl", " ()V,r);
size_t secMid = (size_t) env->GetStaticMethodID(nativeStructsModelClazz,
uf2H, " OV");
size_t methsize = secMid - firMid;      

然後,就以這個methSize作為sizeof (ArtMethod),代入之前的代碼。

memcpy(smeth, dmeth, methSize);      

問題就迎刃而解了。

值得一提的是,由于忽略了底層 ArtMethod 結構的差異,對于所有的 Android 版本都不再需要區分,而統一以 memcpy 實作即可,代碼量大大減少。即 使以後的 Android 版本不斷修改ArtMethod的成員,隻要保證 ArtMethod 數組仍是以線性結構排列,就能直接适用于将來的 Android 8.0、9.0 等新版本,無需再針對新的系統版本進行适配了。

1.5 通路權限的問題

1.5.1 方法調用時的權限檢查

看到這裡,你可能會有疑惑:我們隻是替換了 ArtMethod 的内容,但新替換的方法的所屬類,和原先方法的所屬類,是不同的類,被替換的方法有權限通路這個類的其他 private 方法嗎?

在構造函數 調用同一個類下的私有方法func時,不會做任何權限檢查。也就是說,這時即使我偷梁換柱,也能直接跳過去正常執行而不會報錯。

可以推測,在 dex2oat 生成 AOT 機器碼時是有做一些檢查和優化的,由于在 dex2oat 編譯機器碼時确認了兩個方法同屬一個類,是以機器碼中就不存在權限檢查的相關代碼。 

1.5.2 同包名下的權限問題

但是,并非所有方法都可以這麼順利地進行通路的。我們發現更新檔中的類在通路同包名下的類時,會報出通路權限異常:

具體的校驗邏輯是在虛拟機代碼的 Class : : IsInSamePackage 中:

// android-6.0.I_r62/art/runtime/mirror/class.cc
bool Class::IsInSamePackage(Class* that) {
    Class* klassl = this;
    Class* klass2 = that;
    if (klassl == klass2) {
        return true;
    }
    // Class loaders must match.
    if (klassl->GetClassLoader() != klass2->GetClassLoader()) {
        return false;
    }
    // Arrays are in the same package when their element classes are.
    while (klassl->IsArrayClass0) {
        klassl = klassl->GetComponentType();
    }
    while (klass2->IsArrayClass()) {
        klass2 = klass2->GetComponentType();
    }
    // trivial check again for array types
    if (klassl == klass2) {
        return true;
    }
    // Compare the package part of the descriptor string.
    std::string tempi, temp2;
    return IslnSamePackage(klassl->GetDescriptor(&templ), klass2-
    >GetDescriptor(&temp2));
}      

關鍵點在于,Class loaders must match 這行注釋。

知道了原因就好解決了,我們隻要設定新類的Classloader為原來類就可以了。 而這一步同樣不需要在JNI層構造底層的結構,隻需要通過反射進行設定。這樣仍舊能夠保證良好的相容性。

實作代碼如下:

Field classLoaderField = Class.class.getDeclaredField("classLoader"); 
classLoaderField.setAccessible(true);
classLoaderField.set(newClass, oldClass.getClassLoader());      

這樣就解決了同包名下的通路權限問題。

1.5.3 反射調用非靜态方法産生的問題

當一個非靜态方法被熱替換後,在反射調用這個方法時,會抛出異常。

// BaseBug. test方法已經被熱替換了。
BaseBug bb = new BaseBug();
Method testMeth = BaseBug. class. getDeclaredMethod (11 test"); testMeth.invoke(bb);      

invoke 的時候就會報:

Caused by: java.lang.IllegalArgumentException:
Expected receiver of type com.patch.demo.BaseBug, but got com.patch.demo.BaseBug      

這裡面,expected receiver 的 BaseBug,和 got 到的 BaseBug,雖然都叫 com.patch.demo.BaseBug,但卻是不同的類。

前者是被熱替換的方法所屬的類,由于我們把它的 ArtMethod 的 declaring_class_ 替換了,是以就是新的更新檔類。而後者作為被調用的執行個體對象 bb 的所屬類, 是原有的 BaseBug。兩者是不同的。

那為什麼方法是非靜态才有這個問題呢?因為如果是靜态方法,是在類的級别直接進行調用的,就不需要接收對象執行個體作為參數。是以就沒有這方面的檢查了。

對于這種反射調用非靜态方法的問題,我們會采用另一種冷啟動機制對付,本文在最後會說明如何解決。

1.6 即時生效所帶來的限制

除了反射的問題,像本方案以及 Andfix 這樣直接在運作期修改底層結構的熱修複,都存在着一個限制,那就是隻能支援方法的替換。而對于更新檔類裡面存在方法增加和減少,以及成員字段的增加和減少的情況,都是不适用的。

原因是這樣的,一旦更新檔類中出現了方法的增加和減少,就會導緻這個類以及整個 Dex 的方法數的變化。方法數的變化伴随着方法索引的變化,這樣在通路方法時就無法正常地索引到正确的方法了。

而如果字段發生了增加和減少,和方法變化的情況一樣,所有字段的索引都會發生變化。并且更嚴重的問題是,如果在程式運作中間某個類突然增加了一個字段,那 麼對于原先已經産生的這個類的執行個體,它們還是原來的結構,這是無法改變的。而新 方法使用到這些老的執行個體對象時,通路新增字段就會産生不可預期的結果。

不過新增一個完整的、原先包裡面不存在的新類是可以的,這個不受限制。

總之,隻有兩種情況是不适用的:

  1. 引起原有了類中發生結構變化的修改
  2. 修複了的非靜态方法會被反射調用

而對于其他情況,這種方式的熱修複都可以任意使用。

雖然有着一些使用限制,但一旦滿足使用條件,這種熱修複方式是十分出衆的, 它更新檔小,加載迅速,能夠實時生效無需重新啟動 app,并且具有着完美的裝置相容性。對于較小程度的修複再适合不過了。

2 你所不知的Java

和業界很多熱修複方案不同,Sophix 熱修複一直秉承粒度小、注重快捷修複、無侵入适合原生工程。因為堅持這個原則,我們在研發過程中遇到很多編譯期的問題,這些問題對我們最終方案的實施和熱部署也帶來或多或少地影響,令人印象深刻。

本節列舉了我們在項目實戰中遇到的一些挑戰,這些都是 Java 語言在編譯實作上的一些特點,雖然這些特點與熱修複沒有直接關系,但深入研究它們對 Android 及 Java 語言的了解都頗有脾益。 

2.1 内部類編譯

有時候我們會發現,修改外部類某個方法邏輯為通路内部類的某個方法時,最後打出來的更新檔包竟然提示新增了一個方法,這真的很匪夷所思。所有我們有必要了解 下内部類在編譯期間是怎麼編譯的,首先我們要知道内部類會在編譯期會被編譯為跟 外部類一樣的頂級類。

2.1.1 靜态内部類/非靜态内部類差別

靜态内部類/非靜态内部類的差別大家應該都很熟悉,非靜态内部類持有外部類的引用,靜态内部類不持有外部類的引用。是以在android性能優化中建議handle 的實作盡量使用靜态内部類,防止外部類Activity類不能被回收導緻可能 OOM。非靜态内部類,編譯期間會自動合成 this$0 域表示的就是外部類的引用。

内部類和外部類互相通路

既然内部類實際上跟外部類一樣都是頂級類,既然都是頂級類,那是不是意味着對方 private 的 method/field 是沒法被通路得到的,事實上外部類為了通路内部類私有的域/方法,編譯期間自動會為内部類生成 access&** 相關方法

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

此時外部類 BaseBug 為了能通路到内部類 InnerClass 的私有域 s,是以編譯 器自動為 InnerClass 這個内部類合成 access&100 方法,這個方法的實作簡單返 回私有域s的值。同樣的如果此時匿名内部類需要通路外部類的 private 屬性/方法, 那麼外部類也會自動生成 access&** 相關方法提供給内部類通路使用。

2.1.2 熱部署解決方案

是以有這樣一種場景,patch 前的 test 方法沒通路 inner.s, patch 後的 test 方法通路了 inner.s,那麼更新檔工具最後檢測到了新增了 access&ioo 方法。那麼我們 隻要防止生成 access&** 相關方法,就能走熱部署,也就是底層替換方式熱修複。 

是以隻要滿足以下條件,就能避免編譯器自動生成 access&** 相關方法

  • 一個外部類如果有内部類,把所有 method/field 的 private 通路權限改成 protected 或者預設通路權限或 public。
  • 同時把内部類的所有 method/field 的 private 通路權限改成 protected 或者預設通路權限或 public。 

2.2匿名内部類編譯

匿名内部類其實也是個内部類,是以自然也有上一小節說明情況的影響,但是我 們發現新增一個匿名類(更新檔熱部署模式下是允許新增類),同時規避上一節的情況, 但是匪夷所思的還是提示了 method 的新增,是以接下來看下匿名内部類跟非匿名内 部類相比,又有怎麼樣的特殊性。

2.2.1 匿名内部類編譯命名規則

匿名内部類顧名思義就是沒名字的。匿名内部類的名稱格式一般是外部類 &numble,後面的 numble,編譯期根據該匿名内部類在外部類中出現的先後關系, 依次剛命名。一旦新增或者減少内部類會導緻名字與方法含義出現亂套的情況。

2.2.2 熱部署解決方案

新增/減少匿名内部類,實際上對于熱部署來說是無解的,因為更新檔工具拿到的 已經是編譯後的 .class 檔案,是以根本沒法去區分 DexFixDemo&1/DexFixDemo&2 類。是以這種情況下,如果有更新檔熱部署的需求,應該極力避免插入一個新的匿名内部類。當然如果是匿名内部類是插入到外部類的末尾,那麼是允許的。 

2.3 有趣的域編譯

2.3.1 靜态field,非靜态field編譯

實際上在熱部署方案中除了不支援 method/fleld 的新增,同時也是不支援 <ciinit>的修複,這個方法會在 Dalvik 虛拟機中類加載的時候進行類初始化時候調 用。在 java 源碼中本身并沒有 clinit 這個方法,這個方法是 android 編譯器自動合成的 方法。通過測試發現,靜态field的初始化和靜态代碼塊實際上就會被編譯器編譯在 <ciinit>這個方法,是以我們有必要去了解一下 field/代碼塊到底是怎麼編譯的。這塊内容其實在 Java 類加載機制詳解 一文中也有詳細介紹。

來看個簡單的示例。

public class DexFixDemo {
        {
            i = 2;
        }
        private int i = 1;
        private static int j = 1;
        static {
            j = 2;
        }
    }      

反編譯為smali看下

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

2.3.2 靜态field初始化,靜态代碼塊

上面的示例中,能夠很明顯靜态 field 初始化和靜态代碼塊被編譯器翻譯在 <clinit>方法中。靜态代碼塊和靜态域初始化在 clinit 中的先後關系就是兩者出現在源碼中的先後關系,是以上述示例中最後 j==2 。前面說過,類加載然後進行類初始化的時候,會去調用 clinit 方法,一個類僅加載一次。以下三種情況都會嘗試去 加載一個類:

  1. new —個類的對象(new-instance 指令)
  2. 調用類的靜态方法(invoke-static 指令)
  3. 擷取類的靜态域的值(sget 指令)

首先判斷這個類有沒有被加載過,如果沒有加載過,執行的流程 dvniResolve- Class - >dvmLinkClass- >dvmInitClass,類的初始化時在 dvmlnitClass。dvmlnitClass 這個函數首先會嘗試會對父類進行初始化,然後調用本類的 clinit 方法,是以此時靜态field得到初始化和靜态代碼塊得到執行。

2.3.3 非靜态field初始化,非靜态代碼塊

上面的示例中,能夠很明顯的看到非靜态field初始化和非靜态代碼塊被編譯器翻 譯在<init>預設無參構造函數中。非靜态field和非靜态代碼塊在init方法中的先後順 序也跟兩者在源碼中出現的順序一緻,是以上述示例中最後 i==1。實際上如果存在有參構造函數,那麼每個有參構造函數都會執行一個非靜态域的初始化和非靜态代碼塊。

構造函數會被android編譯器自動翻譯成<init>方法 

前面介紹過clinit方法在類加載初始化的時候被調用,那麼 <init> 構造函數方 法肯定是對類對象進行初始化時候被調用的,簡單來說 new —個對象就會對這個對象進行初始化,并調用這個對象相應的構造函數,看下這行代碼 String s = new String ("test");編譯之後的樣子。 

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

首先執行 new-instance 指令,主要為對象配置設定堆記憶體,同時如果類如果之前沒加載過,嘗試加載類。然後執行 invoke-direct 指令調用類的 init 構造函數方法執行對象的初始化。

2.3.4 熱部署解決方案

由于我們不支援<clinit>方法的熱部署,是以任何靜态field初始化和靜态代碼塊的變更都會被翻譯到 clinit 方法中,導緻最後熱部署失敗,隻能冷啟動生效。如上所見,非靜态 field 和非靜态代碼塊的變更被翻譯到<init>構造函數中,熱部署 模式下隻是視為一個普通方法的變更,此時對熱部署是沒有影響的。

2.4 final static 域編譯

final static 域首先是一個靜态域,是以我們自然認為由于會被翻譯到 clinit 方法中,是以自然熱部署下面也是不能變更。但是測試發現,final static修飾的基 本類型/String常量類型,匪夷所思的竟然沒有被翻譯到 clinit 方法中,見以下分析。

2.4.1 final static域編譯規則

final static 靜态常量域。看下 final static 域被編譯後的樣子。

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

看下反編譯得到的smali檔案 

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術
深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

我們發現,final static int 12 = 2 和 final static String s2 = "haha" 這兩個靜态域竟然沒在中被初始化。其它的非final靜态域均在clinit函數中得到初始化。這裡注意下 "haha" 和 new String ("heihei") 的差別,前者是字元串常量,後者是引用類型。那這兩個final static域(i2和s2)究竟在何處得到初始化?

事實上,類加載初始化 dvmlnitClass 在執行 clinit 方法之前,首先會先執行 initSFieids,這個方法的作用主要就是給static域賦予預設值。如果是引用類型, 那麼預設初始值為NULL。0101 Editor工具檢視 dex 檔案結構,我們能看到在 dex 的類定義區,每個類下面都有這麼一段資料,圖中 encoded_array_item。

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

上述代碼示例中,那塊區域有4個預設初始值,分别是 t1 = =NULL, t2==NULL, s1==NULL, s2=="haha", i1==0, i2 = =2。 其中 t1/t2/s2/i1 在 initSFields 中首先指派了預設初始化值,然後在随後的 clinit 中指派了程式設定的值。而 i2/s2 在 initSFields 得到的預設值就是程式中設定的值。

現在我們知道了 static 和 final static 修飾 field 的差別了。簡單來說:

  • final static 修飾的原始類型和 String 類型域(非引用類型),在并不會被翻譯在 clinit 方法中,而是在類初始化執行 initSFields 方法時号到了初始化指派。
  • final static 修飾的弓I用類型,初始化仍然在 clinit 方法中;

2.4.2 final static域優化原理

另外一方面,我們經常會看到android性能優化相關文檔中有說過,如果一個 field是常量,那麼推薦盡量使用static final作為修飾符。很明顯這句話不大 對,得到優化的僅僅是final static原始類型和String類型域(非引用類型), 如果是引用類型,實際上不會得到任何優化的。

2.4.3 熱部署解決方案

所有我們可以得到最後的結論:

  • 修改 final static 基本類型或者 String 類型域(非引用類型)域,由于編譯期 間引用到基本類型的地方被立即數替換,引用到String類型(非引用類型) 的地方被常量池索引id替換,是以在熱部署模式下,最終所有引用到該 final static 域的方法都會被替換。實際上此時仍然可以走熱部署。
  • •修改 final static 引用類型域,是不允許的,因為這個 field 的初始化會被翻譯到clinit方法中,是以此時沒法走熱部署。

2.5 有趣的方法編譯

2.5.1 應用混淆方法編譯

除了以上的内部類/匿名内部類可能會造成method新增之後,我們發現項目如 果應用了混淆,由于可能導緻方法的内聯和裁剪,那麼最後也可能導緻method的新 增/減少,以下介紹哪些場景會造成方法的内聯和裁剪。

2.5.2 方法内聯

實際上有好幾種情況可能導緻方法被内聯掉。

  1. 方法沒有被其它任何地方引用到,毫無疑問,該方法會被内聯掉
  2. 方法足夠簡單,比如一個方法的實作就隻有一行,該方法會被内聯掉,那麼 任何調用該方法的地方都會被該方法的實作替換掉
  3. 方法隻被一個地方引用到,這個地方會被方法的實作替換掉。

舉個簡單的例子進行說明下。

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

此時假如print方法足夠複雜,同時隻在 test 方法中被調用,假設 test 方法沒被内聯,print 方法由于隻有一個地方調用此時 print 方法會被内聯。

如果恰好将要 patch 的一方法調用了 print方法,那麼print被調用了兩次, 在新的apk中不會被内聯,更新檔工具檢測到新增了 print 方法。那麼該更新檔隻能走冷 啟動方案。

2.5.3 方法裁剪

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

檢視下生成的mapping.txt檔案

com.taobao.hotfix.demo.BaseBug -> com.taobao.hotfix.demo.a:

  void test$faab20d() -> a

此時test方法context參數沒被使用,是以test方法的context參數被裁剪, 混淆任務首先生成test$faab20d()裁剪過後的無參方法,然後再混淆。是以如果 将要patch該test方法,同時恰好用到了 context參數,那麼test方法的context 參數不會被裁剪,那麼更新檔工具檢測到新增了 test (context)方法。那麼該更新檔隻 能走冷啟動方案。

怎麼讓該參數不被裁剪,當然是有辦法的,參數引用住,不讓編譯器在優化的 時候認為這是一個無用的參數就好了,可以采取的方法很多,這裡介紹一種最有效 的方法:

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

注意這裡不能用基本類型false,必須用包裝類Boolean,因為如果寫基本類型 這個if語句也很可能會被優化掉的。

2.5.4 熱部署解期案

實際上隻要混淆配置檔案加上-dontoptimize 這項就不會去做方法的裁剪和内聯。一般情況下項目的混淆配置都會使用到 android sdk 預設的混淆配置檔案 proguard-android-optimize. txt 或者 proguard- android. txt, 兩者的差別就是後者應用了 -dontoptimize 這一項配置而前者沒應用。

2.6 switch case 語句編譯

由于在實作資源修複方案熱部署的過程中要做新舊資源 id 的替換,我們發現竟然存在 switch case 語句中的 id 不會。

是以有必要來探索下switch case語句編譯的特殊性。

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

看下 testContinue/testNotContinue 方法編譯出來有何不同。

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術
深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

testNotContinue 方法的 switch case 語句被翻譯成 sparse-switch 指令。 比較下差異 testContinue的switch 語句的case項是連續的幾個值比較相近的值1,3,5。是以被編譯期翻譯為 packed-switch 指令,可以看到對這幾個連續的數中間的內插補點用 :pswitch_0 補齊,:pswitch_0 标簽處直接 retum-void。testNotContinue 的 switch 語句的 case 項分别是1,3,10,很明顯不夠連續,是以 被編譯期翻譯為 sparse-switch 指令。怎麼才算連續的case值這個是由編譯器來決定的。 

2.6.1 熱部署解決方案

—個資源 id 肯定是const final static變量,此時恰好 switch case語句 被翻譯成 packed-switch 指令,是以這個時候如果不做任何處理就存在資源id替換 不完全的情況。解決方案其實很簡單暴力,修改smali反編譯流程,碰到packed- switch 指令強轉為sparse-switch指令,:pswitch_N 等相關标簽指令也需 要強轉為 :sswitch_N 指令。然後做資源id的暴力替換,然後再回編譯 smali 為dex。再做類方法變更的檢測,是以就需要經過反編譯 -> 資源 id 替換 -> 回編譯的過程,這也會使得打更新檔變得稍慢一些。

2.7 泛型編譯

泛型是 java5 才開始引入的,我們發現泛型的使用,也可能導緻 method 的新增,是以是時候深入了解一下泛型的編譯過程了。

為什麼需要泛型?

  • Java語言中的泛型基本上完全在編譯器中實作,由編譯器執行類型檢查和類 型推斷,然後生成普通的非泛型的位元組碼,就是虛拟機完全無感覺泛型的存在。這種實作技術稱為擦除 (erasure) 編譯器使用泛型類型資訊保證類型安 全,然後在生成位元組碼之前将其清除。
  • Java5才引入泛型,是以擴充虛拟機指令集來支援泛型被認為是無法接受的, 因為這會為 Java 廠商更新其JVM造成難以逾越的障礙。是以采用了可以完 全在編譯器中實作的擦除方法。

2.7.1 類型擦除與多态的沖突和解決

子類中真正重寫基類方法的是編譯器自動合成的bridge方法。而類 B 定義的get和set方法上面的 @Override 隻不過是假象,bridge方法的内部實 現去調用我們自己重寫的print方法而已。是以,虛拟機巧妙使用了橋方法的方式,來解決了類型擦除和多态的沖突

這裡或許也許會有疑問,類B中的位元組碼中的方法 get () Ljava/lang/Nuniber ; 和 get () Ljava/lang/Object;是同時存在的,這就颠覆了我們的認知,如果是我 們自己編寫Java源代碼,這樣的代碼是無法通過編譯器的檢查的,方法的重載隻能 以方法參數而無法以傳回類型别作為函數重載的區分标準,但是虛拟機卻是允許這樣做的,因為虛拟機通過參數類型和傳回類型共同來确定一個方法,是以編譯器為了實 現泛型的多态允許自己做這個看起來“不合法”的事情,然後交給虛拟器自己去差別 處理了。

2.7.2 泛型類型轉換

同時前面我們還留了一個坑,泛型是可以不需要強制類型轉換。

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

代碼示例中,第一個不需要強制類型轉換,但是第二個必須強制類型轉換否則編譯期報incovertiable types錯誤。反編譯看下smali:

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

位元組碼檔案很意外,兩者其實并沒有什麼差別,實際上編譯期間,編譯器發現如 果有一個變量的申明加上了泛型類型的話,編譯器自動加上check-cast類型轉換, 而不需要程式員在源碼檔案中進行強制類型轉換,這裡不需要并不意味着不會類型轉換,可以發現其實隻是類型轉換編譯器自動幫我們完成了而已。

2.7.3 熱部署解決方案

前面類型擦除中說過,如果由 B extends A 變成了 B extends A<Number>, 那麼就可能會新增對應的橋方法。此時新增方法了,隻能走冷部署了。這種情況下, 如果要走熱部署,應該避免類似上面那種的修複。

另外一方面,實際上泛型方法内部會生成一個 dalvik/annotation/Signa- ture 這個系統注解

2.8 Lambda表達式編譯

Lambda 表達式是 java7 才引入的一種表達式,類似于匿名内部類實際上又與 匿名内部類有很大的差別,我們發現 Lambda 表達式的使用也可能導緻方法的新增/減少,導緻最後走不了熱部署模式。是以是時候深入了解一下 Lambda 表達式的編 譯過程了。

2.8.1 Lambda表達式編譯規則

首先簡單介紹下 lambda 表達式,lambda 為 Java 添加了缺失的函數式程式設計 特點,Java現在提供的最接近閉包的概念便是 Lambda 表達式。gradle 就是基于 groovy 存在大量閉包。函數式接口具有兩個主要特征,是一個接口,這個接口具有唯一的一個抽象方法,我們将滿足這兩個特性的接口稱為函數式接口。比如 Java 标準庫中的 java.lang.Runnable 和 java.util.Comparator 都是典型的函數式 接口。跟匿名内部類的差別如下:

  • 關鍵字 this 匿名類的this關鍵字指向匿名類,而lambda表達式的this關鍵 字指向包圍lambda表達式的類。
  • 編譯方式,Java編譯器将lambda表達式編譯成類的私有方法,使用了 Java7 的 invokedynamic 位元組碼指令來動态綁定這個方法。Java 編譯器将匿名内部類編譯成外部類&numble的新類。

dex位元組碼檔案和.class位元組碼檔案對lambda表達式處理的 異同點。

  • 共同點:輻譯期間都會為外部類合成一個static輔助方法,該方法内部邏輯 實作lambda表達式。
  • 不同點:1 .class位元組碼中通過 invokedynamic 指令執行lambda表達式。而.dex位元組碼中執行lambda表達式跟普通方法調用沒有任何差別。2 .class位元組碼中運作時生成新類。.dex位元組碼中編譯期間生成新類。

2.8.2 熱部署解決方案

有了以上知識點做基礎,同時我們知道我們打更新檔是通過反編譯為 smali 然後新 apk 跟基線 apk 進行差異對比,得到最後的更新檔包。是以首先:

新增一個lambda表達式,會導緻外部類新增一個輔助方法,是以此時不支 持走熱部署方案,還有另外一方面,可以看下合成類的命名規則 Test$$Lamb-da$-void_main_j ava_lang_String args_LambdaImpl0.smali:外部類名 + Lambda + Lambda 表達式所在方法的簽名 + Lambdalmpl + 出現的順序号。構成這個合成類。是以此時如果不是在末尾插入了一個新的Lambda 表達式,那麼就會導 緻跟前面說明匿名内部類一樣的問題,會導緻類方法比較亂套。減少一個lambda表 達式熱部署情況下也是不允許的,也會導緻類方法比較亂套。

那麼如果隻是修改 lambda 表達式内部的邏輯,此時看起來僅僅相當于修改了一 個方法,是以此時是看起來是允許走熱部署的。事實上并非如此。我們忽略了一種情 況,lambda表達式通路外部類非靜态 field/method 的場景。

前面我們知道 .dex 位元組碼中 lambda 表達式在編譯期間會自動生成新的輔助類。 注意該輔助類是非靜态的,是以該輔助類如果為了通路 “外部類” 的非靜态field/ method就必須持有"外部類"的引用。如果該輔助類沒有通路"外部類”的非靜态 field/method,那麼就不會持有"外部類"的引用。這裡注意這個輔助類和内部類 的差別。我們前面說過如果是非static内部類的話一定會持有外部類的引用的!

2.9 通路權限檢查對熱替換的影響

通路權限的問題中有提到權限問題對于底層熱替換的影響,下面我們就來深入剖析虛拟機下權限控制可能給我們的熱修複方案帶來的影響,下面代碼示例僅演 示Dalvik虛拟機。

2.9.1 類加載階段父類/實作接口通路權限檢查

如果目前類和實作接口 /父類是非 public,同時負責加載兩者的 classLoader 不一樣的情況下,直接 return false。是以如果此時不進行任何處理的 話,那麼在類加載階段就報錯。我們目前的代碼熱修複方案是基于新 classLoader 加載更新檔類,是以在patch的過程中就會報類似如下的錯誤。

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

2.9.2 類校驗階段通路權限檢查

如果更新檔類中存在非 public 類的通路/非 public 方法/域的調用,那麼都會導緻失敗。更為緻命的是,在更新檔加載階段是檢測不出來的,更新檔會被視為正常加載,但是在運作階 段會直接crash異常退出。

2.10 <clinit>方法

由于更新檔熱部署模式下的特殊性一不允許類結構變更以及不允許變更 <clinit> 方法,是以我們的更新檔工具如果發現了這幾種限制情況,那麼此時隻能走冷啟動重新開機 生效,冷啟動幾乎是無任何限制的,可以做到任何場景的修複。可能有時候在源碼層 面上來看并沒有新增/減少 method 和 field,但是實際上由于要滿足 Java 各種文法 特性的需求,是以編譯器會在編譯期間為我們自動合成一些 method 和 field,最後 就有可能觸發了這幾個限制情況。以上列舉的情況可能并不完全詳細,這些分析也隻是一個抛磚引玉的作用,具體情況還需要具體分析,同時一些難以了解的 java 文法 特性或許從編譯的角度去分析可能就無處遁形了。

3 冷啟動類加載原理

前面我們提到熱部署修複方案有諸多特點(有關熱部署修複方案實作。其根本原 理是基于 native 層方法的替換,是以當類結構變化時,如新增減少類 method/field 在熱部署模式下會受到限制。但冷部署能突破這種限制,可以更好地達到修複目的,再加上冷部署在穩定性上具有的獨特優勢,是以可以作為熱部署的有力補充而存在。 

3.1 冷啟動實作方案概述

冷啟動重新開機生效,現在一般有以下兩種實作方案,同時給出他們各自的優缺點:

深入探索Android熱修複技術原理讀書筆記 —— 代碼熱修複技術

上面的表格,我們能清晰的看到兩個方案的缺點都很明顯。這裡對 tinker 方案

dex merge 缺陷進行簡單說明一下:

dex merge 操作是在 java 層面進行,所有對象的配置設定都是在 java heap 上, 如果此時程序申請的java heap對象超過了 vm heap 規定的大小,那麼程序發生 OOM,那麼系統 memory killer 可能會殺掉該程序,導緻 dex 合成失敗。另外一方 面我們知道 jni 層面 C++ new/malloc 申請的記憶體,配置設定在native heap, native heap 的增長并不受 vm heap 大小的限制,隻受限于RAM,如果 RAM 不足那麼進 程也會被殺死導緻閃退。是以如果隻是從 dexmerge 方面思考,在jni層面進行dex merge,進而可以避免 OOM 提高 dex 合并的成功率。理論上當然可以,隻是jni層 實作起來比較複雜而已

3.2 類校驗

apk 第一次安裝的時候,會對原 dex 執行 dexopt,此時假如 apk隻存在一個 dex,是以 dvmVerifyClass(clazz) 結果為 true。是以 apk 中所有的類都會被打上 class_ispreverifIed 标志,接下來執行dvmOptimizeClass,類接着被打上 CLASS_ISOPTIMIZED 标志。

  • dvmVerifyClass:類校驗,類校驗的目的簡單來說就是為了防止類被篡改校 驗類的合法性。此時會對類的每個方法進行校驗,這裡我們隻需要知道如果 類的所有方法中直接引用到的類(第一層級關系,不會進行遞歸搜尋)和目前 類都在同一個dex中的話,dvmVerifyClass 就傳回 true。
  • dvmOptimizeClass:類優化,簡單來說這個過程會把部分指令優化成虛拟機 内咅B指令,比如方法調用指令:invoke-* 指令變成了 invoke-*-quick, quick指令會從類的vtable表中直接取,vtable簡單來說就是類的所有方法 的一張大表(包括繼承自父類的方法)o是以加快了方法的執行速率。

3.3 Art下冷啟動實作

前面說過更新檔熱部署模式下是一個完整的類,更新檔的粒度是類。現在我們的需 求是更新檔既能走熱部署模式也能走冷啟動模式,為了減少更新檔包的大小,并沒有為 熱部署和冷啟動分别準備一套更新檔,而是同一個熱部署模式下的更新檔能夠降級直接 走冷啟動,是以我們不需要做dex merge。但是前面我們知道為了解決Art下類地 址寫死的問題,tinker通過dex merge成一^全新完整的新dex整個替換掉舊的 dexElements數組。事實上我們并不需要這樣做,Art虛拟機下面預設已經支援多 dex壓縮檔案的加載了。

需要注意一點:

  • 更新檔 dex 必須命名為 classes.dex
  • loadDex 得到的 DexFile 完整替換掉 dexElements 數組而不是插入

3.4 不得不說的其它點

我們知道DexFile.loadDex嘗試把一個dex檔案解析并加載到native記憶體, 在加載到native記憶體之前,如果dex不存在對應的odex,那麼Dalvik下會執行 dexopt, Art 會執行 dexoat,最後得到的都是一個優化後的odex。實際上最後虛 拟機執行的是這個 odex而不是dex。

現在有這麼一個問題,如果dex足夠大那麼dexopt/dexoat實際上是很耗時的, 根據上面我們提到的方案,Dalvik下實際上影響比較小,因為loadDex僅僅是更新檔包。 但是Art下影響是非常大的,因為loadDex是更新檔 dex和apk中原dex合并成的一個 完整更新檔壓縮包,是以dexoat非常耗時。是以如果優化後的odex檔案沒生成或者沒 生成一個完整的odex檔案,那麼loadDex便不能在應用啟動的時候進行的,因為會 阻塞loadDex線程,一般是主線程。是以為了解決這個問題,我們把loadDex當做 一個事務來看,如果中途被打斷,那麼就删除。dex檔案,重新開機的時候如果發現存在 odex檔案,loadDex完之後,反射注入/替換dexElements數組,實作patch。 如果不存在。dex檔案,那麼重新開機另一個子線程loadDex,重新開機之後再生效。

另外一方面為了 patch更新檔的安全性,雖然對更新檔包進行簽名校驗,這個時候能 夠防止整個更新檔包被篡改,但是實際上因為虛拟機執行的是odex而不是dex,還需 要對odex檔案進行md5完整性校驗,如果比對,則直接加載。不比對,則重新生成 —遍 odex 檔案,防止 odex 檔案被篡改。 

3.5 完整的方案考慮

代碼修複冷啟動方案由于它的高相容性,幾乎可以修複任何代碼修複的場景,但 是注入前被加載的類(比如 Application 類)肯定是不能被修複的。是以我們把它作 為一個兜底的方案,在沒法走熱部署或者熱部署失敗的情況,最後都會走代碼冷啟動 重新開機生效,是以我們的更新檔是同一套的。具體實施方案對 Dalvik 下和 Art 下分别做了處理:

  • Dalvik下采用我們自行研發的全量DEX方案
  • Art 下本質上虛拟機已經支援多dex的加載,我們要做的僅僅是把更新檔 dex 作為主 dex(classes.dex) 加載而已。

4 多态對冷啟動類加載的影響

前面我們知道冷啟動方案幾乎是可以修複任何場景的,但 Dalvik 下 QFix 方案存在很大的限制,下面将深入介紹下目前方案下為什麼會有這些限制,同時給出具體的 解決方案。

4.1 重新認識多态

實作多态的技術一般叫做動态綁定,是指在執行期間判斷所引用對象的實際類 型,根據其實際的類型調用其相應的方法。多态一般指的是非靜态非 private 方法的多态。field 和靜态方法不具有多态性。

子類 vtable 的大小等于子類 virtual 方法數+父類vtable的大小。

  • 整個複制父類 vtable 到子類的 vtable
  • 周遊子類的 virtual 方法集合,如果方法原型一緻,說明是重寫父類方法,那麼相同索引位置處,子類重寫方法覆寫掉 vtable 中父類的方法
  • 方法原型不一緻,那麼把該方法添加到vtable的末尾

4.2 冷啟動方案限制

dex檔案第一次加載的時候,會執行dexopt, dexopt 有兩個過程:verify+optimize。

  • dvmVerifyClass:類校驗,類校驗的目的簡單來說就是為了防止類被篡改校 驗類的合法性。此時會對類的每個方法進行校驗,這裡我們隻需要知道如果 類的所有方法中直接引用到的類(第一層級關系,不會進行遞歸搜尋)和目前 類都在同一個dex中的話,dvmVerifyClass就傳回true。
  • dvmOptimizeClass:類優化,簡單來說這個過程會把部分指令優化成虛拟機 内部指令,比如方法調用指令:invoke-virtual-quick, quick 指令會從類的 vtable 表中直接取,vtable 簡單來說就是類的所有方法的一張大表(包括繼 承自父類的方法)。是以加快了方法的執行速率。

是以,如果在更新檔類中新增新的方法有可能會導緻方法調用錯亂。

5 Dalvik下完整DEX方案的新探索

5.1 一種新的全量Dex方案

一般來說,合成完整dex,思路就是把原來的 dex 和 patch 裡的 dex 重新合并 成一個。然而我們的思路是反過來的。

我們可以這樣考慮,既然更新檔中已經有變動的類了,那隻要在原先基線包裡的 dex 裡面,去掉更新檔中也有的 class。這樣,更新檔+去除了更新檔類的基線包,不就等于了新app中的所有類了嗎?

參照 Android 原生 multi-dex 的實作再來看這個方案,會很好了解。multi-dex 是把 apk 裡用到的所有類拆分到 classes.dex、classes2 .dex、classes3.dex、...之中,而每個dex都隻包含了部分的類的定義,但單個 dex 也是可以加載的,因為隻要把所有 dex 都 load 進去,本 dex 中不存在的類就可以在運作期間 在其他的dex中找到。

是以同理,在基線包 dex 裡面在去掉了更新檔中 class 後,原先需要發生變更的舊的class就被消除了,基線包dex裡就隻包含不變的class。而這些不變的class 要用到更新檔中的新class時會自動地找到更新檔dex,更新檔包中的新class在需要用到 不變的 class 時也會找到基線包dex的class。這樣的話,基線包裡面不使用更新檔類的 class仍舊可以按原來的邏輯做odex,最大地保證了 dexopt的效果。

這麼一來,我們不再需要像傳統合成的思路那樣判斷類的增加和修改情況,而且也不需要處理合成時方法數超過的情況,對于dex的結構也不用進行破壞性重構。

現在,合成完整 dex 的問題就簡化為了一如何在基線包dex裡面去掉更新檔包 中包含的所有類。接下來我們看一下在 dex 中去除指定類的具體實作。

需要注意的是,我們并不是要把某個Class的所有資訊都從dex移除,因為如 果這麼做,可能會導緻dex的各個部分都發生變化,進而需要大量調整offset,這樣就變得就費時費力了。我們要做的,僅僅是讓在解析這個 dex 的時候找不到這個 Class 的定義就行了。是以,隻需要移除定義的入口,對于 class 的具體内容不進 行删除,這樣可以最大可能地減少 offset 的修改。

我們隻是去除了類的定義,而對于類的方法實體以及其他dex資訊不做移除, 雖然這樣會把這個被移除類的無用資訊殘留在dex檔案中,但這些資訊占不了太多空 間,并且對 dex 的處理速度是提升很大的,這種移除類操作的方式就變得十分輕快。

5.2 對于 Application 的處理

由此,我們實作了完整的dex合成。但仍然有個問題,這個問題所有完整 dex 替換方案都會遇到,那就是對于 Application 的處理。

衆所周知,Application 是整個 app 的入口,是以,在進入到替換的完整 dex 之前,一定會通過Application的代碼,是以,Application必然是加載在原來的老 dex裡面的。隻有在更新檔加載後使用的類,會在新的完整dex裡面找到。

是以,在加載更新檔後,如果 Application 類使用其他在新dex裡的類,由于不在 同一個dex戛 如果Application被打上了 pre-verified标志,這時就會抛出異常

在 Application 類初始化的時候。此時更新檔還 沒進行加載,是以就會提前加載到原始dex中的類。接下來當更新檔加載完畢後,這些 已經加載的類如果用到了新 dex 中的類,并且又是 pre-verified 時就會報錯。

這裡最大的問題在于,我們無法把更新檔加載提前到 dvmOptResolveClass 之前,因為在一個 app 的生命周期裡,沒有可能到達比入口 Application 初始化更早的 時期了。

而這個問題常見于多dex情形,當存在多dex時,無法保證 Application 的用到的類和它處于同個 dex 中。如果隻有一個 dex,—般就不會有這個問題。

多dex情況下要想解決這個問題,有兩種辦法:

  • 第一種辦法,讓Application用到的所有非系統類都和Application位 于同一個dex裡,這就可以保證pre-verified标志被打上,避免進入 dvmOptResolveClass,而在更新檔加載完之後,我們再清除 pre-verified 标志,使得接下來使用其他類也不會報錯。
  • 第二種辦法,把Application裡面除了熱修複架構代碼以外的其他代碼都剝離開,單獨提出放到一個其他類裡面,這樣使得Application不會直接用到 過多非系統類,這樣,保證這個單獨拿出來的類和 Application 處于同一個 dex的幾率還是比較大的。如果想要更保險,Application可以采用反射方式 通路這個單獨類,這樣就徹底扌巴Application和其他類隔絕開了。

第一種方法實作較為簡單,因為 Android 官方 multi-dex 機制會自動将 Application 用到的類都打包到主 dex 中,是以隻要把熱修複初始化放在 attachBaseContext 的最前面,大多都沒問題。而第二種方法稍加繁瑣,是在代碼架構層面進行重新設計,不過可以一勞永逸地解決問題。 

樹林美麗、幽暗而深邃,但我有諾言尚待實作,還要奔行百裡方可沉睡。 -- 羅伯特·弗羅斯特