寫在前面:
參考文章 熱修復——深入淺出原理與實現
一、簡述和意義
在熱修復之前,一個上線的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包的下發和下載,版本的區分,系統類加載器不同版本源碼的區分等流程和邏輯的控制,也是比較複雜的一套流程。