Android熱修復升級探索——追尋極致的程式碼熱替換

萬壑發表於2017-04-27

前言

前段時間,Android平臺上湧現了一系列熱修復方案,如阿里的Andfix、微信的Tinker、QQ空間的Nuva、手Q的QFix等等。

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

我們也對程式碼的native替換原理重新進行了深入思考,從克服其限制和相容性入手,以一種更加優雅的替換思路,實現了即時生效的程式碼熱修復。

Andfix回顧

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

原因是這樣的,在app執行到一半的時候,所有需要發生變更的Class已經被載入過了,在Android上是無法對一個Class進行解除安裝的。而騰訊系的方案,都是讓Classloader去載入新的類。如果不重啟,原來的類還在虛擬機器中,就無法載入新類。因此,只有在下次重啟的時候,在還沒走到業務邏輯之前搶先載入補丁中的新類,這樣後續訪問這個類時,就會Resolve為新的類。從而達到熱修復的目的。

Andfix採用的方法是,在已經載入了的類中直接在native層替換掉原有方法,是在原來類的基礎上進行修改的。我們這就來看一下Andfix的具體實現。

其核心在於replaceMethod函式

@AndFix/src/com/alipay/euler/andfix/AndFix.java

private static native void replaceMethod(Method src, Method dest);

這是一個native方法,它的引數是在Java層通過反射機制得到的Method物件所對應的jobject。src對應的是需要被替換的原有方法。而dest對應的就是新方法,新方法存在於補丁包的新類中,也就是補丁方法。

@AndFix/jni/andfix.cpp

static void replaceMethod(JNIEnv* env, jclass clazz, jobject src,
        jobject dest) {
    if (isArt) {
        art_replaceMethod(env, src, dest);
    } else {
        dalvik_replaceMethod(env, src, dest);
    }
}

Android的java執行環境,在4.4以下用的是dalvik虛擬機器,而在4.4以上用的是art虛擬機器。

@AndFix/jni/art/art_method_replace.cpp

extern void __attribute__ ((visibility ("hidden"))) art_replaceMethod(
        JNIEnv* env, jobject src, jobject dest) {
    if (apilevel > 23) {
        replace_7_0(env, src, dest);
    } else if (apilevel > 22) {
        replace_6_0(env, src, dest);
    } else if (apilevel > 21) {
        replace_5_1(env, src, dest);
    } else if (apilevel > 19) {
        replace_5_0(env, src, dest);
    }else{
        replace_4_4(env, src, dest);
    }
}

我們以art為例,對於不同Android版本的art,底層Java物件的資料結構是不同的,因而會進一步區分不同的替換函式,這裡我們以Android 6.0為例,對應的就是replace_6_0

@AndFix/jni/art/art_method_replace_6_0.cpp

void replace_6_0(JNIEnv* env, jobject src, jobject dest) {

    // %% 通過Method物件得到底層Java函式對應ArtMethod的真實地址。
    art::mirror::ArtMethod* smeth =
            (art::mirror::ArtMethod*) env->FromReflectedMethod(src);

    art::mirror::ArtMethod* dmeth =
            (art::mirror::ArtMethod*) env->FromReflectedMethod(dest);

    ... ...
    
    // %% 把舊函式的所有成員變數都替換為新函式的。
    smeth->declaring_class_ = dmeth->declaring_class_;
    smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
    smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_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_;

    smeth->ptr_sized_fields_.entry_point_from_interpreter_ =
    dmeth->ptr_sized_fields_.entry_point_from_interpreter_;

    smeth->ptr_sized_fields_.entry_point_from_jni_ =
    dmeth->ptr_sized_fields_.entry_point_from_jni_;
    smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
    dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;

    LOGD("replace_6_0: %d , %d",
         smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_,
         dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_);
}

每一個Java方法在art中都對應著一個ArtMethod,ArtMethod記錄了這個Java方法的所有資訊,包括所屬類、訪問許可權、程式碼執行地址等等。

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

這樣全部替換完之後就完成了熱修復邏輯。以後呼叫這個方法時就會直接走到新方法的實現中了。

虛擬機器呼叫方法的原理

為什麼這樣替換完就可以實現熱修復呢?這需要從虛擬機器呼叫方法的原理說起。

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

@art/runtime/art_method.h

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_from_interpreter_,然後跳轉過去執行。

而如果是AOT的方式,就會先預編譯好Dex Code對應的機器碼,然後執行期直接執行機器碼就行了,不需要一條條地解釋執行Dex Code。如果方法的呼叫者是以AOT機器碼方式執行的,在呼叫這個方法時,就是跳轉到entry_point_from_quick_compiled_code_執行。

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

並沒有這麼簡單。因為不論是解釋模式或是AOT機器碼模式,在執行期間還會需要用到ArtMethod裡面的其他成員欄位。

就以AOT機器碼模式為例,雖然Dex Code被編譯成了機器碼。但是機器碼並不是可以脫離虛擬機器而單獨執行的,以這段簡單的程式碼為例:

public class MainActivity extends Activity {

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
    }

... ...

編譯為AOT機器碼後,是這樣的:

  7: void com.patch.demo.MainActivity.onCreate(android.os.Bundle) (dex_method_idx=20639)
    DEX CODE:
      0x0000: 6f20 4600 1000            | invoke-super {v0, v1}, void android.app.Activity.onCreate(android.os.Bundle) // method@70
      0x0003: 0e00                      | return-void


    CODE: (code_offset=0x006fdbac size_offset=0x006fdba8 size=96)
      ... ...
      0x006fdbe0: f94003e0  ldr x0, [sp]        ;x0 = MainActivity.onCreate對應的ArtMethod指標
      0x006fdbe4: b9400400  ldr w0, [x0, #4]    ;w0 = [x0 + 4] = dex_cache_resolved_methods_欄位
      0x006fdbe8: f9412000  ldr x0, [x0, #576]  ;x0 = [x0 + 576] = dex_cache_resolved_methods_陣列的第72(=576/8)個元素,即對應Activity.onCreate的ArtMethod指標
      0x006fdbec: f940181e  ldr lr, [x0, #48]   ;lr = [x0 + 48] = Activity.onCreate的ArtMethod成員的entry_point_from_quick_compiled_code_執行入口點
      0x006fdbf0: d63f03c0  blr lr              ;呼叫Activity.onCreate
      ... ...

這裡面我去掉了一些校驗之類的無關程式碼,可以很清楚看到,在呼叫一個方法時,取得了ArtMethod中的dex_cache_resolved_methods_,這是一個存放ArtMethod*的指標陣列,通過它就可以訪問到這個Method所在Dex中所有的Method所對應的ArtMethod*。

Activity.onCreate的方法索引是70,由於是64位系統,因此每個指標的大小為8位元組,又由於ArtMethod*元素是從這個陣列的第0x2個位置開始存放的,因此偏移(70 + 2) * 8 = 576的位置正是Activity.onCreate的ArtMethod指標。

這是一個比較簡單的例子,而在實際程式碼中,有許多更為複雜的呼叫情況。很多情況下還需要用到dex_code_item_offset_等欄位。由此可以看出,AOT機器碼的執行過程,還是會有對於虛擬機器以及ArtMethod其他成員欄位的依賴。

因此,當把一箇舊方法的所有成員欄位換成都新方法後,執行時所有資料就可以保持和新方法的一致。這樣在所有執行到舊方法的地方,會取得新方法的執行入口、所屬class、方法索引號以及所屬dex資訊,然後像呼叫舊方法一樣順滑地執行到新方法的邏輯。

相容性問題的根源

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

從剛才的分析可以看到,雖然Andfix是把底層結構強轉為了art::mirror::ArtMethod,但這裡的art::mirror::ArtMethod並非等同於app執行時所在裝置虛擬機器底層的art::mirror::ArtMethod,而是Andfix自己構造的art::mirror::ArtMethod。

@AndFix/jni/art/art_6_0.h

class ArtMethod {
public:

    // Field order required by test "ValidateFieldOrderOfJavaCppUnionClasses".
    // The class we are a part of.
    uint32_t declaring_class_;
    // Short cuts to declaring_class_->dex_cache_ member for fast compiled code access.
    uint32_t dex_cache_resolved_methods_;
    // Short cuts to declaring_class_->dex_cache_ member for fast compiled code access.
    uint32_t 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 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_;
};

我們再來回顧一下Android開原始碼裡面art虛擬機器裡的ArtMethod:

@art/runtime/art_method.h

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_;

... ...
}

可以看到,ArtMethod結構裡的各個成員的大小是和AOSP開原始碼裡完全一致的。這是由於Android原始碼是公開的,Andfix裡面的這個ArtMethod自然是遵照android虛擬機器art原始碼裡面的ArtMethod構建的。

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

比如,在Andfix替換declaring_class_的地方,

    smeth->declaring_class_ = dmeth->declaring_class_;

由於declaring_class_是andfix裡ArtMethod的第一個成員,因此它和以下這行程式碼等價:

    *(uint32_t*) (smeth + 0) = *(uint32_t*) (dmeth + 0)

如果手機廠商在ArtMethod結構體的declaring_class_前面新增了一個欄位additional_,那麼,additional_就成為了ArtMethod的第一個成員,所以smeth + 0這個位置在這臺裝置上實際就變成了additional_,而不再是declaring_class_欄位。所以這行程式碼的真正含義就變成了:

    smeth->additional_ = dmeth->additional_;

這樣就和原先替換declaring_class_的邏輯不一致,從而無法正常執行熱修復邏輯。

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

突破底層結構差異

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

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

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

變成了這樣的整體替換
my_replace_artmethod

因此Andfix這一系列繁瑣的替換:

    // %% 把舊函式的所有成員變數都替換為新函式的。
    smeth->declaring_class_ = dmeth->declaring_class_;
    smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
    smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_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));

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

剛才提到過,不同的手機廠商都可以對底層的ArtMethod進行任意修改,但即使他們把ArtMethod改得六親不認,只要我像這樣把整個ArtMethod結構體完整替換了,就能夠把所有舊方法成員自動對應地換成新方法的成員。

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

對於ROM開發者而言,是在art原始碼裡面,所以一個簡單的sizeof(ArtMethod)就行了,因為這是在編譯期就可以決定的。

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

想要忽略ArtMethod的具體結構成員直接取得其size的精確值,我們還是需要從虛擬機器的原始碼入手,從底層的資料結構及排列特點探尋答案

在art裡面,初始化一個類的時候會給這個類的所有方法分配空間,我們可以看到這個分配空間的地方:

@android-6.0.1_r62/art/runtime/class_linker.cc

void ClassLinker::LoadClassMembers(Thread* self, const DexFile& dex_file,
                                   const uint8_t* class_data,
                                   Handle<mirror::Class> klass,
                                   const OatFile::OatClass* oat_class) {
    ... ...
    
    ArtMethod* const direct_methods = (it.NumDirectMethods() != 0)
        ? AllocArtMethodArray(self, it.NumDirectMethods())
        : nullptr;
    ArtMethod* const virtual_methods = (it.NumVirtualMethods() != 0)
        ? AllocArtMethodArray(self, it.NumVirtualMethods())
        : nullptr;                                   
   
    ... ...                                

類的方法有direct方法和virtual方法。direct方法包含static方法和所有不可繼承的物件方法。而virtual方法就是所有可以繼承的物件方法了。

AllocArtMethodArray函式分配了他們的方法所在區域。

@android-6.0.1_r62/art/runtime/class_linker.cc

ArtMethod* ClassLinker::AllocArtMethodArray(Thread* self, size_t length) {
  const size_t method_size = ArtMethod::ObjectSize(image_pointer_size_);
  uintptr_t ptr = reinterpret_cast<uintptr_t>(
      Runtime::Current()->GetLinearAlloc()->Alloc(self, method_size * length));
  CHECK_NE(ptr, 0u);
  for (size_t i = 0; i < length; ++i) {
    new(reinterpret_cast<void*>(ptr + i * method_size)) ArtMethod;
  }
  return reinterpret_cast<ArtMethod*>(ptr);
}

可以看到,ptr是這個方法陣列的指標,而方法是一個接一個緊密地new出來排列在這個方法陣列中的。這時只是分配出空間,還沒填入真正的ArtMethod的各個成員值,不過這並不影響我們觀察ArtMethod的空間結構。

sizeof_artmethod

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

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

public class NativeStructsModel {
    final public static void f1() {}
    final public static void f2() {}
}

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

那麼我們就可以在JNI層取得它們地址的差值:

    size_t firMid = (size_t) env->GetStaticMethodID(nativeStructsModelClazz, "f1", "()V");
    size_t secMid = (size_t) env->GetStaticMethodID(nativeStructsModelClazz, "f2", "()V");
    size_t methSize = secMid - firMid;

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

    memcpy(smeth, dmeth, methSize);

問題就迎刃而解了。

值得一提的是,由於忽略了底層ArtMethod結構的差異,對於所有的Android版本都不再需要區分,而統一以memcpy實現即可,程式碼量大大減少。即使以後的Android版本不斷修改ArtMethod的成員,只要保證ArtMethod陣列仍是以線性結構排列,就能直接適用於將來的Android 8.0、9.0等新版本,無需再針對新的系統版本進行適配了。事實也證明確實如此,當我們拿到Google剛發不久的Android O(8.0)開發者預覽版的系統時,hotfix demo直接就能順利地載入補丁跑起來了,我們並沒有做任何適配工作,魯棒性極好。

訪問許可權的問題

方法呼叫時的許可權檢查

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

以這段簡單的程式碼為例

public class Demo {
    Demo() {
        func();
    }

    private void func() {
    }
}

Demo建構函式呼叫私有函式func所對應的Dex Code和Native Code為

   void com.patch.demo.Demo.<init>() (dex_method_idx=20628)
    DEX CODE:
      ... ...
      0x0003: 7010 9550 0000            | invoke-direct {v0}, void com.patch.demo.Demo.func() // method@20629
      ... ...
    
    CODE: (code_offset=0x006fd86c size_offset=0x006fd868 size=140)...
      ... ...
      0x006fd8c4: f94003e0  ldr x0, [sp]             ; x0 = <init>的ArtMethod*
      0x006fd8c8: b9400400  ldr w0, [x0, #4]         ; w0 = dex_cache_resolved_methods_
      0x006fd8cc: d2909710  mov x16, #0x84b8         ; x16 = 0x84b8
      0x006fd8d0: f2a00050  movk x16, #0x2, lsl #16  ; x16 = 0x84b8 + 0x20000 = 0x284b8 = (20629 + 2) * 8, 
                                                     ; 也就是Demo.func的ArtMethod*相對於表頭dex_cache_resolved_methods_的偏移。
      0x006fd8d4: f8706800  ldr x0, [x0, x16]        ; 得到Demo.func的ArtMethod*
      0x006fd8d8: f940181e  ldr lr, [x0, #48]        ; 取得其entry_point_from_quick_compiled_code_
      0x006fd8dc: d63f03c0  blr lr                   ; 跳轉執行
      ... ...

這個呼叫邏輯和之前Activity的例子大同小異,需要注意的地方是,在建構函式呼叫同一個類下的私有方法func時,沒有做任何許可權檢查。也就是說,這時即使我把func方法的偷樑換柱,也能直接跳過去正常執行而不會報錯。

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

同包名下的許可權問題

但是,並非所有方法都可以這麼順利地進行訪問的。我們發現補丁中的類在訪問同包名下的類時,會報出訪問許可權異常:

Caused by: java.lang.IllegalAccessError:
Method `void com.patch.demo.BaseBug.test()` is inaccessible to class `com.patch.demo.MyClass` (declaration of `com.patch.demo.MyClass` 
appears in /data/user/0/com.patch.demo/files/baichuan.fix/patch/patch.jar)

雖然com.patch.demo.BaseBugcom.patch.demo.MyClass是同一個包com.patch.demo下面的,但是由於我們替換了com.patch.demo.BaseBug.test,而這個替換了的BaseBug.test是從補丁包的Classloader載入的,與原先的base包就不是同一個Classloader了,這樣就導致兩個類無法被判別為同包名。具體的校驗邏輯是在虛擬機器程式碼的Class::IsInSamePackage中:

android-6.0.1_r62/art/runtime/mirror/class.cc

bool Class::IsInSamePackage(Class* that) {
  Class* klass1 = this;
  Class* klass2 = that;
  if (klass1 == klass2) {
    return true;
  }
  // Class loaders must match.
  if (klass1->GetClassLoader() != klass2->GetClassLoader()) {
    return false;
  }
  // Arrays are in the same package when their element classes are.
  while (klass1->IsArrayClass()) {
    klass1 = klass1->GetComponentType();
  }
  while (klass2->IsArrayClass()) {
    klass2 = klass2->GetComponentType();
  }
  // trivial check again for array types
  if (klass1 == klass2) {
    return true;
  }
  // Compare the package part of the descriptor string.
  std::string temp1, temp2;
  return IsInSamePackage(klass1->GetDescriptor(&temp1), klass2->GetDescriptor(&temp2));
}

關鍵點在於,Class loaders must match這行註釋。

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

實現程式碼如下:

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

這樣就解決了同包名下的訪問許可權問題。

反射呼叫非靜態方法產生的問題

當一個非靜態方法被熱替換後,在反射呼叫這個方法時,會丟擲異常。

比如下面這個例子:

    // BaseBug.test方法已經被熱替換了。
    ... ...
    
    BaseBug bb = new BaseBug();
    Method testMeth = BaseBug.class.getDeclaredMethod("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。兩者是不同的。

在反射invoke這個方法時,在底層會呼叫到InvokeMethod:

jobject InvokeMethod(const ScopedObjectAccessAlreadyRunnable& soa, jobject javaMethod,
                     jobject javaReceiver, jobject javaArgs, size_t num_frames) {
      ... ...
      
      if (!VerifyObjectIsClass(receiver, declaring_class)) {
        return nullptr;
      }
      
      ... ...

這裡面會呼叫VerifyObjectIsClass函式做驗證。

inline bool VerifyObjectIsClass(mirror::Object* o, mirror::Class* c) {
  if (UNLIKELY(o == nullptr)) {
    ThrowNullPointerException("null receiver");
    return false;
  } else if (UNLIKELY(!o->InstanceOf(c))) {
    InvalidReceiverError(o, c);
    return false;
  }
  return true;
}

o表示Method.invoke傳入的第一個引數,也就是作用的物件。
c表示ArtMethod所屬的Class。

因此,只有o是c的一個例項才能夠通過驗證,才能繼續執行後面的反射呼叫流程。

由此可知,這種熱替換方式所替換的非靜態方法,在進行反射呼叫時,由於VerifyObjectIsClass時舊類和新類不匹配,就會導致校驗不通過,從而丟擲上面那個異常。

那為什麼方法是非靜態才有這個問題呢?因為如果是靜態方法,是在類的級別直接進行呼叫的,就不需要接收物件例項作為引數。所以就沒有這方面的檢查了。

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

即時生效所帶來的限制

除了反射的問題,像本方案以及Andfix這樣直接在執行期修改底層結構的熱修復,都存在著一個限制,那就是隻能支援方法的替換。而對於補丁類裡面存在方法增加和減少,以及成員欄位的增加和減少的情況,都是不適用的。

原因是這樣的,一旦補丁類中出現了方法的增加和減少,就會導致這個類以及整個Dex的方法數的變化。方法數的變化伴隨著方法索引的變化,這樣在訪問方法時就無法正常地索引到正確的方法了。

而如果欄位發生了增加和減少,和方法變化的情況一樣,所有欄位的索引都會發生變化。並且更嚴重的問題是,如果在程式執行中間某個類突然增加了一個欄位,那麼對於原先已經產生的這個類的例項,它們還是原來的結構,這是無法改變的。而新方法使用到這些老的例項物件時,訪問新增欄位就會產生不可預期的結果。

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

總之,只有兩種情況是不適用的:1).引起原有了類中發生結構變化的修改,2).修復了的非靜態方法會被反射呼叫,而對於其他情況,這種方式的熱修復都可以任意使用。

總結

雖然有著一些使用限制,但一旦滿足使用條件,這種熱修復方式是十分出眾的,它補丁小,載入迅速,能夠實時生效無需重新啟動app,並且具有著完美的裝置相容性。對於較小程度的修復再適合不過了。

本修復方案將最先在阿里Hotfix最新版本(Sophix)上應用,由手機淘寶技術團隊與阿里雲聯合釋出。

Sophix提供了一套更加完美的客戶端服務端一體的熱更新方案。針對小修改可以採用本文這種即時生效的熱修復,並且可以結合資源修復,做到資源和程式碼的即時生效。

而如果觸及了本文提到的熱替換使用限制,對於比較大的程式碼改動以及被修復方法反射呼叫情況,Sophix也提供了另一種完整程式碼修復機制,不過是需要app重新冷啟動,來發揮其更加完善的修復及更新功能。從而可以做到無感知的應用更新。

並且Sophix做到了圖形介面一鍵打包、加密傳輸、簽名校驗和服務端控制釋出與灰度功能,讓你用最少的時間實現最強大可靠的全方位熱更新。

一張表格來說明一下各個版本熱修復的差別:

方案對比 Andfix開源版本 阿里Hotfix 1.X 阿里Hotfix最新版(Sophix)
方法替換 支援,除部分情況[0] 支援,除部分情況 全部支援
方法增加減少 不支援 不支援 以冷啟動方式支援[1]
方法反射呼叫 只支援靜態方法 只支援靜態方法 以冷啟動方式支援
即時生效 支援 支援 視情況支援[2]
多DEX 不支援 支援 支援
資源更新 不支援 不支援 支援
so庫更新 不支援 不支援 支援
Android版本 支援2.3~7.0 支援2.3~6.0 全部支援包含7.0以上
已有機型 大部分支援[3] 大部分支援 全部支援
安全機制 加密傳輸及簽名校驗 加密傳輸及簽名校驗
效能損耗 低,幾乎無損耗 低,幾乎無損耗 低,僅冷啟動情況下有些損耗
生成補丁 繁瑣,命令列操作 繁瑣,命令列操作 便捷,圖形化介面
補丁大小 不大,僅變動的類 小,僅變動的方法 不大,僅變動的資源和程式碼[4]
服務端支援 支援服務端控制[5] 支援服務端控制

說明:
[0] 部分情況指的是構造方法、引數數目大於8或者引數包括long,double,float基本型別的方法。
[1] 冷啟動方式,指的是需要重啟app在下次啟動時才能生效。
[2] 對於Andfix及Hotfix 1.X能夠支援的程式碼變動情況,都能做到即時生效。而對於Andfix及Hotfix 1.X不支援的程式碼變動情況,會走冷啟動方式,此時就無法做到即時生效。
[3] Hotfix 1.X已經支援絕大部分主流手機,只是在X86裝置以及修改了虛擬機器底層結構的ROM上不支援。
[4] 由於支援了資源和庫,如果有這些方面的更新,就會導致的補丁變大一些,這個是很正常的。並且由於只包含差異的部分,所以補丁已經是最大程度的小了。
[5] 提供服務端的補丁釋出和停發、版本控制和灰度功能,儲存開發者上傳的補丁包。

從現在起,讓你的APP實現隨心所欲的熱更新吧!請猛戳這裡>_<

最後,感謝團隊@悟二和@查鬱冷啟動修復及so庫更新方面的支援,以及@所為在開發過程中的問題討論與文章校稿。

原創文章,轉載請註明出處。手淘公眾號文章連結:http://mp.weixin.qq.com/s/Uv0BS67-wgvCor6Fss6ChQ


相關文章