Android開發之熱修復

一、前言

  任何程序都無法保證上線後不會出現緊急bug,選擇的修復方式不同,其代價也大不相同。所謂熱修復,是相對於正常的版本迭代修復而言的,它可以及時在應用內下載補丁更新程序邏輯,修復bug;而不需要等到下一個版本發佈。舉個簡單的例子,假如有一行代碼的邏輯寫錯了,並且已經編譯出APK,安裝到了用戶的手機上,此時有兩種處理方式:

  1. 等待下一個版本發佈,其中修復了錯誤代碼,即迭代修復
  2. 給用戶推送補丁,及時修復錯誤代碼,即熱修復

下圖對比兩者區別:

 

 從上圖可以看出熱修復相對於迭代修復有很大優勢:

  1. 成本優勢——避免了重新向渠道更新APK版本
  2. 時間優勢——幾乎是即時修復,不必等待版本覆蓋時間
  3. 體驗優勢——避免重新安裝版本,用戶無感修復 

熱修復技術可以爲應用增加一份安全保障,也爲程序更新提供了一種新的可能途徑。


 

二、熱修復技術原理

  從技術角度來說,我們的目的是非常明確的:把錯誤的代碼替換成正確的代碼。注意這裏的替換,並不是直接擦寫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版本爲例,詳細介紹一下其對接流程,主要步驟如下:

  1. 添加robust插件 
  2. 配置插件特性——robust.xml
  3. 配置補丁加載方法——自定義PatchManipulate和RobustCallback子類
  4. 編譯基礎版本(生成mapping.txt,methodMap.robust)
  5. 修復代碼
  6. 生成補丁——patch.jar
  7. 補丁下載/推送
  8. 調用修復命令

(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。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章