標籤:get eclipse 發展 運行時 調用 非官方 thread 理解 任務
攜程Android App的外掛程式化和動態載入架構已上線半年,經曆了初期的探索和持續的打磨最佳化,新架構和工程配置經受住了生產實踐的考驗。本文將詳細介紹Android平台外掛程式式開發和動態載入技術的原理和實現細節,回顧攜程Android App的架構演化過程,期望我們的經驗能協助到更多的Android工程師。
需求驅動
2014年,隨著業務發展需要和攜程無線部門的拆分,各業務產品模組歸屬到各業務BU,原有攜程無線AppTeam Dev被分為基礎架構、酒店、機票、火車票等多個Team Dev,從此攜程App的開發和發布進入了一個全新模式。在這種模式下,開發溝通成本大大提高,之前的協作模式難以為繼,需要新的開發模式和技術解決需求問題。
另一方面,從技術上來說,攜程早在2012年就觸到Android平台史上最坑天花板(沒有之一):65535方法數問題。舊方案是把所有第三方庫放到第二個dex中,並且利用Facebook當年發現的hack方法擴大點LinearAllocHdr分配空間(5M提升到8M),但隨著代碼的膨脹,舊方案也逐漸捉襟見肘。拆or不拆,根本不是可考慮問題,繼續拆分dex是我們的唯一出路。問題在於:怎麼拆才比較聰明?
其次,隨著組織架構調整的影響,給我們的App品質控制帶來極高的挑戰,這種緊張和壓力讓我們的Team Dev心力憔悴。此時除了流著口水羨慕前端同事們的線上更新持續發布能力之外,難道就沒有辦法解決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。例如:
``` java//android.jar中的資源,其PackageID為0x01public static final int cancel = 0x01040000;//使用者app中的資源,PackageID總是0x7Fpublic static final int zip_code = 0x7f090f2e;```
我們修改aapt後,是可以給每個子apk中的資源分派不同頭位元組PackageID,這樣就不會再互相衝突。
代碼的編譯
大家對Java代碼的編譯應該相當熟悉,只需要注意以下幾個問題即可:
有了以上背景知識,我們就可以思考並設計外掛程式化動態載入架構的基本原理和主要流程了。
實現
實現分為兩類: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類中完整複製一份,這樣外掛程式工程即可和之前一樣,完全不用在乎資源來自於宿主或者自身,直接使用即可。當然這樣做帶來的副作用就是宿主和外掛程式的資源不應有重名,這點我們通過開發規範來約束,相對比較容易理解一些。
外掛程式代碼編譯
針對外掛程式的代碼編譯,需要考慮以下幾點:
自此,編譯期所有重要步驟的技術方案都已經確定,剩下的工作就只是把外掛程式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;@Overridepublic AssetManager getAssets() { return getResources().getAssets();}@Overridepublic 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內。
以上代價,或者無傷大雅,或者替代方案成本非常低,都在可接受範圍內。
未來最佳化
還有一些進階特性,因為優先順序關係暫未實現,但隨著各業務線的開發需求也被提到最佳化議程上來,如:
開源
經過以上介紹,相信各位對攜程Android外掛程式化開發和動態載入方案有了初步瞭解。實現細節請移步GitHub開源項目DynamicAPK。攜程無線基礎研發團隊未來會繼續努力,為大家分享更多項目實踐經驗。歡迎關注攜程App團隊的公眾號:CtripMobile。
攜程Android App外掛程式化和動態載入實踐