Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> 關於Android編程 >> Android熱修復原理普及

Android熱修復原理普及

編輯:關於Android編程

這段時間比較難閒,就抽空研究一下Android熱修復的原理。自從Android熱修復這項技術出現之後,隨之而現的是多種熱修復方案的出現。前兩天又看到一篇文章分析了幾種熱修復方案的比較。

看完這篇文章,有點汗顏。有這麼多的熱修復方案,並且他們之間的實現原理也不一樣,各有優缺點。

本文對於這些實踐做出一點總結。本文有些代碼片段、圖片來自上述文章。

首先列出熱修復需要解決的幾個問題:

資源替換 類替換(四大組件、類) SO補丁

基於上面3個問題,我做了幾個測試,分別是動態加載資源、和動態運行APK中的Activity。至於SO補丁方面的,由於本人技術有限,沒有研究。

技術普及

在Android中有兩個類加載器,分別為PathClassLoader和DexClassLoader。其中我們正常開發的APP使用的類加載器就是PathClassLoader。

關於這兩個類在代碼中的實際使用:

PathClassLoader:通過Context getClassLoader() 獲取。 DexClassLoader:通過構造函數 new DexClassLoader()獲取。

DexClassLoader的構造函數原型是:

  public DexClassLoader(String dexPath, String dexOutputDir, String libPath,  
        ClassLoader parent)
dexPath: 表示加載的APK/dex/jar路徑 dexOutOutDir: 解壓文件的路徑,因為APK和JAR最終都要解壓出dex文件,這個路徑是用來存放dex文件的。 libPath:加載的時候用到的lib庫,一般為null parent:DexClassLoader的父加載器

資源的加載

目標:加載另一個APK中的資源文件

思路:Andorid APP默認的類加載是PathClassLoader,這個只能加載自己APK的dex文件,所以我們需要使用DexClassLoader。我們用DexClassLoader加載外部的APK之後,通過反射獲取對應的資源。

項目分為2個工程,一個宿主工程,一個插件工程。

首先我們看插件工程:

public class UIUtil {
    public static String getTextString(Context ctx){
        return ctx.getResources().getString(R.string.text);
    }

    public static Drawable getImageDrawable(Context ctx){
        return ctx.getResources().getDrawable(R.mipmap.ic_launcher);
    }

    public static int getTextBackgroundId(Context ctx){
        return ctx.getResources().getColor(R.color.color_green);
    }
}

插件工程中有一個UIUtil類,提供了幾個靜態的方法分別用來獲取對應的資源(文字,圖標,顏色)

接下來我們看看宿主工程中如何加載這裡的資源。

首先我們需要創建一個DexClassLoader
 DexClassLoader classLoader = new DexClassLoader(filePath, fileRelease, null, getClassLoader());

filePath指的是插件APK的文件路徑,注意:這裡需要放在/data/data/packagename/中才能生效。因為Android系統的限制,自己加載的dex只能在程序獨有的文件中存在。

這裡代碼最後一個參數傳進來的是getClassLoader(),實際上就是PathClassLoader,那麼為什麼需要把這個PathClassLoader作為DexClassLoader的父加載器呢。這裡的ClassLoader符合Java類加載器的雙親委派機制

通過反射調用APK中的方法。

        Class clazz = null;
        try {
            clazz = classLoader.loadClass("com.example.resourceloaderapk.UIUtil");

            //設置文字
            Method method = clazz.getMethod("getTextString", Context.class);
            String str = (String) method.invoke(null, this);
            textV.setText(str);
            //設置背景
            method = clazz.getMethod("getTextBackgroundId", Context.class);
            int color = (int) method.invoke(null, this);
            Log.i("Loader", "color = " + color);
            textV.setBackgroundColor(color);
            //設置圖片
            method = clazz.getMethod("getImageDrawable", Context.class);
            Drawable drawable = (Drawable) method.invoke(null, this);
            Log.i("Loader", "drawable =" + drawable);
            imgV.setImageDrawable(drawable);

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

運行流程是這樣的,首先我們把插件APK打包之後,拷貝到/data/data/宿主apk packagename/ 中的任意目錄。然後上面初始化DexClassLoader的時候把這個路徑傳過去。從代碼可以看到,首先加載UIUtil類,然後調用它的幾個方法來獲取對應的資源。

這樣運行之後發現文字是可以獲取的,但是image和color是無法獲取到,會拋出Resources$NotFoundException,資源找不到異常。我們知道,APK中所有的資源都是通過Resources來獲取的。看回上面的代碼,是通過傳遞一個this關鍵字把宿主的Context對象傳遞過去的。這個Context對象是宿主工程的Context,它並不能訪問插件APK的資源,那麼我們需要做的就是把插件APK的資源加載到宿主Context中對應的Resources對象中。

這裡使用的方法是調用AssetManager的addAssetPath()方法,將一個APK中的資源加載到Resources中,這個方法是隱藏的,我們通過反射獲取,如下:

  /**
     * 此方法的作用是把resource.apk中的資源加載到AssetManager中,
     * 然後在重組一個Resources對象,這個Resources對象包括了resource.apk中的資源。
     * 

* resource.apk 中是使用Context.getResources()獲得Resource對象的, * 所以還要重寫一些getResources()方法,返回該Resources對象 * * @param dexPath */ protected void loadResource(String dexPath) { try { AssetManager assetManager = AssetManager.class.newInstance(); Method method = assetManager.getClass().getMethod("addAssetPath", String.class); method.invoke(assetManager, dexPath); mAssetManager = assetManager; } catch (Exception e) { e.printStackTrace(); } Resources resource = getResources(); mResources = new Resources(mAssetManager, resource.getDisplayMetrics(), resource.getConfiguration()); mTheme = mResources.newTheme(); mTheme.setTo(getTheme()); }

這樣處理之後我們就重新生成了一個Resource對象,AssetManger對象和Theme對象。我們需要重寫下面3個方法達到替換的目的。

  @Override
    public AssetManager getAssets() {
        return mAssetManager == null ? super.getAssets() : mAssetManager;
    }

    @Override
    public Resources getResources() {
        return mResources == null ? super.getResources() : mResources;
    }

    @Override
    public Resources.Theme getTheme() {
        return mTheme == null ? super.getTheme() : mTheme;
    }

這樣getResources獲取到的就是我們新生成的mResources對象了,這個對象包括了原有的資源和插件APK的資源。

至此,加載插件APK中的資源就實現了功能。

總結

這裡僅僅是對動態加載一些R文件的引用成功了,但是還有很多問題沒有深入的去解決,比如相同包名的情況下該如何處理?接口如何統一?資源和原有的資源同名怎麼處理?這麼多問題的存在都需要大量的時間來解決,這裡只是給出一個思路,猶如管中窺豹,由此對於資源的加載有一個感性的基礎認識。

動態加載Activity

由上面的描述我們知道,一個應用的默認類加載器是PathClassLoader,我們加載插件的時候使用的是DexClassLoader。雖然我們可以用DexClassLoader來獲取到Activity的實例,但是我們不能僅僅new一個Intent對象然後啟動Activity,因為我們從DexClassLoader中加載的Activity類僅僅是一個普通的JAVA類,Android四大組件都有自己的啟動流程和生命周期,使用DexClassLoader不會涉及到任何生命流程的東西。

既然這樣,那麼就要從Activity的啟動流程入手了。我們需要做的不是詳細了解Activity的啟動流程,思路是將加載了dex的DexClassLoader綁定到系統啟動Activity的類加載器上就行了。

第一種方案

了解過一點Andorid源碼的小伙伴應該都知道,我們Activity的啟動流程涉及到ActivityThread類,我們來看看它的源碼:

在裡面有一個靜態的sCurrentActivityThread對象,我們暫且不管他是如何創建實例的,因為應用啟動的時候就會啟動一個Activity,這時候sCurrentActivityThread對象肯定不為空。我們獲取到ActivityThread對象之後,我們再看看代碼,裡面有一個mPackages保存的是以packageName為key,LoadedApk為value的map。

再點開LoadedApk來觀察:

裡面有個mClassLoader對象。好了,好嗨森,我們只需要把自己的DexClassLoader設置到這個mClassLoader對象就能正常啟動Activity了

代碼如下:

public void replaceLoadedApk(View v) {
        try {
            //通過替換LoadedApk中的mClassLoader來達到加載apk中的Activity
            String fileDir = getCacheDir().getAbsolutePath();
            String filePath = fileDir + File.separator + Constants.RESOURCE_APK_NAME;  //源dex/jar/apk 目錄
            DexClassLoader loader = new DexClassLoader(filePath, getDir("dex", MODE_PRIVATE).getAbsolutePath(), null, getClassLoader());

            Object currentActivityThread = RefInvoke.invokeStaticMethod("android.app.ActivityThread", "currentActivityThread", new Class[]{}, new Object[]{});
            String packageName = getPackageName();

            //通過反射獲取ActivityThread的 mPackages 對象
            ArrayMap mPackages = (ArrayMap) RefInvoke.getFieldOjbect("android.app.ActivityThread", currentActivityThread, "mPackages");
            //通過反射獲mPackages獲得當前的LoadedApk對象
            WeakReference wr = (WeakReference) mPackages.get(packageName);
            Log.i(TAG, "wr = " + wr.get());
            //替換LoadedApk中的mClassLoader 為我們自己的DexClassLoader
            RefInvoke.setFieldOjbect("android.app.LoadedApk", "mClassLoader", wr.get(), loader);
            Log.i(TAG, "classloader = " + loader);


            startResourceActivity(filePath, loader);


        } catch (Exception e) {
            Log.i(TAG, "load apk error :" + Log.getStackTraceString(e));
        }
    }

    /**
     * 啟動插件Activity
     * @param filePath
     * @param loader
     * @throws ClassNotFoundException
     * @throws NoSuchFieldException
     * @throws IllegalAccessException
     * @throws NoSuchMethodException
     * @throws InvocationTargetException
     */
    private void startResourceActivity(String filePath, ClassLoader loader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException, NoSuchMethodException, InvocationTargetException {
        //加載資源
        loadResources(filePath);


        //加載Activity ,確保這裡的類名和Constants.RESOURCE_APK_NAME 中的 類名相同
        Class clazz = loader.loadClass("com.example.resourceloaderapk.MainActivity");
        //找到R.layout.activity_main
        Class rClazz = loader.loadClass("com.example.resourceloaderapk.R$layout");
        Field field = rClazz.getField("activity_main");
        Integer ojb = (Integer)field.get(null);

        View view = LayoutInflater.from(this).inflate(ojb, null);
        //設置靜態變量。這裡為什麼要設置靜態變量呢。
        // 因為測試發現setContentView() 沒有起作用。
        // 所以在啟動Activity之前保存一個靜態的View,設置到Activity中

        Method method = clazz.getMethod("setLayoutView", View.class);
        method.invoke(null, view);

        //找到MainActivity,然後啟動
        startActivity(new Intent(this, clazz));
    }

噢,忘了說,在插件工程中創建一個MainActivity,包名為com.example.resourceloaderapk,裡面給各個聲明周期打一下log

public class MainActivity extends AppCompatActivity {
    public static final String TAG = "Resource_MainActivity";
    private static View parentView;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if(parentView == null){
            setContentView(R.layout.activity_main);
        }else{
            setContentView(parentView);
        }
    }

    public static void setLayoutView(View view){
        parentView = view;
    }

    @Override
    protected void onResume() {
        super.onResume();
        Log.i(TAG, "resource activity onResume");
    }

    @Override
    protected void onStart() {
        super.onStart();
        Log.i(TAG, "resource activity onStart");
    }

    @Override
    protected void onStop() {
        super.onStop();
        Log.i(TAG, "resource activity onStop");

    }

    @Override
    protected void onPause() {
        super.onPause();
        Log.i(TAG, "resource activity onPause");
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        Log.i(TAG, "resource activity onDestroy");
    }

這裡增加了一個靜態方法設置一個View,然後在onCreate中優先加載這個View。具體原因是因為在實踐過程中,發現setContentView(layoutId)並不生效,所以先生成一個View在加載頁面了。

在這裡的過程中,在運行的時候發現會拋出一個熟悉的錯誤:Unable to find explicit activity class. have you decleared this activity in the AndroidManifest.xml?

沒理由呀,在插件工程中已經聲明了的,但是想想還是能理解,我們在宿主Activity中`startActivity,所以需要在宿主工程中聲明這個組件。

第二種方案

這裡的方法比較貼近QQ空間提出的替換dex的方案。PathClassLoader和DexClassLoader都是屬於BaseDexClassLoader的子類。
然後BaseDexClassLoader中一個成員為DexPath pathList:

再看看DexPathList:

裡面有個Element數組,這個數組是用來存放dex文件的路徑的,系統默認的類加載器是PathClassLoader,程序加載之後會釋放出一個dex文件,那麼我們的做法就是,把DexClassLoader的dexElements和PathClassLoader的dexElements文件合並之後再放到PathClassLoader的pathList中。這樣Activity的啟動流程也是正確的。

如下:

public void injectDexElements(View v){
        Log.i(TAG,"this classloader = " + getClassLoader());
        PathClassLoader pathClassLoader = (PathClassLoader) getClassLoader();
        String fileDir = getCacheDir().getAbsolutePath();
        String filePath = fileDir + File.separator + Constants.RESOURCE_APK_NAME;  //源dex/jar/apk 目錄
        DexClassLoader loader = new DexClassLoader(filePath, getDir("dex", MODE_PRIVATE).getAbsolutePath(), null, getClassLoader());
        try {
            //把PathClassLoader和DexClassLoader的pathList對象中的 dexElements 合並
            Object dexElements = combineArray(
                    getDexElements(getPathList(pathClassLoader)),
                    getDexElements(getPathList(loader)));
            //把合並後的dexElements設置到PathClassLoader的 pathList對象中的 dexElements
            Object pathList = getPathList(pathClassLoader);
            setField(pathList, pathList.getClass(), "dexElements", dexElements);

            startResourceActivity(filePath,pathClassLoader);

        } catch (IllegalArgumentException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
    }
    private static Object getPathList(Object baseDexClassLoader)
            throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
        ClassLoader bc = (ClassLoader)baseDexClassLoader;
        return getField(baseDexClassLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
    }

    private static Object getField(Object obj, Class cl, String field)
            throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
        Field localField = cl.getDeclaredField(field);
        localField.setAccessible(true);
        return localField.get(obj);
    }

    private static Object getDexElements(Object paramObject)
            throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException {
        return getField(paramObject, paramObject.getClass(), "dexElements");
    }
    private static void setField(Object obj, Class cl, String field,
                                 Object value) throws NoSuchFieldException,
            IllegalArgumentException, IllegalAccessException {

        Field localField = cl.getDeclaredField(field);
        localField.setAccessible(true);
        localField.set(obj, value);
    }

    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;
    }

運行的結果是一樣的。

第三種方案

還有一種方式就和前兩種方式的思路截然不同了。這裡的思路是在宿主工程中創建一個代理Activity,然後插件Apk中的Activity就僅僅是一個普通的java類,對應著幾個聲明周期方法,然後通過反射在代理Activity的生命周期方法中調用對應的插件Activity的方法。我這裡沒有實踐過,但是理論上是一種不錯的方案。

總結

還是那句話,熱修復的坑很多,這裡的知識僅僅是冰山一角,還有很多問題需要解決,但是這樣折騰一下,起碼不會對熱修復這東西兩眼懵逼了。

源碼地址

  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved