從去年下半年開始,熱修復技術在 Android 技術社區熱了一陣子,這種不用發布新版本就可以修復線上 bug 的技術確實有很大的需求,最近正好在研究一些開源的熱修復方案,本文就其中常用的 ClassLoader 方式實現的熱修復方案中的 ClassLoader 機制作一個簡單的介紹。
ClassLoader 簡介
對于 Java 程序來說,編寫程序就是編寫類,運行程序也就是運行類(編譯得到的 class 文件),其中起到關鍵作用的就是類加載器 ClassLoader。
任何一個 Java 程序都是由若干個 class 文件組成的一個完整的 Java 程序,在程序運行時,需要將 class 文件加載到 JVM 中才可以使用,負責加載這些 class 文件的就是 Java 的類加載(ClassLoader)機制。
因此 ClassLoader 的作用簡單來說就是加載 class 文件,提供給程序運行時使用。
ClassLoader 的雙親委托模型(Parent Delegation Model )
先來看 jdk 中的 ClassLoader 類的構造方法,其需要傳入一個父類加載器,并持有該引用。
protected ClassLoader(ClassLoader parent) { this(checkCreateClassLoader(), parent);}
當類加載器收到加載類或資源的請求時,通常都是先委托給父類加載器加載,也就是說只有當父類加載器找不到指定類或資源時,自身才會執行實際的類加載過程,具體的加載過程如下:
- 源 ClassLoader 先判斷該 Class 是否已加載,如果已加載,則直接返回 Class,如果沒有則委托給父類加載器。
- 父類加載器判斷是否加載過該 Class,如果已加載,則直接返回 Class,如果沒有則委托給祖父類加載器。
- 依此類推,直到始祖類加載器(引用類加載器)。
- 始祖類加載器判斷是否加載過該 Class,如果已加載,則直接返回 Class,如果沒有則嘗試從其對應的類路徑下尋找 class 字節碼文件并載入。如果載入成功,則直接返回 Class,如果載入失敗,則委托給始祖類加載器的子類加載器。
- 始祖類加載器的子類加載器嘗試從其對應的類路徑下尋找 class 字節碼文件并載入。如果載入成功,則直接返回 Class,如果載入失敗,則委托給始祖類加載器的孫類加載器。
- 依此類推,直到源 ClassLoader。
- 源 ClassLoader 嘗試從其對應的類路徑下尋找 class 字節碼文件并載入。如果載入成功,則直接返回 Class,如果載入失敗,源 ClassLoader 不會再委托其子類加載器,而是拋出異常。
如果需要詳細了解 ClassLoader 的信息,可以借助以下文章深入了解:
Android 中的 ClassLoader
Android 的 Dalvik/ART 虛擬機如同標準 Java 的 JVM 虛擬機一樣,也是同樣需要加載 class 文件到內存中來使用,但是在 ClassLoader 的加載細節上會有略微的差別。
Android 中的 dex 文件
Android 應用打包成 apk 文件時,class 文件會被打包成一個或者多個 dex 文件。將一個 apk 文件后綴改成 .zip 格式解壓后(也可以直接解壓,apk 文件本質是個 zip 文件),里面就有 class.dex 文件,由于 Android 的 65K 問題(不要糾結是 64K 還是 65K),使用 MultiDex 就會生成多個 dex 文件。
當 Android 系統安裝一個應用的時候,會針對不同平臺對 Dex 進行優化,這個過程由一個專門的工具來處理,叫 DexOpt 。DexOpt 是在第一次加載 Dex 文件的時候執行的,該過程會生成一個 ODEX 文件,即 Optimised Dex。執行 ODEX 的效率會比直接執行 Dex 文件的效率要高很多,加快 App 的啟動和響應。
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 文件,不要弄混淆了。
BaseDexClassLoader 及其子類
在 Android 開發者官網上的 ClassLoader 的文檔說明中我們可以看到,ClassLoader 是個抽象類,其具體實現的子類有 BaseDexClassLoader 和SecureClassLoader。
SecureClassLoader 的子類是 URLClassLoader,其只能用來加載 jar 文件,這在 Android 的 Dalvik/ART 上沒法使用的。BaseDexClassLoader 的子類是 PathClassLoader和 DexClassLoader。
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);
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 內。
BaseClassLoader 源碼分析
先來看一眼 BaseClassLoader 的結構:
其中有個重要的字段 private final DexPathList pathList,其繼承 ClassLoader 實現的 findClass()、findResource()
均是基于 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 的路線就清晰了:
- 當傳入一個完整的類名,調用 BaseDexClassLader 的 findClass(String name) 方法
- BaseDexClassLader 的 findClass 方法會交給 DexPathList 的 findClass(String name, List<Throwable> suppressed
方法處理 - 在 DexPathList 方法的內部,會遍歷 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 文件,加載其中的類,并調用其方法。
- 新建一個 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";
}
}
-
導出 jar 包
這一步使用 IntelliJ IDEA 導出有點問題,最終我是用 Eclipse 導出 jar 包的。
使用 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 文件了。
-
將 sayhello_dex.jar文件拷貝到手機存儲空間的根目錄,不一定是內存卡。
- 新建一個 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()等也是可以的
接下來就是運行,運行的結果如圖,和預期的一樣,完美收工。
示例代碼以及 jar 包上傳到 GitHub 了,請前往 這里 去查看。
原文地址:http://jaeger.itscoder.com/android/2016/08/27/android-classloader.html