【Android 熱修復】美團Robust熱修復框架原理解析

南方吳彥祖_藍斯發表於2021-10-08

一、熱修復框架現狀

目前熱修復框架主要有QQ空間補丁、HotFix、Tinker、Robust等。熱修復框架按照原理大致可以分為三類:

  1. 基於 multidex機制 干預 ClassLoader 載入dex
  2. native 替換方法結構體
  3. instant-run 插樁方案

QQ空間補丁和Tinker都是使用的方案一; 阿里的AndFix使用的是方案二; 美團的Robust使用的是方案三。


【Android 熱修復】美團Robust熱修復框架原理解析

1. QQ空間補丁原理

把補丁類生成  patch.dex,在app啟動時,使用反射獲取當前應用的 ClassLoader,也就是  BaseDexClassLoader,反射獲取其中的 pathList,型別為 DexPathList, 反射獲取其中的 Element[] dexElements, 記為 elements1;然後使用當前應用的 ClassLoader作為父 ClassLoader,構造出  patch.dex 的  DexClassLoader,通用透過反射可以獲取到對應的 Element[] dexElements,記為 elements2。將 elements2拼在 elements1前面,然後再去呼叫載入類的方法 loadClass

隱藏的技術難點 CLASS_ISPREVERIFIED 問題

apk在安裝時會進行dex檔案進行驗證和最佳化操作。這個操作能讓app執行時直接載入odex檔案,能夠減少對記憶體佔用,加快啟動速度,如果沒有odex操作,需要從apk包中提取dex再執行。

在驗證過程,如果某個類的呼叫關係都在同一個dex檔案中,那麼這個類會被打上 CLASS_ISPREVERIFIED標記,表示這個類已經預先驗證過了。但是再使用的過程中會反過來校驗下,如果這個類被打上了 CLASS_ISPREVERIFIED但是存在呼叫關係的類不在同一個dex檔案中的話,會直接丟擲異常。

為了解決這個問題,QQ空間給出的解決方案就是,準備一個 AntilazyLoad 類,這個類會單獨打包成一個 hack.dex,然後在所有的類的構造方法中增加這樣的程式碼:

if (ClassVerifier.PREVENT_VERIFY) {
   System.out.println(AntilazyLoad.class);
}
複製程式碼

這樣在 odex 過程中,每個類都會出現 AntilazyLoad 在另一個dex檔案中的問題,所以odex的驗證過程也就不會繼續下去,這樣做犧牲了dvm對dex的最佳化效果了。

2. Tinker 原理

對於Tinker,修復前和修復後的apk分別定義為apk1和apk2,tinker自研了一套dex檔案差分合並演算法,在生成補丁包時,生成一個差分包 patch.dex,後端下發patch.dex到客戶端時,tinker會開一個執行緒把舊apk的class.dex和patch.dex合併,生成新的class.dex並存放在本地目錄上,重新啟動時,會使用本地新生成的class.dex對應的elements替換原有的elements陣列。

3. AndFix 原理

AndFix的修復原理是替換方法的結構體。在native層獲取修復前類和修復後類的指標,然後將舊方法的屬性指標指向新方法。由於不同系統版本下的方法結構體不同,而且davilk與art虛擬機器處理方式也不一樣,所以需要針對不同系統針對性的替換方法結構體。

// AndFix 程式碼目錄結構jni
├─ Android.mk
├─ Application.mk
├─ andfix.cpp
├─ art
│  ├─ art.h
│  ├─ art_4_4.h
│  ├─ art_5_0.h
│  ├─ art_5_1.h
│  ├─ art_6_0.h
│  ├─ art_7_0.h
│  ├─ art_method_replace.cpp
│  ├─ art_method_replace_4_4.cpp
│  ├─ art_method_replace_5_0.cpp
│  ├─ art_method_replace_5_1.cpp
│  ├─ art_method_replace_6_0.cpp
│  └─ art_method_replace_7_0.cpp
├─ common.h
└─ dalvik
   ├─ dalvik.h
   └─ dalvik_method_replace.cpp
複製程式碼

二、美團 Robust 熱修復方案原理

下面,進入今天的主題,Robust熱修復方案。首先,介紹一下 Robust 的實現原理。

以 State 類為例

public long getIndex() {    return 100L;
}
複製程式碼

插樁後的 State 類

public static ChangeQuickRedirect changeQuickRedirect;public long getIndex() {    if(changeQuickRedirect != null) {        //PatchProxy中封裝了獲取當前className和methodName的邏輯,並在其內部最終呼叫了changeQuickRedirect的對應函式
        if(PatchProxy.isSupport(new Object[0], this, changeQuickRedirect, false)) {            return ((Long)PatchProxy.accessDispatch(new Object[0], this, changeQuickRedirect, false)).longValue();
        }
    }    return 100L;
}
複製程式碼

我們生成一個 StatePatch 類, 創一個例項並反射賦值給 State 的 changeQuickRedirect 變數。

public class StatePatch implements ChangeQuickRedirect {    @Override
    public Object accessDispatch(String methodSignature, Object[] paramArrayOfObject) {
        String[] signature = methodSignature.split(":");        // 混淆後的 getIndex 方法 對應 a
        if (TextUtils.equals(signature[1], "a")) {//long getIndex() -> a
            return 106;
        }        return null;
    }    @Override
    public boolean isSupport(String methodSignature, Object[] paramArrayOfObject) {
        String[] signature = methodSignature.split(":");        if (TextUtils.equals(signature[1], "a")) {//long getIndex() -> a
            return true;
        }        return false;
    }
}
複製程式碼

當我們執行出問題的程式碼 getState 時,會轉而執行 StatePatch 中邏輯。這就 Robust 的核心原理,由於沒有干擾系統載入dex過程,所以這種方案相容性最好。

三、Robust 實現細節

Robust 的實現方案很簡單,如果只是這麼簡單瞭解一下,有很多細節問題,我們不去接觸就不會意識到。 Robust 的實現可以分成三個部分:插樁、生成補丁包、載入補丁包。下面先從插樁開始。

1. 插樁

Robust 預先定義了一個配置檔案  robust.xml,在這個配置檔案可以指定是否開啟插樁、哪些包下需要插樁、哪些包下不需要插樁,在編譯 Release 包時,RobustTransform 這個外掛會自動遍歷所有的類,並根據配置檔案中指定的規則,對類進行以下操作:

  1. 類中增加一個靜態變數  ChangeQuickRedirect changeQuickRedirect
  2. 在方法前插入一段程式碼,如果是需要修補的方法就執行補丁包中的方法,如果不是則執行原有邏輯。

常用的位元組碼操縱框架有:

  • ASM
  • AspectJ
  • BCEL
  • Byte Buddy
  • CGLIB
  • Cojen
  • Javassist
  • Serp

美團 Robust 分別使用了ASM、Javassist兩個框架實現了插樁修改位元組碼的操作。個人感覺 javaassist 更加容易理解一些,下面的程式碼分析都以 javaassist 操作位元組碼為例進行闡述。

for (CtBehavior ctBehavior : ctClass.getDeclaredBehaviors()) {    // 第一步: 增加 靜態變數 changeQuickRedirect
    if (!addIncrementalChange) {        //insert the field
        addIncrementalChange = true;        // 建立一個靜態變數並新增到 ctClass 中
        ClassPool classPool = ctBehavior.getDeclaringClass().getClassPool();
        CtClass type = classPool.getOrNull(Constants.INTERFACE_NAME);  // com.meituan.robust.ChangeQuickRedirect
        CtField ctField = new CtField(type, Constants.INSERT_FIELD_NAME, ctClass);  // changeQuickRedirect
        ctField.setModifiers(AccessFlag.PUBLIC | AccessFlag.STATIC);
        ctClass.addField(ctField);
    }    // 判斷這個方法需要修復
    if (!isQualifiedMethod(ctBehavior)) {        continue;
    }    // 第二步: 方法前插入一段程式碼 ...}
複製程式碼

對於方法前插入一段程式碼,

// Robust 給每個方法取了一個唯一idmethodMap.put(ctBehavior.getLongName(), insertMethodCount.incrementAndGet());try {    if (ctBehavior.getMethodInfo().isMethod()) {
        CtMethod ctMethod = (CtMethod) ctBehavior;
        boolean isStatic = (ctMethod.getModifiers() & AccessFlag.STATIC) != 0;
        CtClass returnType = ctMethod.getReturnType();        String returnTypeString = returnType.getName();        // 這個body 就是要塞到方法前面的一段邏輯
        String body = "Object argThis = null;";        // 在 javaassist 中 $0 表示 當前例項物件,等於this
        if (!isStatic) {
            body += "argThis = $0;";
        }        String parametersClassType = getParametersClassType(ctMethod);        // 在 javaassist 中 $args 表示式代表 方法引數的陣列,可以看到 isSupport 方法傳了這些引數:方法所有引數,當前物件例項,changeQuickRedirect,是否是靜態方法,當前方法id,方法所有引數的型別,方法返回型別
        body += "   if (com.meituan.robust.PatchProxy.isSupport($args, argThis, " + Constants.INSERT_FIELD_NAME + ", " + isStatic +                ", " + methodMap.get(ctBehavior.getLongName()) + "," + parametersClassType + "," + returnTypeString + ".class)) {";        // getReturnStatement 負責返回執行補丁包中方法的程式碼
        body += getReturnStatement(returnTypeString, isStatic, methodMap.get(ctBehavior.getLongName()), parametersClassType, returnTypeString + ".class");
        body += "   }";        // 最後,把我們寫出來的body插入到方法執行前邏輯
        ctBehavior.insertBefore(body);
    }
} catch (Throwable t) {    //here we ignore the error
    t.printStackTrace();
    System.out.println("ctClass: " + ctClass.getName() + " error: " + t.getMessage());
}
複製程式碼

再來看看  getReturnStatement 方法,

 private String getReturnStatement(String type, boolean isStatic, int methodNumber, String parametersClassType, String returnTypeString) {        switch (type) {            case Constants.CONSTRUCTOR:                return "    com.meituan.robust.PatchProxy.accessDispatchVoid( $args, argThis, changeQuickRedirect, " + isStatic + ", " + methodNumber + "," + parametersClassType + "," + returnTypeString + ");  ";            case Constants.LANG_VOID:                return "    com.meituan.robust.PatchProxy.accessDispatchVoid( $args, argThis, changeQuickRedirect, " + isStatic + ", " + methodNumber + "," + parametersClassType + "," + returnTypeString + ");   return null;";            // 省略了其他返回型別處理
        }
 }
複製程式碼

PatchProxy.accessDispatchVoid 最終呼叫了  changeQuickRedirect.accessDispatch

至此插樁環節就結束了。

2. 生成補丁包

Robust 定義了一個 Modify 註解,

@Target({ElementType.FIELD, ElementType.METHOD, ElementType.TYPE, ElementType.CONSTRUCTOR})
@Retention(RetentionPolicy.CLASS)
@Documented
public @interface Modify {    String value() default "";
}
複製程式碼

對於要修復的方法,直接在方法宣告時增加  Modify註解

@Modifypublic String getTextInfo() {
    getArray();    //return "error occur " ;
    return "error fixed";
}
複製程式碼

在編譯期間,Robust逐一遍歷所有類,如果這個類有方法需要修復,Robust 會生一個 xxPatch 的類:

  1. 第一步 根據bug類 clone 出 Patch 類, 然後再刪除不需要打補丁的類。(為什麼使用刪除方法而不是新增方法? 刪除更簡單)
  2. 第二步 為 Patch 建立一個構造方法,用來接收bug類的例項物件。
  3. 遍歷 Patch 類中的所有方法,使用 ExprEditor + 反射 修改表示式。
  4. 刪除 Patch 類中所有的變數和父類。

這裡舉個例子,為什麼這裡的處理這麼麻煩。

public class Test {
    private int num = 0;    public void increase() {
        num += 1;
    }    public void decrease() {        // 這裡減錯了
        num -= 2;
    }    public static void main(String[] args) {
        Test t1 = new Test();        // 執行完 num=1
        t1.increase();        // 執行完 num=2
        t1.increase();        // 執行完 num=0, decrease 方法出現了bug,我們本意是減1,結果減2了
        t1.decrease();
    }
}
複製程式碼

所以當我們下發補丁時,對num進行減1的操作也是針對t1物件的num操作。這就是為什麼我們需要建立一個構造方案接受bug類例項物件。再來說下,我們如何在 TestPatch 類中把所有對 TestPatch 變數和方法等呼叫遷移到 Test 上。這就需要使用到 ExprEditor (表示式編輯器)。

// 這個 method 就是 TestPatch 修復後的那個方法method.instrument(    new ExprEditor() {        // 處理變數訪問
        public void edit(FieldAccess f) throws CannotCompileException {            if (Config.newlyAddedClassNameList.contains(f.getClassName())) {                return;
            }
            Map memberMappingInfo = getClassMappingInfo(f.getField().declaringClass.name);            try {                // 如果是 讀取變數,那麼把 f 使用replace方法,替換成括號裡的返回的表示式
                if (f.isReader()) {
                    f.replace(ReflectUtils.getFieldString(f.getField(), memberMappingInfo, temPatchClass.getName(), modifiedClass.getName()));
                }                // 如果是 寫資料到變數
                else if (f.isWriter()) {
                    f.replace(ReflectUtils.setFieldString(f.getField(), memberMappingInfo, temPatchClass.getName(), modifiedClass.getName()));
                }
            } catch (NotFoundException e) {
                e.printStackTrace();                throw new RuntimeException(e.getMessage());
            }
        }
    }
)
複製程式碼

ReflectUtils.getFieldString 方法呼叫的結果是生成一串類似這樣的字串:

\$_=(\$r) com.meituan.robust.utils.EnhancedRobustUtils.getFieldValue(fieldName, instance, clazz)

這樣在 TestPatch 中對變數 num 的呼叫,在編譯期間都會轉為透過反射對 原始bug類物件 t1 的 num 變數呼叫。

ExprEditor 除了變數訪問 FieldAccess, 還有這些情況需要特殊處理。

public void edit(NewExpr e) throws CannotCompileException {
}public void edit(MethodCall m) throws CannotCompileException {
}public void edit(FieldAccess f) throws CannotCompileException {
}public void edit(Cast c) throws CannotCompileException {
}
複製程式碼

需要處理的情況太多了,以致於Robust的作者都忍不住吐槽:  shit !!too many situations need take into consideration

生成完 Patch 類之後,Robust 會從模板類的基礎上生成一個這個類專屬的 ChangeQuickRedirect 類, 模板類程式碼如下:

public class PatchTemplate implements ChangeQuickRedirect {    public static final String MATCH_ALL_PARAMETER = "(\\w*\\.)*\\w*";    public PatchTemplate() {
    }    private static final Map<Object, Object> keyToValueRelation = new WeakHashMap<>();    @Override
    public Object accessDispatch(String methodName, Object[] paramArrayOfObject) {        return null;
    }    @Override
    public boolean isSupport(String methodName, Object[] paramArrayOfObject) {        return true;
    }
}
複製程式碼

以Test類為例,生成 ChangeQuickRedirect 類名為 TestPatchController, 在編譯期間會在 isSupport 方法前加入過濾邏輯,

// 根據方法的id判斷是否是補丁方法執行public boolean isSupport(String methodName, Object[] paramArrayOfObject) {    return "23:".contains(methodName.split(":")[3]);
}
複製程式碼

以上兩個類生成後,會生成一個維護 bug類 --> ChangeQuickRedirect 類的對映關係

public class PatchesInfoImpl implements PatchesInfo {    public List getPatchedClassesInfo() {
        ArrayList arrayList = new ArrayList();
        arrayList.add(new PatchedClassInfo("com.meituan.sample.Test", "com.meituan.robust.patch.TestPatchControl"));
        EnhancedRobustUtils.isThrowable = false;        return arrayList;
    }
}
複製程式碼

以一個類的一個方法修復生成補丁為例,補丁包中包含三個檔案:

  • TestPatch
  • TestPatchController
  • PatchesInfoImpl

生成的補丁包是jar格式的,我們需要使用 jar2dex 將 jar 包轉換成 dex包。

3. 載入補丁包

當線上app反生bug後,可以通知客戶端拉取對應的補丁包,下載補丁包完成後,會開一個執行緒執行以下操作:

  1. 使用 DexClassLoader 載入外部 dex 檔案,也就是我們生成的補丁包。
  2. 反射獲取 PatchesInfoImpl 中補丁包對映關係,如PatchedClassInfo("com.meituan.sample.Test", "com.meituan.robust.patch.TestPatchControl")。
  3. 反射獲取 Test 類插樁生成 changeQuickRedirect 物件,例項化 TestPatchControl,並賦值給 changeQuickRedirect

至此,bug就修復了,無需重啟實時生效。

4. 一些問題

a. Robust 導致Proguard 方法內聯失效

Proguard是一款程式碼最佳化、混淆利器,Proguard 會對程式進行最佳化,如果某個方法很短或者只被呼叫了一次,那麼Proguard會把這個方法內部邏輯內聯到呼叫處。 Robust的解決方案是找到內聯方法,不對內聯的方法插樁。

b. lambada 表示式修復

對於 lambada 表示式無法直接新增註解,Robust 提供了一個 RobustModify 類,modify 方法是空方法,再編譯期間使用 ExprEditor 檢測是否呼叫了 RobustModify 類,如果呼叫了,就認為這個方法需要修復。

new Thread(
        () -> {
            RobustModify.modify();
            System.out.print("Hello");
            System.out.println(" Hoolee");
        }
).start();
複製程式碼

c. Robust 生成方法id是透過編譯期間遍歷所有類和方法,遞增id實現的

一個方法,可以透過類名 + 方法名 + 引數型別唯一確定。我自己的方案是把這三個資料組裝成  類名@方法名#引數型別md5,支援 lambada 表示式( com.orzangleli.demo.Test#lambda$execute$0@2ab6d5a5d73bad3848b7be22332e27ea)。我自己基於 Robust 的核心原理,仿寫了一個熱修復框架 Anivia.

四、總結

首先要認可國內不同熱修復方案的開發者和組織做出的工作,做好熱修復解決方案不是一件簡單的事。 其次,從別人解決熱修復方案實施過程遇到問題上來看,這些開發者遇到問題後,追根溯源,會去找導致這個問題的本質原因,然後才思考解決方案,這一點很值得我們學習。

更多Android技術分享可以關注@我,也可以加入QQ群號:Android進階學習群:345659112,一起學習交流。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69983917/viewspace-2794946/,如需轉載,請註明出處,否則將追究法律責任。

相關文章