攜程Android App插件化和動態加載實踐
編者按:本文爲攜程無線基礎團隊投稿,介紹它們已經開源的Android動態加載解決方案DynamicAPK,本文作者之一,攜程無線研發總監陳浩然將會在ArchSummit北京2015架構師大會上分享架構優化相關內容,歡迎關注。
攜程Android App的插件化和動態加載框架已上線半年,經歷了初期的探索和持續的打磨優化,新框架和工程配置經受住了生產實踐的考驗。本文將詳細介紹Android平臺插件式開發和動態加載技術的原理和實現細節,回顧攜程Android App的架構演化過程,期望我們的經驗能幫助到更多的Android工程師。
需求驅動
2014年,隨着業務發展需要和攜程無線部門的拆分,各業務產品模塊歸屬到各業務BU,原有攜程無線App開發團隊被分爲基礎框架、酒店、機票、火車票等多個開發團隊,從此攜程App的開發和發佈進入了一個全新模式。在這種模式下,開發溝通成本大大提高,之前的協作模式難以爲繼,需要新的開發模式和技術解決需求問題。
另一方面,從技術上來說,攜程早在2012年就觸到Android平臺史上最坑天花板(沒有之一):65535方法數問題。舊方案是把所有第三方庫放到第二個dex中,並且利用Facebook當年發現的hack方法擴大點LinearAllocHdr分配空間(5M提升到8M),但隨着代碼的膨脹,舊方案也逐漸捉襟見肘。拆or不拆,根本不是可考慮問題,繼續拆分dex是我們的唯一出路。問題在於:怎麼拆才比較聰明?
其次,隨着組織架構調整的影響,給我們的App質量控制帶來極高的挑戰,這種緊張和壓力讓我們的開發團隊心力憔悴。此時除了流着口水羨慕前端同事們的在線更新持續發佈能力之外,難道就沒有辦法解決Native架構這一根本性缺陷了嗎?NO!插件化動態加載帶來的額外好處就是客戶端的熱部署能力。
從以上幾點根本性需求可以看出,插件化動態加載架構方案會爲我們帶來多麼巨大的收益,除此之外還有諸多好處:
-
編譯速度提升
工程被拆分爲十來個子工程之後,Android Studio編譯流程繁冗的缺點被迅速放大,在Win7機械硬盤開發機上編譯時間曾突破1小時,令人髮指的龜速編譯讓開發人員叫苦不迭(當然現在換成Mac+SSD快太多)。
-
啓動速度提升
Google提供的MultiDex方案,會在主線程中執行所有dex的解壓、dexopt、加載操作,這是一個非常漫長的過程,用戶會明顯的看到長久的黑屏,更容易造成主線程的ANR,導致首次啓動初始化失敗。
-
A/B Testing
可以獨立開發AB版本的模塊,而不是將AB版本代碼寫在同一個模塊中。
-
可選模塊按需下載
例如用於調試功能的模塊可以在需要時進行下載後進行加載,減少App Size
列舉了這麼多痛點,童鞋們早就心潮澎湃按捺不住了吧?言歸正傳,開始插件化動態加載架構探索之旅。
原理
關於插件化思想,軟件業已經有足夠多的用戶教育。無論是日常使用的瀏覽器,還是陪伴程序員無數日夜的Eclipse,甚至連QQ背後,都有插件化技術的支持。我們要在Android上實現插件化,主要需要考慮2個問題:
- 編譯期:資源和代碼的編譯
- 運行時:資源和代碼的加載
解決了以上2個關鍵問題,之後如何實現插件化的具體接口,就變成個人技術喜好或者具體需求場景差異而已。現在我們就針對以上關鍵問題逐一破解,其中最麻煩的還是資源的編譯和加載問題。
Android是如何編譯的?
首先來回顧下Android是如何進行編譯的。請看下圖:
整個流程龐大而複雜,我們主要關注幾個重點環節:aapt、javac、proguard、dex。相關環節涉及到的輸入輸出都在圖上重點標粗。
資源的編譯
Android的資源編譯依賴一個強大的命令行工具:aapt,它位於<SDK>/build-tools/<buildToolsVersion>/aapt
,有着衆多的
命令行參數,其中有幾個值得我們特別關注:
-
-I add an existing package to base include set
這個參數可以在依賴路徑中追加一個已經存在的package。在Android中,資源的編譯也需要依賴,最常用的依賴就是SDK自帶的android.jar本身。打開android.jar可以看到,其實不是一個普通的jar包,其中不但包含了已有SDK類庫class,還包含了SDK自帶的已編譯資源以及資源索引表resources.arsc文件。在日常的開發中,我們也經常通過
@android:color/opaque_red
形式來引用SDK自帶資源。這一切都來自於編譯過程中aapt對android.jar的依賴引用。同理,我們也可以使用這個參數引用一個已存在的apk包作爲依賴資源參與編譯。 -
-G A file to output proguard options into.
資源編譯中,對組件的類名、方法引用會導致運行期反射調用,所以這一類符號量是不能在代碼混淆階段被混淆或者被裁減掉的,否則等到運行時會找不到佈局文件中引用到的類和方法。-G方法會導出在資源編譯過程中發現的必須keep的類和接口,它將作爲追加配置文件參與到後期的混淆階段中。
-
-J specify where to output R.java resource constant definitions
在Android中,所有資源會在Java源碼層面生成對應的常量ID,這些ID會記錄到R.java文件中,參與到之後的代碼編譯階段中。在R.java文件中,Android資源在編譯過程中會生成所有資源的ID,作爲常量統一存放在R類中供其他代碼引用。在R類中生成的每一個int型四字節資源ID,實際上都由三個字段組成。第一字節代表了Package,第二字節爲分類,三四字節爲類內ID。例如:
//android.jar中的資源,其PackageID爲0x01
public static final int cancel = 0x01040000;
//用戶app中的資源,PackageID總是0x7F
public static final int zip_code = 0x7f090f2e;
我們修改aapt後,是可以給每個子apk中的資源分配不同頭字節PackageID,這樣就不會再互相沖突。
代碼的編譯
大家對Java代碼的編譯應該相當熟悉,只需要注意以下幾個問題即可:
-
classpath
Java源碼編譯中需要找齊所有依賴項,classpath就是用來指定去哪些目錄、文件、jar包中尋找依賴。
-
混淆。
爲了安全需要,絕大部分Android工程都會被混淆。混淆的原理和配置可參考Proguard手冊。
有了以上背景知識,我們就可以思考並設計插件化動態加載框架的基本原理和主要流程了。
實現
實現分爲兩類:1.針對插件子工程做的編譯流程改造,2. 運行時動態加載改造(宿主程序動態加載插件,有兩個壁壘需要突破:資源如何訪問,代碼如何訪問)。
插件資源編譯
,針對插件的資源編譯,我們需要考慮到以下幾點:
-
使用
-I
參數對宿主的apk進行引用。據此,插件的資源、xml佈局中就可以使用宿主的資源和控件、佈局類了。
-
爲aapt增加
--apk-module
參數。如前所述,資源ID其實有一個PackageID的內部字段。我們爲每個插件工程指定獨特的PackageID字段,這樣根據資源ID就很容易判明,此資源需要從哪個插件apk中去查找並加載了。在後文的資源加載部分會有進一步闡述。
-
爲aapt增加
--public-R-path
參數。按照對android.jar包中資源使用的常規手段,引用系統資源可使用它的R類的全限定名
android.R
來引用具體ID,以便和當前項目中的R類區分。插件對於宿主的資源引用,當然也可以使用base.package.name.R
來完成。但由於歷史原因,各子BU的“插件”代碼是從主app中解耦獨立出去的,資源引用還是直接使用當前工程的R。如果改爲標準模式,則當前大量遺留代碼中R
都需要酌情改爲base.R
,工程量大並且容易出錯,未來對bu開發人員的使用也有點不夠“透明”。因此我們在設計上做了讓步,額外增加--public-R-path
參數,爲aapt指明瞭base.R
的位置,讓它在編譯期間把base的資源ID定義在插件的R類中完整複製一份,這樣插件工程即可和之前一樣,完全不用在乎資源來自於宿主或者自身,直接使用即可。當然這樣做帶來的副作用就是宿主和插件的資源不應有重名,這點我們通過開發規範來約束,相對比較容易理解一些。
插件代碼編譯
針對插件的代碼編譯,需要考慮以下幾點:
-
classpath
對於插件的編譯來說,除了對android.jar以及自己需要的第三方庫進行依賴之外,還需要依賴宿主導出的base.jar類庫。同時對宿主的混淆也提出了要求:宿主的所有public/protected都可能被插件依賴,所以這些接口都不允許被混淆。
-
混淆。
插件工程在混淆的時候,當然也要把宿主的混淆後jar包作爲參考庫導入。
自此,編譯期所有重要步驟的技術方案都已經確定,剩下的工作就只是把插件apk導入到先一步生成好的base.apk中並重新進行簽名對齊而已。
萬事俱備,只欠表演。接下來我們看看在運行時插件們是如何登臺亮相的。
運行時資源的加載
平常我們使用資源,都是通過AssetManager類和Resources類來訪問的。獲取它們的方法位於Context類中。
Context.java
/** Return an AssetManager instance for your application's package. */
public abstract AssetManager getAssets();
/** Return a Resources instance for your application's package. */
public abstract Resources getResources();
它們是兩個抽象方法,具體的實現在ContextImpl類中。ContextImpl類中初始化Resources對象後,後續Context各子類包括Activity、Service等組件就都可以通過這兩個方法讀取資源了。
ContextImpl.java
private final Resources mResources;
@Override
public AssetManager getAssets() {
return getResources().getAssets();
}
@Override
public Resources getResources() {
return mResources;
}
既然我們已經知道一個資源ID應該從哪個apk去讀取(前面在編譯期我們已經在資源ID第一個字節標記了資源所屬的package),那麼只要我們重寫這兩個抽象方法,即可指導應用程序去正確的地方讀取資源。
至於讀取資源,AssetManager有一個隱藏方法addAssetPath,可以爲AssetManager添加資源路徑。
/**
* Add an additional set of assets to the asset manager. This can be
* either a directory or ZIP file. Not for use by applications. Returns
* the cookie of the added asset, or 0 on failure.
* {@hide}
*/
public final int addAssetPath(String path) {
synchronized (this) {
int res = addAssetPathNative(path);
makeStringBlocks(mStringBlocks);
return res;
}
}
我們只需反射調用這個方法,然後把插件apk的位置告訴AssetManager類,它就會根據apk內的resources.arsc和已編譯資源完成資源加載的任務了。
以上我們已經可以做到加載插件資源了,但使用了一大堆定製類實現。要做到“無縫”體驗,還需要一步:使用Instrumentation來接管所有Activity、Service等組件的創建(當然也就包含了它們使用到的Resources類)。
話說Activity、Service等系統組件,都會經由android.app.ActivityThread類在主線程中執行。ActivityThread類有一個成員叫mInstrumentation,它會負責創建Activity等操作,這正是注入我們的修改資源類的最佳時機。通過篡改mInstrumentation爲我們自己的InstrumentationHook,每次創建Activity的時候順手把它的mResources類偷天換日爲我們的DelegateResources,以後創建的每個Activity都擁有一個懂得插件、懂得委託的資源加載類啦!
當然,上述替換都會針對Application的Context來操作。
運行時類的加載
類的加載相對比較簡單。與Java程序的運行時classpath概念類似,Android的系統默認類加載器PathClassLoader也有一個成員pathList,顧名思義它從本質來說是一個List,運行時會從其間的每一個dex路徑中查找需要加載的類。既然是個List,一定就會想到,給它追加一堆dex路徑不就得了?實際上,Google官方推出的MultiDex庫就是用以上原理實現的。下面代碼片段展示了修改pathList路徑的細節:
MultiDex.java
private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
File optimizedDirectory)
throws IllegalArgumentException, IllegalAccessException,
NoSuchFieldException, InvocationTargetException, NoSuchMethodException {
/* The patched class loader is expected to be a descendant of
* dalvik.system.BaseDexClassLoader. We modify its
* dalvik.system.DexPathList pathList field to append additional DEX
* file entries.
*/
Field pathListField = findField(loader, "pathList");
Object dexPathList = pathListField.get(loader);
expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
new ArrayList<File>(additionalClassPathEntries), optimizedDirectory));
}
當然,針對不同Android版本,類加載方式略有不同,可以參考MultiDex源碼做具體的區別處理。
至此,之前提出的四個根本性問題,都已經有了具體的解決方案。剩下的就是編碼!
編碼主要分爲三部分:
- 對aapt工具的修改。
- gradle打包腳本的實現。
- 運行時加載代碼的實現。
具體實現可以參考我們在GitHub上的開源項目DynamicAPK。
收益與代價
任何事物都有其兩面性,尤其像動態加載這種使用了非官方Hack技術的方案,更需要在規劃階段把收益和代價考慮清楚,方便完成後進行復盤。
收益
- 插件化架構適應現有組織架構和開發節奏需求,各BU不但從代碼層面,更從項目控制層面做到了高內聚低耦合,極大降低了溝通成本,提高了工作效率。
- 拆分成多個小的插件後,dex從此告別方法數天花板。
- HotFix爲app質量做好最後一層保障方案,再也沒有無法挽回的損失了,而且現在HotFix的級別粒度可控,即可以是傳統class級別(直接使用pathClassLoader實現),也可以是帶資源的apk級別。
- ABTesting脫離古老醜陋的if/else實現,多套方案隨心挑選按需加載。
- 編譯速度大大提高,各BU只需使用宿主的編譯成果更新編譯自己子工程部分,分分鐘搞定。
- App宿主apk大大減小,各業務模塊按需後臺加載或者延遲懶加載,啓動速度優化,告別黑屏和啓動ANR。
- 各BU插件apk獨立,誰胖誰瘦一目瞭然,app size控制有的放矢。
以上收益,基本達到甚至超出了項目的預期目標: D
代價
-
資源別名
Android提供了強大的資源別名規則,參考可以獲取更多細節描述。但不幸的是,在三星S6等部分機型上使用資源別名會出現宿主資源和插件資源ID錯亂導致資源找不到的問題。無奈只能禁止使用這一技術,所幸放棄這個高級特性不會引起根本性損失。
-
重名資源
如前文所述的原因,宿主的資源ID會在插件中完整複製一份。失去了包名這一命名空間的保護,重名資源會直接造成衝突。暫時通過命名規範的方式規避,好在良好的命名習慣也是各開發應該做到的,因此解決代價較小。
-
枚舉
很多控件都會使用枚舉來約束屬性的取值範圍。不幸的是Android的枚舉居然是用命名來唯一確定R中生成的id常量,毫無命名空間或者所屬控件等顧忌。因爲上一點同樣的原因,宿主和插件內的同名枚舉會造成id衝突。暫時同樣通過命名規範的方式規避。
-
外部訪問資源能力。
對於極少數需要從外部訪問apk資源的場合(例如發送延時通知),此時App尚未啓動,資源的獲取由系統代勞,理所當然無法洞悉內部插件的資源位置和獲取方式。對於這種情況實在無能爲力,只好特別准許此類資源直接放在宿主apk內。
以上代價,或者無傷大雅,或者替代方案成本非常低,都在可接受範圍內。
未來優化
還有一些高級特性,因爲優先級關係暫未實現,但隨着各業務線的開發需求也被提到優化日程上來,如:
- 插件工程支持so庫。
- 插件工程支持lib工程依賴、aar依賴、maven遠程依賴等各種高級依賴特性。
- IDE友好,讓開發人員可以更方便的生成插件apk。
開源
經過以上介紹,相信各位對攜程Android插件化開發和動態加載方案有了初步瞭解。細節請移步GitHub開源項目DynamicAPK。攜程無線基礎研發團隊未來會繼續努力,爲大家分享更多項目實踐經驗。