Android插件化探索(一)類加載器DexClassLoader

本文部分內容參考自《Android內核剖析》

基本概念

在Java環境中,有個概念叫做“類加載器”(ClassLoader),其作用是動態裝載Class文件。標準的Java SDK中有一個ClassLoader類,藉助它可以裝載想要的Class文件,每個ClassLoader對象在初始化時必須指定Class文件的路徑

沒有使用過ClassLoader的讀者可能會問:“在過去的程序開發中,當我們需要某個類時,只需使用import關鍵字包含該類就可以了,爲什麼還要類加載器呢?”簡單的講,import中所引用的類文件有兩個特點:

  • 必須存在於本地,當程序運行時需要該類時,內部類裝載器會自動裝載該類,這對程序員來講是透明的,即程序員感知不到這一過程。
  • 編譯時必須在現場,否則編譯不過會因爲找不到引用文件而正常編譯。

但在有些情況下,所需的類卻不能滿足以上兩個條件。比如當該類時從遠程下載並在本地執行時,典型的例子就是通過瀏覽器中的AppleLet執行的Java程序,這些要執行的程序是在服務器端。另一種情況是,要引用的Class文件不方便在編譯時直接參與,而只能運行時動態調用。舉例來講,在Android Framework中,所包含的Class文件是一些通用的類文件,但對於一些設備商而言,他們需要擴充Framework,擴充的具體工作包括兩點:

  • 需要增加一些額外的類文件,這些類文件提供廠商自定義的功能,這些文件一般以獨立的Jar包存在。
  • 需要修改Framework中的已有類文件,比如WindowManagerServcie類,在該類中添加使用自定義Jar包中的代碼。使用自定義Jar包的常用方法是使用import關鍵字包含的自定義的類,但爲了保持和原生Framework的兼容性、對於原生Framework最少化修改,可以使類裝載器動態裝載自定義Jar包。

這就是使用ClassLoader的原因。

在一般情況下,應用程序不需要創建一個全新的ClassLoader對象,而是使用當前環境已經存在的ClassLoader。因爲Javad的Runtime環境在初始化時,其內部會創建一個ClassLoader對象用於加載Runtime所需的各種Java類。

每個ClassLoader必須有一個父ClassLoader,在裝載Class文件時,子ClassLoader會先請求父ClassLoader加載該Class文件,只有當其父ClassLoader找不到該Class文件時,子ClassLoader纔會繼續裝載該類,這是一種安全機制。關係ClassLoader的內部過程,大家可以參考《Inside the Java Virtual Machine》一書,作者爲Bill Venners,相關鏈接如下:

http://www.artima.com/insidejvm/ed2/index.html

對於Android的應用程序,本質上雖然也是用Java開發,並且使用標準的Java編譯器編譯出Class文件,但最終的APK文件中包含的卻是dex類型的文件。dex文件是將所需的所有Class文件重新打包,打包的規則不是簡單的壓縮,而是完全對Class文件內部的各種函數表、變量表等進行優化,併產生一個新的文件,這就是dex文件。由於dex文件是一種經過優化的Class文件,因此要加載這樣特殊的Class文件就需要特殊的類裝載器,這就是DexClassLoader,Android SDK中提供的DexClassLoader類就是出於這個目的。

初始API

//DexClassLoader的構造方法
DexClassLoader (String dexPath, 
                String optimizedDirectory, 
                String libraryPath, 
                ClassLoader parent)
  • dexPath: 指目標類所在的jar/apk文件路徑, 多個路徑使用 File.pathSeparator分隔, Android裏面默認爲 “:”
  • optimizedDirectory: 解壓出的dex文件的存放路徑,以免被注入攻擊,不可存放在外置存儲。
    下面來看DexClassLoader的使用方法。
  • libraryPath :目標類中的C/C++庫存放路徑。
  • parent: 父類裝載器

使用方法

DexClassLoader的使用方法一般有兩種:
1. 從已安裝的apk中讀取dex
2. 從apk文件中讀取dex

假如有兩個APK,一個是宿主APK,叫作HOST,一個是插件APK,叫作Plugin。Plugin中有一個類叫PluginClass,代碼如下:

public class PluginClass {
    public PluginClass() {
        Log.d("JG","初始化PluginClass");
    }

    public int function(int a, int b){
        return a+b;  
    }  
}  

現在如果想調用插件APK中PluginClass內的方法,應該怎麼辦?

從已安裝的apk中讀取dex

先來看第一種方法,這種方法必須建一個Activity,在清單文件中配置Action.

    <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="com.maplejaw.plugin"/>
            </intent-filter>
   </activity>

然後在宿主APK中如下使用

  /**
     * 這種方式用於從已安裝的apk中讀取,必須要有一個activity,且需要配置ACTION
     */
  private void useDexClassLoader(){
        //創建一個意圖,用來找到指定的apk
        Intent intent = new Intent("com.maplejaw.plugin");
        //獲得包管理器
        PackageManager pm = getPackageManager();
        List<ResolveInfo> resolveinfoes =  pm.queryIntentActivities(intent, 0);
        if(resolveinfoes.size()==0){
            return;
        }
        //獲得指定的activity的信息
        ActivityInfo actInfo = resolveinfoes.get(0).activityInfo;

        //獲得包名
        String packageName = actInfo.packageName;
        //獲得apk的目錄或者jar的目錄
        String apkPath = actInfo.applicationInfo.sourceDir;
        //dex解壓後的目錄,注意,這個用宿主程序的目錄,android中只允許程序讀取寫自己
        //目錄下的文件
        String dexOutputDir = getApplicationInfo().dataDir;

        //native代碼的目錄
        String libPath = actInfo.applicationInfo.nativeLibraryDir;

        //創建類加載器,把dex加載到虛擬機中
        DexClassLoader calssLoader = new DexClassLoader(apkPath, dexOutputDir, libPath,
                this.getClass().getClassLoader());

        //利用反射調用插件包內的類的方法

        try {
            Class<?> clazz = calssLoader.loadClass(packageName+".PluginClass");

            Object obj = clazz.newInstance();
            Class[] param = new Class[2];
            param[0] = Integer.TYPE;
            param[1] = Integer.TYPE;

            Method method = clazz.getMethod("function", param);

            Integer ret = (Integer)method.invoke(obj, 12,34);

            Log.d("JG", "返回的調用結果爲:" + ret);

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

我們安裝完兩個APK後,在宿主中就可以直接調用,調用示例如下。

    public void btnClick(View view){
        useDexClassLoader();
    }

可以看出控制檯打印結果如下。

05-24 14:43:12.239 4068-4068/com.maplejaw.host D/JG: 初始化PluginClass
05-24 14:43:12.240 4068-4068/com.maplejaw.host D/JG: 返回的調用結果爲: 46

從apk文件中讀取dex

這種方法由於並不需要安裝,所以不需要通過Intent從activity中解析信息。換言之,這種方法不需要創建Activity。無需配置清單文件。我們只需要打包一個apk,然後放到SD卡中即可。
核心代碼如下:

    //apk路徑
    String path=Environment.getExternalStorageDirectory().getAbsolutePath()+"/1.apk";

    private void useDexClassLoader(String path){

        File codeDir=getDir("dex", Context.MODE_PRIVATE);

        //創建類加載器,把dex加載到虛擬機中
        DexClassLoader calssLoader = new DexClassLoader(path, codeDir.getAbsolutePath(), null,
                this.getClass().getClassLoader());

        //利用反射調用插件包內的類的方法

        try {
            Class<?> clazz = calssLoader.loadClass("com.maplejaw.plugin.PluginClass");

            Object obj = clazz.newInstance();
            Class[] param = new Class[2];
            param[0] = Integer.TYPE;
            param[1] = Integer.TYPE;

            Method method = clazz.getMethod("function", param);

            Integer ret = (Integer)method.invoke(obj, 12,21);

            Log.d("JG", "返回的調用結果爲: " + ret);

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

運行結果如下:

05-24 14:45:12.239 4068-4068/com.maplejaw.host D/JG: 初始化PluginClass
05-24 14:45:12.240 4068-4068/com.maplejaw.host D/JG: 返回的調用結果爲: 33

插件概念

插件是一個邏輯概念,而不是什麼技術標準。總的來講插件的概念包含以下意思:

  • 插件不能獨立運行,必須運行與一個宿主程序中,即由宿主程序去調用插件程序。
  • 插件一般可以獨立安裝。
  • 宿主程序中可以管理不同的插件,包或查看插件的多少,禁用和使用某個插件,如果多個插件功能是互斥的,則可以切換插件。
  • 宿主程序應該保證參見的向下兼容性,即新版本的宿主程序可以運行較老版本的插件,或者說較老版本的插件能夠在新版本的宿主程序中運行。
  • 由於ClassLoader具有動態裝載程序的特點,因此,可以使用該技術來實現一種插件架構。

插件架構化

通過ClassLoader裝載的類,調用其內部函數的過程有點繁瑣,使用反射構造Method對象、構造參數等等。那麼,有沒有一種方法,既能通過動態裝載,利用動態裝載的靈活性,又能像直接類引用那樣方便地調用其函數?答案是有的,接口(Interface)。

首先定義一個interface接口,interface僅僅定義函數的輸入輸出,不定義函數的具體實現。該interface類一方面存在於Plugin項目中,另一方面存在於HOST宿主項目中。

這種方法,需保證接口的完整類名(包名+類名)是一樣的,否則將會報如下異常。

 java.lang.ClassCastException: com.maplejaw.plugin.PluginClass cannot be cast to com.maplejaw.host.Comm

我們應該保證兩者的完整類名是一致的。一般會建一個插件接口庫,給兩個項目分別引用即可。又或者,在兩個工程中創建一個同樣名字的用於存放插件接口的包,然後把插件接口類統一放到那個包下即可。

接口定義如下:

public interface Comm {
    int function(int a, int b);
}

現將PluginClass修改成如下:

public class PluginClass implements Comm {
    public PluginClass() {
        Log.d("JG","初始化PluginClass");
    }

    @Override
    public int function(int a, int b) {
        return a+b;
    }
}  

相應的調用核心代碼修改如下:

   Class<?> clazz = calssLoader.loadClass(pacageName+".PluginClass");
   Comm obj = (Comm) clazz.newInstance();
   Integer integer=obj.function(33,44);
   Log.d("JG", "返回的調用結果爲:" + integer);

打印結果如下

05-24 16:17:22.033 12963-12963/com.maplejaw.host D/JG: 初始化PluginClass
05-24 16:17:22.035 12963-12963/com.maplejaw.host D/JG: 返回的調用結果爲:77

注意!!!如果你按照上面進行操作,會發現這種方法在Android5.0以上運行沒有任何問題,但是在5.0以下運行。你會發現報錯了!!!

java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation 
   at dalvik.system.DexFile.defineClassNative(Native Method) 
   at dalvik.system.DexFile.defineClass(DexFile.java:222) 
   at dalvik.system.DexFile.loadClassBinaryName(DexFile.java:215)

從字面意思可以知道Class預校驗出錯,爲什麼會報這個錯呢?那是因爲插件接口被同一個加載器裝載了兩次。由於插件接口存在於兩個不同的dex文件中,每個dex文件有一個類型id,檢測到不一致所以報錯。如果想加載兩個相同的類,一定要用兩個加載器去分別裝載。你可能心想,我不是new了一個類加載器嗎?明明不一樣啊。由於雙親委託原則,會請求父加載器去加載,所以導致加載器是一樣的。
那麼怎麼解決這一問題呢?思路很簡單。只需保證插件接口只被裝載一次就行了,一般選擇讓宿主APK加載。。
先把插件接口打包成jar包plugin.jar。
然後在宿主apk中如下引用

compile files('libs/plugin.jar')

在插件apk中如下引用,這種方式在打包時不會將jar包一起打包進去

provided files('libs/plugin.jar')

獲取資源文件

在瞭解了ClassLoader的基本用法後,那麼問題來了,如果想訪問插件中的資源文件怎麼辦?
獲取資源的方式比較簡單,首先得知道名字,這些名字最好要事先約定好,根據名字獲取相應的id,最後用id取相應資源。Android中提供的獲取Resource得API:

 Resources res= pm.getResourcesForApplication(packageName);
  • 取圖片資源
    首先在插件APK中的drawable文件夾中放進圖片a.jpg。然後在宿主APK中編寫核心代碼如下。
    private void useDexClassLoader(){
        //創建一個意圖,用來找到指定的apk
        Intent intent = new Intent("com.maplejaw.plugin");
        //獲得包管理器
        PackageManager pm = getPackageManager();
        List<ResolveInfo> resolveinfoes =  pm.queryIntentActivities(intent, 0);
        if(resolveinfoes.size()==0){
            return;
        }
        //獲得指定的activity的信息
        ActivityInfo actInfo = resolveinfoes.get(0).activityInfo;
        //獲得包名
        String packageName = actInfo.packageName;

        try {
            Resources res= pm.getResourcesForApplication(packageName);
            int id=res.getIdentifier("a","drawable",packageName);//根據名字取id
            mImageView.setImageDrawable(res.getDrawable(id));//設置給ImageView
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }

    }
  • 取String
    比如插件APK中的string中寫了author信息。

     <resources>
       <string name="author">maplejaw</string>
    </resources>

    取出author信息

    Resources res= pm.getResourcesForApplication(packageName);
    int id=res.getIdentifier("author","string",packageName);
    Log.d("JG", res.getString(id));
  • 取顏色

    Resources res= pm.getResourcesForApplication(packageName);
    int id=res.getIdentifier("colorPrimary","color",packageName);
    mImageView.setBackgroundColor(  res.getColor(id));

源碼解讀

從上面的例子可以看出,一般使用Class<?> clazz = calssLoader.loadClass("com.maplejaw.plugin.PluginClass");來加載類,那麼這個類做了什麼呢?

由於DexClassLoader繼承自BaseDexClassLoader,且遵循着雙親委託,那我們先來看下BaseDexClassLoader中的源碼。
構造方法如下,可見一個DexClassLoader包含一個DexPathList。DexPathList用一個來存放dex信息的列表

public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }

我們來簡單看一下DexPathList的源碼。
屬性列表如下:

  private static final String DEX_SUFFIX = ".dex";

    //一個ClassLoader對象
    private final ClassLoader definingContext;
    //一個存放dex元素列表,Element是DexPathList的一個內部類
    private final Element[] dexElements;
    //本地庫目錄列表
    private final File[] nativeLibraryDirectories;
    //創建dexElements拋出的異常集合
    private final IOException[] dexElementsSuppressedExceptions;

Element內部類的構造方法如下:

        private final File file;
        private final boolean isDirectory;
        private final File zip;
        private final DexFile dexFile;

   public Element(File file, boolean isDirectory, File zip, DexFile dexFile) {
            this.file = file;
            this.isDirectory = isDirectory;
            this.zip = zip;
            this.dexFile = dexFile;
        }

DexPathList的構造方法如下

    public DexPathList(ClassLoader definingContext, String dexPath,
            String libraryPath, File optimizedDirectory) {
        //..
       //省略了異常相關源碼

        //直接賦值ClassLoader對象
        this.definingContext = definingContext;

         //賦值數組,splitDexPath將多個路徑拆分成集合,makeDexElements根據路徑遍歷存取
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,suppressedExceptions);
          //..
         //省略了異常相關源碼

          //賦值本地庫目錄集合
        this.nativeLibraryDirectories = splitLibraryPath(libraryPath);
    }

我們來看看makeDexElements方法,看看dexElements數組是怎麼賦值的。

private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) {
        ArrayList<Element> elements = new ArrayList<Element>();
        //開始遍歷保存到dexElements集合
        for (File file : files) {
            File zip = null;
            DexFile dex = null;
            String name = file.getName();

            if (file.isDirectory()) {
                // We support directories for looking up resources.
                // This is only useful for running libcore tests.
                elements.add(new Element(file, true, null, null));
            } else if (file.isFile()){
                if (name.endsWith(DEX_SUFFIX)) {
                        dex = loadDexFile(file, optimizedDirectory);
                } else {
                    zip = file;
                   dex = loadDexFile(file, optimizedDirectory);
                }
            } else {
                System.logW("ClassLoader referenced unknown path: " + file);
            }

            if ((zip != null) || (dex != null)) {
                elements.add(new Element(file, false, zip, dex));
            }
        }
        return elements.toArray(new Element[elements.size()]);
    }

我們現在回到Class<?> clazz = calssLoader.loadClass("com.maplejaw.plugin.PluginClass");,從ClassLoader類中找到loadClass源碼,如下

   //loadClass(String)
    public Class<?> loadClass(String className) throws ClassNotFoundException {
        return loadClass(className, false);
    }

內部調用了loadClass的重載方法。

 //loadClass(String,boolean)
  protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
        Class<?> clazz = findLoadedClass(className);//從已裝載過的類中找。

        if (clazz == null) {
            ClassNotFoundException suppressed = null;
            try {
                clazz = parent.loadClass(className, false);//由父類裝載
            } catch (ClassNotFoundException e) {
                suppressed = e;
            }

            if (clazz == null) {
                try {
                    clazz = findClass(className);//由子類裝載
                } catch (ClassNotFoundException e) {
                    e.addSuppressed(suppressed);
                    throw e;
                }
            }
        }

        return clazz;
    }

可以看出,該方法分三步裝載類:
* 從已裝載過的類中找
* 如果從已裝載過的列表中找不到,則從父類裝載
* 如果父類找不到,從子類裝載

先來看看findLoadedClass(已裝載過的類)源碼如下,最終調用虛擬機的裝載器去尋找。

     protected final Class<?> findLoadedClass(String className) {
        ClassLoader loader;
        if (this == BootClassLoader.getInstance())
            loader = null;
        else
            loader = this;
        return VMClassLoader.findLoadedClass(loader, className);
    }

由於第一次裝載一定會走findClass這個方法,我們來看下源碼,可以看出,最終會去pathList中尋找

    @Override
    protected Class<?> findClass(String name)  {
       //..
       //省略了部分源碼
        Class c = pathList.findClass(name, suppressedExceptions);
        if (c == null) {
            //..
         //省略了拋出異常的源碼
        }
        return c;
    }

找到DexPathList的findClass方法。

 public Class findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {//這裏進行遍歷查詢
            DexFile dex = element.dexFile;

            if (dex != null) {
               //從DexFile中試圖加載Class,從這裏看出,從第一個開始遍歷,如果查到就返回,這就是熱修復的基本原理。
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
        //..
        return null;
    }

最後

由於SDK中無法直接查看DexClassLoader相關源碼。這裏把源碼鏈接貼出來。方便大家閱讀。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章