【Android 熱修復】美團Robust熱修復框架原理解析
一、熱修復框架現狀
目前熱修復框架主要有QQ空間補丁、HotFix、Tinker、Robust等。熱修復框架按照原理大致可以分為三類:
- 基於 multidex機制 干預 ClassLoader 載入dex
- native 替換方法結構體
- instant-run 插樁方案
QQ空間補丁和Tinker都是使用的方案一; 阿里的AndFix使用的是方案二; 美團的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 這個外掛會自動遍歷所有的類,並根據配置檔案中指定的規則,對類進行以下操作:
- 類中增加一個靜態變數
ChangeQuickRedirect changeQuickRedirect
- 在方法前插入一段程式碼,如果是需要修補的方法就執行補丁包中的方法,如果不是則執行原有邏輯。
常用的位元組碼操縱框架有:
- 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 的類:
- 第一步 根據bug類 clone 出 Patch 類, 然後再刪除不需要打補丁的類。(為什麼使用刪除方法而不是新增方法? 刪除更簡單)
- 第二步 為 Patch 建立一個構造方法,用來接收bug類的例項物件。
- 遍歷 Patch 類中的所有方法,使用 ExprEditor + 反射 修改表示式。
- 刪除 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後,可以通知客戶端拉取對應的補丁包,下載補丁包完成後,會開一個執行緒執行以下操作:
- 使用 DexClassLoader 載入外部 dex 檔案,也就是我們生成的補丁包。
- 反射獲取 PatchesInfoImpl 中補丁包對映關係,如PatchedClassInfo("com.meituan.sample.Test", "com.meituan.robust.patch.TestPatchControl")。
- 反射獲取 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/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- Android熱修復原理(一)熱修復框架對比和程式碼修復Android框架
- robust 熱修復實踐
- Android熱修復原理Android
- 你值得知道的Android 熱修復,以及熱修復原理Android
- Andfix熱修復框架原理及原始碼解析-上篇框架原始碼
- Andfix熱修復框架原理及原始碼解析-下篇框架原始碼
- Android 熱修復Android
- Android中熱修復框架Robust原理解析+並將框架程式碼從"閉源"變成"開源"(下篇)Android框架
- Robust 2.0:支援Android R8的升級版熱修復框架Android框架
- Android 熱修復 - 各框架原理學習及對比Android框架
- Android 熱修復 Tinker Gradle Plugin 解析AndroidGradlePlugin
- Alibaba-AndFix Bug熱修復框架原理及原始碼解析框架原始碼
- 深入探索Android熱修復技術原理讀書筆記 —— 熱修復技術介紹Android筆記
- 深入探索Android熱修復技術原理讀書筆記 —— 程式碼熱修復技術Android筆記
- 深入探索Android熱修復技術原理讀書筆記 —— 資源熱修復技術Android筆記
- Android 熱修復總結Android
- 熱修復初探
- 熱修復框架原始碼剖析(上)框架原始碼
- Flutter Android 端熱修復(熱更新)實踐FlutterAndroid
- 筆記 深入探索Android熱修復技術原理筆記Android
- Android進階(八)熱修復基本原理Android
- Android 熱補丁動態修復框架小結Android框架
- Android 增量更新完全解析 是增量不是熱修復Android
- 你期待已久的熱修復—Tinker熱修復整合總結
- Tinker 熱修復框架 簡單上手教程框架
- Android熱修復簡單總結Android
- 2018深入解析Android熱修復技術Android
- 熱修復(一)原理與實現詳解
- 簡單易懂的tinker熱修復原理分析
- 熱修復——深入淺出原理與實現
- Android 熱修復其實很簡單Android
- 淺談Android主流熱修復技術Android
- 手把手帶你打造一個 Android 熱修復框架Android框架
- 熱修復和外掛化
- Tinker熱修復整合總結
- 熱修復預備知識
- 淺析“熱更新”(熱修復)解決方案
- Alibaba-AndFix Bug熱修復框架的使用框架