隨著微店業務的發展,App 不可避免地也遇到了 65535 的大坑。除此之外,業務模塊增多、代碼量增大所帶來的問題也逐漸顯現出來。模塊耦合度高、協作開發困難、編譯時間過長等問題嚴重影響了開發進程。在預研了多種方案以后,插件化似乎是解決這些問題比較好的一個方向。雖然業界已經有很多的開源插件化框架,但預研后發現在使用上對我們會有一定的局限。要么追求低侵入性而 Hook 大量系統底層代碼穩定性不敢保證,要么有很高的侵入性不滿足微店定制化的需求。技術要很好地服務業務,我們想在穩定性和低侵入性上尋找一個平衡……
微店從 2016 年 4 月份開始進行插件化改造,到年底基本完成(可見圖 1 路線)。現在一共有 29 個模塊以插件化的方式運行,其中既有如商品、訂單等的業務模塊,也有像 Network、Cache 等的基礎模塊,目前我們的插件化框架已經很好地支持了微店多 Feature 快速并行迭代開發。完成一個插件化框架的 Demo 并不是多難的事兒,然而要開發一款完善的插件化框架卻非易事, 本篇將我們插件化改造過程中所涉及到的一些技術點以及思考與大家分享一下。
插件化技術聽起來高深莫測,實際要解決的就是三個問題:
我們知道 Android 和 Java 一樣都是通過 ClassLoader 來完成類加載,對于動態加載在實現方式上有兩種機制可供選擇,分別為單ClassLoader 機制和多 ClassLoader 機制:
單 ClassLoader 機制:類似于 Google MulDex 機制,運行時把所有的類合并在一塊,插件和宿主程序的類全部都通過宿主的ClassLoader 加載,雖然代碼簡單,但是魯棒性很差;
多 ClassLoader 機制:每個插件都有一個自己的 ClassLoader,類的隔離性會很好。另外多 ClassLoader 還有一個優點,為插件的熱部署提供了可能。如果插件需要升級,直接新建一個 ClassLoader 加載新的插件,然后替換掉原來的即可。
我們的框架在類加載時采用的是多 ClassLoader 機制,框架會創建兩種 ClassLoader。種是 BundleClassLoader,每個 Bundle 安裝時會分配一個 BundleClassLoader,負責該 Bundle 的類加載;第二種是 DispatchClassLoader,它本身并不負責真正類的加載,只是類加載的一個分發器,DispatchClassLoader 持有宿主及所有 Bundle 的 ClassLoader。關系如圖 2 所示。
ClassLoader 關系
ClassLoader?
應用類通過 PathClassLoader 來加載,PathClassLoader 存在于 LoadedApk 中,那么,如何才能替換 LoadedApk 中 PathClassLoader 為我們的DispatchClassLoader 呢?大家首先想到的是反射,但可惜 LoadedApk 對象是 @Hide 的,要替換首先需要 Hook 拿到 LoadedApk 對象,然后再通過反射替換 PathClassLoader。要反射兩次特別是 LoadedApk 對象的獲取我們認為風險很高,那還有沒有其他方案可以注入DispatchClassLoader?我們知道 Java 類加載時基于雙親委派機制,加載應用類的 PathClassLoader 其 Parent 為 BootClassLoader,能否在調用鏈上插入 DispatchClassLoader 呢?
ClassLoader 委派關系
從圖 3 大家可以看到,我們通過修改類的父子關系成功地把 DispatchClassLoader 插入到類的加載鏈中。修改類的父子關系直接通過反射修改 ClassLoader 的 parent 字段即可,雖然也是反射的私有屬性,但相對于 Hook LoadedApk 這個私有對象的私有方法,風險要相對小很多。
不管是 DispatchClassLoader 或 BundleClassLoader,對于依賴 Bundle 類的查找都是通過遍歷來實現的。由于我們把 Network、Cache 等基礎組件也進行了插件化,所以 Bundle 依賴會比較多,這個遍歷過程會有一定的性能損耗。我們想加載類時能否根據 ClassName 快速定位到該類屬于哪一個 Bundle?終,我們采用的方案是:在編譯階段會收集 Bundle 所包含的 PackageName 信息,在插件安裝階段構造一個PackageName 與 Bundle 的對應表,這樣加載 Class 時,根據包名可快速定位該 Class 屬于哪一個 Bundle。當然,由于混淆的原因,不同插件的包名可能重復,對此,我們通過規范來進行保證。
資源加載方案可選擇的余地不多,都是用 AssetManager 的 @hide 方法 addAssetPath,直接構造插件 Apk 的 AssetManager 和 Resouce 對象。需要注意的是,我們采用的是資源合并的方案,通過 addAssetsPath 方法添加資源時,需要同時添加插件程序的資源文件和宿主程序的資源,及其依賴的資源。這樣可以將 Resource 合并到一個 Context 中,解決資源訪問時需要切換上下文的問題。另外,若不進行資源合并,插件也無法引入宿主的資源。
由于我們在構造 AssetManager 時,會把宿主、插件及依賴插件的資源合并在一起,那么宿主資源 ID 與插件資源 ID,或插件資源 ID 之間都有可能重復。我們知道資源 ID 是在編譯時生成的,其生成的規則是 0xPPTTNNNN,要解決沖突就需要對資源進行分段,資源分段常用的有兩種方式,分別為固定 PP 段與固定 TT 段。當時采用哪種資源分段方案對于我們來說是一個比較糾結的選擇,固定 PP 段需要修改 AAPT,代價比較大,固定 TT 段相對來說則較為簡單。初始我們采用的是固定 TT 段,但后來隨著插件的增多,TT 段明顯不夠用,后來還是采用修改 AAPT 固定 PP 段。大家要上插件化,如果可預見后續插件比較多,建議直接采用固定 PP 段方案。
除了 ID 沖突以外,資源名也有可能重復,對于資源名重復的問題我們通過規范來約束,所有的插件都分配有固定的資源前綴。
android 通過 Resources 對象完成資源加載,要 Hook 資源加載過程,首先想到的是能否替換系統的 Resources 對象為我們自定義的Resources 對象。
調研發現要替換 Resouce 對象,至少要替換兩個系統對象 LoadedApk、ContextImpl 的 mResources 屬性,并且 LoadedApk 及 ContextImpl 都是私有對象,基于兼容性的考慮我們放棄了這種方案,而采用直接復寫 Activity 及 Application 的獲取資源的相關方法來完成 Bundle 資源的加載。由于該方案對 Application 及 Activity 都有侵入,所以會帶來一定的接入成本。為此,我們在編譯過程中用代碼注入的方式完成資源加載的 Hook,資源的加載操作對插件開發來說是完全透明的。
注:資源 Hook 涉及到復寫的方法有如下幾個:
對于 Android 來說,并不是類加載進來就可以使用了,很多組件都是有生命的。因此對于這些有血有肉的類,必須給它們注入生命力,也就是所謂的組件生命周期管理。很多插件化框架,比如 DroidPlugin 通過大量 Hook AMS、PMS 等來實現組件的生命周期,從而實現無侵入性。但技術肯定是服務于業務,四大組件真的都需要做插件化嗎?在無侵入性和兼容性上該如何抉擇?對于這個問題我們給出的答案是穩定壓倒一切。綜合當前的業務形態,我們插件化框架定位只實現 Activity 及 BroadCastReceiver 插件化,犧牲部分功能以求穩定性可控。BroadCastReceiver 插件化只是把靜態廣播轉為動態廣播,下面重點分解一下 Activity 插件化。
Activity 插件化實現大致有以下兩種方式:
PluginActivity 繼承自 Activity 基類,把 Activity 基類里涉及生命周期的方法全都重寫一遍;
StubActivity,通過在系統不同層次 Hook,從而實現 StubActivity 和 RealActivity 之間的轉換,以達到偷梁換柱的目的。
由于種方案對插件開發侵入性太大,我們采用的是第二種方案。既然如此,我們就需要對圖 4 中①和②兩個點進行 Hook。
對于①Hook:業內一般的做法是 Hook ActivityThread 類有成員變 mInstrumentation,它會負責創建 Activity 等操作,可以通過篡改 mInstrumentation 為我們自己的 InstrumentationHook,在其 execStartActivity() 方法中完成 RealActivity->StubActivity 的轉化。
對于②Hook:不同的框架選擇在系統不同的層次上進行 Hook,來完成 StubActivity->RealActivity 的還原。
從圖 5 可以看出第二種方案不管在哪一點上的 Hook 都會涉及到系統私有對象的操作,從而引入不可控風險。而我們的原則是盡量少地 Hook,若是以犧牲低侵入性為代價,有沒有一種更安全的方案呢?并且由于只對 Activity 進行插件化,所有啟動 Activity 的地方都是通過 Context 的 startActivity 方法調起,我們只要復寫 Application 及 Activity 的 startActivity() 方法,在 startActivity() 方法調用時完成 RealActivity->StubActivity,在類加載時實現 StubActivity->RealActivity 就可以了。同樣,復寫方法所引入的侵入性完全可以在編譯期通過代碼注入的方式解決掉。
注:實際上,雖然
startActivity有很多重寫方法,但我們只需復寫以下兩個就可以了:
另外,對于 Activity 的 LanchMode,我們是通過在宿主中每種 LaunchMode 都預注冊了多個(8 個)StubActivity 來實現。值得注意的一點是,如果插件 Activity 為透明主題,由于系統限制不能動態設置透明主題,所以對于每種 LaunchMode 類型我們都增加了一個默認是透明主題的 StubActivity。
為了盡可能地保證穩定性,我們插件
Activity支持兩種運行模式,一種是預注冊模式,一種是免注冊模式。對于靜態插件(隨 App 打包)我們默認運行在預注冊模式下,對于動態插件(服務器下發)才運行在免注冊模式下。值得說明的是,靜態插件與宿主AndroidManifest合并是在編譯期自動完成的。
我們拆分插件時,首先明確的是每個插件的業務邊界,有了邊界才有所謂的內聚性,才能區分外部使用者和內部實現者。基于這樣拆分,我們可以看出每個插件既可以依賴于其他插件,也可能被其他插件依賴。為了簡化業務插件與基礎插件之間的依賴關系,我們規定基礎插件不能依賴業務插件,業務插件可以依賴基礎插件,業務插件與業務插件之間、基礎插件與基礎插件之間可以互相依賴。總結來看,插件之間的依賴主要有兩種形式:
頁面跳轉(比如商品 Bundle 跳轉到店鋪 Bundle 某一頁面):Android 可以用 Intent 解耦頁面跳轉,但考慮到多端統一,我們采用的是類似于總線機制,所有跳轉都通過 Page Bus 處理,每個頁面都對應一個別名,跳轉時根據別名來進行。
功能調用(商品 Bundle 用到店鋪 Bundle 信息):我們把每個插件抽象為一個服務提供者,插件對外暴露的服務稱之為本地 Service,它以 Interface 的形式定義,服務提供者保證版本之間的兼容。本地 Service 在插件的 AndroidManifest 中聲明,插件安裝時向框架注冊本地 Service,其他插件使用時直接根據服務別名查詢服務。我們會把本地 Service 的查詢過程直接綁定到 Context 的getSystemService() 方法上,整個使用過程就和調用 Android 系統服務一樣。此外,除了服務以外,插件還有可能對外暴露一些 Class,為了增加內聚性,我們通過@annotation 的方式聲明對外暴露的 Class,在編譯階段 Export 供其他插件依賴,未被注解的類就算是 public,對其他插件也是不可見的。
插件的依賴關系定義在每個插件的 AndroidManifest 文件中。
舉個例子,下面是
Shop-Management模塊在AndroidManifest中的聲明:
其中,
versionName為聲明的依賴插件的小版本號,插件安裝階段會校驗依賴條件是否滿足,若不滿足會進行相應處理(Debug 模式拋RuntimException,Release 模式輸出 error log 并上報監控后臺)。
插件化以后,動態部署和 HotPatch 也是需要說明的兩個點:
我們框架支持 Activity、BroadcastReceiver 的免注冊,若插件沒有新增其他類型(Service、Provider)的組件,則該插件支持動態部署。由于我們采用多 ClassLoader 機制,理論上是支持熱更新的,但考慮到插件有對外導出 Class,為了減少風險,我們對于動態插件生效時間延遲到應用切換至后臺以后,當用戶切換到后臺時直接 Kill 進程。
注:
- 插件更新支持增量更新;
- 對于插件更新檢查有兩個觸發時機:一個是進程初始化時(Pull),另一個是主動 Push 觸發(Push)。
插件化后,App 分為宿主和插件,宿主為源碼依賴,插件為二進制依賴。對于宿主和插件,我們采用不同的 HotPatch 方案:
但我們并不是直接使用的 Tinker,而是在實現思路上與 Tinker 一致,采用全 Dex 替換的方式來規避其他方案的問題。由于我們不僅業務組件實現了插件化,而且大部分基礎組件(Network、Cache 等)也實現了插件化,所以宿主并不是很大(<2.5M),況且宿主里的代碼都比較穩定。
微信的 Tinker 方案在補丁包的大小上的確有很大的優勢,我們敬佩其技術探究的精神,但對于其穩定性持有懷疑態度,基于宿主包可控的前提下,我們選擇犧牲流量來保證穩定性。
我們定位每個插件都是可以獨立迭代 App,插件化以后,整個的工程組織方式為如圖 6 的形式。
在此之中每個工程都對應一個 Git 庫,主庫包含多個子庫,對于這種工程結構,我們很自然地想到用 SubModule 來管理微店工程。然而事與愿違,使用一段 SubModule 后發現有兩個問題嚴重影響開發效率:
開發某個插件時,對于其他插件應該都是二進制依賴,不再需要其他插件的源碼,但 SubModule 會把所有子工程的源碼都 Checkout 出來。考慮到 Gradle 的生命周期,這樣嚴重影響了編譯速度;另外,主工程包含所有子工程的源碼也增加誤操作的風險(全量編譯、引用本地包而非 Release 包);
代碼提交復雜且經常出現沖突:我們知道每次 git 提交都會產生一個 Sha 值,主工程管理所有子工程的 Sha 值,每次子工程變動,除了提交子工程以外,還需要同步更新主工程的 Sha 值。這樣每次子工程的變動都涉及到兩次 Commit,更嚴重的是,如果兩個人同時改動同一個子工程,但忘記了同步提交主工程的 Sha 值,則會產生沖突,而且這種情況下無法更新、無法回滾、無法合并,崩潰……
針對使用 Submodule 過程中遇到的問題,我們引入了 Repo 來管理工程代碼。Repo 不像 Submodule 那樣,通過建立一種主從關系,用主 Module 管理子 Module。在 Repo 里,所有 Module 都是平級關系,每個 Module 的版本管理完全獨立于任何其他 Module,不會像 Submodule 那樣,提交了子 Module 代碼,也會對主 Module 造成影響。
另外,我們在使用過程中,還發現了另外一些好處:
.gitmodules 變成了所有人都更熟悉的 XML 文件,便于配置管理。
插件化以前,我們對所有模塊都是源碼依賴。插件化以后,運行某一模塊時,僅對宿主及當前模塊是源碼依賴,對于其他模塊全部是二進制依賴。集成方式的改變就涉及到如下兩個問題:
我們插件的二進制包是 so 包,其實這些 so 都是正常的 Apk 結構,改為 so 放入 lib 目錄只是為了安裝時借用系統的能力從 Apk 中解壓出來,方便后續安裝。我們目前所有的庫都是基于 Maven 來管理,插件既然是 so 包,正好借用 Maven 管理能力同時,基于開源的 Gradle 插件 android-native-dependencies 實現了插件的集成。
開發插件時,對于其他插件的二進制包都是依賴的已發布版,所有已發布的插件都是混淆包。若開發過程中涉及到其他插件的斷點調試,則會出現無法對應源碼。
對于這種情況,我們制定了一個策略,在 Debug 模式下,會優先使用本地編譯的包。若要調試其他插件,可以把插件源碼檢出來編譯本地包(得益于 Repo 檢出過程非常方便),打包過程若檢索到有本地包,會替換掉從 Maven 遠程倉庫下載的包,當然,這個替換過程是通過編譯腳本自動完成的。
雖然 Android 插件化在國內發展有幾年,各種方案百花齊放,但真的要在業務快速迭代的過程中完成插件化改造工作,其中酸爽也只有親歷者才能體會到。近年來隨著 React Native、Weex 及微信小程序的興起,很多以前需要插件化才能解決的問題,現在或許有了更好的解決方向。但,技術服務于業務,穩定壓倒一切,與大家共勉。
本站文章版權歸原作者及原出處所有 。內容為作者個人觀點, 并不代表本站贊同其觀點和對其真實性負責,本站只提供參考并不構成任何投資及應用建議。本站是一個個人學習交流的平臺,網站上部分文章為轉載,并不用于任何商業目的,我們已經盡可能的對作者和來源進行了通告,但是能力有限或疏忽,造成漏登,請及時聯系我們,我們將根據著作權人的要求,立即更正或者刪除有關內容。本站擁有對此聲明的最終解釋權。