Android外掛化系列三:技術流派和四大元件支援

Android笨鳥之旅發表於2019-10-15

Hello,各位朋友們,我們繼續外掛化系列的學習吧。下面是我這個系列文章的行文思路,

Android外掛化文章框架

本篇文章是本系列比較核心的一篇文章,我計劃這篇文章把外掛化的大體技術給講清楚。期間會涉及到系列的前兩篇文章的內容,推薦先閱讀前面的兩篇基礎文章Android外掛化系列一: 開篇前言,Binder機制,ClassLoaderAndroid外掛化系列二: 資源與打包流程

本篇文章預計需要半小時以上時間閱讀。讀完本篇文章,你將會了解:
1.外掛化的發展和流派
2.外掛化技術

  • 如何載入外掛中的類和資源
  • 如何解析外掛中的資訊
  • 如何利用aapt等方法解決宿主和外掛資源衝突的問題
  • 如何支援四大元件的外掛化

1.發展歷史和流派

先稍微介紹一下外掛化的發展歷史。外掛化技術,主要用在新聞,電商,閱讀,出行,視訊等領域,可以看到包含了我們生活的很多場景。在應用迭代的過程中,1.能快速的修復應用出問題的部分,2.為了搶佔市場,快速的根據市場反應進行迭代,3.將不常用功能模組做成外掛,減少包體積,這幾點對於應用的發展都是相當重要的事情。在這種背景下,外掛化技術應運而生。

下面是比較出名的幾個外掛化框架,根據出現的時間排序,通過研究他們的原理,可以把發展歷史大概分成三代。

時代 代表庫 特點
遠古 AndroidDynamicLoader(屠毅敏) adl基於動態替換Fragment來實現頁面的切換,雖然侷限大,但是給我們提供了想象的基礎
第一代 dynamic-load-apk(任玉剛),DroidPlugin(張勇) dla通過建立ProxyActivity來進行分發,外掛必須繼承ProxyActivity, 侵入性強且必須小心處理context。DroidPlugin是通過hook系統服務來進行Activity跳轉,缺點是hook太多,程式碼複雜且不夠穩定。
第二代 VirtualApk, Small(林光亮),RePlugin 為了同時達到外掛開發的低侵入性(像開發普通app一樣開發外掛)和框架的穩定性,在實現原理上都是趨近於選擇儘量少的hook,並通過在manifest中預埋一些元件實現對四大元件的外掛化。
第三代 VirtualApp,Atlas 在這一代中,外掛相容性,穩定性提升到更高的層次。同時,容器化框架的概念越來越流行。

2015年及以前,外掛化技術分成了明顯的兩派:以DroidPlugin為代表的動態替換方案和以dynamic-load-apk為代表的靜態代理方案。後來動態替換方案因為侵入性低,靈活穩定,逐步得到了更多人的支援。而從熱修復方案和react native開始應用以來,外掛化技術不再是唯一的選擇,而是進入慢慢完善的階段,到2017年以後外掛化技術基本成熟,相容性和穩定性也達到了較高的層次。大家有興趣的可以看看上面講到的幾個開源庫,體會外掛化技術的發展歷程。

2.外掛化技術

外掛化技術的技術主要可以概括為以下幾點:
1.外掛和宿主之間的程式碼和資源互用
2.外掛的四大元件支援和跳轉

這裡我們說到了外掛和宿主之間的程式碼和資源互用。其實這裡也是有學問的。外掛根據是否需要共享資原始碼分為獨立外掛和耦合外掛。獨立外掛是單獨執行在一個程式中的,與宿主完全隔離,崩潰不會影響到宿主。但是耦合外掛卻是和宿主執行在一個程式中,所以外掛崩潰,宿主也崩潰了。所以一般業務要根據資源和程式碼的耦合程度,外掛的可靠性等綜合考慮外掛型別。

我們接下來慢慢講解。

2.1 程式碼和資源互通

外掛與dex

因為可能看我文章的還有沒接觸過外掛化的同學,所以增加這一部分講解外掛和dex到底是怎麼一種存在形式,外掛,我們可以理解為一個單獨打包出來的apk。在專案中我們可以建立module並且在模組的build.gradle中把apply plugin: 'com.android.library'改為apply plugin: 'com.android.application'。這樣對這個模組打包的產物就是apk。

apk在打包的過程中,有一個class檔案打入dex的操作,最終Apk中存在的是dex。載入這種dex中的類,使用的ClassLoader也很有講究。前面我們在Android外掛化系列一: 開篇前言,Binder機制,ClassLoader中講到過,Android常用的就是PathClassLoader和DexClassLoader。PathClassLoader適用於已經安裝了的apk,一般作為預設載入器。而這裡外掛的apk是沒有安裝的,所以我們需要使用DexClassLoader來載入外掛dex中的類。下面是一段基本程式碼,演示瞭如何從外掛apk的dex中讀取類。

// 生成ClassLoader
File apkFile = File(apkPath, apkName);
String dexPath = apkFile.getPath();
File releaseFile = context.getDir("dex", 0);
DexClassLoader loader = new DexClassLoader(dexPath, releaseFile.getAbsolutePath(), null, getClassLoader());

// 載入類,使用類的方法
Class bean = loader.loadClass("xxx.xxx.xxx")  // 填入類的包名
Object obj = bean.newInstance();
Method method = bean.getMethod("xxx")  // 填入方法名
method.setAccessible(true);
method.invoke(obj)
複製程式碼

這樣,我們就可以通過反射來獲取到類,並使用相應的方法了。

面向介面程式設計

大家會看到,如果像上面那樣大量的使用反射,程式碼是相當醜陋的,擴充套件效能也差。這讓我們想到了,能不能參考依賴倒置原則中的面向介面或抽象程式設計的思想,預先定義好介面。這樣等需要使用的時候,就只需要把物件轉換為介面,就能呼叫介面的方法了。

比如我們app模組和外掛模組plugin依賴了介面模組interface, interface中定義了介面IPlugin。IPlugin的定義是

interface IPlugin {
    void sayHello(String name)
}
複製程式碼

plugin中就可以定義實現類

class PluginImpl implement IPlugin {
    @override
    void sayHello(String name) {
        Log.d("log""hello world" + name);
    }
}
複製程式碼

這樣,我們就可以在宿主app模組中去使用。具體的使用方法可以有反射和服務發現機制。為了簡單,這裡只用反射來呼叫具體的實現類。

Class pluginImpl = loader.loadClass("xxx.xxx.xxx")  // PluginIMpl 類的包名
Object obj = pluginImpl.newInstance();              // 生成PluginImpl物件
IPlugin plugin = (IPlugin)obj;
plugin.sayHello("AndroidEarlybird");
複製程式碼

既然介面都給出了,我們想做別的事情肯定就得心應手了。但是值得注意的是這裡的前提是宿主和外掛都需要依賴介面模組,也就是說雙方是有程式碼和資源依賴的,因此這種方法只適用於耦合外掛,獨立外掛的話就只能用反射來呼叫了。

PMS

在外掛化技術中,ActivityManagerServiche(AMS)和PackageManagerService(PMS)都是相當重要的系統服務。AMS自不用說,四大元件各種操作都需要跟它打交道,PMS也十分重要,完成了諸如許可權校撿(checkPermission,checkUidPermission),Apk meta資訊獲取(getApplicationInfo等),四大元件資訊獲取(query系列方法)等重要功能。

使用PMS

android一般使用PMS來進行應用安裝,安裝的時候PMS需要藉助於PackageParser進行apk解析工作,主要負責解析出一個PackageParser.Package物件,這個物件還是很大用途的。下面是這個Package物件的一些屬性值。

Android外掛化系列三:技術流派和四大元件支援

可以看到我們通過這個類可以拿到apk中的四大元件,許可權等資訊,在外掛化中,我們有時候會需要利用這個類去拿到廣播的資訊來處理外掛中的靜態廣播

那麼如何使用PackageParser這個類呢?下面是VirtualApk的一些使用

    public static final PackageParser.Package parsePackage(final Context context, final File apk, final int flags) throws PackageParser.PackageParserException {
        if (Build.VERSION.SDK_INT >= 24) {
            return PackageParserV24.parsePackage(context, apk, flags);
        } else if (Build.VERSION.SDK_INT >= 21) {
            return PackageParserLollipop.parsePackage(context, apk, flags);
        } else {
            return PackageParserLegacy.parsePackage(context, apk, flags);
        }
    }

    private static final class PackageParserV24 {
        static final PackageParser.Package parsePackage(Context context, File apk, int flags) throws PackageParser.PackageParserException {
            PackageParser parser = new PackageParser();
            PackageParser.Package pkg = parser.parsePackage(apk, flags);
            ReflectUtil.invokeNoException(PackageParser.class, null, "collectCertificates",
                    new Class[]{PackageParser.Package.class, int.class}, pkg, flags);
            return pkg;
        }
    }
複製程式碼

因為PackageParser針對系統版本變化很大,所以VirtualApk對這個類做了多版本的適配,我們這裡只展示了一種。

Hook PMS

正如我們需要hook AMS去進行一些外掛化的一些工作,有時候我們也得對PMS進行hook。通過看原始碼,我們知道PMS的獲取也是通過Context獲取的,直奔ContextImpl類的getPackageManager方法。

public PackageManager getPackageManager() {
    if (mPackageManager != null) {
        return mPackageManager;
    }

    IPackageManager pm = ActivityThread.getPackageManager();
    if (pm != null) {
        // Doesn't matter if we make more than one instance.
        return (mPackageManager = new ApplicationPackageManager(this, pm));
    }
    return null;
}

// 繼續跟進到ActivityThread的getPackageManager方法中
public static IPackageManager getPackageManager() {
    if (sPackageManager != null) {
        return sPackageManager;
    }
    IBinder b = ServiceManager.getService("package");
    sPackageManager = IPackageManager.Stub.asInterface(b);
    return sPackageManager;
}
複製程式碼

這裡我們可以看到,要想hook PMS需要把這兩個地方都hook住:

  • ActivityThread的靜態欄位sPackageManager
  • 通過Context類的getPackageManager方法獲取到的ApplicationPackageManager物件裡面的mPM欄位。

示例程式碼如下:

// 獲取全域性的ActivityThread物件
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
Object currentActivityThread = currentActivityThreadMethod.invoke(null);

// 獲取ActivityThread裡面原始的 sPackageManager
Field sPackageManagerField = activityThreadClass.getDeclaredField("sPackageManager");
sPackageManagerField.setAccessible(true);
Object sPackageManager = sPackageManagerField.get(currentActivityThread);

// 準備好代理物件, 用來替換原始的物件
Class<?> iPackageManagerInterface = Class.forName("android.content.pm.IPackageManager");
Object proxy = Proxy.newProxyInstance(iPackageManagerInterface.getClassLoader(),
        new Class<?>[] { iPackageManagerInterface },
        new HookHandler(sPackageManager));

// 1. 替換掉ActivityThread裡面的 sPackageManager 欄位
sPackageManagerField.set(currentActivityThread, proxy);

// 2. 替換 ApplicationPackageManager裡面的 mPM物件
PackageManager pm = context.getPackageManager();
Field mPmField = pm.getClass().getDeclaredField("mPM");
mPmField.setAccessible(true);
mPmField.set(pm, proxy);
複製程式碼

管理ClassLoader

上面我們講到了如何利用ClassLoader來載入dex中的類,現在我們再來深入聊聊這個話題。首先,需要明確的是,因為我們外掛的類都是位於沒有安裝的apk的dex中,所以我們不能直接使用主app的ClassLoader。那麼就會有多種解決方案。

比較直接的思想是通過對每一個外掛都新建一個ClassLoader來做載入。那麼如果我們外掛很多的時候,我們需要做的就是把每個外掛的ClassLoader給記錄下來,當使用某個外掛的類的時候,用它對應的ClassLoader去載入。正如我們上節的例子中展示的那樣。

另一種思想是直接操作dex陣列。宿主和外掛的ClassLoader都會對應一個dex陣列。那麼我們如果能把外掛的dex陣列合併到宿主的dex陣列裡面去的話,我們就能用宿主的ClassLoader來反射載入外掛的dex陣列中的類了。這樣做的目的是不需要管理外掛的ClassLoader,只要用宿主的ClassLoader就行了。比如我們曾經在Android外掛化系列一: 開篇前言,Binder機制,ClassLoader中講到DexClassLoader的原始碼。

public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;

    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
        String libraryPath, ClassLoader parent) {
        super(parent);  //見下文
        //收集dex檔案和Native動態庫【見小節3.2】
        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }
}

public class DexPathList {
    private Element[] dexElements;

    public DexPathList(ClassLoader definingContext, String dexPath, String librarySearchPath, File optimizedDirectory) {
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions, definingContext);
    }

    private static List<File> splitDexPath(String path) {
       return splitPaths(path, false);
    }

    private static List<File> splitPaths(String searchPath, boolean directoriesOnly) {
        List<File> result = new ArrayList<>(); 
        if (searchPath != null) {
            for (String path : searchPath.split(File.pathSeparator)) {
                // 省略
            }
        }
        return result;
     }
}
複製程式碼

從上面我們可以看出,dexPath字串是由多個分號分割的。拆分成字串陣列以後,每個path都是一個外部的dex/apk路徑。那麼我們很自然的想到,能不能把外掛的dex路徑手動新增到宿主的dexElements陣列中呢?答案當然是ok的,方案就是使用Hook。我們可以先反射獲取到ClassLoader的dexPathList,然後再獲取這個list的dexElements陣列,然後手動把外掛構建出Element,再拷貝到dexElements陣列中。熱修復框架Nuwa也是使用這種思想。

第三種思路是ClassLoader delegate。本文推薦這種方法。首先我們自定義ClassLoader,取代原先宿主的ClassLoader,並且把宿主作為Parent,同時在自定義的ClassLoader中用一個集合放置所有外掛的ClassLoader,然後這個自定義ClassLoader在載入任何一個類的時候,依據雙親委託機制,載入類都會先從宿主的ClassLoader中尋找,沒有的話再遍歷ClassLoader集合尋找能載入這個類的外掛ClassLoader。當然這裡又會有提高效率的優化點,比如遍歷集合的方式可以改為先從已載入過的集合中尋找,再從未載入過的集合中尋找。下面是示例程式碼。

class PluginManager {
    public static void init(Application application) {
        //初始化一些成員變數和載入已安裝的外掛
        mPackageInfo = RefInvoke.getFieldObject(application.getBaseContext(), "mPackageInfo");
        mBaseContext = application.getBaseContext();
        mNowResources = mBaseContext.getResources();

        mBaseClassLoader = mBaseContext.getClassLoader();
        mNowClassLoader = mBaseContext.getClassLoader();
        
        ZeusClassLoader classLoader = new ZeusClassLoader(mBaseContext.getPackageCodePath(), mBaseContext.getClassLoader());

        File dexOutputDir = mBaseContext.getDir("dex", Context.MODE_PRIVATE);
        final String dexOutputPath = dexOutputDir.getAbsolutePath();
        for(PluginItem plugin: plugins) {
            DexClassLoader dexClassLoader = new DexClassLoader(plugin.pluginPath,
                    dexOutputPath, null, mBaseClassLoader);
            classLoader.addPluginClassLoader(dexClassLoader);
        }
        // 替換原有的宿主的ClassLoader為自定義ClassLoader,將原來的宿主ClassLoader作為自定義ClassLoader的
        RefInvoke.setFieldObject(mPackageInfo, "mClassLoader", classLoader);
        Thread.currentThread().setContextClassLoader(classLoader);
        mNowClassLoader = classLoader;
    }
}

class ZeusClassLoader extends PathClassLoader {
    private List<DexClassLoader> mClassLoaderList = null;

    public ZeusClassLoader(String dexPath, ClassLoader parent, PathClassLoader origin) {
        super(dexPath, parent);

        mClassLoaderList = new ArrayList<DexClassLoader>();
    }

    /**
     * 新增一個外掛到當前的classLoader中
     */
    protected void addPluginClassLoader(DexClassLoader dexClassLoader) {
        mClassLoaderList.add(dexClassLoader);
    }

    @Override
    protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
        Class<?> clazz = null;
        try {
            //先查詢parent classLoader,這裡實際就是系統幫我們建立的classLoader,目標對應為宿主apk
            clazz = getParent().loadClass(className);
        } catch (ClassNotFoundException ignored) {
        }

        if (clazz != null) {
            return clazz;
        }

        //挨個的到外掛裡進行查詢
        if (mClassLoaderList != null) {
            for (DexClassLoader classLoader : mClassLoaderList) {
                if (classLoader == null) continue;
                try {
                    //這裡只查詢外掛它自己的apk,不需要查parent,避免多次無用查詢,提高效能
                    clazz = classLoader.loadClass(className);
                    if (clazz != null) {
                        return clazz;
                    }
                } catch (ClassNotFoundException ignored) {
                }
            }
        }
        throw new ClassNotFoundException(className + " in loader " + this);
    }
}

複製程式碼

資源

Resources&AssetManager

android中的資源大致分為兩類:一類是res目錄下存在的可編譯的資原始檔,比如anim,string之類的,第二類是assets目錄下存放的原始資原始檔。因為Apk編譯的時候不會編譯這些檔案,所以不能通過id來訪問,當然也不能通過絕對路徑來訪問。於是Android系統讓我們通過Resources的getAssets方法來獲取AssetManager,利用AssetManager來訪問這些檔案。

Resources resources = context.getResources();
AssetManager manager = resources.getAssets();
InputStream is = manager.open("filename");
複製程式碼

Resources和AssetManager的關係就像銷售和研發。Resources負責對外,外部需要的getString, getText等各種方法都是通過Resources這個類來呼叫的。而這些方法其實都是呼叫的AssetManager的私有方法。所以最終兩類資源都是AssetManager在兢兢業業的向Android系統要資源,為外界服務著。

AssetManager裡有個很重要的方法addAssetPath(String path)方法,App啟動的時候會把當前apk的路徑傳進去,然後AssetManager就能訪問這個路徑下的所有資源也就是宿主apk的資源了。那麼idea就冒出來了,如果我們把外掛的地址也傳進這個方法去,是不是就能得到一個能同時訪問宿主和外掛的所有資源的“超級”AssetManager了呢?答案是肯定的,這也是外掛化對資源的一種解決方案

下面是一段示例程式碼展示了獲取宿主的Resources中的AssetManager,然後呼叫addAssetPath新增外掛路徑,最後生成一個新的Resources的方法

// 新生成AssetManager,呼叫addAssetPath
AssetManager assetManager = resources.getAssets();  // 先通過Resources拿到示例程式碼
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, dexPath1);
mAssetManager = assetManager;

// 根據新生成的AssetManager生成Resources
mResources = new Resources(mAssetManager, super.getResources().getDisplayMetrics(), super.getResources().getConfiguration());
複製程式碼

接下來我們要分別將宿主和外掛的原有Resources替換成我們上面生成的Resources。注意這裡傳入的application應該是宿主和外掛對應的Application。

Object contextImpl = RefInvoke.getFieldObject("android.app.ContextImpl", application, "getImpl")  // 獲取Application的context
LoadedApk loadedApk = (LoadedApk)RefInvoke.getFieldObject(contextImpl, "mPackageInfo")
RefInvoke.setFieldObject(loadedApk, "mResources", resources);
RefInvoke.setFieldObject(application.getBaseContext(), "mResources", resources);
複製程式碼

除了需要替換Application的Resources物件,我們也需要替換Activity的Resources物件,宿主和外掛的Resources都需要替換。這是因為他們都是Context,只替換Application的並不能影響到Activity。我們可以在Instrumentation回撥callActivityOnCreate的時候去替換。這點在後面Activity外掛化處理部分再詳細講解。

上面只是展示了使用,想了解更多資訊的可以檢視VirtualApk

解決資源衝突

Android外掛化系列二: 資源與打包流程中我們提到了外掛和宿主分別打包的時候可能會存在資源id衝突的情況,上面我們使用了一個超級Resource之後,id如果重複了,執行的時候使用id來查詢資源就會報錯。

為了解決id衝突的問題一般有三種方案:

  1. 修改android打包的aapt工具,將外掛資源字首改為0x02 - 0x7e之間的數值
  2. 進入到哪個外掛,就為哪個外掛生成新的AssetManager和Resources

其中,方案二比較複雜,並且不利於宿主和外掛資源的互相呼叫。所以我們在上節採用的是超級Resources的方案,所以這裡我們介紹一下方案一,也就是修改aapt工具。

aapt是android打包資源的處理工具,大多數的外掛話開源庫對齊進行改造無外乎都是兩種方式:

可以看到aapt(1)處理外掛化的資源並不是很友好,開發和維護難度都比較大,後來google推出了Android App Bundle這個和外掛很類似的feature,就推出了aapt2來支援了資源分包。我們注意官網上這幾個aapt2的打包引數:

Android外掛化系列三:技術流派和四大元件支援

是不是發現官方已經給我們支援好了按package區分資源字首id,多美好啊哈哈。

當然這裡也是有坑的。那就是需要buildTools版本大於28.0.0 在buildTools 28.0.0以前,aapt2自帶了資源分割槽,通過–package-id引數指定。但是該分割槽只支援>0x7f的PP段,而在Android 8.0之前,是不支援>0x7f的PP段資源的,執行時會拋異常。但是當指定了一個<0x7f的PP段資源後,編譯資源時卻會報錯

error: invalid package ID 0x15. Must be in the range 0x7f-0xff..
複製程式碼

所以對於Android P之前使用的buildTools版本(<28.0.0),我們必須通過修改aapt2的原始碼達到資源分割槽的目的。而在28.0.0以後,aapt2支援了<0x7f預留PP段分割槽的功能,只需要指定引數--allow-reserved-package-id即可。

--allow-reserved-package-id --package-id package-id
複製程式碼
外掛使用宿主資源

在我們為宿主開發外掛的時候,經常不可避免的出現外掛要使用宿主中資源的情況,如果我們把宿主的資源copy一份放在外掛中,那無疑會大大增加包的大小,並且這些都是重複資源,是不應該在App中存在的。那麼我們就得想辦法讓外掛使用宿主的資源。比如這樣

Android外掛化系列三:技術流派和四大元件支援

前面已經講到了,我們可以通過為外掛和宿主一起構建一個超級Resources,包括了外掛和宿主所有的資源,理論上可以通過資源id獲取到所有的資源,那麼問題來了,外掛中的R檔案是不包含宿主的R檔案的,我們在編碼的時候怎麼使用呢?

下面分程式碼使用xml使用兩種使用方式來說解決方案: 程式碼使用:在外掛資源打包任務processResourcesTask完成後將宿主的R.txt檔案(打包過程中產生,位置在build/intermediates/symbols/xx/xx/R.txt)合併到外掛的R.txt檔案,然後再生成R.java,這樣就可以正常的使用R檔案來索引資源了

xml使用:我們需要在aapt2打包的時候指定-I引數。

Android外掛化系列三:技術流派和四大元件支援

這樣,我們通過-I指定宿主的資源包,就可以在xml中使用宿主的資源了。

總結

本節我們首先介紹外掛程式碼的dex載入,給出了利用反射和麵向介面程式設計來獲取外掛中的程式碼的方法,然後介紹了通過自定義delegate ClassLoader的方法來更好的載入外掛和宿主中的類,接下來介紹了PMS如何獲取外掛的資訊以及如何進行自定義hook,最後講到了外掛使用宿主資源的一些知識。到了這一步,我們已經可以獲取到外掛的各種資訊,可以實現宿主和外掛中的程式碼互通,可以實現外掛呼叫宿主的資源,基本上算是邁出了一大步!但是隻有程式碼和資源是不夠的,接下來我們看看怎麼處理android的四大元件,這一塊才是重頭戲,也是外掛化的精髓

2.2 四大元件支援

android的四大元件其實有挺多的共通之處,比如他們都接受ActivityManagerService(AMS)的管理,都需要通過Binder機制請求AMS服務。並且他們的請求流程也是基本相通的,其中Activity又是最重要的元件,出鏡最多,同時也是日常開發接觸最多的元件,我們將會主要以Activity為例,講解外掛化對四大元件的支援,其餘三個元件有不同或值得注意的地方我們會另外指出來。當然,針對四大元件的解決方案有很多種,本文限於篇幅只介紹DroidPlugin的動態替換方案。

Activity

AndroidManifest.xml預佔位

相信做過Android開發的都知道,四大元件基本都是要在AndroidManifest.xml中定義的,不然系統就會報錯,然後問你 have you declared this activity in your AndroidManifest.xml? 【必須在AndroidMainfest.xml中定義四大元件】這一點對外掛化確實是比較嚴重的限制,畢竟我們並沒有辦法提前就把外掛中的Activity宣告進去,但是這個限制也並不是沒辦法解決的。比如DroidPlugin就採用了預佔位Activity到AndroidManifest.xml中的方案。

DroidPlugin的方案思想很簡單,先在AndroidManifest.xml中預定義好各種LaunchMode的佔位Activity和其餘三大元件。比如

<activity
    android:name=".StubSingleTaskActivity1"
    android:exported="true"
    android:launchMode="singleTask"
    android:theme="@style/Theme.NoActionBar"
    android:screenOrientation="portrait" />

<activity
    android:name=".StubSingleTopActivity1"
    android:exported="true"
    android:launchMode="singleTop"
    android:theme="@style/Theme.NoActionBar"
    android:screenOrientation="portrait" />
複製程式碼

這樣的話,我們就要實行狸貓換太子的方法,把本來想要開啟的Activity換成StubActivity,然後躲過了系統對【必須在AndroidMainfest.xml中定義四大元件】的審查真正的開始start Activity的時候再去開啟真正的目的Activity。那麼我們怎麼去實現這個想法呢,這就要求我們熟悉Activity的啟動流程了。

startActivity流程

startActivity的流程比較繁雜,甚至可以作為一篇單獨的文章來講解。網上有很多的文章在講解,比較詳細牛逼的是老羅的Android應用程式的Activity啟動過程簡要介紹和學習計劃。大家如果有興趣的話可以參考。我這裡只簡明扼要的講解部分的流程。

首先先看一個流程圖

Android外掛化系列三:技術流派和四大元件支援

首先我們都是從startActivity進去的,輾轉發現它呼叫了Instrumentation的execStartActivity方法,接著在這個函式裡面呼叫了ActivityManagerNative類的startActivity方法,請求到了ActivityManagerService的服務。這一點就是我們在Android外掛化系列一: 開篇前言,Binder機制,ClassLoader講到過的Binder機制在Activity啟動過程中的體現。可以看到就是在AMS的startActivity的方法中校驗了Activity是否註冊,確定了Activity的啟動模式,AMS我們沒辦法改啊,所以我們們得出個結論一定要在校驗前的流程裡把Activity給替換掉。繼續往下看,可以看到ActivityStackSupervisor把啟動的重任最終委託給了ApplicationThread。

我們在前面的系列一中說過,Binder機制其實是互為Client和Server的,在app申請AMS服務的時候,AMS是Server,AMP是AMS在app的代理。而在申請到AMS服務以後,AMS需要請求App進行後續控制的時候,ApplicationThread就是Server,ApplicationThreadProxy就是ApplicationThread在AMS側的代理。

Android外掛化系列三:技術流派和四大元件支援

繼續往下看,可以看到ActivityThread呼叫了H類,最終呼叫了handleLaunchActivity方法,由Instrumentation建立出了Activity物件,啟動流程結束。

“狸貓換太子”

看完了上面的啟動流程,大家可以想到,在這個流程中我只要在呼叫AMS前把目標Activity替換成StubActivity(上半場),在AMS校驗完,馬上要開啟Activity的時候替換為目標Activity(下半場),這樣就可以達到“狸貓換太子”啟動目標Activity的目的了啊。因為流程較長,參與的類較多,所以我們可以選擇的hook點也是相當多的,但是我們越早hook,後續的操作越多越容易出問題,所以我們選擇比較後面的流程去hook。這裡選擇:

  • 上半場,hook ActivityManagerNative對於startActivity方法的呼叫
  • 下半場,hook H.mCallback物件,替換為我們的自定義實現,

hook AMN 下面是一些示例程式碼,可以看到我們替換掉交給AMS的intent物件,將裡面的TargetActivity的暫時替換成已經宣告好的替身StubActivity。

        if ("startActivity".equals(method.getName())) {
            // 只攔截這個方法
            // 替換引數, 任你所為;甚至替換原始Activity啟動別的Activity偷樑換柱

            // 找到引數裡面的第一個Intent 物件
            Intent raw;
            int index = 0;

            for (int i = 0; i < args.length; i++) {
                if (args[i] instanceof Intent) {
                    index = i;
                    break;
                }
            }
            raw = (Intent) args[index];

            Intent newIntent = new Intent();

            // 替身Activity的包名, 也就是我們自己的包名
            String stubPackage = raw.getComponent().getPackageName();

            // 這裡我們把啟動的Activity臨時替換為 StubActivity
            ComponentName componentName = new ComponentName(stubPackage, StubActivity.class.getName());
            newIntent.setComponent(componentName);

            // 把我們原始要啟動的TargetActivity先存起來
            newIntent.putExtra(AMSHookHelper.EXTRA_TARGET_INTENT, raw);

            // 替換掉Intent, 達到欺騙AMS的目的
            args[index] = newIntent;

            Log.d(TAG, "hook success");
            return method.invoke(mBase, args);

        }
複製程式碼

hook H.mCallback 前面我們說過,ActivityThread是藉助於H這個類完成四大元件的操作管理。H繼承自Handler,我們看看Handler處理訊息的dispatchMessage方法。

public void dispatchMessage(Message msg) {
    if (msg.callback != null) {
        handleCallback(msg);
    } else {
        if (mCallback != null) {
            if (mCallback.handleMessage(msg)) {
                return;
            }
        }
        handleMessage(msg);
    }
}
複製程式碼

而H的handleMessage方法中正是處理LAUNCH_ACTIVITY,CREATE_SERVICE等訊息的地方。所以我們就會想,在mCallback.handleMessage中替換回原來的Activity應該就是最晚的時間點了吧。下面是自定義的Callback類,反射設定為ActivityThread的H的mCallback就行了。

class MockClass2 implements Handler.Callback {

    Handler mBase;

    public MockClass2(Handler base) {
        mBase = base;
    }

    @Override
    public boolean handleMessage(Message msg) {

        switch (msg.what) {
            // ActivityThread裡面 "LAUNCH_ACTIVITY" 這個欄位的值是100
            // 本來使用反射的方式獲取最好, 這裡為了簡便直接使用硬編碼
            case 100:
                handleLaunchActivity(msg);
                break;

        }

        mBase.handleMessage(msg);
        return true;
    }

    private void handleLaunchActivity(Message msg) {
        // 這裡簡單起見,直接取出TargetActivity;
        Object obj = msg.obj;

        // 把替身恢復成真身
        Intent intent = (Intent) RefInvoke.getFieldObject(obj, "intent");

        Intent targetIntent = intent.getParcelableExtra(AMSHookHelper.EXTRA_TARGET_INTENT);
        intent.setComponent(targetIntent.getComponent());
    }
}
複製程式碼
替換Resources

還記得我們在第一節留下了一個問題嗎,就是Activity的資源替換要在Instrumentation回撥callActivityOnCreate的時候進行。這個時間點比較臨近onCreate,Instrumentation也比較方便去hook。下面展示這個技術,需要傳入超級Resources。

public void hookInstrumentation(){
    Object currentActivityThread = RefInvoke.invokeStaticMethod("android.app.ActivityThread", "currentActivityThread");
    // 拿到原始的 mInstrumentation欄位
    Instrumentation mInstrumentation = (Instrumentation) RefInvoke.getFieldObject(currentActivityThread, "mInstrumentation");
    // 建立代理物件
    Instrumentation evilInstrumentation = new EvilInstrumentation(mInstrumentation, resources);  // 這裡的resources是我們的超級Resources
    RefInvoke.setFieldObject(currentActivityThread, "mInstrumentation", evilInstrumentation);
}

// 這裡的Activity是在
public class EvilInstrumentation extends Instrumentation {
    Instrumentation mBase;
    Resources mRes;

    public EvilInstrumentation(Instrumentation base,Resources res) {
        mBase = base;
        mRes = res;
    }

    @override
    public void callActivityOnCreate(Activity activity, Bundle bundle) {
        // 替換Resources
        if (mRes != null) {
            RefInvoke.setFieldObject(activity.getBaseContext().getClass(), activity.getBaseContext(), "mResources", mRes);
        }
        super.callActivityOnCreate(activity, bundle);
    }
}
複製程式碼

Service

Service的處理和Activity的基本一樣,區別是呼叫多次startService並不會啟動多個Service例項,而是隻有一個例項,所以我們的佔位Service得多定義一些。

BroadcastReceiver

BroadcastReceiver的外掛化和Activity的不太一樣。Android中的廣播分為兩種:靜態廣播和動態廣播,動態廣播不需要和AMS互動,就是一個普通類,只要按照前面的ClassLoader方案保證他能載入就行了。但是靜態廣播比較麻煩,除了需要在AndroidManifest.xml中進行註冊以外,他和Activity不一樣的是,他還附加了IntentFilter資訊。而IntentFilter資訊是隨機的,無法被預佔位的。這個時候就只能把取出外掛中的靜態廣播改為動態廣播了。雖然會有一些小問題,但是影響不大

前面我們講到了PackageParser可以獲取到外掛的四大元件的資訊,儲存到Package物件中,那麼我們就有個思路,通過PMS獲取到BroadcastReceiver,然後把其中的靜態廣播改為動態廣播.

    public static void preLoadReceiver(Context context, File apkFile) {
        // 首先呼叫parsePackage獲取到apk物件對應的Package物件
        Object packageParser = RefInvoke.createObject("android.content.pm.PackageParser");
        Class[] p1 = {File.class, int.class};
        Object[] v1 = {apkFile, PackageManager.GET_RECEIVERS};
        Object packageObj = RefInvoke.invokeInstanceMethod(packageParser, "parsePackage", p1, v1);

        // 讀取Package物件裡面的receivers欄位,注意這是一個 List<Activity> (沒錯,底層把<receiver>當作<activity>處理)
        // 接下來要做的就是根據這個List<Activity> 獲取到Receiver對應的 ActivityInfo (依然是把receiver資訊用activity處理了)
        List receivers = (List) RefInvoke.getFieldObject(packageObj, "receivers");

        for (Object receiver : receivers) {
            registerDynamicReceiver(context, receiver);
        }
    }

    // 解析出 receiver以及對應的 intentFilter
    // 手動註冊Receiver
    public static void registerDynamicReceiver(Context context, Object receiver) {
        //取出receiver的intents欄位
        List<? extends IntentFilter> filters = (List<? extends IntentFilter>) RefInvoke.getFieldObject(
                "android.content.pm.PackageParser$Component", receiver, "intents");

        try {
            // 把解析出來的每一個靜態Receiver都註冊為動態的
            for (IntentFilter intentFilter : filters) {
                ActivityInfo receiverInfo = (ActivityInfo) RefInvoke.getFieldObject(receiver, "info");

                BroadcastReceiver broadcastReceiver = (BroadcastReceiver) RefInvoke.createObject(receiverInfo.name);
                context.registerReceiver(broadcastReceiver, intentFilter);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
複製程式碼

ContentProvider

ContentProvider的外掛化方法和BroadcastReceiver的很像,但是和BroadcastReceiver不同的是,BroadcastReceiver中的廣播叫做註冊,但ContentProvider是要“安裝”。方案是: 首先,呼叫PackageParser的parsePackage方法,把得到的Package物件通過generateProviderInfo轉換為ProviderInfo物件。

    public static List<ProviderInfo> parseProviders(File apkFile) throws Exception {

        //獲取PackageParser物件例項
        Class<?> packageParserClass = Class.forName("android.content.pm.PackageParser");
        Object packageParser = packageParserClass.newInstance();

        // 首先呼叫parsePackage獲取到apk物件對應的Package物件
        Class[] p1 = {File.class, int.class};
        Object[] v1 = {apkFile, PackageManager.GET_PROVIDERS};
        Object packageObj = RefInvoke.invokeInstanceMethod(packageParser, "parsePackage",p1, v1);

        // 讀取Package物件裡面的services欄位
        // 接下來要做的就是根據這個List<Provider> 獲取到Provider對應的ProviderInfo
        List providers = (List) RefInvoke.getFieldObject(packageObj, "providers");

        // 呼叫generateProviderInfo 方法, 把PackageParser.Provider轉換成ProviderInfo

        //準備generateProviderInfo方法所需要的引數
        Class<?> packageParser$ProviderClass = Class.forName("android.content.pm.PackageParser$Provider");
        Class<?> packageUserStateClass = Class.forName("android.content.pm.PackageUserState");
        Object defaultUserState = packageUserStateClass.newInstance();
        int userId = (Integer) RefInvoke.invokeStaticMethod("android.os.UserHandle", "getCallingUserId");
        Class[] p2 = {packageParser$ProviderClass, int.class, packageUserStateClass, int.class};

        List<ProviderInfo> ret = new ArrayList<>();
        // 解析出intent對應的Provider元件
        for (Object provider : providers) {
            Object[] v2 = {provider, 0, defaultUserState, userId};
            ProviderInfo info = (ProviderInfo) RefInvoke.invokeInstanceMethod(packageParser, "generateProviderInfo",p2, v2);
            ret.add(info);
        }

        return ret;
    }
複製程式碼

然後我們需要呼叫ActivityThread的installContentProviders方法把這些ContentProvider“安裝”到宿主中。

    public static void installProviders(Context context, File apkFile) throws Exception {
        List<ProviderInfo> providerInfos = parseProviders(apkFile);

        for (ProviderInfo providerInfo : providerInfos) {
            providerInfo.applicationInfo.packageName = context.getPackageName();
        }

        Object currentActivityThread = RefInvoke.getStaticFieldObject("android.app.ActivityThread", "sCurrentActivityThread");

        Class[] p1 = {Context.class, List.class};
        Object[] v1 = {context, providerInfos};

        RefInvoke.invokeInstanceMethod(currentActivityThread, "installContentProviders", p1, v1);
    }
複製程式碼

ContentProvider的外掛化還需要注意:

  1. App安裝自己的ContentProvider是在程式啟動時候進行,比Application的onCreate還要早,所以我們要在Application的attachBaseContext方法中手動執行上述操作。
  2. 讓外界App直接呼叫外掛的App,並不是一件特別好的事情,最好是由App的ContentProvider作為中轉。因為字串是ContentProvider的唯一標誌,轉發機制就特別適用。

Android外掛化系列三:技術流派和四大元件支援

總結

本文首先介紹了外掛化中宿主和外掛程式碼和資源互通的方式,然後介紹了四大元件的外掛化方法,因為外掛化技術太過繁雜,並沒有把所有的細節都覆蓋到,所介紹的方案也只是當今比較實用,經受過考驗的一套,並沒有介紹太多的方法。目的是讓讀者們和我一起,先從整體上理解外掛化的機制,然後就容易去區分各種開源庫的原理和思路了。

參考

感謝下面的各位老師的書籍或文章,讓我受益匪淺。
1.包建強《Android外掛化開發指南》
2.田維術的部落格
3.外掛化載入dex跟資源原理
4.深入理解Android外掛化技術
5.外掛化-解決外掛資源ID與宿主資源ID衝突的問題
6.官網aapt2
7.再談 aapt2 資源分割槽

我是Android笨鳥之旅,笨鳥也要有向上飛的心,我在這裡陪你一起慢慢變強。

Android外掛化系列三:技術流派和四大元件支援

相關文章