1. 程式人生 > >Android外掛化探索(二)資源載入

Android外掛化探索(二)資源載入

前情提要

PathClassLoader和DexClassLoader的區別

DexClassLoader的原始碼如下:

public class DexClassLoader extends BaseDexClassLoader {
    //支援從任何地方的apk/jar/dex中讀取
    public DexClassLoader(String dexPath, String optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(dexPath, new
File(optimizedDirectory), libraryPath, parent); } }

PathClassLoader的原始碼如下,沒有指定optimizedDirectory所以只能載入已安裝的APK,因為已安裝的APK會將dex解壓到了/data/dalvik-cache/目錄下,PathClassLoader會到這裡去找。

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

但是本人本著不作不死的性格,修改Plugin類如下:

    private void useDexClassLoader(String path){

        //建立類載入器,把dex載入到虛擬機器中
        PathClassLoader calssLoader = new
PathClassLoader(path, null, this.getClass().getClassLoader()); //利用反射呼叫外掛包內的類的方法 try { Class<?> clazz = calssLoader.loadClass("com.maplejaw.hotplugin.PluginClass"); Comm obj = (Comm)clazz.newInstance(); Integer ret = obj.function( 12,21); Log.d("JG", "返回的呼叫結果: " + ret); } catch (Exception e) { e.printStackTrace(); } }

在4.4和5.0上分別做了試驗從SD卡上載入dex,發現提示略有差別。
Android 4.4:直接提示找不到該類

java.lang.ClassNotFoundException: Didn't find class "com.maplejaw.hotplugin.PluginClass" on path: DexPathList[[zip file "/storage/sdcard/2.apk"],nativeLibraryDirectories=[/vendor/lib, /system/lib]]

Android 5.0:可以發現在載入該類之前,系統嘗試將dex寫到/data/dalvik-cache/下,由於許可權問題而失敗。

05-25 04:07:02.291 31699-31699/com.maplejaw.hotfix E/dalvikvm: Dex cache directory isn't writable: /data/dalvik-cache
05-25 04:07:02.291 31699-31699/com.maplejaw.hotfix I/dalvikvm: Unable to open or create cache for /storage/sdcard/2.apk (/data/dalvik-cache/[email protected]@[email protected])
05-25 04:07:02.291 31699-31699/com.maplejaw.hotfix W/System.err: java.lang.ClassNotFoundException: Didn't find class "com.maplejaw.hotplugin.PluginClass" on path: DexPathList[[zip file "/storage/sdcard/2.apk"],nativeLibraryDirectories=[/vendor/lib, /system/lib]]

但是!!!筆者此時默默的掏出了大紅米(Android 5.0)測試了一番。居然發現可以呼叫正常,也就是說,MIUI成功將dex寫到了/data/dalvik-cache/下,所以如果你手持MIUI發現PathClassLoader可以載入外部dex時,務必冷靜,用模擬器試試。

雙親委託

什麼叫雙親委託?
為了更好的保證 JAVA 平臺的安全。在此模型下,當一個裝載器被請求載入某個類時,先委託自己的 parent 去裝載,如果 parent 能裝載,則返回這個類對應的 Class 物件,否則,遞迴委託給父類的父類裝載。當所有父類裝載器都裝載失敗時,才由當前裝載器裝載。在此模型下,使用者自定義的類裝載器,不可能裝載應該由父親裝載的可靠類,從而防止不可靠甚至惡意的程式碼代替本應該由父親裝載器裝載的可靠程式碼。

在JVM中預定義了的三種類型類載入器:

  • 啟動(Bootstrap)類載入器:是用原生代碼實現的類裝入器,它負責將 /lib下面的類庫載入到記憶體中(比如rt.jar)。由於引導類載入器涉及到虛擬機器本地實現細節,開發者無法直接獲取到啟動類載入器的引用,所以不允許直接通過引用進行操作。
  • 標準擴充套件(Extension)類載入器:是由 Sun 的 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)實現的。它負責將< Java_Runtime_Home >/lib/ext或者由系統變數 java.ext.dir指定位置中的類庫載入到記憶體中。開發者可以直接使用標準擴充套件類載入器。
  • 系統(System)類載入器:是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)實現的。它負責將系統類路徑(CLASSPATH)中指定的類庫載入到記憶體中。開發者可以直接使用系統類載入器。

委派機制:自定義的ClassLoader->AppClassLoader->ExtClassLoader->BootstrapClassLoader

那麼Android中的委派機制是怎麼樣的呢?

  • BootClassLoader: 載入系統類庫
  • PathClassLoader: 載入已安裝apk中的dex中的類
  • DexClassLoader: 載入外部和內部apk中的類

委派機制:DexClassLoader->PathClassLoader->BootClassLoader。

我們可以打印出其委派機制:

 ClassLoader classLoader=new DexClassLoader(apkPath,getApplicationInfo().dataDir,libPath,getClassLoader());
  try {
            Class<?> clazz = calssLoader.loadClass("com.maplejaw.hotplugin.PluginClass");
            Comm obj = (Comm)clazz.newInstance();
            Integer ret = obj.function( 12,21);

            while(classLoader != null){
                Log.d("JG", "類載入器:"+classLoader);
                classLoader = classLoader.getParent();
            }
              Log.d("JG", "返回的呼叫結果: " + ret);

結果如下:

05-25 09:09:11.330 9100-9100/com.maplejaw.hotfix D/JG: 初始化PluginClass

05-25 09:09:11.330 9100-9100/com.maplejaw.hotfix D/JG: 類載入器:dalvik.system.DexClassLoader[DexPathList[[zip file "/data/app/com.maplejaw.hotplugin-2/base.apk"],nativeLibraryDirectories=[/data/app/com.maplejaw.hotplugin-2/lib/x86_64, /vendor/lib64, /system/lib64]]]

05-25 09:09:11.330 9100-9100/com.maplejaw.hotfix D/JG: 類載入器:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.maplejaw.hotfix-2/base.apk"],nativeLibraryDirectories=[/data/app/com.maplejaw.hotfix-2/lib/x86_64, /vendor/lib64, /system/lib64]]]

05-25 09:09:11.330 9100-9100/com.maplejaw.hotfix D/JG: 類載入器:[email protected]7cd98df

05-25 09:09:11.330 9100-9100/com.maplejaw.hotfix D/JG: 返回的呼叫結果: 33

看到這裡,應該可以明白上一篇中提到的java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation中因為同一載入器載入不同dex中相同的類引發的錯誤了吧。

資源載入

在上一篇中,提到了通過資源名字,然後獲取id,最後獲取資源的思路。先來回顧一下。

getResourcesForApplication

//首先,通過包名獲取該包名的Resources物件
Resources res= pm.getResourcesForApplication(packageName);
//根據約定好的名字,去取資源id;
int id=res.getIdentifier("a","drawable",packageName);//根據名字取id
//根據資源id,取出資源
Drawable drawable=res.getDrawable(id)

這種方式有個特點,就是得清楚每一個資源的名字。但也從側面提現了,這種方式不夠靈活。那麼,有沒有一種方法,可以簡化這一過程呢?

根據這種方式的特點,嘗試著寫了幾個測試例子。

不就是要資源嗎,直接在外掛中把資源提供給宿主不就行了。修改PluginClass如下:

 public Drawable getImage(Context context){
       return context.getResources().getDrawable(R.drawable.a);
    }

宿主核心程式碼修改如下。

 Class<?> clazz = classLoader.loadClass(packageName+".PluginClass");
 Comm obj = (Comm) clazz.newInstance();
 Drawable drawable=obj.getImage(this)
 mImageView.setImageDrawable(drawable);

執行測試,沒有報錯,反而讀到了宿主APK下的一個圖片,應該是資源id和宿主中的id重複了。於是多拷了幾個圖片到外掛drawable目錄,仍然什麼都讀不到。於是仔細回頭看了看程式碼。發現了這一句context.getResources(),可以看出拿到的是宿主APK的Resources物件,懷著好奇心,順藤摸瓜找到了原始碼。在ContextImpl的原始碼中,發現如下:
可以從字面意思看出一個包一個Resources。


    private ContextImpl(ContextImpl container, ActivityThread mainThread,
            LoadedApk packageInfo, ...) {
            //...
            //省略了部分原始碼
        Resources resources = packageInfo.getResources(mainThread);
        mResources = resources;
          //...
            //省略了部分原始碼
        }


    public Resources getResources() {
        return mResources;
    }

既然一個包一個Resources,那我們換種思路,就用外掛包的Resources去載入資源。修改PluginClass如下:

     public Drawable getImage(Resources res){
       return res.getDrawable(R.drawable.a);
    }

然後修改宿主的核心程式碼,測試通過。

  Class<?> clazz = classLoader.loadClass(packageName+".PluginClass");
  Comm obj = (Comm) clazz.newInstance();
  Resources res=pm.getResourcesForApplication(packageName);
  Drawable drawable=obj.getDrawable(res)

可以看出,這種方法比第一種方法要靈活不少,不需要知道每張圖片的名字,只需外掛實現相關介面即可。

AssetManager

但是,有沒有別的方式了?答案當然是肯定的,Android裡有一個類叫AssetManager!在介紹AssetManager之前我們來看看getResource的原始碼。我們知道,呼叫getResource,會呼叫LoadedApk的getResources。

    private ContextImpl(ContextImpl container, ActivityThread mainThread,
            LoadedApk packageInfo, ...) {
            //...
            //省略了部分原始碼
        Resources resources = packageInfo.getResources(mainThread);
        mResources = resources;
          //...
         //省略了部分原始碼
        }

那麼就來看看LoadedApk的getResources原始碼。

    public Resources getResources(ActivityThread mainThread) {
        if (mResources == null) {
            mResources = mainThread.getTopLevelResources(mResDir, mSplitResDirs, mOverlayDirs,
                    mApplicationInfo.sharedLibraryFiles, Display.DEFAULT_DISPLAY, null, this);
        }
        return mResources;
    }

可以看出,LoadedApk會呼叫ActivityThread去載入。最終在ResourcesManager中找到了真身。

 Resources getTopLevelResources(String resDir, String[] splitResDirs,
            String[] overlayDirs, String[] libDirs, int displayId,
            Configuration overrideConfiguration, CompatibilityInfo compatInfo) {

        Resources r;

           //...
            //省略了部分原始碼
        AssetManager assets = new AssetManager();

        if (resDir != null) {
            if (assets.addAssetPath(resDir) == 0) {
                return null;
            }
        }

        if (splitResDirs != null) {
            for (String splitResDir : splitResDirs) {
                if (assets.addAssetPath(splitResDir) == 0) {
                    return null;
                }
            }
        }

        if (overlayDirs != null) {
            for (String idmapPath : overlayDirs) {
                assets.addOverlayPath(idmapPath);
            }
        }

        if (libDirs != null) {
            for (String libDir : libDirs) {
                if (libDir.endsWith(".apk")) {

                    if (assets.addAssetPath(libDir) == 0) {
                        Log.w(TAG, "Asset path '" + libDir +
                                "' does not exist or contains no resources.");
                    }
                }
            }
        }

           //...
            //省略了部分原始碼
          r = new Resources(assets, dm, config, compatInfo);

            return r;
        }
    }

程式碼有點長,但是很顯然,最終的資源載入交給了AssetManager,assets.addAssetPath(libDir)新增資源目錄,然後new了一個Resources物件返回。

那我們現在通過反射來模仿系統的寫法。

    protected void loadResources(String dexPath) {
        try {
            AssetManager assetManager = AssetManager.class.newInstance();
            Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
            addAssetPath.invoke(assetManager, dexPath);
            Resources superRes = super.getResources();
            mResources = new Resources(assetManager, superRes.getDisplayMetrics(),superRes.getConfiguration());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

然後重寫getResources方法:


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

核心程式碼修改如下,

 //先載入外掛資源
   loadResources(apkPath);

 //核心程式碼呼叫
   Class<?> clazz = classLoader.loadClass(packageName+".PluginClass");
   Comm obj = (Comm) clazz.newInstance();
   mImageView.setImageDrawable(obj.getDrawable(getgetResources));

成功將資源載入到宿主APK,測試通過。
當然,為了使通用性更強,不因主題的差異而導致效果不一樣,上面的程式碼一般會這麼寫

    //反射載入資源
    private AssetManager mAssetManager;
    private Resources mResources;
    private Resources.Theme mTheme;
    protected void loadResources(String dexPath) {
        try {
            AssetManager assetManager = AssetManager.class.newInstance();
            Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
            addAssetPath.invoke(assetManager, dexPath);
            mAssetManager = assetManager;
        } catch (Exception e) {
            e.printStackTrace();
        }
        Resources superRes = super.getResources();
        mResources = new Resources(mAssetManager, superRes.getDisplayMetrics(),superRes.getConfiguration());
        mTheme = mResources.newTheme();
        mTheme.setTo(super.getTheme());
    }


  //重寫方法
    @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;
    }

換面板原理

換面板一般有兩種方式。

約定好資源名字

這種方式非常簡單,基本不需要修改什麼程式碼。只需兩部就來完成。
假如我們有一個圖片選單,在宿主和外掛中都叫a.png。

設定選單圖片的程式碼如下。

mImageMenu.setImageDrawable(getResources().getDrawable(R.drawable.a));
  • 重寫getResources
   @Override
    public Resources getResources() {
        return mResources == null ? super.getResources() : mResources;
    }
  • 獲取Resources物件
loadResources或getResourcesForApplication

這樣在需要載入面板的地方loadResources,然後重寫載入就能實現換膚功能。

不約定資源名字

這種方式主要通過介面的方式進行呼叫。讓不同的面板外掛進行呼叫。

  • 實現外掛介面
public Drawable getImageMenu(Resources res){
       return res.getDrawable(R.drawable.a);
    }
  • 重寫getResources
   @Override
    public Resources getResources() {
        return mResources == null ? super.getResources() : mResources;
    }
  • 獲取Resources物件
loadResources或getResourcesForApplication
  • 設定選單圖片
  Class<?> clazz = classLoader.loadClass(packageName+".PluginClass");
  Comm obj = (Comm) clazz.newInstance();

  mImageMenu.setImageDrawable(obj.getImageMenu(getResources()));

從上面可以看出,約定資源名字這種方法,可以少寫好多介面。

最後

由於本人水平有限,如有錯誤敬請指出,萬分感謝。