1. 程式人生 > >Android外掛化系列第(四)篇---外掛載入機制兩種方案

Android外掛化系列第(四)篇---外掛載入機制兩種方案

一、相關概念

1.1、為什麼需要動態載入

這個問題,前面已經介紹過,如下

Android系統使用了ClassLoader機制來進行Activity等元件的載入;apk被安裝之後,APK檔案的程式碼以及資源會被系統存放在固定的目錄(比如/data/app/package_name/1.apk)系統在進行類載入的時候,會自動去這一個或者幾個特定的路徑來尋找這個類;但是系統並不知道存在於外掛中的Activity元件的資訊,外掛可以是任意位置,甚至是網路,系統無法提前預知,因此正常情況下系統無法載入我們外掛中的類;因此也沒有辦法建立Activity的物件,更不用談啟動元件了。這個時候就需要使用動態載入技術了。

1.2、類的載入機制

對於android中的classloader是按照以下的流程,loadClass方法在載入一個類的例項的時候,會先查詢當前ClassLoader例項是否載入過此類,有就返回;如果沒有。查詢Parent是否已經載入過此類,如果已經載入過,就直接返回Parent載入的類;如果繼承路線上的ClassLoader都沒有載入,才由Child執行類的載入工作;這樣做的好處:首先是共享功能,一些Framework層級的類一旦被頂層的ClassLoader載入過就快取在記憶體裡面,以後任何地方用到都不需要重新載入。除此之外還有隔離功能,不同繼承路線上的ClassLoader載入的類肯定不是同一個類,這樣的限制避免了使用者自己的程式碼冒充核心類庫的類訪問核心類庫包可見成員的情況。這也好理解,一些系統層級的類會在系統初始化的時候被載入,比如java.lang.String,如果在一個應用裡面能夠簡單地用自定義的String類把這個系統的String類給替換掉,那將會有嚴重的安全問題

結論:
DexClassLoader可以載入jar/apk/dex,可以從SD卡中載入未安裝的apk;
PathClassLoader只能載入系統中已經安裝過的apk;

現在介紹兩種外掛在宿主中載入的兩種方案。

二、動態載入方案

2.1、合併dexElements陣列

這裡的合併是指,將PathClassLoader和DexClassLoader中的dexElements進行合併,這種思路從何而來呢?通常在Android中我們用上述兩個ClassLoader載入類,他們的父類是BaseDexClassLoader。在父類的建構函式中建立了一個DexPathList物件,從名字看上去,估計這個類表示的是把很多個Dex

檔案的路徑放到一個List集合中。

BaseDexClassLoader.java

來看DexPathList的程式碼

DexPathList.java


類在build之後就會變成一個dex檔案,而這個檔案的路徑就存放在dexElements。所以自然就會想到,我們把宿主和外掛的dex都放到這裡面,這樣系統就會幫我們載入了。

 /**
     * 建立DexClassLoader,不能用DexClassLoader,因為DexClassLoader只能載入安裝過的
     */
    public DexClassLoader createDexClassLoader(Activity pActivity) {
        String cachePath = pActivity.getCacheDir().getAbsolutePath();
        String apkPath = Environment.getExternalStorageDirectory().getAbsolutePath() + "/chajian_demo.apk";
        return new DexClassLoader(apkPath, cachePath, cachePath, getClassLoader());
    }
  */
    public static void injectClassLoader(DexClassLoader loader,Context context){
        //獲取宿主的ClassLoader
        PathClassLoader pathLoader = (PathClassLoader)context.getClassLoader();
        try {
            //獲取宿主pathList
            Object hostPathList = getPathList(pathLoader);
            //獲取外掛pathList
            Object pluginPathList = getPathList(loader);
            //獲取宿主ClassLoader中的dex陣列
            Object hostDexElements = getDexElements(hostPathList);
            //獲取外掛CassLoader中的dex陣列
            Object pluginDexElements = getDexElements(pluginPathList);
            //獲取合併後的pathList
            Object sumDexElements = combineArray(hostDexElements, pluginDexElements);
            //將合併的pathList設定到本應用的ClassLoader
            setField(hostPathList, suZhuPathList.getClass(), "dexElements", sumDexElements);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
   private static Object getPathList(Object baseDexClassLoader)
            throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
            return getField(baseDexClassLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
    }
    private static Object getDexElements(Object paramObject)
            throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException {
        return getField(paramObject, paramObject.getClass(), "dexElements");
    }
   private static Object getField(Object obj, Class<?> cl, String field)
            throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
        //反射需要獲取的欄位
        Field localField = cl.getDeclaredField(field);
        localField.setAccessible(true);
        return localField.get(obj);
    }

上面的程式碼演示了怎麼合併系統預設載入器PathClassLoader和動態載入器DexClassLoader中的dexElements陣列,這種方案還是比較簡單的,現在看麻煩一些的。

2.1、替換LoadedApk中的mClassLoader

LoadedApk是什麼? LoadedApk物件是APK檔案在記憶體中的表示。 Apk檔案的相關資訊,諸如Apk檔案的程式碼和資源,甚至程式碼裡面的Activity,Service等元件的資訊我們都可以通過此物件獲取。

Paste_Image.png

為什麼想到要替換LoadedApk中的mClassLoader,這個答案也是看原始碼的,在Activity的啟動過程中,會獲取LoadedApk物件。

    public final LoadedApk getPackageInfo(String packageName, CompatibilityInfo compatInfo,
            int flags, int userId) {
        final boolean differentUser = (UserHandle.myUserId() != userId);
        synchronized (mResourcesManager) {
            WeakReference<LoadedApk> ref;
            if (differentUser) {
                ref = null;
            } else if ((flags & Context.CONTEXT_INCLUDE_CODE) != 0) {
                ref = mPackages.get(packageName);
            } else {
                ref = mResourcePackages.get(packageName);
            }

            LoadedApk packageInfo = ref != null ? ref.get() : null;

            if (packageInfo != null && (packageInfo.mResources == null
                    || packageInfo.mResources.getAssets().isUpToDate())) {
                if (packageInfo.isSecurityViolation()
                        && (flags&Context.CONTEXT_IGNORE_SECURITY) == 0) {
                    throw new SecurityException(
                            "Requesting code from " + packageName
                            + " to be run in process "
                            + mBoundApplication.processName
                            + "/" + mBoundApplication.appInfo.uid);
                }
                return packageInfo;
            }
        }

首先判斷了是不是同一個userId,如果是同一個user,嘗試獲取快取資料;如果沒有命中快取資料,才通過LoadedApk的建構函式建立了LoadedApk物件;因此當我們拿到這一份快取資料,修改裡面的ClassLoader,自己控制類載入的過程,這樣載入外掛中的Activity類的問題就解決了。

public class HookLoadedApk {

    public static Map<String, Object> sLoadedApk = new HashMap<String, Object>();

    public static void hookLoadedApkInActivityThread(File apkFile) throws Exception {

        // 1、獲取到當前的ActivityThread物件
        Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
        Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
        currentActivityThreadMethod.setAccessible(true);
        Object currentActivityThread = currentActivityThreadMethod.invoke(null);

        //2、 獲取 mPackages 靜態成員變數, 這裡快取了dex包的資訊
        Field mPackagesField = activityThreadClass.getDeclaredField("mPackages");
        mPackagesField.setAccessible(true);
        Map mPackages = (Map) mPackagesField.get(currentActivityThread);

        // 方法簽名:public final LoadedApk getPackageInfoNoCheck(ApplicationInfo ai,CompatibilityInfo compatInfo)
        //3、獲取getPackageInfoNoCheck方法
        Class<?> compatibilityInfoClass = Class.forName("android.content.res.CompatibilityInfo");
        Method getPackageInfoNoCheckMethod = activityThreadClass.getDeclaredMethod("getPackageInfoNoCheck",
                ApplicationInfo.class, compatibilityInfoClass);

        Field defaultCompatibilityInfoField = compatibilityInfoClass.getDeclaredField("DEFAULT_COMPATIBILITY_INFO");
        defaultCompatibilityInfoField.setAccessible(true);

        Object defaultCompatibilityInfo = defaultCompatibilityInfoField.get(null);

        //4、獲取applicationInfo資訊
        ApplicationInfo applicationInfo = generateApplicationInfo(apkFile);

        Object loadedApk = getPackageInfoNoCheckMethod.invoke(currentActivityThread, applicationInfo, defaultCompatibilityInfo);

        String odexPath = Utils.getPluginOptDexDir(applicationInfo.packageName).getPath();

        String libDir = Utils.getPluginLibDir(applicationInfo.packageName).getPath();

        //5、建立DexClassLoader
        ClassLoader classLoader = new DexClassLoader(apkFile.getPath(), odexPath, libDir, ClassLoader.getSystemClassLoader());
        Field mClassLoaderField = loadedApk.getClass().getDeclaredField("mClassLoader");
        mClassLoaderField.setAccessible(true);

        //6、替換掉loadedApk
        mClassLoaderField.set(loadedApk, classLoader);

        // 由於是弱引用, 為了防止被GC,我們必須在某個地方存一份
        sLoadedApk.put(applicationInfo.packageName, loadedApk);

        WeakReference weakReference = new WeakReference(loadedApk);

        mPackages.put(applicationInfo.packageName, weakReference);
    }


    /**
     * 反射generateApplicationInfo方法,得到ApplicationInfo物件
     *
     * generateApplicationInfo方法簽名:
     * public static ApplicationInfo generateApplicationInfo(Package p, int flags,PackageUserState state, int userId) 
     * 
     * 這個方法需要Package引數和PackageUserState引數
     * 
     * 
     */
    public static ApplicationInfo generateApplicationInfo(File apkFile) throws Exception{

        // 獲取PackageParser類
        Class<?> packageParserClass = Class.forName("android.content.pm.PackageParser");
       // 獲取PackageParser$Package類
        Class<?> packageParser$PackageClass = Class.forName("android.content.pm.PackageParser$Package");
        Class<?> packageUserStateClass = Class.forName("android.content.pm.PackageUserState");

        Method generateApplicationInfoMethod = packageParserClass.getDeclaredMethod("generateApplicationInfo",
                packageParser$PackageClass,
                int.class,
                packageUserStateClass);

        // 創建出一個PackageParser物件供使用
        Object packageParser = packageParserClass.newInstance();

        // 呼叫 PackageParser.parsePackage 解析apk的資訊
        Method parsePackageMethod = packageParserClass.getDeclaredMethod("parsePackage", File.class, int.class);

        // 得到第一個引數 :PackageParser.Package 物件
        Object packageObj = parsePackageMethod.invoke(packageParser, apkFile, 0);

        //得到第三個引數:PackageUserState物件
        Object defaultPackageUserState = packageUserStateClass.newInstance();

        // 反射generateApplicationInfo得到ApplicationInfo物件
        ApplicationInfo applicationInfo = (ApplicationInfo) generateApplicationInfoMethod.invoke(packageParser,
                packageObj, 0, defaultPackageUserState);
        String apkPath = apkFile.getPath();

        applicationInfo.sourceDir = apkPath;
        applicationInfo.publicSourceDir = apkPath;

        return applicationInfo;
    }
}

這種方法參考了weishu,比較複雜,因為ActivityThread對於LoadedApk有快取機制,我們才有機可乘,把自定義的ClassLoader的外掛資訊新增進mPackages中,從而完成了外掛的載入。關於這兩種方案,不能說哪一種更好,雖然第一種方案易理解,程式碼少,但是有一個問題,一旦外掛之間甚至外掛與宿主之間使用的類庫有衝突,就會崩潰,DroidPlugin採用的就是第二種方案,Small採用的是第一種方案,合併dexElements陣列。第二種方案也有缺點,除了Hook過程複雜外,每一個版本的apk解析都有差別,使用的PackageParser的相容性就比較差,根據不同版本來分別Hook。詳細的可以參考weishu,解釋的比我更清楚。

Please accept mybest wishes for your happiness and success !

參考部落格