前言
在上篇Android ClassLoader淺析中我們分析了安卓ClassLoader
和熱更新的原理,這篇我們在上篇熱更新分析的基礎上寫個簡單的demo實踐一下。
概述
我們先回顧下熱更新的原理
PathClassLoader
是安卓中默認的類加載器,加載類是通過findClass()
方法,而這個方法最終是通過遍歷DexPathList
中的Element[]
數組加載我們需要的類,那麼要想實現熱更新只需要在出問題的類還沒加載前,把補丁的Element
插入到數組前面,這樣加載的時候就會優先加載已經修復的類,從而實現了bug的修復。
原理知道了再來屢一下實現思路。
- 通過
DexClassLoader
加載補丁,然後通過反射拿到生成的Element[]
數組。 - 拿到安卓中默認的類加載器
PathClassLoader
,然後通過反射拿到Element[]
數組。 - 將補丁
Element[]
和系統的Element[]
數組合並(補丁元素放在合併數組前面),並重新賦值給PathClassLoader
。
Show Code
在showcode之前我們還有個重要的事情要做就是貼出類加載中相關的源碼,因爲等會反射會用到。DexClassLoader
和PathClassLoader
只是調用了BaseDexClassLoader
構造方法這裏就不貼了。
public class BaseDexClassLoader extends ClassLoader {
private final DexPathList pathList;
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String librarySearchPath, ClassLoader parent, boolean isTrusted) {
super(parent);
this.pathList = new DexPathList(this, dexPath, librarySearchPath, null, isTrusted);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
Class c = pathList.findClass(name, suppressedExceptions);
return c;
}
}
final class DexPathList {
private Element[] dexElements;
DexPathList(ClassLoader definingContext, String dexPath,
String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
suppressedExceptions, definingContext, isTrusted);
}
public Class<?> findClass(String name, List<Throwable> suppressed) {
for (Element element : dexElements) {
Class<?> clazz = element.findClass(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
}
好了接下來就是熱更新的核心代碼了
public class HotFixUtil {
private final String TAG = "zhuliyuan";
private final String FIELD_DEX_ELEMENTS = "dexElements";
private final String FIELD_PATH_LIST = "pathList";
private final String CLASS_NAME = "dalvik.system.BaseDexClassLoader";
private final String DEX_SUFFIX = ".dex";
private final String JAR_SUFFIX = ".jar";
private final String APK_SUFFIX = ".apk";
private final String SOURCE_DIR = "patch";
private final String OPTIMIZE_DIR = "odex";
public void startFix() throws IllegalAccessException, NoSuchFieldException, ClassNotFoundException {
// 默認補丁目錄 /storage/emulated/0/Android/data/rocketly.hotfixdemo/files/patch
File sourceFile = MyApplication.getContext().getExternalFilesDir(SOURCE_DIR);
if (!sourceFile.exists()) {
Log.i(TAG, "補丁目錄不存在");
return;
}
// 默認 dex優化存放目錄 /data/data/rocketly.hotfixdemo/app_odex
File optFile = MyApplication.getContext().getDir(OPTIMIZE_DIR, Context.MODE_PRIVATE);
if (!optFile.exists()) {
optFile.mkdir();
}
StringBuilder sb = new StringBuilder();
File[] listFiles = sourceFile.listFiles();
for (int i = 0; i < listFiles.length; i++) {//遍歷查找文件中patch開頭, .dex .jar .apk結尾的文件
File file = listFiles[i];
if (file.getName().startsWith("patch") && file.getName().endsWith(DEX_SUFFIX)//這裏我默認的補丁文件名是patch
|| file.getName().endsWith(JAR_SUFFIX)
|| file.getName().endsWith(APK_SUFFIX)) {
if (i != 0) {
sb.append(File.pathSeparator);//多個dex路徑 添加默認分隔符 :
}
sb.append(file.getAbsolutePath());
}
}
String dexPath = sb.toString();
String optPath = optFile.getAbsolutePath();
ClassLoader pathClassLoader = MyApplication.getContext().getClassLoader();//拿到系統默認的PathClassLoader加載器
DexClassLoader dexClassLoader = new DexClassLoader(dexPath, optPath, null, MyApplication.getContext().getClassLoader());//加載我們自己的補丁dex
Object pathElements = getElements(pathClassLoader);//獲取PathClassLoader Element[]
Object dexElements = getElements(dexClassLoader);//獲取DexClassLoader Element[]
Object combineArray = combineArray(pathElements, dexElements);//合併數組
setDexElements(pathClassLoader, combineArray);//將合併後Element[]數組設置回PathClassLoader pathList變量
}
/**
* 獲取Element[]數組
*/
private Object getElements(ClassLoader classLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
Class<?> BaseDexClassLoaderClazz = Class.forName(CLASS_NAME);//拿到BaseDexClassLoader Class
Field pathListField = BaseDexClassLoaderClazz.getDeclaredField(FIELD_PATH_LIST);//拿到pathList字段
pathListField.setAccessible(true);
Object DexPathList = pathListField.get(classLoader);//拿到DexPathList對象
Field dexElementsField = DexPathList.getClass().getDeclaredField(FIELD_DEX_ELEMENTS);//拿到dexElements字段
dexElementsField.setAccessible(true);
return dexElementsField.get(DexPathList);//拿到Element[]數組
}
/**
* 合併Element[]數組 將補丁的放在前面
*/
private Object combineArray(Object pathElements, Object dexElements) {
Class<?> componentType = pathElements.getClass().getComponentType();
int i = Array.getLength(pathElements);
int j = Array.getLength(dexElements);
int k = i + j;
Object result = Array.newInstance(componentType, k);// 創建一個類型爲componentType,長度爲k的新數組
System.arraycopy(dexElements, 0, result, 0, j);
System.arraycopy(pathElements, 0, result, j, i);
return result;
}
/**
* 將Element[]數組 設置回PathClassLoader
*/
private void setDexElements(ClassLoader classLoader, Object value) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
Class<?> BaseDexClassLoaderClazz = Class.forName(CLASS_NAME);
Field pathListField = BaseDexClassLoaderClazz.getDeclaredField(FIELD_PATH_LIST);
pathListField.setAccessible(true);
Object dexPathList = pathListField.get(classLoader);
Field dexElementsField = dexPathList.getClass().getDeclaredField(FIELD_DEX_ELEMENTS);
dexElementsField.setAccessible(true);
dexElementsField.set(dexPathList, value);
}
}
主要就是通過反射獲取字段然後數組合併在設置回去,我基本都貼上了註釋比較容易看懂就不過多說明了。
不過有兩點需要注意
- 我默認是加載名稱爲patch的文件
- 因爲有文件讀寫這裏別忘了加上讀寫權限並且授予權限,我之前在target27上測試的,搞了好久才發現權限沒打開。建議target低於23測試,不然demo中沒做權限申請得手動授予。
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
這裏貼上demo地址HotFixDemo
測試
加載補丁
demo中是在MainActivity
中有兩個按鈕,點擊加載補丁按鈕默認加載/storage/emulated/0/Android/data/rocketly.hotfixdemo/files/patch
目錄下的補丁,然後測試按鈕是調用Function
的test()
方法默認會拋出一個運行時異常。
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
findViewById(R.id.loadPatch).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
new HotFixUtil().startFix();//加載補丁
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
});
findViewById(R.id.test).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
new Function().test();//測試
}
});
}
}
public class Function {
public void test() {
throw new RuntimeException();
// Toast.makeText(MyApplication.getContext(),"補丁加載成功",Toast.LENGTH_LONG).show();
}
}
那麼我們先將這個有bug的apk安裝到手機這個時候點擊測試是會崩潰的。
生成class文件
將Function
的test()
方法異常代碼註釋了打開Toast代碼註釋,點擊AS的Rebuild Project
然後在app的build/intermediates/classes/debug/rocketly/hotfixdemo/
目錄下可以找到編譯好的Function.class文件
生成Dex文件
接下來將Function.class文件連帶包目錄複製到一個自己指定的目錄,我這裏複製到桌面dex文件夾下
然後通過dx指令生成dex文件
dx指令的使用跟java指令的使用條件一樣,有2種選擇:
- 配置環境變量(添加到classpath),然後命令行窗口(終端)可以在任意位置使用。
- 不配環境變量,直接在build-tools/安卓版本 目錄下使用命令行窗口(終端)使用。
由於這個指令不常使用所以我直接切換到目錄下運行命令爲:
dx --dex --output=輸出的dex文件完整路徑 (空格) 要打包的完整class文件所在目錄
把Dex文件推到SD卡上
在通過adb命令adb push <local> <remote>
將dex文件推到手機指定目錄,我demo中是推到/storage/emulated/0/Android/data/rocketly.hotfixdemo/files/patch
目錄下。
重啓app,點擊測試可以發現還是崩潰,然後再次啓動app點擊加載補丁再點擊測試彈出補丁加載成功的toast代表補丁加載成功,這裏就大功告成了。