Android插件化與熱修復(六)-微信Tinker原理分析

Tinker熱修復原理分析

熱補丁技術是在用戶不需要重新安裝應用的情況下實現應用更新,可快速解決一些線上問題。熱補丁省去了Android應用發布版本的成本,而且用戶端的更新也是無感知的。

Tinker 是微信官方發布的 Android 熱補丁解決方案,它支持動態下發代碼、So庫以及資源,讓應用能夠在不需要重新安裝的情況下實現更新。本文中主要介紹一下Tinker的熱補丁實現原理以及部分關鍵代碼,本文中只涉及動態下發代碼的方案,So庫以及資源的更新在后續文章中再介紹。
在介紹Tinker熱修復方案之前,我們先簡單介紹一下QZONE 的熱修復方案,因為兩者的原理類似,Tinker 是一個更優化的方案。QZONE和Tinker都利用了Android 的類加載機制,Android中有兩個主要的Classloader,PathClassLoader和DexClassLoader,它們都繼承自BaseDexClassLoader,這兩個類加載器的主要區別是:Android系統通過PathClassLoader來加載系統類和主dex中的類。而DexClassLoader則可用于加載指定路徑的apk、jar或dex文件。上述兩個類都是繼承自BaseDexClassLoader


我們可以看一下關鍵源碼:

//BaseDexClassLoader 

    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);//創建一個DexPathList的實例
    }
    
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        Class c = pathList.findClass(name, suppressedExceptions); // 調用DexPathList中的findclass方法
        return c; 
    }


// DexPathList
    public Class findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {
            DexFile dex = element.dexFile;
            if (dex != null) {
               Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }

BaseDexClassLoader 的構造函數中創建一個DexPathList實例,DexPathList的構造函數會創建一個dexElements 數組,BaseDexClassLoader 在findclass方法中調用了pathList.findClass,這個方法中會遍歷dexpathlist中的dexElements數組,然后初始化DexFile,如果DexFile不為空那么調用DexFile類的loadClassBinaryName方法返回Class實例。簡言之,ClassLoader會遍歷dexelements,然后加載這個數組中的dex文件. ClassLoader在加載到正確的類之后就會停止加載此類,因此我們將包含正確的類的Dex文件中插入在dexElements數組前面就可以完成對問題類的修復。這便是QQzone方案的主要機制,如下圖所示:

但是QQ空間熱修復技術存在一個問題:假設A類在static方法,private方法,構造函數,override方法中直接引用到B類。如果A類和B類在同一個dex中,那么A類就會被打上CLASS_ISPREVERIFIED標記,被打上這個標記的類不能引用其他dex中的類,否則就會報錯,那使用上述的熱修復方法就會出問題。為了防止熱修復失敗,需要防止類被打上CLASS_ISPREVERIFIED的標志(詳細機制請參考QQ空間技術文章https://mp.weixin.qq.com/s?__biz=MzI1MTA1MzM2Nw==&mid=400118620&idx=1&sn=b4fdd5055731290eef12ad0d17f39d4a
Qzone熱修復方案會對所有類進行插樁操作,也就是在所有類的構造函數中引用另外一個單獨的dex文件 heck.dex文件中的類,這種插樁操作導致所有類都無法打上CLASS_ISPREVERIFIED標識,也就解決了之前描述的問題。但這有一個副作用,會直接導致所有的verify與optimize操作在加載類時觸發。這會產生一定的性能損耗。
為了優化性能需要避免進行插樁操作,微信Tinker針對這一問題采用了一種更優的方案。首先我們先通過下圖來總體了解下Tinker熱修復方案的流程


Tinker進行熱修復的流程為:

  1. 新dex與舊dex通過dex差分算法生成差異包 patch.dex
  2. 將patch dex下發到客戶端,客戶端將patch dex與舊dex合成為新的全量dex
  3. 將合成后的全量dex 插入到dex elements前面(此部分和QQ空間機制類似),完成修復

可見,Tinker和QQ空間方案最大的不同是,Tinker 下發新舊DEX的差異包,然后將差異包和舊包合成新dex之后進行dex的全量替換,這樣也就避免了QQ空間中的插樁操作。然后我們詳細看下每一個流程的詳細實現細節。
Tinker的差量包patch.dex是如何生成的,Tinker生成新舊dex的差異包使用了微信自研的dexdiff算法,dexdiff算法是基于dex文件的結構來設計的,首先我們詳細看一下dex文件結構:


以下為dex文件中各個區域的內容含義:
header: dex文件頭部,記錄整個dex文件的相關屬性
string_ids: 涉及了文件中所用到的所有字符串,包括內部名稱(比如類型描述符)或者代碼引用的常量對象。按照UTF-16來進行排序
type_ids: 涉及了文件中所有用到的類型(如類、數組或者原始類型),不論是否是在文件中定義。按照類型字符串在string_ids中的索引來排序
proto_ids: 文件中所有用到的方法原型。列表按返回值類型在type_ids中的索引進行排序,索引相同的話再按參數類型在type_ids中的索引排序。
field_ids: 文件中所有用到的類屬性,不論其是否在文件中定義。列表依次按照所在類的類型(type_ids索引)、屬性名(string_ids索引)、自身類型(type_ids)進行排序
method_ids: 涉及了文件中所有用到的方法,不論是否在文件中定義。列表依次按照方法所在類的類型(按type_ids索引)、方法名(按string_ids索引)、方法原型(按proto_ids索引),不得含有重復條目
class_defs: 類定義列表,列表的順序必須符合一個類的基類以及其所實現的接口在這個類的前面這一規則。此外,列表中出現多個同名類的定義是無效的。
data: 數據區,包含上述各個結構所需的所有支持數據。不同的條目有不同的數據對齊要求,如果有需要,會在條目之前插入若干字節以滿足合適的對齊
為了更直觀的來理解,我們可以將如下代碼編譯生成dex文件,然后使用dex查看工具來看下Dex中的詳細內容

public class Hello
{
    public static void main(String[] argc)
    {
        System.out.println("Hello, Android!\n");
    }
}

使用dex查看工具查看生成的dex文件,其中 string_ids區域的內容如下:

以string section為例,string section記錄的是文件中所用到的所有字符串,包括內部名稱(比如類型描述符)或者代碼引用的常量對象,按照UTF-16來進行排序,如果修改代碼后dex文件中這一區域數據發生了變化,我們如何記錄數據的變更呢,我們通過一個簡化的方案來描述diff算法,如下圖示意圖(圖片引用自文章),假設左邊為原dex文件中的string section區域數據,右側為修改代碼后新dex中string section的數據,并且數據是順序排列的。那么從old變為new,我們就需要知道哪些位置的數據要刪除,哪些位置要添加數據,并把這些變化通過最少的數據量記錄下來。



看著上面這個簡化的示意圖,我們描述一下diff算法的主要流程

  1. 對于dex文件中的每項Section,遍歷其每一項Item,進行新數據與舊數據的對比(新舊數據的對比方法是oldItem.compareTo(newItem),結果小于0記為DEL,大于0記為ADD),記錄在哪些位置需要ADD一個值,哪些位置需要DEL一個值,并把這些操作項存放于patchOperationList中。按照圖中的例子,就是刪除位置2的數據,在位置5添加f。為了減少存儲的數據,
  2. Tinker中會遍歷patchOperationList,將同一個位置既有DEL標識又有ADD標識的情況,替換為REPLACE標識,最后將ADD,DEL,REPLACE標識數據分別記錄到各自的List中
  3. 最后將操作記錄列表寫入補丁patch.dex中
    生成patch.dex后,進行下一步是將patch下發到客戶端后合成全量的dex,合成dex這部分內容此處不再展開說,是差量過程的反過程

下面通過關鍵代碼來分析下合成dex結束后加載全量dex的流程:因為打完補丁的全量dex的加載是在Application 啟動之后onBaseContextAttached完成的,這樣無法對application類進行修復,為了解決這一問題Tinker中也是使用了一個application的代理類來完成application的實際邏輯處理。我們從代理application類的onBaseContextAttached方法開始看,設計到的幾個關鍵類如下:

以下為源碼部分

    @Override
    private void onBaseContextAttached(Context base) {
        loadTinker(); // 通過反射初始化我們之前傳過來的com.tencent.tinker.loader.TinkerLoader,并且調用它的tryLoad方法,這個是加載補丁包的關鍵方法
        ensureDelegate();// 生成application代理類,處理生命周期調用
        applicationLike.onBaseContextAttached(base);
        ...
    }

通過反射來調用TinkerLoader中的tryload方法,

    private void loadTinker() {
        tinkerResultIntent = new Intent();
        try {
            //通過反射調用TinkerLoader中的tryload方法
            Class<?> tinkerLoadClass = Class.forName(loaderClassName, false, getClassLoader());
            Method loadMethod = tinkerLoadClass.getMethod(TINKER_LOADER_METHOD, TinkerApplication.class, int.class, boolean.class);
            Constructor<?> constructor = tinkerLoadClass.getConstructor();
            tinkerResultIntent = (Intent) loadMethod.invoke(constructor.newInstance(), this, tinkerFlags, tinkerLoadVerifyFlag);
        } catch (Throwable e) {
            ShareIntentUtil.setIntentReturnCode(tinkerResultIntent, ShareConstants.ERROR_LOAD_PATCH_UNKNOWN_EXCEPTION);
            tinkerResultIntent.putExtra(INTENT_PATCH_EXCEPTION, e);
        }
    }

TinkerLoader 類

    public Intent tryLoad(TinkerApplication app, int tinkerFlag, boolean tinkerLoadVerifyFlag) {
        Intent resultIntent = new Intent();
        long begin = SystemClock.elapsedRealtime();
        tryLoadPatchFilesInternal(app, tinkerFlag, tinkerLoadVerifyFlag, resultIntent); //在tryLoadPatchFilesInternal中首先要進行環境校驗,完成校驗流程后再加載補丁,校驗的詳細內容不展開討論
        long cost = SystemClock.elapsedRealtime() - begin;
        ShareIntentUtil.setIntentPatchCostTime(resultIntent, cost);
        return resultIntent;
    }

tryLoadPatchFilesInternal中首先進行校驗工作,這部分校驗工作的代碼細節省略

    private void tryLoadPatchFilesInternal(TinkerApplication app, int tinkerFlag, boolean tinkerLoadVerifyFlag, Intent resultIntent) {

....省略掉安全校驗部分代碼
        final boolean isEnabledForDex = ShareTinkerInternals.isTinkerEnabledForDex(tinkerFlag);  // 檢查是否支持dex文件修復

        if (isEnabledForDex) {
            //tinker/patch.info/patch-641e634c/dex
            boolean dexCheck = TinkerDexLoader.checkComplete(patchVersionDirectory, securityCheck, resultIntent); // 進一步檢查dex文件是否存在
            if (!dexCheck) {
                Log.w(TAG, "tryLoadPatchFiles:dex check fail");
                return;
            }
        }
        boolean isSystemOTA = ShareTinkerInternals.isVmArt() && ShareTinkerInternals.isSystemOTA(patchInfo.fingerPrint);
        resultIntent.putExtra(ShareIntentUtil.INTENT_PATCH_SYSTEM_OTA, isSystemOTA);  //如果用戶是在ART環境并且做了OTA升級則在加載dex補丁的時候就會先把最近一次的補丁全部DexFile.loadDex一遍重新生成odex.再加載dex補丁
        if (isEnabledForDex) {
            boolean loadTinkerJars = TinkerDexLoader.loadTinkerJars(app, tinkerLoadVerifyFlag, patchVersionDirectory, resultIntent, isSystemOTA);
            if (!loadTinkerJars) {
                Log.w(TAG, "tryLoadPatchFiles:onPatchLoadDexesFail");
                return;
            }
        }
        ····
        return;
    }
//TinkerDexLoader
    @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
    public static boolean loadTinkerJars(Application application, boolean tinkerLoadVerifyFlag, String directory, Intent intentResult, boolean isSystemOTA) {

        PathClassLoader classLoader = (PathClassLoader) TinkerDexLoader.class.getClassLoader();
        String dexPath = directory + "/" + DEX_PATH + "/";
        File optimizeDir = new File(directory + "/" + DEX_OPTIMIZE_PATH);
        ArrayList<File> legalFiles = new ArrayList<>();
        final boolean isArtPlatForm = ShareTinkerInternals.isVmArt();
        // 獲取合法的文件列表
        for (ShareDexDiffPatchInfo info : dexList) {
            //for dalvik, ignore art support dex
            if (isJustArtSupportDex(info)) {
                continue;
            }
            String path = dexPath + info.realName;
            File file = new File(path);
            }
            legalFiles.add(file);
        }
//isSystemOTA判斷,如果用戶是ART環境并且做了OTA升級,加載dex補丁的時候首先將最近一次的補丁全部DexFile.loadDex一遍.之所以這樣做是因為有些場景做了OTA后,OTA的規則可能發生變化,在這種情況下去加載上個系統版本oat過的dex就會出現問題.
        if (isSystemOTA) {
            parallelOTAResult = true;
            parallelOTAThrowable = null;
            Log.w(TAG, "systemOTA, try parallel oat dexes!!!!!");

            TinkerParallelDexOptimizer.optimizeAll(
                legalFiles, optimizeDir,
                new TinkerParallelDexOptimizer.ResultCallback() {
                    long start;

                    @Override
                    public void onSuccess(File dexFile, File optimizedDir, File optimizedFile) {
                        // Do nothing.
                        Log.i(TAG, "success to optimize dex " + dexFile.getPath() + "use time " + (System.currentTimeMillis() - start));
                    }
                    @Override
                    public void onFailed(File dexFile, File optimizedDir, Throwable thr) {
                        parallelOTAResult = false;
                        parallelOTAThrowable = thr;
                        Log.i(TAG, "fail to optimize dex " + dexFile.getPath() + "use time " + (System.currentTimeMillis() - start));
                    }
                }
            );          
               intentResult.putExtra(ShareIntentUtil.INTENT_PATCH_EXCEPTION, parallelOTAThrowable);
                ShareIntentUtil.setIntentReturnCode(intentResult, ShareConstants.ERROR_LOAD_PATCH_VERSION_PARALLEL_DEX_OPT_EXCEPTION);
                return false;
            }
        }
        try {
            //接下來就是調用SystemClassLoaderAdder的installDexes方法
            SystemClassLoaderAdder.installDexes(application, classLoader, optimizeDir, legalFiles);
        } catch (Throwable e) {
            intentResult.putExtra(ShareIntentUtil.INTENT_PATCH_EXCEPTION, e);
            ShareIntentUtil.setIntentReturnCode(intentResult, ShareConstants.ERROR_LOAD_PATCH_VERSION_DEX_LOAD_EXCEPTION);
            return false;
        }
        return true;
    }

經過前面的各種校驗操作后,接下來到了真正加載dex補丁的流程。Tinker加載dex補丁按照系統版本不同會分別進行處理,如果加載失敗將記錄失敗信息到intentResult中。

// SystemClassLoaderAdder
    public static void installDexes(Application application, PathClassLoader loader, File dexOptDir, List<File> files)
        throws Throwable {
        if (!files.isEmpty()) {
            ClassLoader classLoader = loader;
            if (Build.VERSION.SDK_INT >= 24) {
                classLoader = AndroidNClassLoader.inject(loader, application);
            }
            //because in dalvik, if inner class is not the same classloader with it wrapper class.
            //it won't fail at dex2opt
            if (Build.VERSION.SDK_INT >= 23) {
                V23.install(classLoader, files, dexOptDir);
            } else if (Build.VERSION.SDK_INT >= 19) {
                V19.install(classLoader, files, dexOptDir);
            } else if (Build.VERSION.SDK_INT >= 14) {
                V14.install(classLoader, files, dexOptDir);
            } else {
                V4.install(classLoader, files, dexOptDir);
            }
            //install done
            sPatchDexCount = files.size();
            if (!checkDexInstall(classLoader)) {
                //reset patch dex
                SystemClassLoaderAdder.uninstallPatchDex(classLoader);
                throw new TinkerRuntimeException(ShareConstants.CHECK_DEX_INSTALL_FAIL);
            }
        }
    }

對于每個系統版本的不同處理我們就不一一展開看,看下V23里面的處理流程

//V23
        private static void install(ClassLoader loader, List<File> additionalClassPathEntries,File optimizedDirectory)
            throws IllegalArgumentException, IllegalAccessException,
            NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException {
            Field pathListField = ShareReflectUtil.findField(loader, "pathList");
            Object dexPathList = pathListField.get(loader); //通過反射拿到classloader的patchlist變量
            ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
            // 通過反射獲取pathList的dexElements參數,把經過合并后的DexElements設置為pathList的dexElements。
            ShareReflectUtil.expandFieldArray(dexPathList, "dexElements", makePathElements(dexPathList,new ArrayList<File>(additionalClassPathEntries), optimizedDirectory,suppressedExceptions));
        }


        private static Object[] makePathElements(
            Object dexPathList, ArrayList<File> files, File optimizedDirectory,
            ArrayList<IOException> suppressedExceptions)
            throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {

            Method makePathElements;
            try {
            //反射pathList的makeDexElements方法,傳入插件補丁dexList路徑與優化過的opt目錄,通過這個方法生成一個新的DexElements,這個DexElements為插件的DexElements。
                makePathElements = ShareReflectUtil.findMethod(dexPathList, "makePathElements", List.class, File.class,
                    List.class);
            } catch (NoSuchMethodException e) {
                Log.e(TAG, "NoSuchMethodException: makePathElements(List,File,List) failure");
                try {
                    makePathElements = ShareReflectUtil.findMethod(dexPathList, "makePathElements", ArrayList.class, File.class, ArrayList.class);
                } catch (NoSuchMethodException e1) {
                    Log.e(TAG, "NoSuchMethodException: makeDexElements(ArrayList,File,ArrayList) failure");
                    try {
                        Log.e(TAG, "NoSuchMethodException: try use v19 instead");
                        return V19.makeDexElements(dexPathList, files, optimizedDirectory, suppressedExceptions);
                    } catch (NoSuchMethodException e2) {
                        Log.e(TAG, "NoSuchMethodException: makeDexElements(List,File,List) failure");
                        throw e2;
                    }
                }
            }

            return (Object[]) makePathElements.invoke(dexPathList, files, optimizedDirectory, suppressedExceptions);
        }
    }

我們看到上面的流程,就是在文章開頭的時候講到的和QZONE一樣的原理,通過反射獲取到patchlist的dexElements字段,然后將合成補丁后的dex文件插入到dexelements數組的前面,這樣打了補丁后的dex就可以先記載到從而完成修復工作。
微信tinker的資源與so的加載將在后續文章中繼續介紹。

參考文章列表:
微信Tinker的一切都在這里,包括源碼
微信熱補丁Tinker – 補丁流程
[dex文件格式]
(http://www.cnblogs.com/dacainiao/p/6035274.html)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,363評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,497評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,305評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,962評論 1 311
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,727評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,193評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,257評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,411評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,945評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,777評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,978評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,519評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,216評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,657評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,960評論 2 373

推薦閱讀更多精彩內容