基于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,不知是不是使用java bytecode editor手動操作,如果有同學知道,希望留言中告知。

接著寫一個補丁記錄類,用來記錄有哪些補丁

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.遍歷PatchBox中記錄的補丁類并實例化,反射對應原有類的$savior字段,賦值為補丁實例。

至此,補丁類就已經加載完畢,此時調用原有類的bug方法,實際上調用的是補丁類的修復方法。

結論

以上就是一個簡單的HotFix庫,當然這只供大家學習使用,離商用還差的很遠,真正的HotFix庫要考慮的地方還有很多,畢竟是用來修復bug的,總不能庫自身一堆bug吧。不過Instant Run確實是實現HotFix的一個很好的方案,不需要考慮android版本兼容性的問題,還可以實現修復的即時生效,期待美團Hotfix庫的開源!

代碼地址:https://github.com/HalfStackDeveloper/Savior

(轉載請注明ID:半棧工程師,歡迎訪問個人博客https://halfstackdeveloper.github.io/)

歡迎關注我的知乎專欄:https://zhuanlan.zhihu.com/halfstack

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容