1. 程式人生 > >Android熱修復(二):以DexClassLoader類載入原理編寫demo實現類替換修復

Android熱修復(二):以DexClassLoader類載入原理編寫demo實現類替換修復

上一篇文章簡易總結了熱修復實現的幾大原理,並詳細介紹了Android中的類載入機制及原始碼探索,Android的類載入機制涉及到ClassLoader、DexClassLoader 、PathClassLoader 、BaseDexClassLoader 、DexPathList、DexFile多個類之間方法互相呼叫,但是真正的核心實現類其實是DexPathList,它具體實現了findClass方法,即所謂的“類載入”概念。

其實此方法中的實現邏輯給開發者暗示了熱修復的實現思路,此篇文章將一探究竟,剖析出如何根據DexClassLoader類載入機制來實現類替換修復,通過分析思路具體編碼實踐整個過程。

此篇文章將學習:

  • DexClssLoader類載入核心
  • 通過修改dexElement陣列實現類替換修復
  • 整體編碼過程思路及原始碼展示( dx命令生成dex檔案)

一. 如何通過DexClssLoader類載入實現熱修復

1. 找到突破口——DexPathList

在經過上一篇對DexClassLoader相關原始碼分析後,可以發現類載入的主要邏輯處理都在DexPathList類中進行,類中有一個重要的成員變數——Element型別陣列,和兩個重要的方法——建構函式和findClass

  • 主要在建構函式中處理對Element型別陣列賦值(遍歷指定路徑中的所有檔案,將dex檔案相關資訊賦值到陣列中);
  • findClass方法中遍歷Element陣列,找到對應類名的dex檔案(Element型別的dexFile方法可轉化為DexFile型別),呼叫本身native方法獲取位元組碼檔案返回。

這裡寫圖片描述

以上我又再次強調歸納一遍DexPathList類的邏輯處理,這幾乎代表著Android類載入機制的重點核心了,也與後續“熱修復處理”有關。

在多次總結中可見DexPathList類的成員變數Element型別陣列很關鍵,而且又是通過Element陣列中的成員載入得到class位元組碼檔案,因此可以說所有載入的類都在Element陣列中!

這裡寫圖片描述

見上圖,此時客戶端中的Test類(Test.class)有bug,而伺服器已有修復好的Test.class,如若能夠將已修復的Test.class代替客戶端上的,是否就達到成功修復bug的功能?

這裡寫圖片描述

如上圖,再仔細檢視DexPathList類中對Element陣列的註釋:dex或者資源(類路徑)的集合,更應該命名為pathElement,但是Facebook app使用反射來修改 ‘dexElement’。以上谷歌註釋相當於預設開發者可以這麼玩:修改dexElement

2. 思路

在獲取谷歌助攻之後,實現熱修復功能萌生的第一個想法是:反射獲取到dexElement,直接修改其中有bug的類替換成已修復的類。

不過此方法進一步具體實現有些不太理想,因為dexElement是一個數組,需要一個一個遍歷找到對應的再做替換,過程實現是比較麻煩的。再仔細觀察DexPathList類的findClass方法,發現內部迴圈的一個重點邏輯:在遍歷dexElement陣列時,若載入到的指定類class不為空時,直接return結束遍歷,將class位元組碼返回!

這裡寫圖片描述

綜上所述,第二個想法由此萌生:根據findClass方法裡的內部邏輯,遍歷查詢到指定類載入,class不為空就直接返回,那我們可以直接將已修復的類放到有bug類的前面,一旦載入到已修復好的類,那後面的bug類就不會有任何影響。

第二個想法的思路很簡單,相當於做了一個攔截此時你可能還在糾結第一個想法,在dexElement陣列中刪去有bug的去替換成已修復的,這操作過程並沒有表面那麼容易,與第二個想法比起來,為何不採用更簡單的呢?

3.具體實現方法

實現熱修復功能思路:直接將已修復的dex放到dexElement陣列中有bug類的dex前面!

再結合Android系統思考詳細操作:Android類載入載入的是dex檔案,也就是解壓APK後的classes.dex,而一個dex檔案是包含了整個工程的class位元組碼檔案。在發現原工程中有某個類或涉及到的幾個類有錯誤,將這些已修復好的類打包到修復好的補丁包classes2.dex中,可以通過推送或其他方式由伺服器傳送到客戶端中,那麼接下來的任務就是將這兩個dex合併,其注意目的也就是將修復的class放置到原來出bug的class(其實這裡只需要將其放置到陣列中第一個位置,就可以一勞永逸!)

這裡寫圖片描述

如上圖,這是github上Tinker技術的介紹圖,其原理與上述實現思路是不是非常類似?實則兩者實現原理相同,皆從DexClssLoader類載入原理入手,但是此篇文章並不詳細介紹Tinker使用及原理(下一篇介紹),而是瞭解以上原理自行編寫demo實現類替換修復。

二. 編碼過程

(以下過程採用Genymotion模擬器操作,AS的java目錄如下)

這裡寫圖片描述

1. 測試場景搭建

(1)測試UI介面

如下圖,測試介面十分簡單,一個activity中2個button,點選後的處理邏輯如下:

  • TEST按鈕:點選後會進行10/0這樣一個錯誤運算,點選之後程式必然出錯閃退,以此來模擬應用閃退的情況(實際編碼中不會出現此疏漏,在此僅為模擬)。
  • FIX按鈕: 將已修復好的dex檔案(設定其名稱為classes2.dex)移置到應用程式目錄中,再開始進行熱修復類替換。

注意:此過程中的伺服器傳送給客戶端已修復的dex檔案(實際可通過推送或其他方式),此步驟筆者並未詳細實現,而自行簡化直接將已修復好的dex檔案拖入Genymotion模擬器中,重在突出熱修復邏輯處理過程。

這裡寫圖片描述

(2)TEST按鈕方法實現

如上所述,點選TEST按鈕後將進行10/0這個錯誤運算,即呼叫DexFixTest類的testFix方法,程式肯定會出錯閃退,後續將修復此bug類,並生成已修復的dex用於熱修復。

DexFixTest類僅用於測試,程式碼如下:

public class DexFixTest {
    public  void testFix(Context context){
        int dividend = 10;
        //bug:除數不可為0
        int divisor = 0;
        Toast.makeText(context, "shit:"+dividend/divisor, Toast.LENGTH_SHORT).show();
    }
}

(3)FIX按鈕方法實現

此按鈕點選會呼叫fixBug()方法:

  • 方法作用: 就是將已修復好的dex檔案(設定其名稱為classes2.dex)放置到應用程式目錄中,做好準備工作後呼叫 DexFixUtils 進行熱修復操作。
  • 方法邏輯:是對dex檔案進行讀寫操作,主要易混點是dex檔案下載到的路徑和規定應用程式下的目錄路徑。

注意:此處可能有人會疑惑為何需要移置dex檔案位置?因為此熱修復方案採用的原理是DexClssLoader類載入,DexClassLoader有一個限制就是載入指定的dex檔案必須要在應用程式的目錄下面!因此先要移動dex檔案到指定位置,再實行熱修復。

獲取規定應用程式下的目錄路徑

Context.java
public File getDir(String name, int mode)

此API返回/data/data/youPackageName/app_name(”app_” 是返回時系統自己加上的)下的指定名稱的資料夾File物件,如果該資料夾不存在則用指定名稱建立一個新的資料夾。用此API在內部儲存中的對應app下建立name資料夾,用於存放移置後的已修復dex檔案,後續DexClassLoader會載入此處dex來進行熱修復。

獲取dex檔案下載到的路徑

Environment.getExternalStorageDirectory().getAbsolutePath()

此API返回sdcard路徑。在測試過程中是直接將已修復的dex檔案拖入模擬器/真機中,通過此API獲取到下載好的dex檔案,準備移置。

fixBug()方法完整程式碼如下:

    /*
    * 把下載完成的已修復檔案classes2.dex(SD卡)移置到 應用目錄filePath
    * */
    private void fixBug() {
        String dexName = "classes2.dex";
        File fileDir = getDir(HotfixConstants.DEX_DIR, Context.MODE_PRIVATE);
        //⭐️⭐️⭐️⭐️⭐️DexClassLoader指定的應用程式目錄
        String filePath = fileDir.getPath()+File.separator+dexName;
        File dexFile = new File(filePath);
        if(dexFile.exists()){
            dexFile.delete();
        }

        //移置dex檔案位置(從sd卡中移置到應用目錄)
        InputStream is = null;
        FileOutputStream os = null;
        // ⭐️⭐️⭐️⭐️⭐️sd卡路徑應為"/storage/emulated/0",此處直接將檔案拖入Genymotion,因此路徑還需加上"/Download"
        String sdPath = Environment.getExternalStorageDirectory().getAbsolutePath()+"/Download";

        try {
            is = new FileInputStream(sdPath+File.separator+dexName);
            os = new FileOutputStream(filePath);
            int len = 0;
            byte[] buffer = new byte[1024];
            while((len = is.read(buffer)) != -1){
                os.write(buffer, 0, len);
            }

            //測試是否成功寫入
            File fileTest = new File(filePath);
            if(fileTest.exists()){
                Toast.makeText(this, "dex重寫成功", Toast.LENGTH_SHORT).show();
            }

            //獲取到已修復的dex檔案,進行熱修復操作
            DexFixUtils.loadFixedDex(this);

        }catch (Exception e){
            e.printStackTrace();
        }

    }

最後還是要強調一下sd卡路徑和應用程式目錄路徑,筆者在編寫程式碼時對此處有些混淆,因此在第一大點中增加了Android內外存的相關介紹。分別用Genymotion模擬器(Google Nexus 5 API 23)和真機(華為 API 23)測試的結果如下:

  • 應用程式目錄路徑: 兩者都是一樣,為/data/user/0/com.lemon.hotfix/app_odex,odex是自定義的資料夾名,此資料夾用於存放移置後的已修復dex檔案。
  • sd卡獲取路徑:
    • Genymotion模擬器:/storage/emulated/0
    • 真機:/storage/sdcard0

下圖舉例模擬器測試時debug到的路徑:

這裡寫圖片描述

2. 熱修復邏輯實現DexFixUtils

DexFixUtils類的主要作用是:

/*
* @author lemonGuo
* 處理熱修復主要邏輯
* */
public class DexFixUtils {
    private static HashSet<File> loadedDex = new HashSet<File>();

    static{
        loadedDex.clear();
    }
    ......
}

(1)loadFixedDex 載入dex方法

/*
    * 遍歷所有的dex檔案儲存到成員變數loadedDex中,用於後續合併
    * */
    public static void loadFixedDex(Context context) {
        if(context == null){
            return ; 
        }

        File fileDir = context.getDir(HotfixConstants.DEX_DIR, Context.MODE_PRIVATE);
        File[] listFiles = fileDir.listFiles();
        for (File file:listFiles){
            if(file.getName().startsWith("classes") && file.getName().endsWith("dex")){
                loadedDex.add(file);
            }
        }
        //合併之前到dex
        doDexInject(context,fileDir);
    }

(2)doDexInject 合併替換dex方法

目前的條件是已經獲取到dex集合,即原本的classes.dex和已修復好的classes.dex2,而接下來的任務就是合併這兩個dex檔案,再回顧以下兩個載入器作用:

  • PathClassLoader:用來載入已安裝的應用程式dex;
  • DexClassLoader:支援載入外部的APK、Jar或dex檔案;(限制:必須要在應用程式目錄下)

根據以上載入器的各自作用,再結合第一點中的思路講解,實現dex檔案合併稍稍有了頭緒,整體邏輯可分為3個步驟:

  1. 首先獲得載入應用程式dex檔案的PathClassLoader,即通過Context上下文context.getClassLoader()獲取的便是載入應用的PathClassLoader;
  2. 然後獲得載入制定路徑下dex檔案的DexClassLoader,這裡的DexClassLoader需要自行建立,其構造方法中的四個引數分別是:指定要載入dex檔案的路徑dexPath、指定dex檔案需要被寫入的目錄,一般是應用程式內部路徑optimizedDirectory(不可以為null)、包含native庫的目錄列表librarySearchPath(可能為null)、父類載入器parent;
  3. 最後通過這兩個載入器去重寫 DexPathList類中的Element型別陣列dexElements,即直接將已修復的dex放到dexElement陣列中有bug類的dex前面。

上面3個步驟,重點在於獲取到PathClassLoader、DexClassLoader載入器後,第三步如何詳細實現第三步,即重寫dexElements陣列?

首先濾清思路既然要重寫dexElements陣列,首先要獲得載入程式PathClassLoader所載入的dexElements陣列,和載入已修復dex檔案DexClassLoader所載入的dexElements陣列,獲得這兩組陣列之後,再進行重寫。條件是已獲得PathClassLoader、DexClassLoader兩個載入器,接下來任務是如何獲取其對應的dexElements陣列?

檢視以下原始碼,可見我們已經洞悉dexElements陣列最終藏身之處,可是在AS中是無法直接查閱BaseDexClassLoader及相關原始碼,這將意味著首先需要通過反射獲取到BaseDexClassLoader類,再反射獲取類中的DexPathList類,即可獲取到dexElements陣列。

//原始碼
BaseDexClassLoader{
    DexPathList pathList;
}

DexPathList{
    Element[] dexElements;
}

注意此過程還沒有結束,獲取到兩個陣列後,首先要進行合併,即將已修復的dex放到dexElement陣列中有bug類的dex前面,然後將合併後的陣列去替換成程式載入器PathClassLoader所載入的陣列,因為DexClassLoader只是用來載入程式之外的已修復dex檔案,最終載入程式應用的還是PathClassLoader。如此一來即可大功告成!

以上doDexInject方法邏輯完整實現原始碼如下:

    /**
     * 通過PathClassLoader、DexClassLoader合併dex檔案,實現類替換修復
     * @param context 上下文環境
     * @param filesDir dex所在的檔案目錄
     */
    private static void doDexInject(Context context, File filesDir) {
        //dex檔案需要被寫入的目錄
        String optimizeDir = filesDir.getAbsolutePath()+File.separator+"opt_dex";
        File fileOpt = new File(optimizeDir);
        if(!fileOpt.exists()){
            fileOpt.mkdirs();
        }

        //1.獲得載入應用程式dex的PathClassLoader
        PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();

        for (File dex : loadedDex) {
            //2.獲得載入指定路徑下dex的DexClassLoader
            DexClassLoader dexClassLoader = new DexClassLoader(
                    dex.getAbsolutePath(),
                    fileOpt.getAbsolutePath(),
                    null,
                    pathClassLoader);
            //3.合併dex
            try {
                Object dexObj = getPathList(dexClassLoader);
                Object pathObj = getPathList(pathClassLoader);
                Object fixDexElements = getDexElements(dexObj);
                Object pathDexElements = getDexElements(pathObj);
                //合併兩個陣列
                Object newDexElements = combineArray(fixDexElements,pathDexElements);
                //重新賦值給PathClassLoader 中的exElements陣列
                Object pathList = getPathList(pathClassLoader);
                setField(pathList,pathList.getClass(),"dexElements",newDexElements);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

(3)反射相關方法

此部分有關反射getField的程式碼較為常規,另筆者多做了一層封裝,通過getPathListgetDexElements呼叫getField 反射方法獲取指定類,這樣整體程式碼風格看起來更優雅。

注意:此處兩個反射獲取BaseDexClassLoader類和dexElements陣列時提供的引數字串不同,獲取類需要以“包+類名”,例如dalvik.system.BaseDexClassLoader;獲取成員變數只需“變數名”即可,例如dexElements

    private static Object getPathList(Object baseDexClassLoader) throws Exception {
        return getField(baseDexClassLoader,Class.forName("dalvik.system.BaseDexClassLoader"),"pathList");
    }

    private static Object getDexElements(Object obj) throws Exception {
        return getField(obj,obj.getClass(),"dexElements");
    }

    /**
     * 通過反射獲得對應類
     * @param obj Object類物件
     * @param cl class物件
     * @param field 獲得類的字串名稱
     * @return
     */
    private static Object getField(Object obj, Class<?> cl, String field) throws NoSuchFieldException, IllegalAccessException {
        Field localField = cl.getDeclaredField(field);
        localField.setAccessible(true);
        return localField.get(obj);
    }

    /**
     * 通過反射修改值
     * @param obj 待修改值
     * @param cl  class物件
     * @param field  待修改值的字串名稱
     * @param value  修改值
     */
    private static void setField(Object obj,Class<?> cl, String field, Object value) throws Exception {
        Field localField = cl.getDeclaredField(field);
        localField.setAccessible(true);
        localField.set(obj,value);
    }

(4)數組合並方法

此方法的引數就是DexClassLoader、PathClassLoader所載入的這兩個dexElements陣列,主要目的就是將已修復的dex放到dexElement陣列中有bug類的dex前面。

正如第一大點所說,沒必要去找到那個bug類所在的陣列位置,而去剛剛好將已修復的放置它前面,有一個最簡單的方法:DexClassLoader所載入的dexElements陣列是已修復過的,而PathClassLoader所載入的dexElements陣列是存有bug的,因此直接建立一個新組,長度是兩者之和,先賦值已修復過的陣列中的值,然後再去賦值存在bug的陣列中的值,如此一來根據findClass方法的return機制,遍歷找到指定類後直接return,陣列後面的值不再考慮,即可完美簡化解決數組合並問題。

方法邏輯圖如下:

這裡寫圖片描述

該方法具體實現程式碼如下:

    /**
     * 兩個數組合並
     * @param arrayLhs
     * @param arrayRhs
     * @return
     */
    private static Object combineArray(Object arrayLhs, Object arrayRhs) {
        Class<?> localClass = arrayLhs.getClass().getComponentType();
        int i = Array.getLength(arrayLhs);
        int j = i + Array.getLength(arrayRhs);
        Object result = Array.newInstance(localClass, j);
        for (int k = 0; k < j; ++k) {
            if (k < i) {
                Array.set(result, k, Array.get(arrayLhs, k));
            } else {
                Array.set(result, k, Array.get(arrayRhs, k - i));
            }
        }
        return result;
    }

三. 過程測試

1 . 程式閃退展示

首先程式執行後直接點選TEST按鈕,客戶端處理10/0運算,必然出錯,出現程式閃退,效果GIF如下:

這裡寫圖片描述

2 . 生成已修復的dex檔案

筆者本想通過AS來獲取這個已修復的dex檔案,可是錯誤類只有DexFixTest這一個類,因此只需要對其編譯成class檔案,再到dex檔案即可(此處暫時不考慮混淆),可是AS每次編譯是將整個專案工程一起編譯,那生成的dex檔案是包含所有類的,如此一來還需什麼替換,直接用此dex不就行了?

這樣並非達到熱修復本意,已修復dex檔案只需要包含對錯誤類的修復,因此筆者採用手動通過命令來生成。過程如下:

  • 首先在AS中的DexFixTest類修改運算10/010/1,點選編譯,在/Users/lemon/Desktop/gym/android_project/apps_gym/HotfixDemo/app/build/intermediates/classes/debug/com/lemon/hotfix/test/DexFixTest.class 可以找到該類的class檔案。
  • 在藉助AS獲取到DexFixTest.class檔案後,在桌面隨意建一個資料夾放置該檔案,注意包名需對應資料夾名!(圖片如下),開啟終端輸入dx --dex --output=/Users/lemon/Desktop/test/classes2.dex /Users/lemon/Desktop/test/,即可生成對應的dex檔案。
  • 直接將檔案拖入到模擬器或真機即可

這裡寫圖片描述

這裡寫圖片描述

3 . 熱修復功能展示

最終演示效果圖如下,首先點選TEST按鈕,程式執行10/0出錯閃退。再次進入程式點選FIX按鈕,進行類替換修復,將DexFixTest類中的運算替換為10/1,螢幕吐司顯示dex檔案已被重寫,再點選TEST按鈕計算,此時計算的是10/1,吐司顯示1,表示類已經被替換,熱修復成功。

這裡寫圖片描述

注意:在編寫程式碼中一定要關閉AS 的Instant Run功能,它雖然可以加快構建和部署流程的速度,但其本身有太多限制。筆者掉入坑中好久,一直除錯有問題,最後才發現是這廝在作怪!

以上是根據第一篇解析了DexClassLoader類載入相關原始碼後,探索到可實現熱修復的思路,即修改dexElement,讓findClass方法預先載入已修復的dex檔案而忽略後續的bug檔案,並憑藉此思路最終實現類替換修復功能。

整體實現思路並不複雜,不過其中涉及到一些底層知識,例如Android內外存、反射、dex檔案相關知識等等,筆者掉了不少坑,繼續努力。這兩篇主要學習DexClassLoader類載入相關原理並自行實現了一個小demo,對此理解已經不少,下一篇將學習記錄騰訊Tinker的使用及原理,共勉~

(原始碼正在整理中,後續放出)

若有錯誤,虛心指教~