基于Instant Run思想的HotFix方案實現
近一年來,各種HotFix庫層出不窮,各家大廠百花齊放,QQ空間最早提出了自己的熱修復實現,接著阿里也開源了自家的AndFix(貌似阿里百川已經給開發者提供了新的Hotfix功能),現在微信又有了Tinker,各家都如此關心HotFix,無非是線上版本的bug對產品影響太大,尤其是DAU比較高的app,更是不能容忍。前幾天看到美團基于Instant run原理推出了自己的Hotfix庫,不過貌似沒有開源,于是自己就按照Instant run的原理也鼓搗出了一個簡單的HotFix實現, 可以在不重啟App和Activity的條件下實現修復, 代碼地址會在文章最后貼出,供大家研究學習。
實現效果
先讓大家看看具體的實現效果是怎樣的,很簡單,一個Acitivty中點擊按鈕,會彈出Toast“我有bug!”,然后加載補丁,再點擊按鈕,會彈出“我是補丁,沒有bug啦!”
接下來讓我們看看怎么實現這個庫。
Instant Run原理
Instant run的原理是采用了貍貓換太子的戲法,在編譯階段給每個類都注入了一個$change(代理,即補丁)變量,并且在每個方法前都注入了一段代碼,判斷$change是否為空,如果不為空,就執行代理里的方法。
關于Instant Run具體的原理,我在文章 《淺談Instan-Run中的熱替換》 中已經講了很多,這里不再贅述,建議不了解的同學在閱讀本文前先看看這篇文章。
實現
Step1:代碼注入
上面說到Instan Run在編譯期間給每個類都注入了變量和代碼,那么這是怎么實現的呢?其實很簡單,android studio給我們提供了transform Api,transform其實也就是打包過程中的一個task,我們可以根據這個特性,利用javassist來注入代碼。關于代碼注入這塊,我參考了文章 《通過自定義Gradle插件修改編譯后的class文件》 ,謝謝這位同學慷慨的分享,具體過程大家可以看看這篇文章,我就不講詳細的步驟了。
代碼注入的實現代碼如下:
File dir = new File(path)
if (dir.isDirectory()) {
dir.eachFileRecurse { File file ->
String filePath = file.absolutePath
//確保當前文件是class文件,并且不是系統自動生成的class文件
if (filePath.endsWith(".class")
&& !filePath.contains('R$')
&& !filePath.contains('R.class')
&& !filePath.contains("BuildConfig.class")
&& !filePath.contains("\$Patch.class")
&& !filePath.contains("PatchBox.class")) {
// 判斷當前目錄是否是在我們的應用包里面
int index = filePath.indexOf(packageName);
boolean isMyPackage = index != -1;
if (isMyPackage) {
int end = filePath.length() - 6 // .class = 6
String className = filePath.substring(index, end).replace('\\', '.').replace('/', '.')
//開始修改class文件
CtClass c = pool.getCtClass(className)
if (c.isFrozen()) {
c.defrost()
}
pool.importPackage("com.wangxiandeng.savior")
//給類添加$savior變量,即補丁變量
CtField savior = new CtField(pool.get("com.wangxiandeng.savior.Savior"), "\$savior", c);
savior.setModifiers(Modifier.STATIC);
c.addField(savior);
//遍歷類的所有方法
CtMethod[] methods = c.getDeclaredMethods();
for (CtMethod method : methods) {
//在每個方法之前插入判斷語句,判斷類的補丁實例是否存在
StringBuilder injectStr = new StringBuilder();
injectStr.append("if(\$savior!=null){\n")
String javaThis = "null,"
if (!Modifier.isStatic(method.getModifiers())) {
javaThis = "this,"
}
String runStr = "\$savior.dispatchMethod(" + javaThis + "\"" + method.getName() + "." + method.getSignature() + "\" ,\$args)"
injectStr.append(addReturnStr(method, runStr))
injectStr.append("}")
print("插入了:" + injectStr.toString() + "語句")
method.insertBefore(injectStr.toString())
}
c.writeFile(path)
c.detach()
}
}
}
}
上面這段代碼中,我們給每個類都注入了一個類型為Savior的靜態變量$savior,并且在每個方法前加入了一段代碼,判斷$savior是否為null,如果不為null,則執行$savior.dispatchMethod(),傳入方法的方法簽名和參數,讓補丁類代以執行。
Step2:制作補丁類
補丁類的命名方式必須為XXX$Patch,比如MainActivity有bug,那么就制作一個名為MainActivity$Patch的補丁類,注意補丁類必須和原有類要放在同一包下。
先讓我們寫一個MainActivity,該類有bug(當然不是真的有bug啊)
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
//將補丁文件從資源目錄拷貝到sd卡
FileUtil.copyJarToFile(this);
findViewById(R.id.btn_show).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
show();
}
});
//點擊加載補丁
findViewById(R.id.btn_load).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
PatchLoader.getInstance().loadPatch(PatchUtil.PATCH_PATH);
Toast.makeText(MainActivity.this, "load success", Toast.LENGTH_SHORT).show();
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
public void show() {
Toast.makeText(this, "我有bug!", Toast.LENGTH_SHORT).show();
}
}
現在我們要制作一個補丁類,用來修復bug。如果原有類繼承自某個類,則補丁類同樣要繼承自該類,并且要實現Savior接口。
public class MainActivity$Patch extends Activity implements Savior{
@Override
public Object dispatchMethod(Object host, String methodSign, Object[] params) {
MainActivity mainActivity = (MainActivity) host;
switch (methodSign.hashCode()) {
case -641568046:
onCreate(mainActivity, (Bundle) (params[0]));
break;
case -340027132:
show(mainActivity);
break;
}
return null;
}
protected void onCreate(final MainActivity host, Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
findViewById(R.id.btn_show).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
show(host);
}
});
findViewById(R.id.btn_load).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
PatchLoader.getInstance().loadPatch(getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS).getPath() + "/patch.dex");
Toast.makeText(host, "load success", Toast.LENGTH_SHORT).show();
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
public void show(MainActivity host) {
Toast.makeText(host, "我是補丁,沒有bug啦!", Toast.LENGTH_SHORT).show();
}
}
補丁類需要實現原有類的所有方法,并且要重寫原有類有bug的方法,在該例子中,原有類的show()方法含有bug,所以重寫了show()方法,當然其他方法可能也會視情況有一些變動。補丁類需要重寫接口中dispatchMethod()方法,根據方法簽名的hashcode來進行具體的方法調用。
這里還有一個我未解決的問題,即在補丁類中調用原有類的super()方法,Instant run采用的是在每個原有類里又添加了一個函數access$super(),用來調用super方法,這樣補丁類遇到super方法時,直接調用原有類的access$super()方法即可,不過就像美團在文章中所說的,該方法會增加app的方法數,所以不采用。美團采用的方法是修改super方法的調用指令。
我們來用javap -c 命令看一下MainActivity的字節碼
其中調用super.onCreate()的指令為:
30: invokespecial #2 // Method android/support/v7/app/AppCompatActivity.onCreate:(Landroid/os/Bundle;)V
可以看見調用的是invokespecial指令(不知美團為何說是invokesuper),該指令用于調用實例構造器方法,私有方法和父類方法。美團的意思應該是去修改指令為invokespecial,具體我還不知怎么操作,如果有同學知道,希望留言中告知。
接著寫一個補丁記錄類,用來記錄有哪些補丁
public class PatchBox implements IPatchBox {
@Override
public List<String> getPatchClasses() {
List<String> list = new ArrayList<>();
list.add("com.wangxiandeng.saviortest.MainActivity$Patch");
return list;
}
}
Step3:補丁打包
補丁寫完后,需要打包成dex,首先在編譯過程,拷貝出補丁類和PatchBox.class文件,依據補丁類所在包名,放在文件夾下,比如新建一個總文件夾patch,再新建一個
com/wangxiandeng/saviortest/文件夾,放入MainActivity$Patch.class和PatchBox.class, 然后按照以下步驟操作:
1.cd 到patch目錄;
2.利用jar cvf patch.jar * 指令打包成jar文件。
3.利用build-tools目錄下的dx指令:dx –dex –output=patch_dex.jar patch.jar指令,打包成dex的jar包,patch_dex.jar即為我們打包好的補丁。
Step4:補丁加載
將補丁放在sd卡中,執行補丁加載過程。補丁加載的核心代碼如下:
//加載補丁Dex文件
DexClassLoader dexClassLoader = new DexClassLoader(patchPath, getOdexPath(), null, getClass().getClassLoader());
//加載補丁裝載類PatchBox
Class<?> patchBoxClass = Class.forName(mPatchBoxName, true, dexClassLoader);
IPatchBox patchBox = (IPatchBox) patchBoxClass.newInstance();
//遍歷加載補丁類
for (String className : patchBox.getPatchClasses()) {
Class<?> patchClass = dexClassLoader.loadClass(className);
Object patchInstance = patchClass.newInstance();
//反射修改bug類的mSavior字段
int index = className.indexOf("$Patch");
if (index == -1) {
Log.e("Savior:", "incorrect name for patch, please rename your patch according to the README.md");
return;
}
String bugClassName = className.substring(0, index);
Class<?> bugClass = getClass().getClassLoader().loadClass(bugClassName);
Field saviorField = bugClass.getDeclaredField("$savior");
saviorField.setAccessible(true);
saviorField.set(null, patchInstance);
}
補丁加載過程主要分為3步:
1.利用DexClassLoader加載補丁;
2.加載補丁記錄類PatchBox;
3.根據遍歷記錄的補丁類并實例化,反射原有類的$savior字段,賦值為補丁實例。
至此,補丁類就已經加載完畢,此時調用原有類的bug方法,實際上調用的是補丁類的修復方法。
來自:https://halfstackdeveloper.github.io/2016/09/23/基于Instant-Run思想的HotFix方案實現/