吹爆系列: Android 外掛化的今生前世大揭祕

語言: CN / TW / HK

本文作者

作者: ZYLAB

連結:

https://juejin.cn/post/6844903885476233229

本文由作者授權釋出。

預備知識

1、瞭解 android 基本開發。

2、瞭解 android 四大元件基本原理。

3、瞭解 ClassLoader 相關知識。

看完本文可以達到什麼程度

1、瞭解外掛化常見的實現原理

閱讀前準備工作

1、clone  CommonTec  專案

https://github.com/5A59/android-training/tree/master/common-tec/CommonTec

文章概覽

1

外掛化框架歷史

整個外掛化框架歷史部分參考了 包建強在 2016GMTC 全球開發大會上的演講

https://www.infoq.cn/article/android-plug-ins-from-entry-to-give-up/

2012 年  AndroidDynamicLoader  給予 Fragment 實現了外掛化框架,可以動態載入外掛中的 Fragment 實現頁面的切換。

https://github.com/mmin18/AndroidDynamicLoader

2013 年 23Code 提供了一個殼,可以在殼裡動態化下載外掛然後執行。


2013 年 阿里技術沙龍上,伯奎做了 Atlas 外掛化框架的分享,說明那時候阿里已經在做外掛化的運用和開發了。

2014 年 任玉剛開源了  dynamic-load-apk ,通過代理分發的方式實現了動態化,如果看過 Android 開發藝術探索這本書,應該會對這個方式有了解。

https://github.com/singwhatiwanna/dynamic-load-apk

2015 年 張勇 釋出了  DroidPlugin ,使用 hook 系統方式實現外掛化。

https://github.com/DroidPluginTeam/DroidPlugin

2015 年 攜程釋出  DynamicApk

https://github.com/CtripMobile/DynamicAPK

2015 - 2016 之間(這塊時間不太確定),Lody 釋出了  VirtualApp ,可以直接執行未安裝的 apk,基本上還是使用 hook 系統的方式實現的,不過裡面的實現要精緻很多,實現了自己的一套 AMS 來管理外掛 Activity 等等。

https://github.com/asLody/VirtualApp

2017 年阿里推出  Atlas

https://github.com/apache/atlas

2017 年 360 推出  RePlugin

https://github.com/Qihoo360/RePlugin

2017 年滴滴推出  VirtualApk

https://github.com/didi/VirtualAPK

2019 年騰訊推出了  Shadow ,號稱是零反射,並且框架自身也可實現動態化,看了程式碼以後發現,其實本質上還是使用了代理分發生命週期實現四大元件動態化,然後抽象介面來實現框架的動態化。後面有機會可以對其做一下分析。

https://github.com/Tencent/Shadow

這基本上就是外掛化框架的歷史,從 2012 至今,可以說外掛化技術基本成型了,主要就是代理和 hook 系統兩種方式(這裡沒有統計熱修復的發展,熱修復其實和外掛化還是有些相通的地方,後面的文章會對熱修復進行介紹)。如果看未來的話,斗膽預測,外掛化技術的原理,應該不會有太大的變動了。

2

名詞解釋

在外掛化中有一些專有名詞,如果是第一次接觸可能不太瞭解,這裡解釋一下。


宿主

負責載入外掛的 apk,一般來說就是已經安裝的應用本身。


StubActivity
宿主中的佔位 Activity,註冊在宿主 Manifest 檔案中,負責載入外掛 Activity。


PluginActivity
外掛 Activity,在外掛 apk 中,沒有註冊在 Manifest 檔案中,需要 StubActivity 來載入。

3

使用 gradle 簡化外掛開發流程

在學習和開發外掛化的時候,我們需要動態去載入外掛 apk,所以開發過程中一般需要有兩個 apk,一個是宿主 apk,一個是外掛 apk,對應的就需要有宿主專案和外掛專案。


在 CommonTec 這裡建立了 app 作為宿主專案,plugin 為外掛專案。為了方便,我們直接把生成的外掛 apk 放到宿主 apk 中的 assets 中,apk 啟動時直接放到內部儲存空間中方便載入。


這樣的專案結構,我們除錯問題時的流程就是下面這樣:


修改外掛專案 -> 編譯生成外掛 apk -> 拷貝外掛 apk 到宿主 assets -> 修改宿主專案 -> 編譯生成宿主 apk -> 安裝宿主 apk -> 驗證問題。


如果每次我們修改一個很小的問題,都經歷這麼長的流程,那麼耐心很快就耗盡了。最好是可以直接編譯宿主 apk 的時候自動打包外掛 apk 並拷貝到宿主 assets 目錄下,這樣我們不管修改什麼,都直接編譯宿主專案就好了。如何實現呢?還記得我們之前講解過的 gradle 系列麼?現在就是學以致用的時候了。

首先在 plugin 專案的 build.gradle 新增下面的程式碼:

project.afterEvaluate {
    project.tasks.each {
        if (it.name == "assembleDebug") {
            it.doLast {
                copy {
                    from new File(project.getBuildDir(), 'outputs/apk/debug/plugin-debug.apk').absolutePath
                    into new File(project.getRootProject().getProjectDir(), 'app/src/main/assets')
                    rename 'plugin-debug.apk', 'plugin.apk'
                }
            }
        }
    }
}

這段程式碼是在 afterEvaluate 的時候,遍歷專案的 task,找到打包 task 也就是 assembleDebug ,然後在打包之後,把生成的 apk 拷貝到宿主專案的 assets 目錄下,並且重新命名為 plugin.apk 。然後在 app 專案的 build.gradle 新增下面的程式碼:

project.afterEvaluate {
    project.tasks.each {
        if (it.name == 'mergeDebugAssets') {
            it.dependsOn ':plugin:assembleDebug'
        }
    }
}

找到宿主打包的 mergeDebugAssets 任務,依賴外掛專案的打包,這樣每次編譯宿主專案的時候,會先編譯外掛專案,然後拷貝外掛 apk 到宿主 apk 的 assets 目錄下,以後每次修改,只要編譯宿主專案就可以了。

4

ClassLoader

ClassLoader 是外掛化中必須要掌握的,因為外掛是未安裝的 apk,系統不會處理其中的類,所以需要我們自己來處理。

4.1 java 中的 ClassLoader

BootstrapClassLoader

負責載入 JVM 執行時的核心類,比如 JAVA_HOME/lib/rt.jar 等等。

ExtensionClassLoader

負責載入 JVM 的擴充套件類,比如 JAVA_HOME/lib/ext 下面的 jar 包。

AppClassLoader 負責載入 classpath 裡的 jar 包和目錄。

4.2 android 中的 ClassLoader

在這裡,我們統稱 dex 檔案,包含 dex 的 apk 檔案以及 jar 檔案為 dex 檔案 PathClassLoader 用來載入系統類和應用程式類,可以載入已經安裝的 apk 目錄下的 dex 檔案。

DexClassLoader 用來載入 dex 檔案,可以從儲存空間載入 dex 檔案。

我們在外掛化中一般使用的是 DexClassLoader

4.3 雙親委派機制

每一個 ClassLoader 中都有一個 parent 物件,代表的是父類載入器,在載入一個類的時候,會先使用父類載入器去載入,如果在父類載入器中沒有找到,自己再進行載入,如果 parent 為空,那麼就用系統類載入器來載入。通過這樣的機制可以保證系統類都是由系統類載入器載入的。下面是 ClassLoader 的 loadClass 方法的具體實現。

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) {
                // 沒有找到,再自己載入
                c = findClass(name);
            }
        }
        return c;
}

4.4 如何載入外掛中的類

要載入外掛中的類,我們首先要建立一個 DexClassLoader ,先看下 DexClassLoader 的建構函式需要哪些引數。

public class DexClassLoader extends BaseDexClassLoader {
    public DexClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent) {
        // ...
    }
}

建構函式需要四個引數:

dexPath 是需要載入的 dex / apk / jar 檔案路徑。


optimizedDirectory 是 dex 優化後存放的位置,在 ART 上,會執行 oat 對 dex 進行優化,生成機器碼,這裡就是存放優化後的 odex 檔案的位置。


librarySearchPath 是 native 依賴的位置。


parent 就是父類載入器,預設會先從 parent 載入對應的類。

創建出 DexClassLaoder 例項以後,只要呼叫其 loadClass(className) 方法就可以載入外掛中的類了。具體的實現在下面:

// 從 assets 中拿出外掛 apk 放到內部儲存空間
private fun extractPlugin() {
    var inputStream = assets.open("plugin.apk")
    File(filesDir.absolutePath, "plugin.apk").writeBytes(inputStream.readBytes())
}

private fun init() {
    extractPlugin()
    pluginPath = File(filesDir.absolutePath, "plugin.apk").absolutePath
    nativeLibDir = File(filesDir, "pluginlib").absolutePath
    dexOutPath = File(filesDir, "dexout").absolutePath
    // 生成 DexClassLoader 用來載入外掛類
    pluginClassLoader = DexClassLoader(pluginPath, dexOutPath, nativeLibDir, this::class.java.classLoader)
}

5

外掛化需要解決的難點

外掛化,就是從外掛中載入我們想要的類並執行,如果這個類是一個普通類,那麼使用上面說到的 DexClassLoader 就可以直接載入了,如果這個類是特殊的類,比如說 Activity 等四大元件,那麼就需要一些特殊的處理,因為四大元件是需要和系統進行互動的。外掛化中,四大元件需要解決的難點如下:

Activity

1、生命週期如何呼叫。

2、如何使用外掛中的資源。

Service

1、生命週期如何呼叫。

BroadcastReceiver

1、靜態廣播和動態廣播的註冊。

ContentProvider

1、如何註冊外掛 Provider 到系統。

6

Activity 的外掛化實現

6.1 難點分析

我們之前說到 Activity 外掛化的難點,我們先來理順一下為什麼會有這兩個問題。


因為外掛是動態載入的,所以外掛的四大元件不可能註冊到宿主的 Manifest 檔案中,而沒有在 Manifest 中註冊的四大元件是不能和系統直接進行互動的。


可能有些同學會問,那為什麼不能直接把外掛的 Activity 註冊到宿主 Manifest 裡呢?這樣是可以,不過就失去了外掛化的動態特性,如果每次外掛中新增 Activity 都要修改宿主 Manifest 並且重新打包,那就和直接寫在宿主中沒什麼區別了。


我們再來說一下為什麼沒有註冊的 Activity 不能和系統互動。


這裡的不能直接互動的含義有兩個:

1、系統會檢測 Activity 是否註冊 如果我們啟動一個沒有在 Manifest 中註冊的 Activity,會發現報如下 error:

android.content.ActivityNotFoundException: Unable to find explicit activity class {com.zy.commontec/com.zy.plugin.PluginActivity}; have you declared this activity in your AndroidManifest.xml?

這個 log 在 Instrumentation checkStartActivityResult 方法中可以看到:

public class Instrumentation {
    public static void checkStartActivityResult(int res, Object intent) {
        if (!ActivityManager.isStartResultFatalError(res)) {
            return;
        }

        switch (res) {
            case ActivityManager.START_INTENT_NOT_RESOLVED:
            case ActivityManager.START_CLASS_NOT_FOUND:
                if (intent instanceof Intent && ((Intent)intent).getComponent() != null)
                    throw new ActivityNotFoundException(
                            "Unable to find explicit activity class "
                            + ((Intent)intent).getComponent().toShortString()
                            + "; have you declared this activity in your AndroidManifest.xml?");
                throw new ActivityNotFoundException(
                        "No Activity found to handle " + intent);
                ...
        }
    }
}Activity 的生命週期無法被呼叫 其實一個 Activity 主要的工作,都是在其生命週期方法中呼叫了,既然上一步系統檢測了 Manifest 註冊檔案,啟動 Activity 被拒絕,那麼其生命週期方法也肯定不會被呼叫了。從而外掛 Activity 也就不能正常運行了。

2、其實上面兩個問題,最終都指向同一個難點,那就是外掛中的 Activity 的生命週期如何被呼叫。解決問題之前我們先看一下正常系統是如何啟動一個 Activity 的。


這裡對 Activity 的啟動流程進行一些簡單的介紹,具體的流程程式碼就不分析了,因為分析的話大概又能寫一篇文章了,而且其實關於 Activity 的啟動過程也有不少文章有分析了。這裡放一張簡圖說明一下:

整個呼叫路徑如下:

Activity.startActivity -> Instrumentation.execStartActivity -> Binder -> AMS.startActivity -> ActivityStarter.startActivityMayWait -> startActivityLocked -> startActivityUnChecked -> ActivityStackSupervisor.resumeFocusedStackTopActivityLocked -> ActivityStatk.resumeTopAcitivityUncheckLocked -> resumeTopActivityInnerLocked -> ActivityStackSupervisor.startSpecificActivityLocked -> realStartActivityLocked -> Binder -> ApplictionThread.scheduleLauchActivity -> H -> ActivityThread.scheduleLauchActivity -> handleLaunchActivity -> performLaunchActivity -> Instrumentation.newActivity 建立 Activity -> callActivityOnCreate 一系列生命週期

其實我們可以把 AMS 理解為一個公司的背後大 Boss,Activity 相當於小職員,沒有許可權直接和大 Boss 說話,想做什麼事情都必須經過祕書向上彙報,然後祕書再把大 Boss AMS 的命令傳達下來。而且大 Boss 那裡有所有職員的名單,如果想要混入非法職員是不可能的。而我們想讓沒有在大 Boss 那裡註冊的編外人員執行任務,只有兩種方法,一種是正式職員領取任務,再分發給編外人員,另一種就是欺騙 Boss,讓 Boss 以為這個職員是已經註冊的。

對應到實際的解決方法就是:

1、我們手動去呼叫外掛 Activity 的生命週期。

2、欺騙系統,讓系統以為 Activity 是註冊在 Manifest 中的。

說完生命週期的問題,再來看一下資源的問題。


在 Activity 中,基本上都會展示介面,而展示介面基本上都要用到資源。

在 Activity 中,有一個 mResources 變數,是 Resources 型別。這個變數可以理解為代表了整個 apk 的資源。

在宿主中呼叫的 Activity, mResources 自然代表了宿主的資源,所以需要我們對外掛的資源進行特殊的處理。


我們先看一下如何生成代表外掛資源的 Resources 類。

首先要生成一個 AssetManager 例項,然後通過其 addAssetPath 方法新增外掛的路徑,這樣 AssetManager 中就包含了外掛的資源。然後通過 Resources 建構函式生成外掛資源。具體程式碼如下:

private fun handleResources() {
    try {
        // 首先通過反射生成 AssetManager 例項
        pluginAssetManager = AssetManager::class.java.newInstance()
        // 然後呼叫其 addAssetPath 把外掛的路徑新增進去。
        val addAssetPathMethod = pluginAssetManager?.javaClass?.getMethod("addAssetPath", String::class.java)
        addAssetPathMethod?.invoke(pluginAssetManager, pluginPath)
    } catch (e: Exception) {
    }
    // 呼叫 Resources 建構函式生成例項
    pluginResources = Resources(pluginAssetManager, super.getResources().displayMetrics, super.getResources().configuration)
}

前期準備的知識點差不多介紹完了,我們接著就看看具體的實現方法。

6.2 手動呼叫 Activity 生命週期

手動呼叫生命週期原理如下圖:

我們手動呼叫外掛 Activity 生命週期時,需要在正確的時機去呼叫,如何在正確的時機呼叫呢?那就是啟動一個真正的 Activity,我們俗稱佔坑 Activity(StubActivity),然後在 StubActivity 的生命週期裡呼叫外掛 Activity 對應的生命週期,這樣就間接的啟動了外掛 Activity。

StubActivity 中呼叫 外掛 Activity 生命週期的方法有兩種,一種是直接反射其生命週期方法,粗暴簡單,唯一的缺點就是反射的效率問題。另外一種方式就是生成一個介面,接口裡對應的是生命週期方法,讓外掛 Activity 實現這個介面,在 StubActivity 裡就能直接呼叫介面方法了,從而避免了反射的效率低下問題。

具體的程式碼實現在CommonTec專案裡可以找到,這裡貼一下主要的實現(這裡的實現和 CommonTec 裡的可能會有些區別,CommonTec 裡有些程式碼做了一些封裝,這裡主要做原理的解釋)。

6.2.1 通過反射呼叫 Activity 生命週期

具體的實現見  反射呼叫生命週期 ,下面列出了重點程式碼。

https://github.com/5A59/android-training/tree/master/common-tec/CommonTec/app/src/main/java/com/zy/commontec/activity/reflect

class StubReflectActivity : Activity() {
    protected var activityClassLoader: ClassLoader? = null
    protected var activityName = ""
    private var pluginPath = ""
    private var nativeLibDir: String? = null
    private var dexOutPath: String? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        nativeLibDir = File(filesDir, "pluginlib").absolutePath
        dexOutPath = File(filesDir, "dexout").absolutePath
        pluginPath = intent.getStringExtra("pluginPath")
        activityName = intent.getStringExtra("activityName")
        // 建立外掛 ClassLoader
        activityClassLoader = DexClassLoader(pluginPath, dexOutPath, nativeLibDir, this::class.java.classLoader)
    }

    // 以 onCreate 方法為例,其他 onStart 等生命週期方法類似
    fun onCreate(savedInstanceState: Bundle?) {
        // 獲取外掛 Activity 的 onCreate 方法並呼叫
        getMethod("onCreate", Bundle::class.java)?.invoke(activity, savedInstanceState)
    }

    fun getMethod(methodName: String, vararg params: Class<*>): Method? {
        return activityClassLoader?.loadClass(activity)?.getMethod(methodName, *params)
    }
}

6.2.2 通過介面呼叫 Activity 生命週期

具體的實現見  介面呼叫生命週期 ,下面列出了重點程式碼。通過介面呼叫 Activity 生命週期的前提是要定義一個介面 IPluginActivity

https://github.com/5A59/android-training/tree/master/common-tec/CommonTec/app/src/main/java/com/zy/commontec/activity/ainterface

interface IPluginActivity {
    fun attach(proxyActivity: Activity)
    fun onCreate(savedInstanceState: Bundle?)
    fun onStart()
    fun onResume()
    fun onPause()
    fun onStop()
    fun onDestroy()
}

然後在外掛 Activity 中實現這個介面。

open class BasePluginActivity : Activity(), IPluginActivity {
    var proxyActivity: Activity? = null

    override fun attach(proxyActivity: Activity) {
        this.proxyActivity = proxyActivity
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        if (proxyActivity == null) {
            super.onCreate(savedInstanceState)
        }
    }
    // ...
}

StubActivity 通過介面呼叫外掛 Activity 生命週期。

class StubInterfaceActivity : StubBaseActivity() {
    protected var activityClassLoader: ClassLoader? = null
    protected var activityName = ""
    private var pluginPath = ""

    private var activity: IPluginActivity? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        nativeLibDir = File(filesDir, "pluginlib").absolutePath
        dexOutPath = File(filesDir, "dexout").absolutePath
        pluginPath = intent.getStringExtra("pluginPath")
        activityName = intent.getStringExtra("activityName")
        // 生成外掛 ClassLoader
        activityClassLoader = DexClassLoader(pluginPath, dexOutPath, nativeLibDir, this::class.java.classLoader)
        // 載入外掛 Activity 類並轉化成 IPluginActivity 介面
        activity = activityClassLoader?.loadClass(activityName)?.newInstance() as IPluginActivity?
        activity?.attach(this)
        // 通過介面直接呼叫對應的生命週期方法
        activity?.onCreate(savedInstanceState)
    }
}

6.2.3 資源處理方式

由於手動呼叫生命週期的方式,會重寫大量的 Activity 生命週期方法,所以我們只要重寫 getResources 方法,返回外掛的資源例項就可以了。下面是具體程式碼。

open class StubBaseActivity : Activity() {

    protected var activityClassLoader: ClassLoader? = null
    protected var activityName = ""
    private var pluginPath = ""
    private var pluginAssetManager: AssetManager? = null
    private var pluginResources: Resources? = null
    private var pluginTheme: Resources.Theme? = null
    private var nativeLibDir: String? = null
    private var dexOutPath: String? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        nativeLibDir = File(filesDir, "pluginlib").absolutePath
        dexOutPath = File(filesDir, "dexout").absolutePath
        pluginPath = intent.getStringExtra("pluginPath")
        activityName = intent.getStringExtra("activityName")
        activityClassLoader = DexClassLoader(pluginPath, dexOutPath, nativeLibDir, this::class.java.classLoader)
        handleResources()
    }

    override fun getResources(): Resources? {
        // 這裡返回外掛的資源,這樣外掛 Activity 中使用的就是外掛資源了
        return pluginResources ?: super.getResources()
    }

    override fun getAssets(): AssetManager {
        return pluginAssetManager ?: super.getAssets()
    }

    override fun getClassLoader(): ClassLoader {
        return activityClassLoader ?: super.getClassLoader()
    }

    private fun handleResources() {
        try {
            // 生成 AssetManager
            pluginAssetManager = AssetManager::class.java.newInstance()
            // 新增外掛 apk 路徑
            val addAssetPathMethod = pluginAssetManager?.javaClass?.getMethod("addAssetPath", String::class.java)
            addAssetPathMethod?.invoke(pluginAssetManager, pluginPath)
        } catch (e: Exception) {
        }
        // 生成外掛資源
        pluginResources = Resources(pluginAssetManager, super.getResources().displayMetrics, super.getResources().configuration)
    }
}

6.3 hook 系統相關實現的方式欺騙系統,讓系統呼叫生命週期

6.3.1 hook Instrumentation

上面講了如何通過手動呼叫外掛 Activity 的生命週期方法來啟動外掛 Activity,現在來看一下欺騙系統的方法。

上面簡單介紹了 Activity 的啟動流程,我們可以看到,其實 Android 系統的執行是很巧妙的,AMS 是系統服務,應用通過 Binder 和 AMS 進行互動,其實和我們日常開發中客戶端和服務端互動有些類似,只不過這裡使用了 Binder 做為互動方式,關於 Binder,可以簡單看看 這篇文章 。我們暫時只要知道通過 Binder 應用可以和 AMS 進行對話就行。

https://juejin.cn/post/6844903882133356558


這種架構的設計方式,也為我們提供了一些機會。理論上來說,我們只要在啟動 Activity 的訊息到達 AMS 之前把 Activity 的資訊就行修改,然後再訊息回來以後再把資訊恢復,就可以達到欺騙系統的目的了。


在這個流程裡,有很多 hook 點可以進行,而且不同的外掛化框架對於 hook 點的選擇也不同,這裡我們選擇 hook Instrumentation 的方式進行介紹(原因是個人感覺這種方式要簡單一點)。


簡化以後的流程如下:

Instrumentation 相當於 Activity 的管理者,Activity 的建立,以及生命週期的呼叫都是 AMS 通知以後通過 Instrumentation 來呼叫的。

我們上面說到,AMS 相當於一個公司的背後大 Boss,而 Instrumentation 相當於祕書,Activity 相當於小職員,沒有許可權直接和大 Boss 說話,想做什麼事情都必須經過祕書向上彙報,然後 Instrumentation 再把大 Boss AMS 的命令傳達下來。而且大 Boss 那裡有所有職員的名單,如果想要混入非法職員是不可能的。

不過在整個過程中,由於 java 的語言特性,大 Boss 在和祕書 Instrumentation 對話時,不會管祕書到底是誰,只會確認這個人是不是祕書(是否是 Instrumentation 型別)。

我們載入外掛中的 Activity,相當於讓一個不在 Boss 名單上的編外職員去申請執行任務。 在正常情況下,大 Boss 會檢查職員的名單,確認職員的合法性,一定是通過不了的。但是上有政策,下有對策,我們悄悄的替換了祕書,在祕書和 Boss 彙報時,把職員名字改成大 Boss 名單中的職員,在 Boss 安排工作以後,祕書再把名字換回來,讓編外職員去執行任務。

而我們 hook 的方式就是替換調 Instrumentation ,修改 Activity 類名,達到隱瞞 AMS 的效果。

hook 方式原理圖

接下來看看具體的程式碼實現。具體的實現見 hook 實現外掛化

,下面主要講解重點程式碼。

替換

Instrumentation 之前,首先我們要實現一個我們自己的 Instrumentation ,具體實現如下:

https://github.com/5A59/android-training/tree/master/common-tec/CommonTec/app/src/main/java/com/zy/commontec/activity/hook

class AppInstrumentation(var realContext: Context, var base: Instrumentation, var pluginContext: PluginContext) :
    Instrumentation() {
    private val KEY_COMPONENT = "commontec_component"

    companion object {
        fun inject(activity: Activity, pluginContext: PluginContext) {
            // hook 系統,替換 Instrumentation 為我們自己的 AppInstrumentation,Reflect 是從 VirtualApp 裡拷貝的反射工具類,使用很流暢~
            var reflect = Reflect.on(activity)
            var activityThread = reflect.get<Any>("mMainThread")
            var base = Reflect.on(activityThread).get<Instrumentation>("mInstrumentation")
            var appInstrumentation = AppInstrumentation(activity, base, pluginContext)
            Reflect.on(activityThread).set("mInstrumentation", appInstrumentation)
            Reflect.on(activity).set("mInstrumentation", appInstrumentation)
        }
    }

    override fun newActivity(cl: ClassLoader, className: String, intent: Intent): Activity? {
        // 建立 Activity 的時候會呼叫這個方法,在這裡需要返回外掛 Activity 的例項
        val componentName = intent.getParcelableExtra<ComponentName>(KEY_COMPONENT)
        var clazz = pluginContext.classLoader.loadClass(componentName.className)
        intent.component = componentName
        return clazz.newInstance() as Activity?
    }

    private fun injectIntent(intent: Intent?) {
        var component: ComponentName? = null
        var oldComponent = intent?.component
        if (component == null || component.packageName == realContext.packageName) {
            // 替換 intent 中的類名為佔位 Activity 的類名,這樣系統在 Manifest 中查詢的時候就可以找到 Activity
            component = ComponentName("com.zy.commontec", "com.zy.commontec.activity.hook.HookStubActivity")
            intent?.component = component
            intent?.putExtra(KEY_COMPONENT, oldComponent)
        }
    }

    fun execStartActivity(
        who: Context,
        contextThread: IBinder,
        token: IBinder,
        target: Activity,
        intent: Intent,
        requestCode: Int
    ): Instrumentation.ActivityResult? {
        // 啟動 activity 的時候會呼叫這個方法,在這個方法裡替換 Intent 中的 ClassName 為已經註冊的宿主 Activity
        injectIntent(intent)
        return Reflect.on(base)
            .call("execStartActivity", who, contextThread, token, target, intent, requestCode).get()
    }
    // ...
}

AppInstrumentation 中有兩個關鍵點, execStartActivity newActivity

execStartActivity 是在啟動 Activity 的時候必經的一個過程,這時還沒有到達 AMS,所以,在這裡把 Activity 替換成宿主中已經註冊的 StubActivity ,這樣 AMS 在檢測 Activity 的時候就認為已經註冊過了。 newActivity 是建立 Activity 例項,這裡要返回真正需要執行的外掛 Activity,這樣後面系統就會基於這個 Activity 例項來進行對應的生命週期的呼叫。

6.3.2 hook 系統的資源處理方式

因為我們 hook 了 Instrumentation 的實現,還是把 Activity 生命週期的呼叫交給了系統,所以我們的資源處理方式和手動呼叫生命週期不太一樣,這裡我們生成 Resources 以後,直接反射替換掉 Activity 中的 mResource 變數即可。下面是具體程式碼。

class AppInstrumentation(var realContext: Context, var base: Instrumentation, var pluginContext: PluginContext) : Instrumentation() {
    private fun injectActivity(activity: Activity?) {
        val intent = activity?.intent
        val base = activity?.baseContext
        try {
            // 反射替換 mResources 資源
            Reflect.on(base).set("mResources", pluginContext.resources)
            Reflect.on(activity).set("mResources", pluginContext.resources)
            Reflect.on(activity).set("mBase", pluginContext)
            Reflect.on(activity).set("mApplication", pluginContext.applicationContext)
            // for native activity
            val componentName = intent!!.getParcelableExtra<ComponentName>(KEY_COMPONENT)
            val wrapperIntent = Intent(intent)
            wrapperIntent.setClassName(componentName.packageName, componentName.className)
            activity.intent = wrapperIntent
        } catch (e: Exception) {
        }
    }

    override fun callActivityOnCreate(activity: Activity?, icicle: Bundle?) {
        // 在這裡進行資源的替換
        injectActivity(activity)
        super.callActivityOnCreate(activity, icicle)
    }
}

public class PluginContext extends ContextWrapper {
    private void generateResources() {
        try {
            // 反射生成 AssetManager 例項
            assetManager = AssetManager.class.newInstance();
            // 呼叫 addAssetPath 新增外掛路徑
            Method method = assetManager.getClass().getMethod("addAssetPath", String.class);
            method.invoke(assetManager, pluginPath);
            // 生成 Resources 例項
            resources = new Resources(assetManager, context.getResources().getDisplayMetrics(), context.getResources().getConfiguration());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

講完上面兩種方法,我們這裡對比一下這兩種方法的優缺點:

實現方法 優點 缺點
手動呼叫 1. 比較穩定,不需要 hook 系統實現 2. 實現相對簡單,不需要對系統內部實現做過多瞭解 通過反射效率太低,通過介面需要實現的方法數量很多
hook 系統 1. 不需要實現大量介面方法 2. 由於最終還是交給系統去處理,各種處理相對比較完整 1. 需要適配不同的系統及裝置 2. 對開發者要求比較高,需要對系統實現有深入的瞭解

7

Service 的外掛化實現

Service 比起 Activity 要簡單不少,Service 沒有太複雜的生命週期需要處理,類似的 onCreate 或者 onStartCommand 可以直接通過代理分發。可以直接在宿主 app 裡新增一個佔位 Service,然後在對應的生命週期裡呼叫外掛 Service 的生命週期方法即可。

class StubService : Service() {
    var serviceName: String? = null
    var pluginService: Service? = null

    companion object {
        var pluginClassLoader: ClassLoader? = null
        fun startService(context: Context, classLoader: ClassLoader, serviceName: String) {
            pluginClassLoader = classLoader
            val intent = Intent(context, StubService::class.java)
            intent.putExtra("serviceName", serviceName)
            context.startService(intent)
        }
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val res = super.onStartCommand(intent, flags, startId)
        serviceName = intent?.getStringExtra("serviceName")
        pluginService = pluginClassLoader?.loadClass(serviceName)?.newInstance() as Service
        pluginService?.onCreate()
        return pluginService?.onStartCommand(intent, flags, startId) ?: res
    }

    override fun onDestroy() {
        super.onDestroy()
        pluginService?.onDestroy()
    }

    override fun onBind(intent: Intent?): IBinder? {
        return null
    }
}

8

BroadcastReceiver 的外掛化實現

動態廣播的處理也比較簡單,也沒有複雜的生命週期,也不需要在 Manifest 中進行註冊,使用的時候直接註冊即可。所以只要通過 ClassLoader 載入外掛 apk 中的廣播類然後直接註冊就好。

class BroadcastUtils {
    companion object {
        private val broadcastMap = HashMap<String, BroadcastReceiver>()

        fun registerBroadcastReceiver(context: Context, classLoader: ClassLoader, action: String, broadcastName: String) {
            val receiver = classLoader.loadClass(broadcastName).newInstance() as BroadcastReceiver
            val intentFilter = IntentFilter(action)
            context.registerReceiver(receiver, intentFilter)
            broadcastMap[action] = receiver
        }

        fun unregisterBroadcastReceiver(context: Context, action: String) {
            val receiver = broadcastMap.remove(action)
            context.unregisterReceiver(receiver)
        }
    }
}

靜態廣播稍微麻煩一點,這裡可以解析 Manifest 檔案找到其中靜態註冊的 Broadcast 並進行動態註冊,這裡就不對 Manifest 進行解析了,知道其原理即可。

9

ContentProvider 的外掛化實現

其實在日常開發中對於外掛化中的 ContentProvider 使用還是比較少的,這裡只介紹一種比較簡單的 ContentProvider 外掛化實現方法,就是類似 Service,在宿主 app 中註冊佔位 ContentProvider ,然後轉發相應的操作到外掛 ContentProvider 中。程式碼如下:

class StubContentProvider : ContentProvider() {

    private var pluginProvider: ContentProvider? = null
    private var uriMatcher: UriMatcher? = UriMatcher(UriMatcher.NO_MATCH)

    override fun insert(uri: Uri?, values: ContentValues?): Uri? {
        loadPluginProvider()
        return pluginProvider?.insert(uri, values)
    }

    override fun query(uri: Uri?, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? {
        loadPluginProvider()
        if (isPlugin1(uri)) {
            return pluginProvider?.query(uri, projection, selection, selectionArgs, sortOrder)
        }
        return null
    }

    override fun onCreate(): Boolean {
        uriMatcher?.addURI("com.zy.stubprovider", "plugin1", 0)
        uriMatcher?.addURI("com.zy.stubprovider", "plugin2", 0)
        return true
    }

    override fun update(uri: Uri?, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
        loadPluginProvider()
        return pluginProvider?.update(uri, values, selection, selectionArgs) ?: 0
    }

    override fun delete(uri: Uri?, selection: String?, selectionArgs: Array<out String>?): Int {
        loadPluginProvider()
        return pluginProvider?.delete(uri, selection, selectionArgs) ?: 0
    }

    override fun getType(uri: Uri?): String {
        loadPluginProvider()
        return pluginProvider?.getType(uri) ?: ""
    }

    private fun loadPluginProvider() {
        if (pluginProvider == null) {
            pluginProvider = PluginUtils.classLoader?.loadClass("com.zy.plugin.PluginContentProvider")?.newInstance() as ContentProvider?
        }
    }

    private fun isPlugin1(uri: Uri?): Boolean {
        if (uriMatcher?.match(uri) == 0) {
            return true
        }
        return false
    }
}

這裡面需要處理的就是,如何轉發對應的 Uri 到正確的外掛 Provider 中呢,解決方案是在 Uri 中定義不同的外掛路徑,比如 plugin1 的 Uri 對應就是 content://com.zy.stubprovider/plugin1,plugin2 對應的 uri 就是 content://com.zy.stubprovider/plugin2 ,然後在 StubContentProvider 中根據對應的 plugin 分發不同的外掛 Provider。

總結

本文介紹了外掛化的相關實現,主要集中在 Activity 的實現上。重點如下:

最後推薦大家在學習外掛化的同時,也去學習一些四大元件以及 Binder 的系統實現~

最後推薦一下我做的網站,玩Android:  wanandroid.com ,包含詳盡的知識體系、好用的工具,還有本公眾號文章合集,歡迎體驗和收藏!

推薦閱讀

Android 開發太難了,這異常竟然捕獲不到?

省程式碼新方案,Hook AMS + APT 實現集中式登入框架

Retrofit 妙用,拒絕重複程式碼!

點選  關注我的公眾號

如果你想要跟大家分享你的文章,歡迎投稿~

┏(^0^)┛明天見!