Android熱修復原理(一)熱修復框架對比和程式碼修復

劉望舒發表於2018-03-13

相關文章
解析ClassLoader系列

前言

在Android應用開發中,熱修復技術被越來越多的開發者所使用,也出現了很多熱修復框架,比如:AndFix、Tinker、Dexposed和Nuwa等等。如果只是會這些熱修復框架的使用那意義並不大,我們還需要了解它們的原理,這樣不管熱修復框架如何變化,只要基本原理不變,我們就可以很快的掌握它們。這一個系列不會對某些熱修復框架原始碼進行解析,而是講解熱修復框架的通用原理。

1.熱修復的產生概述

在開發中我們會遇到如下的情況:

  1. 剛釋出的版本出現了嚴重的bug,這就需要去解決bug、測試並打渠道包在各個應用市場上重新發布,這會耗費大量的人力物力,代價會比較大。
  2. 已經改正了此前釋出版本的bug,如果下一個版本是一個大版本,那麼兩個版本的間隔時間會很長,這樣要等到下個大版本釋出再修復bug,這樣此前版本的bug會長期的影響使用者。
  3. 版本升級率不高,並且需要很長時間來完成版本覆蓋,此前版本的bug就會一直影響不升級版本的使用者。
  4. 有一個小而重要的功能,需要短時間內完成版本覆蓋,比如節日活動。

為了解決上面的問題,熱修復框架就產生了。對於Bug的處理,開發人員不要過於依賴熱修復框架,在開發的過程中還是要按照標準的流程做好自測、配合測試人員完成測試流程。

2.熱修復框架的對比

熱修復框架的種類繁多,按照公司團隊劃分主要有以下幾種:

類別 成員
阿里系 AndFix、Dexposed、阿里百川、Sophix
騰訊系 微信的Tinker、QQ空間的超級補丁、手機QQ的QFix
知名公司 美團的Robust、餓了麼的Amigo、美麗說蘑菇街的Aceso
其他 RocooFix、Nuwa、AnoleFix

雖然熱修復框架很多,但熱修復框架的核心技術主要有三類,分別是程式碼修復、資源修復和動態連結庫修復,其中每個核心技術又有很多不同的技術方案,每個技術方案又有不同的實現,另外這些熱修復框架仍在不斷的更新迭代中,可見熱修復框架的技術實現是繁多可變的。作為開發需需要了解這些技術方案的基本原理,這樣就可以以不變應萬變。

部分熱修復框架的對比如下表所示。

特性 AndFix Tinker/Amigo QQ空間 Robust/Aceso
即時生效
方法替換
類替換
類結構修改
資源替換
so替換
支援gradle
支援ART
支援Android7.0

我們可以根據上表和具體業務來選擇合適的熱修復框架,當然上表的資訊很難做到完全準確,因為部分的熱修復框架還在不斷更新迭代。 從表中也可以發現Tinker和Amigo擁有的特性最多,是不是就選它們呢?也不盡然,擁有的特性多也意味著框架的程式碼量龐大,我們需要根據業務來選擇最合適的,假設我們只是要用到方法替換,那麼使用Tinker和Amigo顯然是大材小用了。另外如果專案需要即時生效,那麼使用Tinker和Amigo是無法滿足需求的。對於即時生效,AndFix、Robust和Aceso都滿足這一點,這是因為AndFix的程式碼修復採用了底層替換方案,而Robust和Aceso的程式碼修復借鑑了Instant Run原理,現在我們就來學習程式碼修復。

3.程式碼修復

程式碼修復主要有三個方案,分別是底層替換方案、類載入方案和Instant Run方案。

3.1 類載入方案

類載入方案基於Dex分包方案,什麼是Dex分包方案呢?這個得先從65536限制和LinearAlloc限制說起。 65536限制 隨著應用功能越來越複雜,程式碼量不斷地增大,引入的庫也越來越多,可能會在編譯時提示如下異常:

com.android.dex.DexIndexOverflowException: method ID not in [0, 0xffff]: 65536
複製程式碼

這說明應用中引用的方法數超過了最大數65536個。產生這一問題的原因就是系統的65536限制,65536限制的主要原因是DVM Bytecode的限制,DVM指令集的方法呼叫指令invoke-kind索引為16bits,最多能引用 65535個方法。 LinearAlloc限制 在安裝時可能會提示INSTALL_FAILED_DEXOPT。產生的原因就是LinearAlloc限制,DVM中的LinearAlloc是一個固定的快取區,當方法數過多超出了快取區的大小時會報錯。

為了解決65536限制和LinearAlloc限制,從而產生了Dex分包方案。Dex分包方案主要做的是在打包時將應用程式碼分成多個Dex,將應用啟動時必須用到的類和這些類的直接引用類放到主Dex中,其他程式碼放到次Dex中。當應用啟動時先載入主Dex,等到應用啟動後再動態的載入次Dex,從而緩解了主Dex的65536限制和LinearAlloc限制。

Dex分包方案主要有兩種,分別是Google官方方案、Dex自動拆包和動態載入方案。因為Dex分包方案不是本章的重點,這裡就不再過多的介紹,我們接著來學習類載入方案。 在Android解析ClassLoader(二)Android中的ClassLoader中講到了ClassLoader的載入過程,其中一個環節就是呼叫DexPathList的findClass的方法,如下所示。 libcore/dalvik/src/main/java/dalvik/system/DexPathList.java

 public Class<?> findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {//1
            Class<?> clazz = element.findClass(name, definingContext, suppressed);//2
            if (clazz != null) {
                return clazz;
            }
        }
        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }
複製程式碼

Element內部封裝了DexFile,DexFile用於載入dex檔案,因此每個dex檔案對應一個Element。 多個Element組成了有序的Element陣列dexElements。當要查詢類時,會在註釋1處遍歷Element陣列dexElements(相當於遍歷dex檔案陣列),註釋2處呼叫Element的findClass方法,其方法內部會呼叫DexFile的loadClassBinaryName方法查詢類。如果在Element中(dex檔案)找到了該類就返回,如果沒有找到就接著在下一個Element中進行查詢。 根據上面的查詢流程,我們將有bug的類Key.class進行修改,再將Key.class打包成包含dex的補丁包Patch.jar,放在Element陣列dexElements的第一個元素,這樣會首先找到Patch.dex中的Key.class去替換之前存在bug的Key.class,排在陣列後面的dex檔案中的存在bug的Key.class根據ClassLoader的雙親委託模式就不會被載入,這就是類載入方案,如下圖所示。

13.1.png

類載入方案需要重啟App後讓ClassLoader重新載入新的類,為什麼需要重啟呢?這是因為類是無法被解除安裝的,因此要想重新載入新的類就需要重啟App,因此採用類載入方案的熱修復框架是不能即時生效的。 雖然很多熱修復框架採用了類載入方案,但具體的實現細節和步驟還是有一些區別的,比如QQ空間的超級補丁和Nuwa是按照上面說得將補丁包放在Element陣列的第一個元素得到優先載入。微信Tinker將新舊apk做了diff,得到patch.dex,然後將patch.dex與手機中apk的classes.dex做合併,生成新的classes.dex,然後在執行時通過反射將classes.dex放在Element陣列的第一個元素。餓了麼的Amigo則是將補丁包中每個dex 對應的Element取出來,之後組成新的Element陣列,在執行時通過反射用新的Element陣列替換掉現有的Element 陣列。

採用類載入方案的主要是以騰訊係為主,包括微信的Tinker、QQ空間的超級補丁、手機QQ的QFix、餓了麼的Amigo和Nuwa等等。

3.2 底層替換方案

與類載入方案不同的是,底層替換方案不會再次載入新類,而是直接在Native層修改原有類,由於是在原有類進行修改限制會比較多,不能夠增減原有類的方法和欄位,如果我們增加了方法數,那麼方法索引數也會增加,這樣訪問方法時會無法通過索引找到正確的方法,同樣的欄位也是類似的情況。 底層替換方案和反射的原理有些關聯,就拿方法替換來說,方法反射我們可以呼叫java.lang.Class.getDeclaredMethod,假設我們要反射Key的show方法,會呼叫如下所示。

   Key.class.getDeclaredMethod("show").invoke(Key.class.newInstance());
複製程式碼

Android 8.0的invoke方法,如下所示。 libcore/ojluni/src/main/java/java/lang/reflect/Method.java

    @FastNative
    public native Object invoke(Object obj, Object... args)
            throws IllegalAccessException, IllegalArgumentException, InvocationTargetException;

複製程式碼

invoke方法是個native方法,對應Jni層的程式碼為: art/runtime/native/java_lang_reflect_Method.cc

static jobject Method_invoke(JNIEnv* env, jobject javaMethod, jobject javaReceiver,
                             jobject javaArgs) {
  ScopedFastNativeObjectAccess soa(env);
  return InvokeMethod(soa, javaMethod, javaReceiver, javaArgs);
複製程式碼

Method_invoke函式中又呼叫了InvokeMethod函式: art/runtime/reflection.cc

jobject InvokeMethod(const ScopedObjectAccessAlreadyRunnable& soa, jobject javaMethod,
                     jobject javaReceiver, jobject javaArgs, size_t num_frames) {

...
  ObjPtr<mirror::Executable> executable = soa.Decode<mirror::Executable>(javaMethod);
  const bool accessible = executable->IsAccessible();
  ArtMethod* m = executable->GetArtMethod();//1
...
}
複製程式碼

註釋1處獲取傳入的javaMethod(Key的show方法)在ART虛擬機器中對應的一個ArtMethod指標,ArtMethod結構體中包含了Java方法的所有資訊,包括執行入口、訪問許可權、所屬類和程式碼執行地址等等,ArtMethod結構如下所示。 art/runtime/art_method.h

class ArtMethod FINAL {
...
 protected:
  GcRoot<mirror::Class> declaring_class_;
  std::atomic<std::uint32_t> access_flags_;
  uint32_t dex_code_item_offset_;
  uint32_t dex_method_index_;
  uint16_t method_index_;
  uint16_t hotness_count_;
 struct PtrSizedFields {
    ArtMethod** dex_cache_resolved_methods_;//1
    void* data_;
    void* entry_point_from_quick_compiled_code_;//2
  } ptr_sized_fields_;
}
複製程式碼

ArtMethod結構中比較重要的欄位是註釋1處的dex_cache_resolved_methods_和註釋2處的entry_point_from_quick_compiled_code_,它們是方法的執行入口,當我們呼叫某一個方法時(比如Key的show方法),就會取得show方法的執行入口,通過執行入口就可以跳過去執行show方法。 替換ArtMethod結構體中的欄位或者替換整個ArtMethod結構體,這就是底層替換方案。 AndFix採用的是替換ArtMethod結構體中的欄位,這樣會有相容問題,因為廠商可能會修改ArtMethod結構體,導致方法替換失敗。Sophix採用的是替換整個ArtMethod結構體,這樣不會存在相容問題。 底層替換方案直接替換了方法,可以立即生效不需要重啟。採用底層替換方案主要是阿里係為主,包括AndFix、Dexposed、阿里百川、Sophix。

3.3 Instant Run方案

除了資源修復,程式碼修復同樣也可以借鑑Instant Run的原理, 可以說Instant Run的出現推動了熱修復框架的發展。 Instant Run在第一次構建apk時,使用ASM在每一個方法中注入了類似如下的程式碼:

IncrementalChange localIncrementalChange = $change;//1
		if (localIncrementalChange != null) {//2
			localIncrementalChange.access$dispatch(
					"onCreate.(Landroid/os/Bundle;)V", new Object[] { this,
							paramBundle });
			return;
		}
複製程式碼

其中註釋1處是一個成員變數localIncrementalChange ,它的值為$change$change實現了IncrementalChange這個抽象介面。當我們點選InstantRun時,如果方法沒有變化則$change為null,就呼叫return,不做任何處理。如果方法有變化,就生成替換類,這裡我們假設MainActivity的onCreate方法做了修改,就會生成替換類MainActivity$override,這個類實現了IncrementalChange介面,同時也會生成一個AppPatchesLoaderImpl類,這個類的getPatchedClasses方法會返回被修改的類的列表(裡面包含了MainActivity),根據列表會將MainActivity的$change設定為MainActivity$override,因此滿足了註釋2的條件,會執行MainActivity$overrideaccess$dispatch方法,access$dispatch方法中會根據引數"onCreate.(Landroid/os/Bundle;)V"執行MainActivity$override的onCreate方法,從而實現了onCreate方法的修改。 借鑑Instant Run的原理的熱修復框架有Robust和Aceso。

Android熱修復原理(一)熱修復框架對比和程式碼修復

相關文章