一、前言
任何程序都無法保證上線後不會出現緊急bug,選擇的修復方式不同,其代價也大不相同。所謂熱修復,是相對於正常的版本迭代修復而言的,它可以及時在應用內下載補丁更新程序邏輯,修復bug;而不需要等到下一個版本發佈。舉個簡單的例子,假如有一行代碼的邏輯寫錯了,並且已經編譯出APK,安裝到了用戶的手機上,此時有兩種處理方式:
- 等待下一個版本發佈,其中修復了錯誤代碼,即迭代修復
- 給用戶推送補丁,及時修復錯誤代碼,即熱修復
下圖對比兩者區別:
從上圖可以看出熱修復相對於迭代修復有很大優勢:
- 成本優勢——避免了重新向渠道更新APK版本
- 時間優勢——幾乎是即時修復,不必等待版本覆蓋時間
- 體驗優勢——避免重新安裝版本,用戶無感修復
熱修復技術可以爲應用增加一份安全保障,也爲程序更新提供了一種新的可能途徑。
二、熱修復技術原理
從技術角度來說,我們的目的是非常明確的:把錯誤的代碼替換成正確的代碼。注意這裏的替換,並不是直接擦寫dx文件,而是提供一份新的正確代碼,讓應用運行時繞過錯誤代碼,執行新的正確代碼。
想法簡單直接,但實現起來並不容易。目前主要有三類技術方案:
- native底層替換方案
- 類加載方案
- Instant Run方案
(1)native底層替換方案
Android/Java代碼的最小組織方式是方法(Method,實際上,每一個dex文件最多可以包含65536(0xffff)個方法),每個方法在ART虛擬機中都有一個ArtMethod結構體指針與之對應,ArtMethod結構體中包含了Java方法的所有信息,包括執行入口、訪問權限、所屬類和代碼執行地址等等。換句話說,虛擬機就是通過ArtMethod結構體來操縱Java方法的。ArtMethod結構如下:
class ArtMethod FINAL {
...
protected:
GcRoot<mirror::Class> declaring_class_;
std::atomic<std::uint32_t> access_flags_;
// 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_;
uint16_t method_index_;
uint16_t hotness_count_;
struct PtrSizedFields {
// Depending on the method type, the data is
// - native method: pointer to the JNI function registered to this method
// or a function to resolve the JNI function,
// - conflict method: ImtConflictTable,
// - abstract/interface method: the single-implementation if any,
// - proxy method: the original interface method or constructor,
// - other methods: the profiling data.
void* data_;
// 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_quick_compiled_code_
也就是說,這個指針指向方法體編譯後對應的彙編指令。那麼,如果我們能hook這個指針,由原來指向有bug的方法,變成指向正確的方法,就達到了修復的目的。這就是native層替換方案的核心原理。具體實現方案可以是改變指針指向(AndFix),也可以直接替換整個結構體(Sophix)。
需要注意的是,底層替換方案雖然是即使生效的,但是因爲不會加載新類,而是直接修改原類,所以修改的代碼不能增加新的方法,否則會造成索引數與方法數不匹配,無法通過索引找到正確方法,字段同理。
(2)類加載方案
native底層替換方案hook的是method指針,類加載方案則將目標定在類上。我們寫的.java代碼,最終是由ClassLoader加載的。
上面提到過每一個dex文件最多可以包含65536(0xffff)個方法,超過了就需要用到分包方案,也就是說,每個APK中可能包含多個dex文件。而每個dex文件,最終對應DexPathList中的一個Element實例:
static class Element {
private final File path;
private final DexFile dexFile;
private ClassPathURLStreamHandler urlHandler;
private boolean initialized;
public Element(DexFile dexFile, File dexZipPath) {
this.dexFile = dexFile;
this.path = dexZipPath;
}
......
}
如果加載一個類,會調用DexPathList中的findClass函數:
public Class<?> findClass(String name, List<Throwable> suppressed) {
DexPathList.Element[] var3 = this.dexElements; //多個dex文件對應Element數組
int var4 = var3.length;
for(int var5 = 0; var5 < var4; ++var5) {
DexPathList.Element element = var3[var5];
Class<?> clazz = element.findClass(name, this.definingContext, suppressed); //以此從dex文件中查找目標Class
if (clazz != null) {
return clazz;
}
}
if (this.dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(this.dexElementsSuppressedExceptions));
}
return null;
}
如上代碼,當需要加載一個類時,會依次從dex文件檢索,直至找到目標類後停止:
實際上,類替換方案的核心思想就是:將修改後的patch(包含bug類文件)打包成dex文件,然後hook ClassLoader加載流程,將這個dex文件插入到Element數組的第一個元素。因爲加載類是依次進行的,所以虛擬機從第一個Element找到類後,就不會再加載bug類了。
類加載方案也有缺點,因爲類加載後無法卸載,所以類加載方案必須重啓App,讓bug類重新加載後才能生效。
(3)Instant Run方案
Instant Run 方案的核心思想是——插樁,在編譯時通過插樁在每一個方法中插入代碼,修改代碼邏輯,在需要時繞過錯誤方法,調用patch類的正確方法。
首先,在編譯時Instant Run爲每個類插入IncrementalChange變量:
IncrementalChange $change;
爲每一個方法添加類似如下代碼:
public void onCreate(Bundle savedInstanceState) {
IncrementalChange var2 = $change;
//$change不爲null,表示該類有修改,需要重定向
if(var2 != null) {
//通過access$dispatch方法跳轉到patch類的正確方法
var2.access$dispatch("onCreate.(Landroid/os/Bundle;)V", new Object[]{this, savedInstanceState});
} else {
super.onCreate(savedInstanceState);
this.setContentView(2130968601);
this.tv = (TextView)this.findViewById(2131492944);
}
}
如上代碼,當一個類被修改後,Instant Run會爲這個類新建一個類,命名爲xxx&override,且實現IncrementalChange接口,並且賦值給原類的$change變量。
public class MainActivity$override implements IncrementalChange {
}
此時,在運行時原類中每個方法的var2 != null,通過accessdispatch(參數是方法名和原參數)定位到patch類MainActivitydispatch(參數是方法名和原參數)定位到patch類MainActivityoverride中修改後的方法。
Instant Run是google在AS2.0時用來實現“熱部署”的,同時也爲“熱修復”提供了一個絕佳的思路。美團的Robust就是基於此。
總結:
以上是三種方案的基本原理,每種方案又有不同的實現方案,導致目前熱修復出現百家爭鳴的現象。無論哪種熱修復方案,都不是一蹴而就的,需要在長期的實戰中不斷完善。
衆方案各有所長,且基於自家業務不斷更新迭代。統計如下:
特性 | Dexposed | AndFix | Tinker/Amigo | QQ Zone | Robust/Aceso | Sophix |
---|---|---|---|---|---|---|
技術原理 | native底層替換 | 類加載 | Instant Run | 混合 | ||
所屬 | 阿里 | 微信/餓了麼 | QQ空間 | 美團/蘑菇街 | 阿里 | |
即時生效 | YES | YES | NO | NO | YES | 混合 |
方法替換 | YES | YES | YES | YES | YES | YES |
類替換 | NO | NO | YES | YES | YES | YES |
類結構修改 | NO | NO | YES | NO | NO | YES |
資源替換 | NO | NO | YES | YES | NO | YES |
so替換 | NO | NO | YES | NO | NO | YES |
支持gradle | NO | NO | YES | YES | YES | YES |
支持ART | NO | YES | YES | YES | YES | YES |
可以看出,阿里系多采用native底層方案,騰訊系多采用類加載機制。其中,Sophix是商業化方案;Tinker/Amigo支持特性較多,同時也更復雜,如果需要修復資源和so,可以選擇;如果僅需要方法替換,且需要即時生效,Robust是不錯的選擇。
三、自定義熱修復方案
以類加載機制爲例,自定義一個簡單的熱修復demo,核心代碼如下(尚未驗證通過,待研究插件化技術之後補齊):
public class Hotfix {
public static void patch(Context context, String patchDexFile, String patchClassName)
throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException {
//獲取系統PathClassLoader的"dexElements"屬性值
PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
Object origDexElements = getDexElements(pathClassLoader);
//新建DexClassLoader並獲取“dexElements”屬性值
String otpDir = context.getDir("dex", 0).getAbsolutePath();
Log.i("hotfix", "otpdir=" + otpDir);
DexClassLoader nDexClassLoader = new DexClassLoader(patchDexFile, otpDir, patchDexFile, context.getClassLoader());
Object patchDexElements = getDexElements(nDexClassLoader);
//將patchDexElements插入原origDexElements前面
Object allDexElements = combineArray(origDexElements, patchDexElements);
//將新的allDexElements重新設置回pathClassLoader
setDexElements(pathClassLoader, allDexElements);
//重新加載類
pathClassLoader.loadClass(patchClassName);
}
private static Object getDexElements(ClassLoader classLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
//首先獲取ClassLoader的“pathList”實例
Field pathListField = Class.forName("dalvik.system.BaseDexClassLoader").getDeclaredField("pathList");
pathListField.setAccessible(true);//設置爲可訪問
Object pathList = pathListField.get(classLoader);
//然後獲取“pathList”實例的“dexElements”屬性
Field dexElementField = pathList.getClass().getDeclaredField("dexElements");
dexElementField.setAccessible(true);
//讀取"dexElements"的值
Object elements = dexElementField.get(pathList);
return elements;
}
//合拼dexElements
private static Object combineArray(Object obj, Object obj2) {
Class componentType = obj2.getClass().getComponentType();
//讀取obj長度
int length = Array.getLength(obj);
//讀取obj2長度
int length2 = Array.getLength(obj2);
Log.i("hotfix", "length=" + length + ",length2=" + length2);
//創建一個新Array實例,長度爲ojb和obj2之和
Object newInstance = Array.newInstance(componentType, length + length2);
for (int i = 0; i < length + length2; i++) {
//把obj2元素插入前面
if (i < length2) {
Array.set(newInstance, i, Array.get(obj2, i));
} else {
//把obj元素依次放在後面
Array.set(newInstance, i, Array.get(obj, i - length2));
}
}
//返回新的Array實例
return newInstance;
}
private static void setDexElements(ClassLoader classLoader, Object dexElements) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
//首先獲取ClassLoader的“pathList”實例
Field pathListField = Class.forName("dalvik.system.BaseDexClassLoader").getDeclaredField("pathList");
pathListField.setAccessible(true);//設置爲可訪問
Object pathList = pathListField.get(classLoader);
//然後獲取“pathList”實例的“dexElements”屬性
Field declaredField = pathList.getClass().getDeclaredField("dexElements");
declaredField.setAccessible(true);
//設置"dexElements"的值
declaredField.set(pathList, dexElements);
}
}
四、Robust方案對接
Robust是美團團隊基於Instant Run 技術開發的開源(dian zan)熱修復框架,Github地址:https://github.com/Meituan-Dianping/Robust
下面以Robust 4.9版本爲例,詳細介紹一下其對接流程,主要步驟如下:
- 添加robust插件
- 配置插件特性——robust.xml
- 配置補丁加載方法——自定義PatchManipulate和RobustCallback子類
- 編譯基礎版本(生成mapping.txt,methodMap.robust)
- 修復代碼
- 生成補丁——patch.jar
- 補丁下載/推送
- 調用修復命令
(1)添加robust插件
共有兩處需要添加,在項目外層build.gradle添加:
dependencies {
classpath 'com.meituan.robust:gradle-plugin:0.4.90'
classpath 'com.meituan.robust:auto-patch-plugin:0.4.90'
}
在app module的build.gradle添加:
apply plugin: 'com.android.application'
//此兩項緊跟com.android.application,生成補丁時打開auto-patch-plugin插件
//apply plugin: 'auto-patch-plugin'
apply plugin: 'robust'
(2)配置插件特性——robust.xml
將robust.xml配置文件拷貝到app根目錄下,並按需求配置插件特性:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<switch>
<!--true代表打開Robust,請注意即使這個值爲true,Robust也默認只在Release模式下開啓-->
<!--false代表關閉Robust,無論是Debug還是Release模式都不會運行robust-->
<turnOnRobust>true</turnOnRobust>
<!--<turnOnRobust>false</turnOnRobust>-->
<!--是否開啓手動模式,手動模式會去尋找配置項patchPackname包名下的所有類,自動的處理混淆,然後把patchPackname包名下的所有類製作成補丁-->
<!--這個開關只是把配置項patchPackname包名下的所有類製作成補丁,適用於特殊情況,一般不會遇到-->
<!--<manual>true</manual>-->
<manual>false</manual>
<!--是否強制插入插入代碼,Robust默認在debug模式下是關閉的,開啓這個選項爲true會在debug下插入代碼-->
<!--但是當配置項turnOnRobust是false時,這個配置項不會生效-->
<!--<forceInsert>true</forceInsert>-->
<forceInsert>false</forceInsert>
<!--是否捕獲補丁中所有異常,建議上線的時候這個開關的值爲true,測試的時候爲false-->
<catchReflectException>true</catchReflectException>
<!--<catchReflectException>false</catchReflectException>-->
<!--是否在補丁加上log,建議上線的時候這個開關的值爲false,測試的時候爲true-->
<!--<patchLog>true</patchLog>-->
<patchLog>false</patchLog>
<!--項目是否支持progaurd-->
<proguard>true</proguard>
<!--<proguard>false</proguard>-->
<!--項目是否支持ASM進行插樁,默認使用ASM,推薦使用ASM,Javaassist在容易和其他字節碼工具相互干擾-->
<useAsm>true</useAsm>
<!--<useAsm>false</useAsm>-->
</switch>
<!--需要熱補的包名或者類名,這些包名下的所有類都被會插入代碼-->
<!--這個配置項是各個APP需要自行配置,就是你們App裏面你們自己代碼的包名,
這些包名下的類會被Robust插入代碼,沒有被Robust插入代碼的類Robust是無法修復的-->
<packname name="hotfixPackage">
<name>com.xibeixue.hotfix</name>
<!--<name>com.sankuai</name>-->
<!--<name>com.dianping</name>-->
</packname>
<!--不需要Robust插入代碼的包名,Robust庫不需要插入代碼,如下的配置項請保留,還可以根據各個APP的情況執行添加-->
<exceptPackname name="exceptPackage">
<name>com.meituan.robust</name>
<name>com.meituan.sample.extension</name>
</exceptPackname>
<!--補丁的包名,請保持和類PatchManipulateImp中fetchPatchList方法中設置的補丁類名保持一致( setPatchesInfoImplClassFullName("com.meituan.robust.patch.PatchesInfoImpl")),
各個App可以獨立定製,需要確保的是setPatchesInfoImplClassFullName設置的包名是如下的配置項,類名必須是:PatchesInfoImpl-->
<patchPackname name="patchPackname">
<name>com.xibeixue.hotfix</name>
</patchPackname>
<!--自動化補丁中,不需要反射處理的類,這個配置項慎重選擇-->
<noNeedReflectClass name="classes no need to reflect">
</noNeedReflectClass>
</resources>
注意:如果是調試,請打開forceInsert,關閉proguard。一般packname和patchPackname需要自行配置,其他選項保持默認即可。
(3)配置補丁加載方法
第(2)步配置的是插件的工作方式,爲了生成補丁patch.jar;程序還需要知道如何加載補丁,比如補丁在哪裏,要解壓到哪裏等。這就需要自定義PatchManipulate子類:
public class PatchManipulateImp extends com.meituan.robust.PatchManipulate {
@Override
protected List<Patch> fetchPatchList(Context context) {
//將app自己的robustApkHash上報給服務端,服務端根據robustApkHash來區分每一次apk build來給app下發補丁
//apkhash is the unique identifier for apk,so you cannnot patch wrong apk.
//String robustApkHash = RobustApkHashUtils.readRobustApkHash(context);
Patch patch = new Patch();
patch.setName("123");
//we recommend LocalPath store the origin patch.jar which may be encrypted,while TempPath is the true runnable jar
patch.setLocalPath(Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + "patch");
/*上面的路徑看似設置的是目錄,其實不是,在get方法中默認追加了.jar;temp默認則追加_temp.jar.可以理解爲設置補丁的文件名.建議放在程序內部目錄,提高安全性*/
/*com.xibeixue.hotfix.PatchesInfoImpl要和robut.xml中patchPackname節點裏面的包名保持一致*/
patch.setPatchesInfoImplClassFullName("com.xibeixue.hotfix.PatchesInfoImpl");
List patches = new ArrayList<Patch>();
patches.add(patch);
return patches;
}
@Override
protected boolean verifyPatch(Context context, Patch patch) {
patch.setTempPath(context.getCacheDir() + File.separator + "robust" + File.separator + "patch");
//in the sample we just copy the file
try {
copy(patch.getLocalPath(), patch.getTempPath());
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("copy source patch to local patch error, no patch execute in path " + patch.getTempPath());
}
return true;
}
@Override
protected boolean ensurePatchExist(Patch patch) {
return true;
}
public void copy(String srcPath, String dstPath) throws IOException {
Log.i("hotfix","srcPath=" + srcPath);
File src = new File(srcPath);
if (!src.exists()) {
throw new RuntimeException("source patch does not exist ");
}
File dst = new File(dstPath);
if (!dst.getParentFile().exists()) {
dst.getParentFile().mkdirs();
}
InputStream in = new FileInputStream(src);
try {
OutputStream out = new FileOutputStream(dst);
try {
// Transfer bytes from in to out
byte[] buf = new byte[1024];
int len;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
}
} finally {
out.close();
}
} finally {
in.close();
}
}
}
注意:setPatchesInfoImplClassFullName時需要和robust.xml中的patchPackname包名保持一致。
如果要對補丁加載過程監聽,需要自定義RobustCallback子類:
public class RobustCallBackSample implements com.meituan.robust.RobustCallBack {
@Override
public void onPatchListFetched(boolean result, boolean isNet, List<Patch> patches) {
Log.d("RobustCallBack", "onPatchListFetched result: " + result);
Log.d("RobustCallBack", "onPatchListFetched isNet: " + isNet);
for (Patch patch : patches) {
Log.d("RobustCallBack", "onPatchListFetched patch: " + patch.getName());
}
}
@Override
public void onPatchFetched(boolean result, boolean isNet, Patch patch) {
Log.d("RobustCallBack", "onPatchFetched result: " + result);
Log.d("RobustCallBack", "onPatchFetched isNet: " + isNet);
Log.d("RobustCallBack", "onPatchFetched patch: " + patch.getName());
}
@Override
public void onPatchApplied(boolean result, Patch patch) {
Log.d("RobustCallBack", "onPatchApplied result: " + result);
Log.d("RobustCallBack", "onPatchApplied patch: " + patch.getName());
}
@Override
public void logNotify(String log, String where) {
Log.d("RobustCallBack", "logNotify log: " + log);
Log.d("RobustCallBack", "logNotify where: " + where);
}
@Override
public void exceptionNotify(Throwable throwable, String where) {
Log.e("RobustCallBack", "exceptionNotify where: " + where, throwable);
}
}
(4)編譯基礎版本
到目前爲止,就可以編譯基礎版本了,這時插件會生成兩個文件
//方法記錄文件,該文件在打補丁的時候用來區別到底哪些方法需要被修復
build/outputs/robust/methodsMap.robust
//該文件列出了原始的類、方法和字段名與混淆後代碼間的映射,需要開啓proguard配置項後纔會出現
build/outputs/mapping/mapping.txt
將這兩個文件拷貝到app根目錄下的robust文件夾下(沒有就自行創建),後面生成補丁時會用到。
(5)修復代碼
//修復代碼,需要添加Modify註釋或者調用RobustModify.modify()方法,作爲修復標記
@Modify
public void run() {
// Log.i("hotfix", "我有一個嚴重Bug需要修復!");
Log.i("hotfix", "我的Bug已經被修復!");
}
//添加代碼需要添加Add註釋,作爲標記
@Add
public void run2(){
Log.i("hotfix", "我是一個新添加的方法!");
}
(6)生成補丁
生成補丁,只需要打開auto-patch-plugin補丁插件,重新編譯即可:
//打開補丁插件
apply plugin: 'auto-patch-plugin'
apply plugin: 'robust'
此時,會在app根目錄下的robust文件夾下生成patch.jar補丁文件。
(7)補丁下載、推送
Robust熱修復框架並沒有補丁下載模塊,需要自行和後臺服務協商下載或推送方案。但是patch.jar必須下載到PatchManipulateImp指定的localPath。另外,如果下載到sd卡,一定要申請sd卡讀寫權限!
(8)調用修復命令
在合適的時機,調用修復命令,一般下載後儘早調用:
new PatchExecutor(getApplicationContext(), new PatchManipulateImp(),
new RobustCallBackSample()).start();
調用修復命令後,不用重啓進程,再次調用被修復方法時,發現已經開始執行修復邏輯了!
Demo源碼:https://github.com/JiaxtHome/hotfix
五、總結
儘管熱修復(或熱更新)相對於迭代更新有諸多優勢,市面上也有很多開源方案可供選擇,但目前熱修復依然無法替代迭代更新模式。有如下原因:
- 熱修復框架多多少少會增加性能開銷,或增加APK大小
- 熱修復技術本身存在侷限,比如有些方案無法替換so或資源文件
- 熱修復方案的兼容性,有些方案無法同時兼顧Dalvik和ART,有些深度定製系統也無法正常工作
- 監管風險,比如蘋果系統嚴格限制熱修復
所以,對於功能迭代和常規bug修復,版本迭代更新依然是主流。一般的代碼修復,使用Robust可以解決,如果還需要修復資源或so庫,可以考慮Tinker。