Android外掛化原理和實踐 (三) 之 載入外掛中的元件程式碼
我們在上一篇文章《Android外掛化原理和實踐 (二) 之 載入外掛中的類程式碼》中埋下了一個懸念,那就是通過構造一個DexClassLoader物件後使用反射只能反射出普通的類,而不能正常使用四大元件,因為會報出異常。今天我們就來解開這個懸念和提出解決方法。
1 揭開懸念
還記得《Android應用程式啟動詳解(二)之Application和Activity的啟動過程》中有介紹了Activity的啟動過程嗎?在ActivityThread.java中有下面的程式碼:
private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent){ …… Activity activity = null; try { // 關鍵程式碼 java.lang.ClassLoader cl = r.packageInfo.getClassLoader(); activity = mInstrumentation.newActivity( cl, component.getClassName(), r.intent); StrictMode.incrementExpectedActivityCount(activity.getClass()); r.intent.setExtrasClassLoader(cl); r.intent.prepareToEnterProcess(); if (r.state != null) { r.state.setClassLoader(cl); } } catch (Exception e) { …… } …… }
在上述關鍵程式碼地方,通過獲取一個ClassLoader來作為引數,然後創建出一個Activity例項,而這個ClassLoader物件實質上是一個PathClassLoader,因為通過跟蹤原始碼可以發現此物件的建立地方在ClassLoader.java中,如程式碼:
/** * Encapsulates the set of parallel capable loader types. */ private static ClassLoader createSystemClassLoader() { String classPath = System.getProperty("java.class.path", "."); String librarySearchPath = System.getProperty("java.library.path", ""); // String[] paths = classPath.split(":"); // URL[] urls = new URL[paths.length]; // for (int i = 0; i < paths.length; i++) { // try { // urls[i] = new URL("file://" + paths[i]); // } // catch (Exception ex) { // ex.printStackTrace(); // } // } // // return new java.net.URLClassLoader(urls, null); // TODO Make this a java.net.URLClassLoader once we have those? return new PathClassLoader(classPath, librarySearchPath, BootClassLoader.getInstance()); }
這裡的ClassLoader物件就是PathClassLoader物件,所以我們在App的Activity中,通過getClassLoader獲取到的是PathClassLoader。就是意味著,Activity的建立只能在PathClassLoader中存在的類,其實中大元件的建立都是一樣。也就是說元件類必須是要定義在宿主中才可以正常創建出來。我們在上一篇文章的最後提出,在通過DexClassLoader來載入起外掛後,再使用startService來啟動外掛的一個服務,那麼當然就會報出異常。
2 ClassLoader相關類原始碼分析
其實解決方法說起來是很簡單的,就是要把外掛的ClassLoader對應的dex檔案塞入到宿主的ClassLoader中去就可以了。至少怎樣塞法?那就要先來看看PathClassLoader和DexClassLoader 它們的父類BaseDexClassLoader
ClassLoader.java
public abstract class ClassLoader {
private final ClassLoader parent;
……
private ClassLoader(Void unused, ClassLoader parent) {
this.parent = parent;
}
……
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
c = findClass(name);
}
}
return c;
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
……
}
我們都知道Android中Class的載入是執行的ClassLoader的loadClass方法。loadClass方法中可以看到,在開始時,會先檢查類是否被載入過,如果沒有載入過,則會優先委派它的父類去載入類,如果最後沒有哪個父類載入過,那就自己通過findClass方法來載入這個類。這個就是雙親委派機制。再繼續來看BaseDexClassLoader的程式碼,因為findClass的實現在它這裡。
BaseDexClassLoader.java
public class BaseDexClassLoader extends ClassLoader {
private final DexPathList pathList;
public BaseDexClassLoader(String dexPath, File optimizedDirectory, String libraryPath, ClassLoader parent) {
super(parent);
this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
Class c = pathList.findClass(name, suppressedExceptions);
if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
}
throw cnfe;
}
return c;
}
……
}
BaseDexClassLoader的建構函式中建立了一個DexPathList型別的物件pathList,然後在findClass的時候,實質上是呼叫了pathList的findClass方法,接下來看看DexPathList的原始碼:
DexPathList.java
/*package*/ final class DexPathList {
private final Element[] dexElements;
/**
* Constructs an instance.
*
* @param definingContext the context in which any as-yet unresolved
* classes should be defined
* @param dexPath list of dex/resource path elements, separated by
* {@code File.pathSeparator}
* @param libraryPath list of native library directory path elements,
* separated by {@code File.pathSeparator}
* @param optimizedDirectory directory where optimized {@code .dex} files
* should be found and written to, or {@code null} to use the default
* system directory for same
*/
public DexPathList(ClassLoader definingContext, String dexPath,
String libraryPath, File optimizedDirectory) {
if (definingContext == null) {
throw new NullPointerException("definingContext == null");
}
if (dexPath == null) {
throw new NullPointerException("dexPath == null");
}
if (optimizedDirectory != null) {
if (!optimizedDirectory.exists()) {
throw new IllegalArgumentException("optimizedDirectory doesn't exist: " + optimizedDirectory);
}
if (!(optimizedDirectory.canRead() && optimizedDirectory.canWrite())) {
throw new IllegalArgumentException("optimizedDirectory not readable/writable: " + optimizedDirectory);
}
}
this.definingContext = definingContext;
ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions);
if (suppressedExceptions.size() > 0) {
this.dexElementsSuppressedExceptions = suppressedExceptions.toArray(new IOException[suppressedExceptions.size()]);
} else {
dexElementsSuppressedExceptions = null;
}
this.nativeLibraryDirectories = splitLibraryPath(libraryPath);
}
private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory,
ArrayList<IOException> suppressedExceptions) {
ArrayList<Element> elements = new ArrayList<Element>();
/*
* Open all files and load the (direct or contained) dex files
* up front.
*/
for (File file : files) {
File zip = null;
DexFile dex = null;
String name = file.getName();
if (name.endsWith(DEX_SUFFIX)) {
// Raw dex file (not inside a zip/jar).
try {
dex = loadDexFile(file, optimizedDirectory);
} catch (IOException ex) {
System.logE("Unable to load dex file: " + file, ex);
}
} else if (name.endsWith(APK_SUFFIX) || name.endsWith(JAR_SUFFIX)|| name.endsWith(ZIP_SUFFIX)) {
zip = file;
try {
dex = loadDexFile(file, optimizedDirectory);
} catch (IOException suppressed) {
/*
* IOException might get thrown "legitimately" by the DexFile constructor if the
* zip file turns out to be resource-only (that is, no classes.dex file in it).
* Let dex == null and hang on to the exception to add to the tea-leaves for
* when findClass returns null.
*/
suppressedExceptions.add(suppressed);
}
} else 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 {
System.logW("Unknown file type for: " + file);
}
if ((zip != null) || (dex != null)) {
elements.add(new Element(file, false, zip, dex));
}
}
return elements.toArray(new Element[elements.size()]);
}
public Class findClass(String name, List<Throwable> suppressed) {
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;
}
……
}
DexPathList這個類非常重要,其中關鍵就在於以上三個方法。首先看它的建構函式,建構函式的第4個引數就是前面所說的PathClassLoader和DexClassLoader的區別,我們來看一下它的註釋翻譯,意思大概是:接收dex檔案路徑,若為空,那麼使用系統預設路徑,所以說PathClassLoader傳空就到預設目錄/data/dalvik-cache下去載入dex,因為我們的應用已經安裝並優化了,優化後的dex存在於/data/dalvik-cache目錄下。接著來看看建構函式後面,那裡通過makeDexElements方法獲取一個Element[]的陣列物件dexElements。
再繼續來看下makeDexElements方法,該方法是載入了dex檔案,並建立了一個Element[]的陣列物件elements來儲存dex檔案的相關資訊。
最後看看findClass方法,它就是BaseClassLoader的findClass方法呼叫了DexPathList的findClass方法,它邏輯很簡單,就是遍歷dexElements陣列,然後從陣列每個物件中去查詢目標類,若找到就立即返回並停止遍歷。
3 解決方案
看完相關關鍵原始碼後,迴歸正傳,我們其實要做的事情,就是要把外掛的dex塞入到宿主的deElements陣列中就可以了。所以這裡我們使用了反射,其步驟如下:
- 根據宿主的ClassLoader,獲取宿主的dexElements陣列,就是要反射出BaseDexClassLoader的DexPathList物件pathList,然後再反射出pathList裡頭的dexElements陣列
- 根據外掛的apk檔案,反射出一個Element型別物件,也就是外掛的dex
- 把外掛的dex和宿主的dexElements合併成一個新的dex陣列,替換宿方原有的dexElements陣列
上述步驟通過程式碼實現如下:
private void loadPlugin(Context context, String apkName)
throws IllegalAccessException, NoSuchMethodException, IOException, InvocationTargetException, InstantiationException, NoSuchFieldException {
ClassLoader pathClassLoaderClass = context.getClassLoader();
// 獲取 PathClassLoader(BaseDexClassLoader) 的 DexPathList 物件變數 pathList
Class baseDexClassLoaderClass = BaseDexClassLoader.class;
Field pathListField = baseDexClassLoaderClass.getDeclaredField("pathList");
pathListField.setAccessible(true);
Object pathListObj = pathListField.get(pathClassLoaderClass);
// 獲取 DexPathList 的 Element[] 物件變數 dexElements
Class dexPathListClass = pathListObj.getClass();
Field dexElementsField = dexPathListClass.getDeclaredField("dexElements");
dexElementsField.setAccessible(true);
Object[] dexElementListObj = (Object[])dexElementsField.get(pathListObj);
// 獲得 Element 型別
Class<?> elementClass = dexElementListObj.getClass().getComponentType();
// 建立一個新的Element[], 將用於替換原始的陣列
Object[] newElementListObj = (Object[]) Array.newInstance(elementClass, dexElementListObj.length + 1);
// 構造外掛的Element,建構函式引數:(File file, boolean isDirectory, File zip, DexFile dexFile)
File apkFile = context.getFileStreamPath(apkName);
File optDexFile = context.getFileStreamPath(apkName.replace(".apk", ".dex"));
Class[] paramClass = {File.class, boolean.class, File.class, DexFile.class};
Object[] paramValue = {apkFile, false, apkFile, DexFile.loadDex(apkFile.getCanonicalPath(), optDexFile.getAbsolutePath(), 0)};
Constructor elementCtor = elementClass.getDeclaredConstructor(paramClass);
elementCtor.setAccessible(true);
Object pluginElementObj = elementCtor.newInstance(paramValue);
Object[] pluginElementListObj = new Object[] { pluginElementObj };
// 把原來 PathClassLoader 中的 elements 複製進去新的Element[]中
System.arraycopy(dexElementListObj, 0, newElementListObj, 0, dexElementListObj.length);
// 把外掛的 element 複製進去新的 Element[] 中
System.arraycopy(pluginElementListObj, 0, newElementListObj, dexElementListObj.length, pluginElementListObj.length);
// 替換原來 PathClassLoader 中的 dexElements 值
Field field = pathListObj.getClass().getDeclaredField("dexElements");
field.setAccessible(true);
field.set(pathListObj, newElementListObj);
}
將上面方法替換到上一遍文章Demo的MainActivity.java中的同名方法和修改呼叫處不用接收返回值,接著把外掛中的AndroidMainifest.xml關於要呼叫的Service的聲明覆制到宿主中的AndroidMainifest.xml中並補充完整包名,最後修改onDo方法中的呼叫程式碼就大功造成,MainActivity.java程式碼如下:
public class MainActivity extends Activity {
private final static String sApkName = "Plugin-debug.apk";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
simulationDownload(this, sApkName);
try {
loadPlugin(this, sApkName);
} catch (Exception e) {
e.printStackTrace();
}
onDo();
}
/**
* 載入外掛
* @param context
* @param apkName
* @throws IllegalAccessException
* @throws NoSuchMethodException
* @throws IOException
* @throws InvocationTargetException
* @throws InstantiationException
* @throws NoSuchFieldException
*/
private void loadPlugin(Context context, String apkName)
throws IllegalAccessException, NoSuchMethodException, IOException, InvocationTargetException, InstantiationException, NoSuchFieldException {
……
}
/**
* 執行外掛程式碼 和 啟動外掛中的服務
*/
private void onDo() {
try {
Class mLoadClassBean = Class.forName("com.zyx.plugin.TestBean");
Object testBeanObject = mLoadClassBean.newInstance();
Method getNameMethod = mLoadClassBean.getMethod("getName");
getNameMethod.setAccessible(true);
String name = (String) getNameMethod.invoke(testBeanObject);
Toast.makeText(getApplicationContext(), name, Toast.LENGTH_LONG).show();
Intent intent = new Intent();
String serviceName = "com.zyx.plugin.TestService";
intent.setClassName(MainActivity.this, serviceName);
startService(intent);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 模擬下載,實際上是將Assets中的外掛apk檔案複製到/data/data/files 目錄下
* @param context
* @param sourceName
*/
private void simulationDownload(Context context, String sourceName) {
……
}
}
此時執行App後,依然會彈出一個Toast,內容就是TestBean中的mName值,而且還會正常啟動Service。好了,到這裡就介紹完了宿主是如何加到外掛中的程式碼了,其實反過來,外掛要使用宿主中的程式碼是一樣的,只要在保證外掛載入完成後,通過反射呼叫宿主的類的可以了,這裡不作過多的演展了,讀者可以自己去嘗試。
至於Demo中為什麼要用Service來驗證,是因為Service不像Activity那樣,Service在啟動後不需要載入任何資源。上述Demo僅僅是解決了宿主載入外掛的問題,而關於資源的載入,我們留到下一遍文章中來詳細介紹。
順便一提,其實這種合併dex方案也可應用於熱修復。當補丁的dex和宿主dex合併後,它們存在了相同的類和方法,但位於Elements陣列前面的dex中的類和方法在遍歷過程中優先執行並跳出,那麼後面原來舊的就會生效。