本文轉自:微信前端
攜程Android App的插件化和動態加載框架已上線半年,經歷了初期的探索和持續的打磨優化,新框架和工程配置經受住了生產實踐的考驗。本文將詳細介紹Android平臺插件式開發和動態加載技術的原理和實現細節,回顧攜程Android App的架構演化過程,指望咱們的經驗能幫助到更多的Android工程師。java
2014年,隨着業務發展須要和攜程無線部門的拆分,各業務產品模塊歸屬到各業務BU,原有攜程無線App開發團隊被分爲基礎框架、酒店、機票、火車票等多個開發團隊,今後攜程App的開發和發佈進入了一個全新模式。在這種模式下,開發溝通成本大大提升,以前的協做模式難覺得繼,須要新的開發模式和技術解決需求問題。android
另外一方面,從技術上來講,攜程早在2012年就觸到Android平臺史上最坑天花板(沒有之一):65535方法數問題。舊方案是把全部第三方庫放到第二個dex中,而且利用Facebook當年發現的hack方法擴大點LinearAllocHdr分配空間(5M提高到8M),但隨着代碼的膨脹,舊方案也逐漸捉襟見肘。拆or不拆,根本不是可考慮問題,繼續拆分dex是咱們的惟一出路。問題在於:怎麼拆才比較聰明?程序員
其次,隨着組織架構調整的影響,給咱們的App質量控制帶來極高的挑戰,這種緊張和壓力讓咱們的開發團隊心力憔悴。此時除了流着口水羨慕前端同事們的在線更新持續發佈能力以外,難道就沒有辦法解決Native架構這一根本性缺陷了嗎?NO!插件化動態加載帶來的額外好處就是客戶端的熱部署能力。sql
從以上幾點根本性需求能夠看出,插件化動態加載架構方案會爲咱們帶來多麼巨大的收益,除此以外還有諸多好處:瀏覽器
編譯速度提高安全
工程被拆分爲十來個子工程以後,Android Studio編譯流程繁冗的缺點被迅速放大,在Win7機械硬盤開發機上編譯時間曾突破1小時,使人髮指的龜速編譯讓開發人員叫苦連天(固然如今換成Mac+SSD快太多)。微信
啓動速度提高cookie
Google提供的MultiDex方案,會在主線程中執行全部dex的解壓、dexopt、加載操做,這是一個很是漫長的過程,用戶會明顯的看到長久的黑屏,更容易形成主線程的ANR,致使首次啓動初始化失敗。架構
A/B Testing
能夠獨立開發AB版本的模塊,而不是將AB版本代碼寫在同一個模塊中。
可選模塊按需下載
例如用於調試功能的模塊能夠在須要時進行下載後進行加載,減小App Size
列舉了這麼多痛點,童鞋們早就心潮澎湃按捺不住了吧?言歸正傳,開始插件化動態加載架構探索之旅。
關於插件化思想,軟件業已經有足夠多的用戶教育。不管是平常使用的瀏覽器,仍是陪伴程序員無很多天夜的Eclipse,甚至連QQ背後,都有插件化技術的支持。咱們要在Android上實現插件化,主要須要考慮2個問題:
編譯期:資源和代碼的編譯
運行時:資源和代碼的加載
解決了以上2個關鍵問題,以後如何實現插件化的具體接口,就變成我的技術喜愛或者具體需求場景差別而已。如今咱們就針對以上關鍵問題逐一破解,其中最麻煩的仍是資源的編譯和加載問題。
首先來回顧下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爲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。攜程無線基礎研發團隊將來會繼續努力,爲你們分享更多項目實踐經驗。歡迎關注攜程App團隊的微信公衆號:CtripMobile。