插件化,一個陌生有熟悉的名詞,從我們學習Android伊始,總能隱約聽到關于它的消息,從360的RePlugin,到DiDi的VirtualAPK更新換代,再到Tencent Shadow橫空出世,可以說插件化已經從一個劍走偏鋒的黑科技,蛻變成了獨步天下的高級技能,其中蘊含的各種思想和變化,也成了考核高級開發工程師無法避開的++障礙++,今天我們就來簡單的說一下這些『高深』的技術。
1. 什么是插件化
插件化,通俗來說就是一種動態加載技術。我們可以通過一個已安裝的Apk來加載本地的apk文件,通過這種動態的加載技術來實現應用功能的拓展、動態更新、灰度發布、A/B Test等功能,本質上宿主以及所有的插件都是Apk,只不過宿主可以把其他的插件Apk加載并且運行起來。
2. 插件化的原理
這里我們不去把四大組件的插件化都介紹一遍,而是把Activity的原理講明白,其實原理都是大同小異的,大家能理解一個其他的也不在話下。
2.1 Apk是什么?
Apk作為Android系統中的安裝文件,其本質就是一個壓縮包,你可以直接把它當做一個zip包,里面有各種二進制和資源文件,只是Android系統通過有規律的加載并運行這些文件,并把這些在手機屏幕上顯示出來。
在繼續往下看之前,我們要拋開原先的一些『概念』,插件化里面的Activity不是一個界面,而是一個class文件,Service也不是服務,也是一個Class文件,Receiver、Provider以及各種資源都是這樣,只有以文件的角度去看待,才能以系統的角度去思考。簡單的說Apk里的文件分為兩類 Class文件 和 資源文件。加載這兩種文件也需要不同的方式,Class文件用ClassLoader加載,資源文件用AssetManager加載。
2.2 Activity啟動
我們明明說的是插件化,為什么先扯到了Activity的啟動上了呢?其實這里才是插件化里的最核心的地方,因為所有的插件化框架都是依托源碼才做出來的,我們要做的相當于一個『小系統』,如果我們都不能明白源碼時如何實現的,我們又怎么能在其基礎上構建我們的框架呢?所以我們在繼續往下之前,必須要把啟動的過程都縷清楚,這樣在面對眾多的插件化框架時才能有條不紊發去分析。
2.2.1 入門
Activity的啟動流程可以簡單的理解為一個進程間的通信過程,只不過一端是我們的App,另一端是Android系統。試想我們現有的邏輯,如果想要打開一個界面,就是調用startActivity()方法,我們就要把這個Class交給系統,系統驗證通過,實例化這個界面并且交還給我們,我們才可以使用。這就是一個進程間通訊的過程,由于Android IPC使用Binder作為進程間通訊的主要手段,我們甚至可以直接把它看作是一個C/S的模型,我們的App就是客戶端而系統是服務端,我們所做的也只是一個請求,而系統也為我們做了大部分的事情,比如Class的加載、實例化、Activity生命周期的控制、權限的管理等等。
2.2.1 進階
上述的流程只是讓大家有一個大致的印象,想要了解更多就的去分析源碼了。
上圖是基于Android 7.1 Activity啟動流程的整理,要用文字的方式去講清楚這么一件事,其實并不容易,何況是這么一件挺麻煩的事,這幅圖也只是一個參考,方便我們的講解,請大家一定要對照圖和下面的方法引用圖去看一下源碼,只有先把源碼看明白,才能對插件化有一個自己的認識。
對于Activity的啟動來說,其實每個版本的差別并不大,但是低版本的源碼封裝的沒那么復雜,更便于我們閱讀。
Activity啟動方法調用圖
2.2.3 思考
在看完源碼之后,就要開始真正的思考了,如果讓你去實現一個插件化的框架你要從哪里開始呢?
自然是先打一個插件Apk的包,試著去打開并加載。那么,用什么去打開?用什么去加載呢?
ClassLoader
其實在Android中,Activity也是通過ClassLoader來實例化的,只不過和我們認為的Java中不太一樣。Android里并沒有完全使用Java的加載模型但是借鑒了相似的思路。在Android中ClassLoader分為3種,每一種ClassLoader分別加載不同的文件。
- BootClassLoader:為系統預加載使用
- PathClassLoader:給程序、系統程序、應用程序 加載class
- DexClassLoader:加載apk、zip 文件
一般而言,Boot是系統用的,Path是App用的,Dex是用戶用的。有了DexClassLoader,我們就可以把Apk加載到內存中使用了。但是這種方法過于簡單粗暴,并且在實例化之后丟失了Context的環境,而丟失上下文的后果就是,我們所熟知的大部分方法都無法使用了,比如:findViewById()、startActivity()、startService()等等,要解決這個問題,我們就得從ClassLoader的底層加載去入手了。
我們看Activity啟動流程圖中的方法9和10,可以看出在performLaunchActivity中獲取ClassLoader,Class的其實是在Instrumentation中通過newInstance()創建的,這里我們具體了解一下這一段的調用流程。
我們從ActivityThread.performLaunchActivity() 方法出發,找到ClassLoader,逐步向上找去,發現其實這個ClassLoader其實就是PathClassLoader。
在PathClassLoader中,真正的加載其實都是通過BaseDexClassLoader來進行的,而BaseDexClassLoader中有一個pathList字段,這個變量相等于一個Dex數組,各種Dex的信息都在里面,而DexClassLoader的父類也是BaseDexClassLoader,這里就是一個完美的Hook點,既然可以獲取相同的Dex數據,那也能把類似的數據拼接到一起。
分析PathClassLoader的加載流程,我們通過Hook,從而把插件的Class直接『掛載』到上面,從而讓插件里的Class無縫接入到主App中。
資源文件的加載
AssetManager本就是個十分強大的資源管理器,只是有些功能沒有對我們開放,我們需要做的只是通過一些方法(反射)把Apk里的資源加進去就好了。
在AssetManager的源碼中,mStringBlocks就是用來保存資源文件的變量,我們通過addAssetPath()方法把插件的路徑加載進來,之后反射調用ensureStringBlocks()確保文件都加載進來,最后構造一個Resources在工程中使用即可。
// 執行此 public final int addAssetPath(String path) 方法,能把插件的路徑添加進去
Method method = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
method.setAccessible(true);
method.invoke(assetManager, file.getAbsolutePath());
// 實例化 ensureStringBlocks()
Method ensureStringBlocksMethod = assetManager.getClass().getDeclaredMethod("ensureStringBlocks");
ensureStringBlocksMethod.setAccessible(true);
// 執行了ensureStringBlocks 初始化 string.xml color.xml anim.xml 等文件
ensureStringBlocksMethod.invoke(assetManager);
// 加載插件資源
Resources r = getResources(); // 拿到宿主的配置信息
resources = new Resources(assetManager, r.getDisplayMetrics(), r.getConfiguration());
啟動
現在Class和Recourse都已經準備好了,可以試著去啟動了。
但是當你啟動了之后你會遇到一個不可回避的問題,這就是我們可能時常忘記的Manifest聲明錯誤。原因很顯然,我們加載的是插件中的Activity,這個類甚至都不在系統里,更不要說在Manifest里面聲明了。
如果沒有源碼的話,我們的插件化之路可能已經要以失敗告終了,好在Android是開源的,我們可以去研究為什么會報這個錯誤,或許可以解決這個限制。
你可以直接使用打印的錯誤在源碼里面搜索,最后會在Instrumentation的 checkStartActivityResult方法中找到這段話,如果我們向上尋找的話就會發現,這個方法的調用者就是execStartActivity()。
也許你會想到一個方案,就是事先在Manifest中聲明好呀,這樣不就很容易的解決了?我想在插件化發展中一定有這樣的過程,這是一個簡單且行之有效的方法,但是我們所熟知的框架里面卻沒有一個這樣實現的,因為這是不實用的。試想,如果要用個插件化,這個App必然是有眾多邏輯與界面,有多個業務的航母級應用,開發人員幾十上百個,如果要這樣去開發就違背了我們插件化的初衷,在開發難度上也并沒有降低。
于是,大家就開始思考解決方案,最先出現的就是——占位式插件化。
預先在Manifest中聲明一個ProxyActivity,所有的界面都以這個Activity作為跳板啟動(把目標Activity的class路徑放在extra里面),在打開ProxyActivity之后再通過ClassLoader加載并實例化目標Activity,這個Activity就啟動成功了。
至此,我們的插件化框架就完成了,這個思路可以讓這個『原始』的框架在Android9.0上運行,這是其他很多利用反射框架不可企及的,但是慢慢的你也會發現它的很多缺點,就是上文說提及的『侵入性』太強。因為這個Activity是完全不受系統管理的(這個Activity是我們自己實例化的),我們需要在ProxyActivity中接管Activity的生命周期,我們無法去管理Activity的啟動棧了,我們甚至無法使用Context了。
顯然,開發者和使用者對這樣的實現方式并不滿意,隨后便有了更加便于開發的Hook式。
我們知道ActivityNotFoundException這個錯誤是在哪里拋出來的,也知道原本的代碼,那么我們為什么不去繞過它?或者讓系統不去執行這個方法呢?
Hook式的插件化實現起來稍顯復雜,說復雜也只是因為要看的源碼有點多。
這里我們不去深究代碼實現,大家講起來都差不多,我們的目標是把思路搞明白,如果想深入研究的話可以去看下深入理解Android插件化技術
可以看到,Hook的方式也是需要ProxyActivity的,只不過使用的地方不一樣,我們原來是直接啟動ProxyActivity,但是現在這部分工作被Hook做了,既然有替換的過程,必然也有還原的地方,這個點就是在newActivity的Handler中。通過這個『神不知鬼不覺?』的過程,我們用另一種方式實現了插件化。
但是這樣的方式也是有不足的地方的,Hook本就是『不安全』的,源碼中更改了一個字段,刪除了某個方法,都會造成不可預料的后果,事實也確實是這樣,每個版本的啟動過程都會有更改,而我們只能提前去適配新版本,避免出現問題。
3. 總結
這里只是說明了插件化的基本原理,其實完整的插件化框架還有很多東西,四大組件、Activity棧、插件中的組件相互啟動、各個版本適配……如果你有興趣不如去自己試一試,相信對你的成長有很大幫助。
一個好的插件化框架是需求足夠且充足的前置知識,比如ClassLoader的加載,Hook、動態代理、Activity的啟動流程等等。如果大家想學習FrameWork這是一個很好的切入點,因為大多數Hook的代碼,都得你去閱讀源碼之后才能下手,而這無論是對于四大組件的啟動流程,還是個版本之間的差異,你都需要把這些做到了如指掌。
原始的插件化還是需要借助各種反射的邏輯,尋找我們可能去著手的Hook點去做,但是隨著Google從9.0開始對于『危險代碼』的緊縮和Android版本之間的兼容性問題,Hook的方式也慢慢顯露出各種問題,而騰訊基于無反射的實現,相信是未來插件化的發展方向。
源碼地址:https://github.com/devilsen/PluginTest