深入探索Android熱修復技術原理讀書筆記 —— 程式碼熱修復技術

huansky發表於2021-05-08

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

底層熱替換原理

在各種 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 作為整體進行替換,這樣不就可以了嗎?

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

 

變成了這樣的整體替換:

// %%把舊函式的所有成員變數都替換為新函式的。
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 的各個 成員值:

 

正是這裡給了我們啟示,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.09.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,並且具有著完美的裝置相容性。對於較小程度的修復再適合不過了。

你所不知的Java

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

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

2.1 內部類編譯

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

2.1.1 靜態內部類/非靜態內部類區別

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

內部類和外部類互相訪問

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

此時外部類 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看下

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");編譯之後的樣子。 

首先執行 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 域被編譯後的樣子。

看下反編譯得到的smali檔案 

 

我們發現,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。

上述程式碼示例中,那塊區域有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. 方法只被一個地方引用到,這個地方會被方法的實現替換掉。

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

此時假如print方法足夠複雜,同時只在 test 方法中被呼叫,假設 test 方法沒被內聯,print 方法由於只有一個地方呼叫此時 print 方法會被內聯。

如果恰好將要 patch 的一方法呼叫了 print方法,那麼print被呼叫了兩次, 在新的apk中不會被內聯,補丁工具檢測到新增了 print 方法。那麼該補丁只能走冷 啟動方案。

2.5.3 方法裁剪

檢視下生成的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)方法。那麼該補丁只 能走冷啟動方案。

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

 

注意這裡不能用基本型別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語句編譯的特殊性。

 

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

 

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 泛型型別轉換

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

 

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

位元組碼檔案很意外,兩者其實並沒有什麼區別,實際上編譯期間,編譯器發現如 果有一個變數的申明加上了泛型型別的話,編譯器自動加上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的過程中就會報類似如下的錯誤。

2.9.2 類校驗階段訪問許可權檢查

如果補丁類中存在非 public 類的訪問/非 public 方法/域的呼叫,那麼都會導致失敗。更為致命的是,在補丁載入階段是檢測不出來的,補丁會被視為正常載入,但是在執行階 段會直接crash異常退出。

2.10 <clinit>方法

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

冷啟動類載入原理

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

3.1 冷啟動實現方案概述

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

上面的表格,我們能清晰的看到兩個方案的缺點都很明顯。這裡對 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) 載入而已。

多型對冷啟動類載入的影響

前面我們知道冷啟動方案几乎是可以修復任何場景的,但 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 的最前面,大多都沒問題。而第二種方法稍加繁瑣,是在程式碼架構層面進行重新設計,不過可以一勞永逸地解決問題。 

相關文章