閱讀376 返回首頁    go 阿裏雲 go 技術社區[雲棲]


Android熱修複升級探索——追尋極致的代碼熱替換

前言

前段時間,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,並且具有著完美的設備兼容性。對於較小程度的修複再適合不過了。

本修複方案將最先在**阿裏Hotfix2.0(Sophix)**上應用,手機淘寶技術團隊近期將與阿裏雲聯合發布。

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

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

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

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

方案對比 Andfix開源版本 阿裏Hotfix 1.X 阿裏Hotfix 2.0(Sophix)
方法替換 支持,除部分情況[0] 支持,除部分情況
方法增加減少 以冷啟動方式支持[1]
方法反射調用 以冷啟動方式支持
即時生效 視情況支持[2]
多DEX
資源更新
so庫更新
Android版本 支持2.3~7.0 支持2.3~6.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] 提供服務端的補丁發布和停發、版本控製和灰度功能,存儲開發者上傳的補丁包。

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

最後更新:2017-04-27 20:00:55

  上一篇:go 當安全遇上AI 阿裏聚安全算法挑戰賽完美收官
  下一篇:go PHP靈活用isset替換count