Android熱修復技術(一) 原理和實現

寫在前面:
參考文章 熱修復——深入淺出原理與實現

一、簡述和意義

在熱修復之前,一個上線的app如果出現了bug,即使非常小,要是想及時更新就必須將app重新打包發佈到應用市場,讓用戶重新下載安裝,使得用戶體驗非常差,而且很多用戶不願意去經常更新app,所以嚴重的bug還會造成用戶流失,甚至帶來嚴重的後果。

熱修復技術就是能在用戶不用下載安裝新的app,甚至無感知的情況下修復一些緊急或者必須的bug的技術。該技術是這幾年比較火的技術,也是項目非常需要的技術,更是作爲開發者必須學習的技能之一。

目前比較火的熱修復方案分爲兩派,分別是:

  • 阿里系 Hotfix Sophix 從底層二進制入手
  • 騰訊系 Tinker 從java加載機制入手

備註:本篇就是基於java加載機制,來研究熱修復的原理和實現

二、Dex分包方案分析

2.1 分包方案由來(Dalvik限制)

當一個app功能越來越複雜,可能就會出現編譯失敗,因爲一個jvm中存儲方法個數id用的是short類型,導致dex中方法數不能超過65535

基於此,就需要對app編譯的時候進行分包,即將編譯好的class文件拆分打包成多個dex,繞過dex方法數量的限制以及安裝時的檢查,在運行時在動態加載其他的dex文件,即其他dex文件在Application初始化的時候被注入到系統的ClassLoader中。

2.2 Dex分包加載的原理(ClassLoader源碼分析)

在Android中,要加載dex中的class文件就需要用到PathClassLoder和DexClassLoder這兩個專用的類加載器,它們都繼承於BaseDexClassLoader

以下是Android5.0中的部分源碼
PathClassLoader.java
DexClassLoader.java
BaseDexClassLoader.java
DexPathList.java

  • 使用場景

    • PathClassLoader:只能加載已經安裝到Android系統中的apk文件(data/app目錄),是Android默認的類加載器
    • DexClassLoader:可以加載任意目錄下的dex/jar/zip文件,比PathClassLoader靈活,是實現熱修復的關鍵
  • 代碼差異

// PathClassLoader
public class PathClassLoader extends BaseDexClassLoader {
    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }

    public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}
// DexClassLoader
public class DexClassLoader extends BaseDexClassLoader {
    public DexClassLoader(String dexPath, String optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
        super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
    }
}
  • 參數解釋:
    • dexPath:要加載的patch包文件(一般是dex文件,也可以是jar/zip/apk文件)的文件目錄
    • optimizedDierctory:dex文件的輸出目錄(因爲在加載jar/apk/zip等壓縮格式的文件是會解壓出其中的dex文件,該目錄就是用於存放這些被解壓出來的dex文件的)
    • libreayPath:要加載patch文件時需要用到的庫路徑
    • parent:父類加載器

通過上面的比對,可以得出2個結論:

  • PathClassLoader和DexClassLoader都繼承於BaseDexClassLoader
  • PathClassLoader與DexClassLoader在構造方法中都是調用的父類的構造方法,但是DexClassLoader多傳了一個optimizedDirectory

所以我們重點還是看BaseDexClassLoader都做了什麼

public class BaseDexClassLoader extends ClassLoader {

   private final DexPathList pathList;

   public BaseDexClassLoader(String dexPath, File optimizedDirectory, String libraryPath, ClassLoader parent){
        super(parent);
        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }
    ...

    //查找要加載的class
    @Override
 protected Class<?> findClass(String name) throws ClassNotFoundException {
    List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
    // 實質是通過pathList的對象findClass()方法來獲取class
    Class c = pathList.findClass(name, suppressedExceptions);
    if (c == null) {
        ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
        for (Throwable t : suppressedExceptions) {
            cnfe.addSuppressed(t);
        }
        throw cnfe;
    }
    return c;
 }
}

類加載器會提供一個方法供外界找到它所加載的class,這個方法就是上面的findClass(),可以看到findClass()內部是通過構造方法中創建的對象DexPathList的findClass()來獲取對應的Class的,接下來分析DexPathList的源碼

private final Element[] dexElements;
public DexPathList(ClassLoader definingContext, String dexPath,
        String libraryPath, File optimizedDirectory) {
        ...
        this.definingContext = definingContext;
        this.dexElements = makeDexElements(splitDexPath(dexPath),  
        optimizedDirectory,suppressedExceptions);
        ...
}

上面構造方法中,調用了makeDexElements方法得到了Element[]數組,這個數組就是通過將一個個patch包封裝成一個個Element對象後得到的集合,對於內部調用的splitDexPath(dexPath)是將傳進來的dexPath按照(” : “)分割成的一個List集合,所以可以得知dexPath是一個以(” : “)分割的多個dex文件目錄拼成的字符串,這個方法比較簡單,代碼就不貼出來了,下面makeDexElements的源碼加註釋:

private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) {
    // 1.創建Element集合
    ArrayList<Element> elements = new ArrayList<Element>();
    // 2.遍歷所有dex文件(也可能是jar、apk或zip文件)
    for (File file : files) {
        ZipFile zip = null;
        DexFile dex = null;
        String name = file.getName();
        ...
        // 如果是dex文件,loadDexFile()是加載dex文件的核心方法
        if (name.endsWith(DEX_SUFFIX)) {
            dex = loadDexFile(file, optimizedDirectory);

        // 如果是apk、jar、zip文件(這部分在不同的Android版本中,處理方式有細微差別)
        } else {
            zip = file;
            dex = loadDexFile(file, optimizedDirectory);
        }
        ...
        // 3.將dex文件或壓縮文件包裝成Element對象,並添加到Element集合中
        if ((zip != null) || (dex != null)) {
            elements.add(new Element(file, false, zip, dex));
        }
    }
    // 4.將Element集合轉成Element數組返回
    return elements.toArray(new Element[elements.size()]);
}

最後再看DexPathList的findClass()方法:

public Class findClass(String name, List<Throwable> suppressed) {
    for (Element element : dexElements) {
        // 遍歷出一個dex文件
        DexFile dex = element.dexFile;

        if (dex != null) {
            // 在dex文件中查找類名與name相同的類
            Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
            if (clazz != null) {
                return clazz;
            }
        }
    }
    if (dexElementsSuppressedExceptions != null) {
        suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
    }
    return null;
}

從上面可以看出DexPathList的findClass非常簡單,就是對Element[]數組進行遍歷,一但找到與name相同的類時,就直接返回找到的class,找不到則返回null

三、基於類加載器的熱修復的實現原理

經過上面對PathClassLoader、DexClassLoader、BaseDexClassLoader、DexPathList的分析,我們知道,Android的類加載器在加載一個類的時候會從DexPathList中的Element[]數組進行遍歷,找到對應的DexFile,使用DexFile的loadClassBinaryName去加載對應的類。

這裏寫圖片描述

所以,我們只需要讓已經修復好的class打包成一個dex文件,使用DexClassLoader去加載dex文件,得到對應的Element數組,放在應用原Element數組的最前面,這樣就能保證獲取到的Class是最新修復好的class了,(其實有bug的Class也是存在的,只是沒有機會被加載到而已)

實現方式是:創建對象DexClassLoader去加載補丁,加載後反射獲取得到的pathList和dexElements數組,將補丁dexElements數組與App原來的dexElements數組進行合併,然後將這個新的Element數組通過反射的方式賦值給當前類加載器的pathList的屬性dexElements,這樣在加載類的時候就保證先加載到修復好bug的Class了

這裏寫圖片描述

四、熱修復的簡單實現

經過上面那麼多的理論分析,是時候實踐一下了

1. 得到dex補丁

  • 修復好有問題的java文件
  • 將java文件編譯成class文件(Android studio Build->Rebuild Project,完成後從build目錄找到對應的class文件)
    這裏寫圖片描述

    將修復好的class文件複製出來,注意在複製該class文件時,需要將它所在的完整包目錄一起復制,對應於上圖,複製出來的目錄結構應該是:
    這裏寫圖片描述

  • 將class文件打包成dex文件
    要想將class文件打包成dex文件,需要用到dx命令,這個命令類似於java命令,我們知道,java命令有javac、jar等等,之所以可以使用這類命令,是因爲我們有jdk,而dx命令是由Android SDK提供的,它在build_tools目錄下的各個Android版本目錄中
    這裏寫圖片描述
    要想使用dx指定,有兩種方式

    • 配置環境變量(添加到classpath),然後終端在任意位置都能使用
    • 不配置環境變量,只能在dx所在目錄使用

    dx –dex –output=dex/classes2.dex dex
    dx –dex –output=(dex輸出目錄) 空格 (要打包的完整class所在目錄)

2. 加載dex格式補丁

根據原理,以下爲加載patch包的簡單工具類

public class FileDexUtils {
    private static final String DEX_SUFFIX = ".dex";
    private static final String APK_SUFFIX = ".apk";
    private static final String JAR_SUFFIX = ".jar";
    private static final String ZIP_SUFFIX = ".zip";
    public static final String DEX_DIR = "odex";
    private static final String OPTIMIZE_DEX_DIR = "optimize_dex";

    private static FileDexUtils INSTANCE;

    private FileDexUtils() {
    }

    public static FileDexUtils getInstance() {
        if (INSTANCE == null) {
            synchronized (FileDexUtils.class) {
                if (INSTANCE == null) {
                    INSTANCE = new FileDexUtils();
                }
            }
        }
        return INSTANCE;
    }

    /**
     * 根據指定目錄將指定目錄下的所有符合patch包規則的文件 的文件目錄以:分割拼接成字符串
     *
     * @param context
     * @param patchFileDir
     */
    public void loadFixedDex(Context context, File patchFileDir) {
        if (context == null) {
            return;
        }
        StringBuilder loadedDex = new StringBuilder();
        File fileDir = patchFileDir != null ? patchFileDir : new File(context.getFilesDir(), DEX_DIR);
        File[] listFiles = fileDir.listFiles();
        if (listFiles == null) {
            return;
        }
        for (File file : listFiles) {
            if (file.getName().startsWith("classes") &&
                    (file.getName().endsWith(DEX_SUFFIX)
                            || file.getName().endsWith(APK_SUFFIX)
                            || file.getName().endsWith(ZIP_SUFFIX)
                            || file.getName().endsWith(JAR_SUFFIX))) {
                loadedDex.append(file.getAbsolutePath()).append(":");
            }
        }
        if (loadedDex.length() > 0) {
            loadedDex.replace(loadedDex.length() - 1, loadedDex.length(), "");
        }
        doDexInject(context, loadedDex.toString());
    }

    /**
     * 根據拼接好的patch包文件目錄去進行加載
     * @param context
     * @param loadedDex
     */
    private void doDexInject(Context context, String loadedDex) {
        if (TextUtils.isEmpty(loadedDex)) {
            return;
        }
        String optimizeDir = context.getFilesDir().getAbsolutePath() + File.separator + OPTIMIZE_DEX_DIR;
        File fopt = new File(optimizeDir);
        if (!fopt.exists()) {
            fopt.mkdirs();
        }
        try {
            PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
            //1 加載指定要修復的dex文件
            DexClassLoader dexClassLoader = new DexClassLoader(loadedDex, optimizeDir, null, pathClassLoader);
            //2 獲取BaseDexClassLoader內部的DexPathList
            Object dexPathList = getPathList(dexClassLoader);
            Object pathPathList = getPathList(pathClassLoader);
            //3 獲取DexPathList內部的DexElements
            Object leftDexElements = getDexElements(dexPathList);
            Object rightDexElements = getDexElements(pathPathList);
            //4 將兩個DexElements合併
            Object dexElements = combineArray(leftDexElements, rightDexElements);
            //重新給DexPathList的Element[] 賦值
            Object pathList = getPathList(pathClassLoader);
            setField(pathList, pathList.getClass(), "dexElements", dexElements);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }

    private Object getDexElements(Object pathList) throws NoSuchFieldException, IllegalAccessException {
        return getField(pathList, pathList.getClass(), "dexElements");
    }

    private Object getPathList(ClassLoader classLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
        return getField(classLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
    }

    /**
     * 反射得到對象中的屬性值
     */
    private Object getField(Object obj, Class<?> cl, String field) throws NoSuchFieldException, IllegalAccessException {
        Field localField = cl.getDeclaredField(field);
        localField.setAccessible(true);
        return localField.get(obj);
    }

    /**
     * 反射給對象的屬性重新賦值
     */
    private void setField(Object obj, Class<?> cl, String field, Object value) throws NoSuchFieldException, IllegalAccessException {
        Field localField = cl.getDeclaredField(field);
        localField.setAccessible(true);
        localField.set(obj, value);
    }

    /**
     * 數組合並
     *
     * @param arrayLeft
     * @param arrayRight
     * @return
     */
    private Object combineArray(Object arrayLeft, Object arrayRight) {
        Class<?> componentType = arrayLeft.getClass().getComponentType();
        int i = Array.getLength(arrayLeft);
        int j = Array.getLength(arrayRight);
        int k = i + j;
        Object result = Array.newInstance(componentType, k);
        System.arraycopy(arrayLeft, 0, result, 0, i);
        System.arraycopy(arrayRight, 0, result, i, j);
        return result;
    }
}

在application中加載patch包

public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        boolean sdCardExist = Environment.getExternalStorageState()
                .equals(android.os.Environment.MEDIA_MOUNTED);
        if (sdCardExist) {
            FileDexUtils.getInstance().loadFixedDex(this, Environment.getExternalStorageDirectory());
        }
    }
}

下面就可以寫demo按照上面步驟嘗試以下bug修復啦

上面只是簡單的patch包的加載,是基於類加載機制的熱修復的核心原理,但若真想運用到項目中還要考慮patch包的下發和下載,版本的區分,系統類加載器不同版本源碼的區分等流程和邏輯的控制,也是比較複雜的一套流程。

結束語:謝謝大家的聆聽!

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