1. 程式人生 > >關於 android 熱更新的討論方案

關於 android 熱更新的討論方案

Android 不僅系統版本眾多,機型眾多,而且各個市場都各有各的政策和稽核速度,每次釋出一個版本對於開發同學來講都是一種漫長的煎熬。相比於 iOS 兩三天就能達到 80% 的覆蓋速度而言,Android 應用版本升級至少需要兩週才能達到 80% 的升級率,嚴重阻礙了版本迭代速度。也導致市場上 App 版本分散,處理 bug 和投訴等也越來越麻煩。

修復的 bug 需要等待下個版本釋出窗口才能釋出?
已經 ready 的需求排隊上線,需要等待其他 Feature Team 合入程式碼?
老版本升級速度慢?頻繁上線版本提醒使用者升級,影響使用者體驗?

這幾個問題是每個 App 開發同學都必然要面對的。那麼有沒有方法能在使用者無感知的情況下加速 bug 處理和版本迭代速度?

在這方面 PC 端 Chrome 瀏覽器的 patch 升級方案給我們了一個很好的借鑑:當 Chrome 有版本升級的時候會自動下載 patch 檔案。下次啟動後,Chrome 就已經是新版本。

他山之石,可以攻玉

近一兩年 Android 熱補丁框架非常熱門。從最初 360 動態下發 lua 指令碼,到後來出現的各種方案,如雨後春筍般出現。早期的補丁框架偏向於以程式碼修復為主,主要分為兩大類:native hook 方案和 Multidex 方案。

native hook 方案如阿里巴巴的 AndFix 和 Dexposed。Multidex 方案如 Qzone。切入點都是替換掉將要執行的程式碼。基於 Qzone 方案的思路,出現了 nuwa 這個比較完善的庫,工具鏈比較完善。

類似 Chrome 的 patch 升級方案足以滿足加速 bug 處理和版本迭代速度的需求,給了我們很大的借鑑意義。在安卓系統上,可以通過 hotfix 的思路來達到這一目的:下發補丁檔案,更新 App 版本。

站在巨人的肩膀上

在今年 3 月份開始做技術選型的時候把上面的幾種方案試了一輪。其中 AndFix 甚至跟上了現網的一個釋出版本,但是由於影響正向開發過程(只能修改方法、不能修改 field、不能新增類等問題)、庫本身難於維護(需要依賴外部開源力量進行維護)以及發現的莫名其妙的 bug(導致我們 App 下發 patch 後白屏),所以即使跟上了釋出版本也沒有使用。nuwa 僅支援更新 Java 程式碼,不能更新資源和 so 檔案,滿足不了我們的需求。

沒有好用的輪子,我們決定自己造一個,於是有了現在的 patch 方案。

App 只是一個載入器

既然做安卓 patch 方案,最好的結果就是能支援更新 App 所有的程式碼和資源。但是

Application 類是 App 啟動之初就被安卓系統載入起來,所以至少 Application 類和它啟動依賴的其他業務類是不能被更新的?
修復 bug 或者版本迭代過程中難免會遇到需要修改資原始檔的情況。資原始檔能更新嗎?
native 實現的 so 檔案如何更新?

針對上面三個問題, 我們的設計是把 App 僅僅當做一個載入器。系統啟動 App 之後,載入器決定將要執行的程式碼和資源的位置。當有新功能或者 bugfix 需要推送給使用者,替換載入器內容即可。

支援更新全部程式碼

上面提到 Application 由於啟動就被載入而不能被更新的問題,我們代理了真實 Application 類的建立過程。通過代理 Application,控制 Application 從新 dex 檔案中載入。假設真實的 Application 類是 MyApplication。我們在編譯期間自動修改 AndroidManifest.xml 檔案,把 MyApplication 替換為 MoaiApplication(是 App 的入口 Application)。App 啟動後由 MoaiApplication 載入完相應的檔案(dex/資原始檔/so 檔案)後,再將控制權交回給 MyApplication。

代理生命週期

將控制權交回給 MyApplication,我們最初是代理 MyApplication 的生命週期。具體做法是,MoaiApplication
決定載入哪裡的業務程式碼、資原始檔以及 so 檔案之後依然負責接收 App 的全部生命週期,然後把生命週期代理給 MyApplication,簡單例子如下:

還有比較多生命週期函式上面程式碼就沒一一列舉。

從上面程式碼容易想到代理方案的缺點:必須要完整代理所有生命週期介面。否則 MyApplication 會由於生命週期不完整而出現奇怪的 bug。比如我們最初版本在測試過程中就出現了沒有代理
registerActivityLifecycleCallbacks 函式而導致拿不到 Activity 生命週期 onActivityCreated/onActivityDestroyed
等回撥。

反射 Application

踩到生命週期回撥不完整的坑之後,我們開始考慮能不能把 App 執行期間 Application 的引用全部替換成 MyApplication ?這樣就無需 MoaiApplication 把生命週期代理給 MyApplication,而是由 MyApplication 直接接收系統回撥。安卓系統 ContextWrapper 的實現是包裝了一層真正的 mBase 上下文,App 真正使用到的就是這個 mBase。通過反射 mBase 以及其中欄位對 Application 的引用,『徹底』解決了需要手寫代理 Application 全部生命週期的方法。

dex分包

Qzone 方案下發的 patch 檔案是變更過的 Java 類組成的 patch.dex,在 dalvik 和 ART 虛擬機器下分別需要解決 Class ref in pre-verified class resolved to unexpected implementation 和記憶體地址錯亂問題。這些問題根源在於改變了類原本所屬的 dex 檔案。既然改變類所在的 dex 會導致各種各樣的問題,那直接替換掉整個 dex 不就好了?在調研 JRebal for Android 和 Instant Run 的時候也發現了他們有類似的做法。

我們把 App 的 dex 分成兩部分:

patch 庫的 dex 檔案 -> classes.dex
其他業務程式碼的 dex 檔案 -> classes[N].dex

其中 classes.dex 中僅包含了 patch 庫的全部程式碼,並不包含任何其他業務程式碼。

假設 apk 中包含三個檔案:classes.dex、classes2.dex、classes3.dex。classes.dex 充當的角色就是載入器,負責啟動 App 並且載入後面的兩個 dex。這樣做的目的是,App 啟動需要用到的所有類都集中在 classes.dex 中,所有業務程式碼的類都集中在 classes[N].dex 中。如果某次下發 patch 程式碼把 classes2.dex 變更為 classes2-1.dex,那麼由載入器載入
classes2-1.dex 和 classes3.dex 即可實現更新包含 MyApplication 類在內的所有程式碼。

怎麼載入更新後的程式碼?

如果 dex 檔案有更新,載入器會選擇載入更新後的檔案。我們最初採用了 Google 官方的 Multidex 方案,擴充套件 DexPathList 的 dexElements 欄位。

Multidex 方案存在問題

Multidex 方案上線後發現某些機型(比如三星s6 5.0.2 ROM)並不能載入擴充套件進去的 dex 中的程式碼。debug 階段卻能順利載入(debugger 拖慢程式碼執行速度)。目前的猜測是某些廠商在 5.x 以上版本改動 ROM 導致 App 啟動邏輯有多執行緒併發執行。

最終我們棄用了 Multidex 方案,轉而 Hack 系統 ClassLoader。

ClassLoader Hack 方案

所有執行緒使用的是同一個 ClassLoader 物件。所以一旦 Hack 了這個物件,所有執行緒都開始使用 Hack 過的物件,從而能夠解決多執行緒導致載入不到擴充套件的 dex 檔案中程式碼的問題。

安卓系統載入程式碼的 ClassLoader 是 PathClassLoader 和 BootClassLoader。我們最初設計的方案是在 PathClassLoader 和 BootClassLoader 之間插入一個 BaseDexClassLoader,讓所有業務程式碼都在這個插入的 BaseDexClassLoader 中載入。但是這樣的設計存在缺陷:業務程式碼的
ClassLoader 會變成 BaseDexClassLoader,如果業務程式碼依賴了 patch 庫的程式碼(在 classes.dex 中),會出現 ClassNotFoundException。

在這方面 Instant Run 的設計很精巧。它讓 PathClassLoader 插入的父 loader (IncrementalClassLoader)包裝了
DelegateClassLoader,並且把 DelegateClassLoader 的父 loader 設定為 PathClassLoader,使得類載入的路徑變成:

在 DelegateClassLoader 載入業務程式碼的時候(業務程式碼在 classesN.dex 中),流程會沿著標記的順序最終第 5 步成功載入到業務程式碼。業務程式碼如果依賴 patch 庫的程式碼,會在
PathClassLoader 載入。這樣所有程式碼都可以被載入到。

怎麼更新資源?

單純更新 Java 程式碼的 patch 框架,實用性會受到很大的侷限。開發同學需要仔細驗證提交內容,確保提交中不包含資原始檔的變更以及 native so 的改動,會導致本就複雜的開發流程變得更加繁瑣。所以我們在支援更新 Java 程式碼的基礎之上,也支援更新資源和 native so 檔案。

App 載入資源是依賴 Context#getResources 函式返回的 Resources 物件。Resources 內部包裝了
AssetManager,最終由 AssetManager 從 apk 檔案中載入資源。所以我們反射了替換系統預設的 Resources,讓 AssetManager 從我們更新後的 apk 中載入資源。現階段的實現支援比如 string/anim/drawable/color/layout 等資原始檔的變更。由於 Android 系統在安裝 apk 時候已經把
AndroidManifest.xml 檔案解析並寫入到系統中,目前還不支援修改四大元件,比如增加 Activity。後續會繼續研究如何做到無縫修改四大元件。

怎麼更新 so 檔案?

在 Android 專案中使用 native 函式前需要先呼叫 System.loadLibrary(libName)。

當 lib 檔案需要更新或者有 bug 時候怎麼辦?首先想到的是在程式碼中把載入 so 檔案的程式碼改成System.load(libFilePath),讓系統載入自己指定的
libFilePath 檔案。然而這樣的改動需要

在原始碼中修改或者使用工具在編譯期把 loadLibrary 介面改為 load
patch 庫把 so 檔案從 patch 檔案中複製到特定目錄

這樣在執行期才有可能載入更新後的 so 檔案。

通過分析系統載入 so 檔案的方式後,我們使用了更簡單的處理方法。查詢 lib 檔案是通過呼叫 PathClassLoader 的 findLibrary,最終呼叫到 DexPathList 的 findLibrary。DexPathList 會在自己維護的列表目錄中查詢對應的
lib 檔案是否存在。所以我們在發現 patch 檔案中有 so 檔案變更的時候,會在 PathClassLoader 的 nativeLibraryDirectories(Android6.0以下)或者nativeLibraryPathElements
(Android 6.0及以上)的最前面插入自定義的lib檔案目錄。這樣 ClassLoader 在 findLibrary 的時候會先在自定義的 lib 目錄中查詢,優先載入變更過的 so 檔案。

patch 包的生成與應用

回到我們最初的目標:patch 不應該影響正向開發流程。我們生成 patch 檔案是針對 apk 進行的,開發同學無需關心此次釋出是 patch 版本還是正常版本,只需要正常開發並且打包要釋出的 apk 即可,不會對正向開發流程產生任何影響。

我們提供 python 指令碼生成兩個 apk 的:對比兩個 apk 中的所有檔案,找出有變更的檔案進行 diff,把 diff 結果寫入 patch 檔案。線上使用者下載 patch 檔案到本地之後,啟動一條新的程序使用
context.getApplicationInfo().sourceDir 路徑的 apk 與 patch 檔案合併,得到新的 apk(包含資原始檔,不包含 dex 檔案)以及 dex 檔案、native so 檔案,並在這條程序中提前做 dex 優化(dex2oat/dexopt)。針對 dex 優化過程太慢的問題(優化過程慢會導致程序可能會系統kill,降低 patch 成功率)我們併發了 dex 優化過程,使 patch 過程耗時相對減小。新 apk、dex檔案、so 檔案就可以在下次啟動
App 的時候由載入器載入。

優勢和不足

正所謂沒有完美的架構,只有適合自己的架構。當前的開源方案並不能滿足我們加速 bug處理和版本迭代速度的需求,於是有了站在巨人肩膀上的思考和我們現在的 patch 方案。我們目前的優勢:

全面支援 patch Java 程式碼、資原始檔 和 native so 檔案。版本只需要正常滾動,開發同學無需關心是釋出 patch 版本還是正常版本
使用相對簡單(減少接入成本也是我們的最初思考點之一),只需要在 build.gradle 中加入三行程式碼即可,無需更多配置。

從我們團隊釋出的多個 patch 版本來看,下發的 diff 結果檔案稍大。大檔案下載過程可能出現的錯誤也會間接影響到 patch 鋪開的速度,所以我們也在嘗試更好的 diff 方案。Chrome 最初升級方案也是 bsdiff,而後慢慢演變出 Courgette 演算法。

演進與思考

我們對於補丁框架的定義不僅僅是『修復bug』就足夠,除此之外,如何快速接入,如何做到不影響現有流程,這對於很多應用來說至關重要。在此之上,搞清楚框架的定位,適當捨棄一些不重要方面的時候,快速迭代,在迭代中持續優化,事情往往比想象的更加簡單。

持續交付一直都是快速迭代思想的一種踐行方式,對於 App 開發而言,如果我們通過構造補丁框架這樣一個渠道,可以通過自動化系統把補丁快速地把新功能推送給使用者,那這個事情的意義就不僅僅是『修復 bug』這麼簡單。減少線上 crash 率和加速版本迭代、讓新功能儘早與使用者見面,從而可以在更短的時間內不斷收集使用者反饋資訊對產品進行打磨。

目前我們已經在微信讀書線上三個版本開始試行了用補丁代替版本釋出或者加速老版本升級的做法,期待將來能通過這個渠道,為安卓開發同學們做到無感知的持續交付過程