深刻理解Android中ClassLoader

從去年下半年開始,熱修復技術在 Android 技術社區熱了一陣子,這種不用發佈新版本就能夠修復線上 bug 的技術確實有很大的需求,最近正好在研究一些開源的熱修復方案,本文就其中經常使用的 ClassLoader 方式實現的熱修復方案中的 ClassLoader 機制做一個簡單的介紹。php

一. ClassLoader簡介

任何一個 Java 程序都是由若干個 class 文件組成的一個完整的 Java 程序,在程序運行時,須要將 class 文件加載到 JVM 中才可使用,負責加載這些 class 文件的就是 Java 的類加載(ClassLoader)機制。java

                  

 

二. ClassLoader 的雙親委託模型(Parent Delegation Model )

先來看 jdk 中的 ClassLoader 類的構造方法,其須要傳入一個父類加載器,並持有該引用。android

protected ClassLoader(ClassLoader parent) {
    this(checkCreateClassLoader(), parent);
}

當類加載器收到加載類或資源的請求時,一般都是先委託給父類加載器加載,也就是說只有當父類加載器找不到指定類或資源時,自身才會執行實際的類加載過程,具體的加載過程以下:segmentfault

  1. 源 ClassLoader 先判斷該 Class 是否已加載,若是已加載,則直接返回 Class,若是沒有則委託給父類加載器。
  2. 父類加載器判斷是否加載過該 Class,若是已加載,則直接返回 Class,若是沒有則委託給祖父類加載器。
  3. 依此類推,直到始祖類加載器(引用類加載器)。
  4. 始祖類加載器判斷是否加載過該 Class,若是已加載,則直接返回 Class,若是沒有則嘗試從其對應的類路徑下尋找 class 字節碼文件並載入。若是載入成功,則直接返回 Class,若是載入失敗,則委託給始祖類加載器的子類加載器。
  5. 始祖類加載器的子類加載器嘗試從其對應的類路徑下尋找 class 字節碼文件並載入。若是載入成功,則直接返回 Class,若是載入失敗,則委託給始祖類加載器的孫類加載器。
  6. 依此類推,直到源 ClassLoader。
  7. 源 ClassLoader 嘗試從其對應的類路徑下尋找 class 字節碼文件並載入。若是載入成功,則直接返回 Class,若是載入失敗,源 ClassLoader 不會再委託其子類加載器,而是拋出異常。

若是須要詳細瞭解 ClassLoader 的信息,能夠藉助如下文章深刻了解:數組

 

三. Android中的ClassLoader

Android 的 Dalvik/ART 虛擬機如同標準 Java 的 JVM 虛擬機同樣,也是一樣須要加載 class 文件到內存中來使用,可是在 ClassLoader 的加載細節上會有略微的差異。緩存

1. Android 中的 dex 文件

Android 應用打包成 apk 文件時,class 文件會被打包成一個或者多個 dex 文件。將一個 apk 文件後綴改爲 .zip 格式解壓後(也能夠直接解壓,apk 文件本質是個 zip 文件),裏面就有 class.dex 文件,因爲 Android 的 65K 問題(不要糾結是 64K 仍是 65K),使用 MultiDex 就會生成多個 dex 文件。app

          

當 Android 系統安裝一個應用的時候,會針對不一樣平臺對 Dex 進行優化,這個過程由一個專門的工具來處理,叫 DexOpt 。DexOpt 是在第一次加載 Dex 文件的時候執行的,該過程會生成一個 ODEX 文件,即 Optimised Dex。執行 ODEX 的效率會比直接執行 Dex 文件的效率要高不少,加快 App 的啓動和響應。ide

ODEX 相關的細節能夠閱讀如下文章擴展:函數

注:本人的 5.0 機器 ODEX 優化後的文件是在 /data/dalvilk-cache文件夾下的,6.0 機器該文件夾下只有 framework 和部份內置的 App 的優化後的 dex 文件,查找相關資料後沒有找到明確的說法,目前猜想和 ROM 有關係,後續再深究下這個問題。工具

總之,Android 中的 Dalvik/ART 沒法像 JVM 那樣 直接 加載 class 文件和 jar 文件中的 class,須要經過 dx 工具來優化轉換成 Dalvik byte code 才行,只能經過 dex 或者 包含 dex 的jar、apk 文件來加載(注意 odex 文件後綴多是 .dex 或 .odex,也屬於 dex 文件),所以 Android 中的 ClassLoader 工做就交給了 BaseDexClassLoader 來處理。

注:若是 jar 文件包含有 dex 文件,此時 jar 文件也是能夠用來加載的,不過實際加載的仍是其中的 dex 文件,不要弄混淆了。

2. BaseDexClassLoader及其子類

在 Android 開發者官網上的ClassLoader的文檔說明中咱們能夠看到,ClassLoader 是個抽象類,其具體實現的子類有 BaseDexClassLoader 和 SecureClassLoader

SecureClassLoader 的子類是URLClassLoader ,其只能用來加載 jar 文件,這在 Android 的 Dalvik/ART 上無法使用的。

BaseDexClassLoader 的子類是 PathClassLoader DexClassLoader 

( 1 ). PathClassLoader

PathClassLoader 在應用啓動時建立,從 data/app/… 安裝目錄下加載 apk 文件。

其有 2 個構造函數,以下所示,這裏聽從以前提到的雙親委託模型:

public PathClassLoader(String dexPath, ClassLoader parent) {
    super(dexPath, null, null, parent);
}

public PathClassLoader(String dexPath, String libraryPath,
        ClassLoader parent) {
    super(dexPath, null, libraryPath, parent);
}
  • dexPath: 包含 dex 的 jar 文件或 apk 文件的路徑集,多個以文件分隔符分隔,默認是「:」
  • libraryPath: 包含 C/C++ 庫的路徑集,多個一樣以文件分隔符分隔,能夠爲空

PathClassLoader 裏面除了這 2 個構造方法之外就沒有其餘的代碼了,具體的實現都是在 BaseDexClassLoader 裏面,其 dexPath 比較受限制,通常是已經安裝應用的 apk 文件路徑。

在 Android 中,App 安裝到手機後,apk 裏面的 class.dex 中的 class 均是經過 PathClassLoader 來加載的。

咱們能夠新建一個項目來驗證下,在 MainActivity 中添加以下代碼:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ClassLoader loader = MainActivity.class.getClassLoader();
        while (loader != null) {
            System.out.println(loader.toString());
            loader = loader.getParent();
        }
    }
}

運行結果:

I/System.out: dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.jaeger.testclassloader-2/base.apk"],nativeLibraryDirectories=[/vendor/lib, /system/lib]]]
 I/System.out: java.lang.BootClassLoader@1d9c6226

/data/app/com.jaeger.testclassloader-2/base.apk 就是示例應用安裝在手機上的位置。

BootClassLoader 是 PathClassLoader 的父加載器,其在系統啓動時建立,在 App 啓動時會將該對象傳進來,具體的調用在 com.android.internal.os.ZygoteInit 的 main() 方法中調用了preload(), 而後調用 preloadClasses() 方法,在該方法內部調用了 Class 的 forName() 方法:

Class.forName(line, true, null);

forName() 方法源碼以下,方法內部獲取到 BootClassLoader 實例:

public static Class<?> forName(String className, boolean shouldInitialize,
        ClassLoader classLoader) throws ClassNotFoundException {
    if (classLoader == null) {
        classLoader = BootClassLoader.getInstance();
    }
    // Catch an Exception thrown by the underlying native code. It wraps
    // up everything inside a ClassNotFoundException, even if e.g. an
    // Error occurred during initialization. This as a workaround for
    // an ExceptionInInitializerError that's also wrapped. It is actually
    // expected to be thrown. Maybe the same goes for other errors.
    // Not wrapping up all the errors will break android though.
    Class<?> result;
    try {
        result = classForName(className, shouldInitialize, classLoader);
    } catch (ClassNotFoundException e) {
        Throwable cause = e.getCause();
        if (cause instanceof LinkageError) {
            throw (LinkageError) cause;
        }
        throw e;
    }
    return result;
}

而 PathClassLoader 的實例化又是在哪進行的呢?在源碼中尋找下其構造方法調用的地方,結果以下:

          

其中:

在 ZygoteInit 中的調用是用來啓動相關的系統服務

在 ApplicationLoaders 中用來加載系統安裝過的 apk,用來加載 apk 內的 class ,其調用是在 LoadApk 類中的 getClassLoader() 方法中調用的,獲得的就是 PathClassLoader:

mClassLoader = ApplicationLoaders.getDefault().getClassLoader(zip, lib,
        mBaseClassLoader);

( 2 ). DexClassLoader

介紹 DexClassLoader 以前,先來看看其官方描述:

A class loader that loads classes from .jar and .apk filescontaining a classes.dex entry. This can be used to execute code notinstalled as part of an application.    

很明顯,對比 PathClassLoader 只能加載已經安裝應用的 dex 或 apk 文件,DexClassLoader 則沒有此限制,能夠從 SD 卡上加載包含 class.dex 的 .jar 和 .apk 文件,這也是插件化和熱修復的基礎,在不須要安裝應用的狀況下,完成須要使用的 dex 的加載。

DexClassLoader 的源碼裏面只有一個構造方法,這裏也是聽從雙親委託模型:

public DexClassLoader(String dexPath, String optimizedDirectory,
        String libraryPath, ClassLoader parent) {
    super(dexPath, new File(optimizedDirectory), libraryPath, parent);
}

參數說明:

  • String dexPath: 包含 class.dex 的 apk、jar 文件路徑 ,多個用文件分隔符(默認是 :)分隔
  • String optimizedDirectory: 用來緩存優化的 dex 文件的路徑,即從 apk 或 jar 文件中提取出來的 dex 文件。該路徑不能夠爲空,且應該是應用私有的,有讀寫權限的路徑(實際上也可使用外部存儲空間,可是這樣的話就存在代碼注入的風險),能夠經過如下方式來建立一個這樣的路徑:  
File dexOutputDir = context.getCodeCacheDir();

注:後續發現,getCodeCacheDir() 方法只能在 API 21 以上可使用。 

  • String libraryPath: 存儲 C/C++ 庫文件的路徑集
  • ClassLoader Parent: 父類加載器,聽從雙親委託模型

簡單介紹了 PathClassLoader 和 DexClassLoader,但這二者都是對 BaseDexClassLoader 的一層簡單封裝,真正的實現都在 BaseClassLoader 內。

3. ClassLoader源碼分析

先來看一眼 BaseDexClassLoader 的結構:

其中有個重要的字段 private final DexPathList pathList,其繼承 ClassLoader 實現的findClass() findRecource() 均是基於 pathList 來實現的(省略了部分源碼):

@Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        Class c = pathList.findClass(name, suppressedExceptions);
        ...
        return c;
    }
    @Override
    protected URL findResource(String name) {
        return pathList.findResource(name);
    }
    @Override
    protected Enumeration<URL> findResources(String name) {
        return pathList.findResources(name);
    }
    @Override
    public String findLibrary(String name) {
        return pathList.findLibrary(name);
    }

那麼重要的部分則是在 DexPathList 類的內部了,DexPathList 的構造方法也較爲簡單,和以前介紹的相似:

public DexPathList(ClassLoader definingContext, String dexPath,
        String libraryPath, File optimizedDirectory) {
    ...
}

受以前傳進來的包含 dex 的 apk/jar/dex 的路徑集、native 庫的路徑集和緩存優化的 dex 文件的路徑,而後調用 makePathElements() 方法生成一個 Element[] dexElements 數組,Element 是 DexPathList 的一個嵌套類,其有如下字段:

static class Element {
	private final File dir;
	private final boolean isDirectory;
	private final File zip;
	private final DexFile dexFile;
	private ZipFile zipFile;
	private boolean initialized;
}

makePathElements() 是如何生成 Element 數組的?繼續看源碼:

private static Element[] makePathElements(List<File> files, File optimizedDirectory,
                                          List<IOException> suppressedExceptions) {
    List<Element> elements = new ArrayList<>();
    // 遍歷全部的包含 dex 的文件
    for (File file : files) {
        File zip = null;
        File dir = new File("");
        DexFile dex = null;
        String path = file.getPath();
        String name = file.getName();
        // 判斷是否是 zip 類型
        if (path.contains(zipSeparator)) {
            String split[] = path.split(zipSeparator, 2);
            zip = new File(split[0]);
            dir = new File(split[1]);
        } else if (file.isDirectory()) {
            // 若是是文件夾,則直接添加 Element,這個通常是用來處理 native 庫和資源文件
            elements.add(new Element(file, true, null, null));
        } else if (file.isFile()) {
            // 直接是 .dex 文件,而不是 zip/jar 文件(apk 歸爲 zip),則直接加載 dex 文件
            if (name.endsWith(DEX_SUFFIX)) {
                try {
                    dex = loadDexFile(file, optimizedDirectory);
                } catch (IOException ex) {
                    System.logE("Unable to load dex file: " + file, ex);
                }
            } else {
                // 若是是 zip/jar 文件(apk 歸爲 zip),則將 file 值賦給 zip 字段,再加載 dex 文件
                zip = file;
                try {
                    dex = loadDexFile(file, optimizedDirectory);
                } catch (IOException suppressed) {
                    suppressedExceptions.add(suppressed);
                }
            }
        } else {
            System.logW("ClassLoader referenced unknown path: " + file);
        }
        if ((zip != null) || (dex != null)) {
            elements.add(new Element(dir, false, zip, dex));
        }
    }
    // list 轉爲數組
    return elements.toArray(new Element[elements.size()]);
}

loadDexFile() 方法最終會調用 JNI 層的方法來讀取 dex 文件,這裏再也不深刻探究,有興趣的能夠閱讀 從源碼分析 Android dexClassLoader 加載機制原理 這篇文章深刻了解。

接下來看如下 DexPathList 的 findClass() 方法,其根據傳入的完整的類名來加載對應的 class,源碼以下:

public Class findClass(String name, List<Throwable> suppressed) {
	// 遍歷 dexElements 數組,依次尋找對應的 class,一旦找到就終止遍歷
    for (Element element : dexElements) {
        DexFile dex = element.dexFile;
        if (dex != null) {
            Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
            if (clazz != null) {
                return clazz;
            }
        }
    }
    // 拋出異常
    if (dexElementsSuppressedExceptions != null) {
        suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
    }
    return null;
}

這裏有關於熱修復實現的一個點,就是將補丁 dex 文件放到 dexElements 數組前面,這樣在加載 class 時,優先找到補丁包中的 dex 文件,加載到 class 以後就再也不尋找,從而原來的 apk 文件中同名的類就不會再使用,從而達到修復的目的,雖說起來較爲簡單,可是實現起來還有不少細節須要注意,本文先熱身,後期再分析具體實現。

至此,BaseDexClassLader 尋找 class 的路線就清晰了:

  1. 當傳入一個完整的類名,調用 BaseDexClassLader 的 findClass(String name) 方法
  2. BaseDexClassLader 的 findClass 方法會交給 DexPathList 的 findClass(String name, List<Throwable> suppressed) 方法處理
  3. findClass(String name, List<Throwable> suppressed) 方法的內部,會遍歷 dexFile ,經過 DexFile 的 dex.loadClassBinaryName(name, definingContext, suppressed) 來完成類的加載

實際使用

須要注意到的是,在項目中使用 BaseDexClassLoader 或者 DexClassLoader 去加載某個 dex 或者 apk 中的 class 時,是沒法調用 findClass() 方法的,由於該方法是包訪問權限,你須要調用 loadClass(String className) ,該方法實際上是 BaseDexClassLoader 的父類 ClassLoader 內實現的:

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

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

上面這段代碼結合以前提到的雙親委託模型就很好理解了,先查找當前的 ClassLoader 是否已經加載過,若是沒有就交給父 ClassLoader 去加載,若是父 ClassLoader 沒有找到,才調用當前 ClassLoader 來加載,此時就是調用上面分析的 findClass() 方法了。

 

四. ClassLoader使用示例

上面說了這麼多理論知識,只說不練假把式,接下來實戰:從 SD 卡中動態加載一個包含 class.dex 的 jar 文件,加載其中的類,並調用其方法。

1. 新建一個 Java 項目,包含兩個文件:ISayHello.java 和 HelloAndroid.java

package com.jaeger;
    
   public interface ISayHello {
       String say();
   }
package com.jaeger;
    
   public class HelloAndroid implements ISayHello {
       @Override
       public String say() {
           return "Hello Android";
       }
   }

2.導出jar包

這一步使用 IntelliJ IDEA 導出有點問題,最終我是用 Eclipse 導出 jar 包的。

              

3. 使用 SDK 目錄 > platform-tools 裏面的 dx 工具生成包含 class.dex 的 jar 包

將上一步生成的 sayhello.jar 放到 你的 SDK 下的 platform-tools 文件夾下,使用下面的命令生成 dex 化的 jar 文件,其中是 output 後面的 sayhello_dex.jar 就是最終生成的 jar 包。

dx --dex --output=sayhello_dex.jar sayhello.jar

生成 sayhello_dex.jar 以後,用解壓解壓後就會發現其已經包含了 class.dex 文件了。

               

4. 將 sayhello_dex.jar 文件拷貝到手機存儲空間的根目錄,不必定是內存卡。

5. 新建一個 Android 項目,在 MainActivity 中添加以下的代碼:

public class MainActivity extends AppCompatActivity {
    private static final String TAG = "TestClassLoader";
    private TextView mTvInfo;
    private Button mBtnLoad;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mTvInfo = (TextView) findViewById(R.id.tv_info);
        mBtnLoad = (Button) findViewById(R.id.btn_load);
        mBtnLoad.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                // 獲取到包含 class.dex 的 jar 包文件
                final File jarFile =
                    new File(Environment.getExternalStorageDirectory().getPath() + File.separator + "sayhello_dex.jar");
                   
                // 若是沒有讀權限,肯定你在 AndroidManifest 中是否聲明瞭讀寫權限
                Log.d(TAG, jarFile.canRead() + "");

                if (!jarFile.exists()) {
                    Log.e(TAG, "sayhello_dex.jar not exists");
                    return;
                }

                // getCodeCacheDir() 方法在 API 21 才能使用,實際測試替換成 getExternalCacheDir() 等也是能夠的
                // 只要有讀寫權限的路徑都可
                DexClassLoader dexClassLoader =
                    new DexClassLoader(jarFile.getAbsolutePath(), getExternalCacheDir().getAbsolutePath(), null, getClassLoader());
                try {
                    // 加載 HelloAndroid 類
                    Class clazz = dexClassLoader.loadClass("com.jaeger.HelloAndroid");
                    // 強轉成 ISayHello, 注意 ISayHello 的包名須要和 jar 包中的一致
                    ISayHello iSayHello = (ISayHello) clazz.newInstance();
                    mTvInfo.setText(iSayHello.say());
                } catch (ClassNotFoundException e) {
                    e.printStackTrace();
                } catch (InstantiationException e) {
                    e.printStackTrace();
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
            }
        });
    }
}

同時須要新建一個和第一步建立的 Java 項目中包名一致的 ISayHello 接口(注意包名一樣須要一致):

package com.jaeger;

public interface ISayHello {
    String say();
}
  • 這裏須要注意幾點:
  • 由於須要從存儲空間中讀取 jar 文件,須要在 AndroidManifest 中聲明讀寫權限
  • ISayHello 接口的包名必須一致

getCodeCacheDir() 方法在 API 21 才能使用,實際測試替換成 getExternalCacheDir() 等也是能夠的

6. 接下來就是運行,運行的結果如圖,和預期的同樣,完美收工。

                               

相關文章
相關標籤/搜索