微店 Android 插件化實踐

隨着微店業務的發展,App 不可避免地也遇到了 65535 的大坑。除此之外,業務模塊增多、代碼量增大所帶來的問題也逐漸顯現出來。模塊耦合度高、協作開發困難、編譯時間過長等問題嚴重影響了開發進程。在預研了多種方案以後,插件化似乎是解決這些問題比較好的一個方向。雖然業界已經有很多優秀的開源插件化框架,但預研後發現在使用上對我們會有一定的侷限。要麼追求低侵入性而 Hook 大量系統底層代碼穩定性不敢保證,要麼有很高的侵入性不滿足微店定製化的需求。技術要很好地服務業務,我們想在穩定性和低侵入性上尋找一個平衡……

圖 1 微店插件化改造流程

微店從 2016 年 4 月份開始進行插件化改造,到年底基本完成(可見圖 1 路線)。現在一共有 29 個模塊以插件化的方式運行,其中既有如商品、訂單等的業務模塊,也有像 Network、Cache 等的基礎模塊,目前我們的插件化框架已經很好地支持了微店多 Feature 快速並行迭代開發。完成一個插件化框架的 Demo 並不是多難的事兒,然而要開發一款完善的插件化框架卻非易事, 本篇將我們插件化改造過程中所涉及到的一些技術點以及思考與大家分享一下。

插件化技術原理

插件化技術聽起來高深莫測,實際要解決的就是三個問題:

  1. 代碼加載;
  2. 資源加載;
  3. 組件的生命週期。

代碼加載

我們知道 Android 和 Java 一樣都是通過 ClassLoader 來完成類加載,對於動態加載在實現方式上有兩種機制可供選擇,分別爲單 ClassLoader 機制和多 ClassLoader 機制:

  • 單 ClassLoader 機制:類似於 Google MulDex 機制,運行時把所有的類合併在一塊,插件和宿主程序的類全部都通過宿主的 ClassLoader 加載,雖然代碼簡單,但是魯棒性很差;

  • 多 ClassLoader 機制:每個插件都有一個自己的 ClassLoader,類的隔離性會很好。另外多 ClassLoader 還有一個優點,爲插件的熱部署提供了可能。如果插件需要升級,直接新建一個 ClassLoader 加載新的插件,然後替換掉原來的即可。

我們的框架在類加載時採用的是多 ClassLoader 機制,框架會創建兩種 ClassLoader。第一種是 BundleClassLoader,每個 Bundle 安裝時會分配一個 BundleClassLoader,負責該 Bundle 的類加載;第二種是 DispatchClassLoader,它本身並不負責真正類的加載,只是類加載的一個分發器,DispatchClassLoader 持有宿主及所有 Bundle 的 ClassLoader。關係如圖 2 所示。

圖2 插件化框架 ClassLoader 關係

如何 Hook 系統的 ClassLoader

應用類通過 PathClassLoader 來加載,PathClassLoader 存在於 LoadedApk 中,那麼,如何才能替換 LoadedApkPathClassLoader 爲我們的 DispatchClassLoader 呢?大家首先想到的是反射,但可惜 LoadedApk 對象是 @Hide 的,要替換首先需要 Hook 拿到 LoadedApk 對象,然後再通過反射替換 PathClassLoader。要反射兩次特別是 LoadedApk 對象的獲取我們認爲風險很高,那還有沒有其他方案可以注入 DispatchClassLoader?我們知道 Java 類加載時基於雙親委派機制,加載應用類的 PathClassLoader 其 Parent 爲 BootClassLoader,能否在調用鏈上插入 DispatchClassLoader 呢?

圖3 ClassLoader 委派關係

從圖 3 大家可以看到,我們通過修改類的父子關係成功地把 DispatchClassLoader 插入到類的加載鏈中。修改類的父子關係直接通過反射修改 ClassLoaderparent 字段即可,雖然也是反射的私有屬性,但相對於 Hook LoadedApk 這個私有對象的私有方法,風險要相對小很多。

類加載優化

不管是 DispatchClassLoaderBundleClassLoader,對於依賴 Bundle 類的查找都是通過遍歷來實現的。由於我們把 Network、Cache 等基礎組件也進行了插件化,所以 Bundle 依賴會比較多,這個遍歷過程會有一定的性能損耗。我們想加載類時能否根據 ClassName 快速定位到該類屬於哪一個 Bundle?最終,我們採用的方案是:在編譯階段會收集 Bundle 所包含的 PackageName 信息,在插件安裝階段構造一個 PackageName 與 Bundle 的對應表,這樣加載 Class 時,根據包名可快速定位該 Class 屬於哪一個 Bundle。當然,由於混淆的原因,不同插件的包名可能重複,對此,我們通過規範來進行保證。

資源加載

資源加載方案可選擇的餘地不多,都是用 AssetManager@hide 方法 addAssetPath,直接構造插件 Apk 的 AssetManagerResouce 對象。需要注意的是,我們採用的是資源合併的方案,通過 addAssetsPath 方法添加資源時,需要同時添加插件程序的資源文件和宿主程序的資源,及其依賴的資源。這樣可以將 Resource 合併到一個 Context 中,解決資源訪問時需要切換上下文的問題。另外,若不進行資源合併,插件也無法引入宿主的資源。

資源 ID 衝突問題

由於我們在構造 AssetManager 時,會把宿主、插件及依賴插件的資源合併在一起,那麼宿主資源 ID 與插件資源 ID,或插件資源 ID 之間都有可能重複。我們知道資源 ID 是在編譯時生成的,其生成的規則是 0xPPTTNNNN,要解決衝突就需要對資源進行分段,資源分段常用的有兩種方式,分別爲固定 PP 段與固定 TT 段。當時採用哪種資源分段方案對於我們來說是一個比較糾結的選擇,固定 PP 段需要修改 AAPT,代價比較大,固定 TT 段相對來說則較爲簡單。初始我們採用的是固定 TT 段,但後來隨着插件的增多,TT 段明顯不夠用,後來還是採用修改 AAPT 固定 PP 段。大家要上插件化,如果可預見後續插件比較多,建議直接採用固定 PP 段方案。

除了 ID 衝突以外,資源名也有可能重複,對於資源名重複的問題我們通過規範來約束,所有的插件都分配有固定的資源前綴。

如何 Hook 資源加載過程

Android 通過 Resources 對象完成資源加載,要 Hook 資源加載過程,首先想到的是能否替換系統的 Resources 對象爲我們自定義的 Resources 對象。

調研發現要替換 Resouce 對象,至少要替換兩個系統對象 LoadedApkContextImplmResources 屬性,並且 LoadedApkContextImpl 都是私有對象,基於兼容性的考慮我們放棄了這種方案,而採用直接複寫 ActivityApplication 的獲取資源的相關方法來完成 Bundle 資源的加載。由於該方案對 ApplicationActivity 都有侵入,所以會帶來一定的接入成本。爲此,我們在編譯過程中用代碼注入的方式完成資源加載的 Hook,資源的加載操作對插件開發來說是完全透明的。

注:資源 Hook 涉及到複寫的方法有如下幾個:

Override
public Resources getResources() {
}

Override
public AssetManager getAssets() {
}

Override
public Resources.Theme getTheme() {
}

@Override
public Object getSystemService(String name) {
   if (Context.LAYOUT_INFLATER_SERVICE.equals(name)) {
      // 自定義 LayoutInflater
   }
   return super.getSystemService(name);
}

組件生命週期

對於 Android 來說,並不是類加載進來就可以使用了,很多組件都是有生命的。因此對於這些有血有肉的類,必須給它們注入生命力,也就是所謂的組件生命週期管理。很多插件化框架,比如 DroidPlugin 通過大量 Hook AMS、PMS 等來實現組件的生命週期,從而實現無侵入性。但技術肯定是服務於業務,四大組件真的都需要做插件化嗎?在無侵入性和兼容性上該如何抉擇?對於這個問題我們給出的答案是穩定壓倒一切。綜合當前的業務形態,我們插件化框架定位只實現 ActivityBroadCastReceiver 插件化,犧牲部分功能以求穩定性可控。BroadCastReceiver 插件化只是把靜態廣播轉爲動態廣播,下面重點分解一下 Activity 插件化。

Activity 插件化

Activity 插件化實現大致有以下兩種方式:

  • 一種是靜態代理,寫一個 PluginActivity 繼承自 Activity 基類,把 Activity 基類裏涉及生命週期的方法全都重寫一遍;
  • 另一種方式是動態替換,宿主中預註冊樁 StubActivity,通過在系統不同層次 Hook,從而實現 StubActivityRealActivity 之間的轉換,以達到偷樑換柱的目的。

由於第一種方案對插件開發侵入性太大,我們採用的是第二種方案。既然如此,我們就需要對圖 4 中①和②兩個點進行 Hook。

圖4 Hook 點選取

  • 對於①Hook:業內一般的做法是 Hook ActivityThread 類有成員變 mInstrumentation,它會負責創建 Activity 等操作,可以通過篡改 mInstrumentation 爲我們自己的 InstrumentationHook,在其 execStartActivity() 方法中完成 RealActivity->StubActivity 的轉化。

  • 對於②Hook:不同的框架選擇在系統不同的層次上進行 Hook,來完成 StubActivity->RealActivity 的還原。

圖5 現有插件化框架 Hook 策略

從圖 5 可以看出第二種方案不管在哪一點上的 Hook 都會涉及到系統私有對象的操作,從而引入不可控風險。而我們的原則是儘量少地 Hook,若是以犧牲低侵入性爲代價,有沒有一種更安全的方案呢?並且由於只對 Activity 進行插件化,所有啓動 Activity 的地方都是通過 ContextstartActivity 方法調起,我們只要複寫 ApplicationActivitystartActivity() 方法,在 startActivity() 方法調用時完成 RealActivity->StubActivity,在類加載時實現 StubActivity->RealActivity 就可以了。同樣,複寫方法所引入的侵入性完全可以在編譯期通過代碼注入的方式解決掉。

注:實際上,雖然 startActivity 有很多重寫方法,但我們只需複寫以下兩個就可以了:

@Override
public void startActivityForResult(Intent intent, int requestCode) {
}

@Override
public void startActivityForResult(Intent intent, int requestCode, Bundle options) {
}

另外,對於 ActivityLanchMode,我們是通過在宿主中每種 LaunchMode 都預註冊了多個(8 個)StubActivity 來實現。值得注意的一點是,如果插件 Activity 爲透明主題,由於系統限制不能動態設置透明主題,所以對於每種 LaunchMode 類型我們都增加了一個默認是透明主題的 StubActivity

爲了儘可能地保證穩定性,我們插件 Activity 支持兩種運行模式,一種是預註冊模式,一種是免註冊模式。對於靜態插件(隨 App 打包)我們默認運行在預註冊模式下,對於動態插件(服務器下發)才運行在免註冊模式下。值得說明的是,靜態插件與宿主 AndroidManifest 合併是在編譯期自動完成的。

插件間依賴

我們拆分插件時,首先明確的是每個插件的業務邊界,有了邊界纔有所謂的內聚性,才能區分外部使用者和內部實現者。基於這樣拆分,我們可以看出每個插件既可以依賴於其他插件,也可能被其他插件依賴。爲了簡化業務插件與基礎插件之間的依賴關係,我們規定基礎插件不能依賴業務插件,業務插件可以依賴基礎插件,業務插件與業務插件之間、基礎插件與基礎插件之間可以互相依賴。總結來看,插件之間的依賴主要有兩種形式:

  1. 頁面跳轉(比如商品 Bundle 跳轉到店鋪 Bundle 某一頁面):Android 可以用 Intent 解耦頁面跳轉,但考慮到多端統一,我們採用的是類似於總線機制,所有跳轉都通過 Page Bus 處理,每個頁面都對應一個別名,跳轉時根據別名來進行。

  2. 功能調用(商品 Bundle 用到店鋪 Bundle 信息):我們把每個插件抽象爲一個服務提供者,插件對外暴露的服務稱之爲本地 Service,它以 Interface 的形式定義,服務提供者保證版本之間的兼容。本地 Service 在插件的 AndroidManifest 中聲明,插件安裝時向框架註冊本地 Service,其他插件使用時直接根據服務別名查詢服務。我們會把本地 Service 的查詢過程直接綁定到 Context 的 getSystemService() 方法上,整個使用過程就和調用 Android 系統服務一樣。此外,除了服務以外,插件還有可能對外暴露一些 Class,爲了增加內聚性,我們通過@annotation 的方式聲明對外暴露的 Class,在編譯階段 Export 供其他插件依賴,未被註解的類就算是 public,對其他插件也是不可見的。

插件的依賴關係定義在每個插件的 AndroidManifest 文件中。

舉個例子,下面是 Shop-Management 模塊在 AndroidManifest 中的聲明:

<!-- 以下定義的爲 Shop-Management 依賴的 Bundle-->
<dependent-bundle android:name="com.koudai.weishop.lib.network" android:versionName="7.7.0.0"/>
<dependent-bundle android:name="com.koudai.weishop.lib.location" android:versionName="7.7.0.0"/>
<dependent-bundle android:name="com.koudai.weishop.lib.image" android:versionName="7.7.0.0"/>
<dependent-bundle android:name="com.koudai.weishop.lib.boostbus" android:versionName="7.7.0.0"/>
<dependent-bundle android:name="com.koudai.weishop.lib.base" android:versionName="7.7.5.0"/>
<dependent-bundle android:name="com.koudai.weishop.lib.account" android:versionName="7.7.0.0"/>

<!-- 以下定義的爲 Shop-Management 對外暴露的服務-->
<local-service android:name="ShopManagerService" android:value="com.koudai.weishop.shop.management.impl.ShopManager"/>

其中,versionName 爲聲明的依賴插件的最小版本號,插件安裝階段會校驗依賴條件是否滿足,若不滿足會進行相應處理(Debug 模式拋 RuntimException,Release 模式輸出 error log 並上報監控後臺)。

動態部署及 HotPatch

插件化以後,動態部署和 HotPatch 也是需要說明的兩個點:

動態部署

我們框架支持 ActivityBroadcastReceiver 的免註冊,若插件沒有新增其他類型(Service、Provider)的組件,則該插件支持動態部署。由於我們採用多 ClassLoader 機制,理論上是支持熱更新的,但考慮到插件有對外導出 Class,爲了減少風險,我們對於動態插件生效時間延遲到應用切換至後臺以後,當用戶切換到後臺時直接 Kill 進程。

注:

  1. 插件更新支持增量更新;
  2. 對於插件更新檢查有兩個觸發時機:一個是進程初始化時(Pull),另一個是主動 Push 觸發(Push)。

HotPatch

插件化後,App 分爲宿主和插件,宿主爲源碼依賴,插件爲二進制依賴。對於宿主和插件,我們採用不同的 HotPatch 方案:

  • 插件——因爲插件支持動態部署,若插件需要補丁,我們直接升級插件即可。況且插件支持增
    是升級,補丁包的大小也可以得到有效控制;
  • 宿主——宿主不支持動態部署,只能走傳統的 HotPatch 方案,經過多種方案的對比,我們採
    用的是類似於 Tinker 方案,具體原因大家可以參考《微信熱補丁演進之路》。

但我們並不是直接使用的 Tinker,而是在實現思路上與 Tinker 一致,採用全 Dex 替換的方式來規避其他方案的問題。由於我們不僅業務組件實現了插件化,而且大部分基礎組件(Network、Cache 等)也實現了插件化,所以宿主並不是很大(<2.5M),況且宿主裏的代碼都比較穩定。

微信的 Tinker 方案在補丁包的大小上的確有很大的優勢,我們敬佩其技術探究的精神,但對於其穩定性持有懷疑態度,基於宿主包可控的前提下,我們選擇犧牲流量來保證穩定性。

代碼管理

我們定位每個插件都是可以獨立迭代 App,插件化以後,整個的工程組織方式爲如圖 6 的形式。

圖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 造成影響。

另外,我們在使用過程中,還發現了另外一些好處:

  • 剝離了主 Module 和子 Module 的關係,檢出、同步、提交等操作都比 Sumodule 要快好多倍;
  • 模塊管理配置由一個陌生的 .gitmodules 變成了所有人都更熟悉的 XML 文件,便於配置管理。

開發調試

插件化以前,我們對所有模塊都是源碼依賴。插件化以後,運行某一模塊時,僅對宿主及當前模塊是源碼依賴,對於其他模塊全部是二進制依賴。集成方式的改變就涉及到如下兩個問題:

  • 打包時如何集成插件包?
  • 如何進行斷點調試?

插件包集成

我們插件的二進制包是 so 包,其實這些 so 都是正常的 Apk 結構,改爲 so 放入 lib 目錄只是爲了安裝時借用系統的能力從 Apk 中解壓出來,方便後續安裝。我們目前所有的庫都是基於 Maven 來管理,插件既然是 so 包,正好借用 Maven 管理能力同時,基於開源的 Gradle 插件 android-native-dependencies 實現了插件的集成。

斷點調試

開發插件時,對於其他插件的二進制包都是依賴的已發佈版,所有已發佈的插件都是混淆包。若開發過程中涉及到其他插件的斷點調試,則會出現無法對應源碼。

對於這種情況,我們制定了一個策略,在 Debug 模式下,會優先使用本地編譯的包。若要調試其他插件,可以把插件源碼檢出來編譯本地包(得益於 Repo 檢出過程非常方便),打包過程若檢索到有本地包,會替換掉從 Maven 遠程倉庫下載的包,當然,這個替換過程是通過編譯腳本自動完成的。

總結

雖然 Android 插件化在國內發展有幾年,各種方案百花齊放,但真的要在業務快速迭代的過程中完成插件化改造工作,其中酸爽也只有親歷者才能體會到。近年來隨着 React Native、Weex 及微信小程序的興起,很多以前需要插件化才能解決的問題,現在或許有了更好的解決方向。但,技術服務於業務,穩定壓倒一切,與大家共勉。

作者: 彭昌虎,先後在華爲、騰訊從事Android開發工作,2011年加入微店,負責口袋購物、微店等多款產品的架構設計,2016年主導微店App完成插件化改造工作。
責編: 唐小引(@唐門教主),歡迎技術投稿、約稿、給文章糾錯,請發送郵件至[email protected]
版權聲明: 本文爲 CSDN 原創文章,未經允許,請勿轉載。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章