1. 程式人生 > >Android外掛化原理和實踐 (三) 之 載入外掛中的元件程式碼

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物件,所以我們AppActivity中,通過getClassLoader獲取到的是PathClassLoader。就是意味著,Activity的建立只能在PathClassLoader中存在的類,其實中大元件的建立都是一樣。也就是說元件類必須是要定義在宿主中才可以正常創建出來。我們在上一篇文章的最後提出,在通過DexClassLoader來載入起外掛後,再使用startService來啟動外掛的一個服務,那麼當然就會報出異常。

2 ClassLoader相關類原始碼分析

其實解決方法說起來是很簡單的,就是要把外掛的ClassLoader對應的dex檔案塞入到宿主的ClassLoader中去就可以了。至少怎樣塞法?那就要先來看看PathClassLoader和DexClassLoader 它們的父類BaseDexClassLoader

和BaseDexClassLoader的父類ClassLoader的原始碼了:

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

我們都知道AndroidClass的載入是執行的ClassLoaderloadClass方法。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陣列中就可以了。所以這裡我們使用了反射,其步驟如下:

  1. 根據宿主的ClassLoader,獲取宿主的dexElements陣列,就是要反射出BaseDexClassLoader的DexPathList物件pathList,然後再反射出pathList裡頭的dexElements陣列
  2. 根據外掛的apk檔案,反射出一個Element型別物件,也就是外掛的dex
  3. 把外掛的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中的類和方法在遍歷過程中優先執行並跳出,那麼後面原來舊的就會生效。

 

點選下載Demo完整程式碼