1. 程式人生 > >Android 手動實現熱更新

Android 手動實現熱更新

前言

在上篇Android ClassLoader淺析中我們分析了安卓ClassLoader和熱更新的原理,這篇我們在上篇熱更新分析的基礎上寫個簡單的demo實踐一下。

概述

我們先回顧下熱更新的原理

PathClassLoader是安卓中預設的類載入器,載入類是通過findClass()方法,而這個方法最終是通過遍歷DexPathList中的Element[]陣列載入我們需要的類,那麼要想實現熱更新只需要在出問題的類還沒載入前,把補丁的Element插入到陣列前面,這樣載入的時候就會優先載入已經修復的類,從而實現了bug的修復。

原理知道了再來屢一下實現思路。

  1. 通過DexClassLoader載入補丁,然後通過反射拿到生成的Element[]陣列
  2. 拿到安卓中預設的類載入器PathClassLoader,然後通過反射拿到Element[]陣列
  3. 將補丁Element[]和系統的Element[]數組合並(補丁元素放在合併陣列前面),並重新賦值給PathClassLoader

Show Code

在showcode之前我們還有個重要的事情要做就是貼出類載入中相關的原始碼,因為等會反射會用到。DexClassLoaderPathClassLoader只是呼叫了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);
    }
}

主要就是通過反射獲取欄位然後數組合並在設定回去,我基本都貼上了註釋比較容易看懂就不過多說明了。

不過有兩點需要注意

  1. 我預設是載入名稱為patch的檔案
  2. 因為有檔案讀寫這裡別忘了加上讀寫許可權並且授予許可權,我之前在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目錄下的補丁,然後測試按鈕是呼叫Functiontest()方法預設會丟擲一個執行時異常。

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檔案

Functiontest()方法異常程式碼註釋了開啟Toast程式碼註釋,點選AS的Rebuild Project

然後在app的build/intermediates/classes/debug/rocketly/hotfixdemo/ 目錄下可以找到編譯好的Function.class檔案

生成Dex檔案

接下來將Function.class檔案連帶包目錄複製到一個自己指定的目錄,我這裡複製到桌面dex資料夾下

然後通過dx指令生成dex檔案

dx指令的使用跟java指令的使用條件一樣,有2種選擇:

  1. 配置環境變數(新增到classpath),然後命令列視窗(終端)可以在任意位置使用。
  2. 不配環境變數,直接在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代表補丁載入成功,這裡就大功告成了。