在輸出Android模塊時,有時會由於個別緣由(好比來自業務的不可抗力),要求將模塊打包成一個文件提供給接入方。這就意味着在輸出模塊由多個子模塊組成的狀況下,咱們須要把多個AAR(或JAR)合併成一個大AAR輸出,這個合併過程涉及到了不少有用的知識和難點,這篇文章就詳細解析下其中的內容。java
AAR文件是一種Android歸檔包(類比Jar:Java Archive),這種歸檔包是由Gradle構建庫的Android Library插件產出的。它是一個壓縮包,裏面的內容能夠總結爲5個目錄和5個文件:android
賣個關子,在上面10個內容中,其中有一個是已經合併後的結果,它默認已經包含了全部子模塊的內容,你能猜出來是那個麼?
圖裏文字已經基本解釋清楚了各個內容,再也不贅述,如今咱們根據現有的瞭解設想下合併AAR的大體思路:AAR裏無非是幾個目錄和文件,因此最終AAR的5個目錄下,必然包含了全部子模塊的對應內容,而5個文件也確定是各個子文件合併的結果。那麼如何實現包含和合並呢?面試
第一反應是寫個腳本對AAR們作解壓、整合,再壓縮的思路,但稍微推演下就知道不現實(理論上也不可行,AAR和普通壓縮文件仍是有區別的),再思考下,既然直接拿產物作合併不可行,能不能在構建主模塊AAR時,就順便將子模塊內容歸入進來呢?編程
這無疑是個優雅的思路,理論上是否可行呢?答案是能夠的,別忘了AAR是Android Gradle Plugin構建產出的,而Gradle強大的拓展支持恰好能實現咱們的需求。因此合併AAR的方法,其實就是修改Gradle構建流程,在默認構建的基礎上,插入咱們本身的操做,最終產出一個包含子模塊內容的大AAR。性能優化
在入題以前,有必要先理解下Gradle是如何支持構建拓展的,下圖是Gradle官網截圖:架構
Gradle
中定義了Project
和Task
兩個概念,同時也將構建流程面向對象化(即構建是針對Project
執行的一系列Task
)。對Gradle的描述關鍵在於基於依賴編程,具體解釋就是能夠用Gradle來定義Task和Task間的依賴關係,最終得出一個Task的有向無環圖來描述構建。app
這意味着,咱們須要將合併的操做封裝爲Task,並在合適的生命週期內,將自定義Task插入到任務圖的依賴關係中去,進而得出一個新的Task圖,最終構建出咱們要的產物。
咱們在主工程下執行./gradlew assembleRelease
,看一下默認構建中都執行了哪些Task:jvm
能夠看到依次執行了收集依賴,合併資源、Javac編譯、打包文件等Task。同時,經過觀察/build/intermediates
目錄能夠發現,不一樣任務會產出各自的構建中間結果,最終的AAR就是由這些中間結果打包而來的。因此理論上,咱們須要在主模塊執行打包Task前將子模塊內容混入到中間結果中,而因爲構建Task間存在依賴關係,混入操做須要分階段、找時機執行。性能
合併前須要先肯定到底須要合併哪些子模塊。咱們經過定義一個dependency configuration: emeded
來標記須要被合併的模塊,而後在gradle
構建的afterEvaluate
階段收集被emeded
依賴的模塊信息:gradle
afterEvaluate { def dependencies = new ArrayList(configurations.embedded.resolvedConfiguration.firstLevelModuleDependencies) dependencies.reverseEach { ... it.moduleArtifacts.each { artifact -> if (artifact.type == 'aar') { if (!embeddedAarFiles.contains(artifact)) { //要合併的AAR文件 embeddedAarFiles.add(artifact) } if (!embeddedAarDirs.contains(modulePath)) { ... //每一個AAR的解壓目錄 embeddedAarDirs.add(modulePath) } } else if (artifact.type == 'jar') { ... //要合併的JAR文件 embeddedJars.add(artifactPath) } ... } } }
能夠看出,咱們收集了三個集合:
AAR和JAR分開收集是由於合併這兩種文件的操做不一樣,JAR只需歸入將其Class文件,而AAR須要合併更多內容。
下面針對AAR中的5和目錄和5個文件,逐個介紹合併Task,從最簡單的開始:
合併assets
內容的Task很簡單,只需將子模塊解壓後的assets目錄添加到主工程的assets.srcDirs
中便可
task embedAssets << { embeddedAarDirs.each { aarPath -> android.sourceSets.main.assets.srcDirs += file("$aarPath/assets") } }
經過conventionMapping
,修改構建流程task packageRecourses
的輸入集合,將收集到的子模塊/res
目錄追加到Task輸入參數中
task embedLibraryResources << { def oldInputResourceSet = task_packageResources.inputResourceSets task_packageResources.conventionMapping.map("inputResourceSets") { getMergedInputResourceSets(oldInputResourceSet) } } private List getMergedInputResourceSets(List inputResourceSet) { ... List newInputResourceSet = new ArrayList(inputResourceSet) embeddedAarDirs.each { aarPath -> ... rs.addSource(file("$aarPath/res")) newInputResourceSet += rs } return newInputResourceSet }
遍歷子模塊解壓目錄,將其中的so
文件copy
到主工程build
目錄下
task embedJniLibs << { embeddedAarDirs.each { aarPath -> copy { from fileTree(dir: "$aarPath/jni") into file("$bundle_release_dir/jni") } } }
注意:這個文件與AAR構建自身時的混淆操做無關,只做用於接入到宿主後的混淆操做
task embedProguard << { //bundle_release_dir = "build/intermediates/bundles/release" def proguardFile = file("$bundle_release_dir/proguard.txt") //遍歷aar解壓目錄,將子模塊的proguard.txt文件內容追加到主工程build目錄內的proguard.txt中 embeddedAarDirs.each { aarPath -> ... def proguardLibFile = file("$aarPath/proguard.txt") if (proguardLibFile.exists()) //調用file.append()方法能夠在txt文件裏追加內容 proguardFile.append("\n" + proguardLibFile.text) ... } }
合併AndroidManifest
使用官方庫manifest-merger
便可,這裏遇到了第一個難點:想要把子模塊AndroidManifest
合併進來必須使用MergeType
爲APPLICATION的ManifestMerger
對象,但在這種MergeType
下會替換定義在AndroidManifest
裏的PlaceHolder
,好比${applicationId}
。
然而矛盾的是,此時的構建尚未接入到宿主App,也就拿不到最終的ApplicationId
,怎麼辦呢?經過閱讀源碼發現,官方仁慈的給咱們開放了一個功能位,經過在初始化ManifestMerger
對象時傳入一個NO_PLACEHOLDER_REPLACEMENT
功能位,便可禁止在APPLICATION
模式下替換PlaceHolder
。
buildscript { repositories { jcenter() } dependencies { classpath 'com.android.tools.build:manifest-merger:25.3.2' } } ... task embedManifests << { ... embeddedAarDirs.each { aarPath -> File dependencyManifest = file("$aarPath/AndroidManifest.xml") if (!libraryManifests.contains(aarPath) && dependencyManifest.exists()) { //先收集須要合併的子模塊AndroidManifest libraryManifests.add(dependencyManifest) } } ... Invoker manifestMergerInvoker = ManifestMerger2.newMerger(origManifest, mLogger, MergeType.APPLICATION) //經過Invoker.Feature.NO_PLACEHOLDER_REPLACEMENT這個Flag禁止執行PlaceHolder替換 .withFeatures(Invoker.Feature.NO_PLACEHOLDER_REPLACEMENT) manifestMergerInvoker.addLibraryManifests(libraryManifests.toArray(new File[libraryManifests.size()])) manifestMergerInvoker.setMergeReportFile(reportFile); //執行合併 MergingReport mergingReport = manifestMergerInvoker.merge(); ... }
這彷佛是最重要的文件,可是合併方法卻不難:對於AAR類型的子模塊,咱們須要提取兩樣東西:一是解壓子模塊內部classes.jar獲得的Class文件,二是看看子模塊內部有沒有自身攜帶的JAR文件。對於Class文件,將它們copy到主工程build/intermediates/classes/release目錄下,隨後的構建任務會從這個目錄打包出主模塊的classes.jar;對於子模塊內部的JAR文件,將它們和JAR類型的子模塊一塊兒,放入主工程的build/intermediates/bundles/release/libs目錄下,做爲依賴包攜帶便可
task embedClassesAndJars(dependsOn: embedRJar) << { embeddedAarDirs.each { aarPath -> ... embeddedAarFiles.each { artifact -> ... //找到每一個aar裏的clasess.jar def aarFile = aarFileTree.files.find { it.name.contains("classes.jar") } // 解壓clasess.jar,將classes放入build/intermediates/classes/release copy { from zipTree(aarFile) into classes_dir } } ... //找到每一個aar裏攜帶的額外jar包 FileTree jars = fileTree(dir: jar_dir, include: '*.jar', exclude: 'classes.jar') ... //放入build/intermediates/bundles/release/libs copy { from jars into file("$bundle_release_dir/libs") } } //主工程直接依賴的jar包,也放入build/intermediates/bundles/release/libs copy { from embeddedJars into file("$bundle_release_dir/libs") } }
至此,咱們已經合併了三個文件和四個目錄:
剩下的內容裏,/aidl
目錄和annotation.zip
不經常使用到,咱們暫不理會,乍一看好像已經合併完成了,只剩下個看似無害的R.txt
,它是幹嗎用的?咱們當真完成了麼?
答案是沒有,假如按照目前的改造構建出一個AAR並接入到App中運行,結果是App會在每一處使用到AAR子模塊資源文件的時候崩潰,堆棧日誌很簡單:NoClassDefFoundError
- 沒有子模塊包名下的R文件。啊~忘了R文件這個東西了!可R文件不是構建時自動生成的麼?沒錯,是自動生成的,但因爲咱們合併了模塊,子模塊將丟失它們的R文件,仔細梳理下App構建流程就會發現緣由:
如圖上部所示,正常狀況下,不論是主模塊仍是子模塊,都是做爲一個個獨立的dependence
引入到宿主工程的,App構建時會給每個dependence生成它們包名下的R文件;而當咱們自主的將一羣AAR合併爲一個AAR後(圖下半部),對於宿主來講最後只接入了一個dependence
(主模塊),子模塊本該對應的dependences
不存在了,因此App構建時只給主模塊生成了R文件!知道緣由了,怎麼解決呢?
咱們知道,構建主模塊時,會在/build/generated/source/r
目錄下生成全部包名的R文件,固然也包括子模塊R文件,那麼咱們把這個目錄下的子模塊R文件打成Jar包,放入主工程build/intermediates/bundles/release/libs
目錄下,這樣R.jar會做爲依賴庫被AAR攜帶,不就能夠了嗎?咱們興沖沖地改了腳本,再次構建運行,結果仍是崩潰!看下堆棧信息,提示資源找不到:Resources$NotFoundException
,這又是爲何?R文件但是自動生成的呀?爲何生成的文件內容(也就是資源ID)找不到對應資源?
在解釋緣由以前,先拋幾個問題:
R.java
裏的域不是final
修飾的?R.java
,那麼輸入是什麼?在回答這些問題以前,先複習下Java基礎知識,下面是一段簡單的Java代碼:
public class Test { private static final int SOME_ID = 0x07111111; private int getID(){ return SOME_ID; } }
假如咱們將SOME_ID
的final
修飾符去掉,那麼編譯後的字節碼跟不去掉final
相比,會有什麼不一樣呢?咱們反編譯看下結果:
//帶final修飾符 public class Test { private static final int SOME_ID = 118558993; public Test() { } private int getID() { return 118558993; } } //不帶final修飾符 public class Test { private static int SOME_ID = 118558993; public Test() { } private int getID() { return SOME_ID; } }
對比發現,帶final
時,getID()
方法直接返回了數值;而不帶final
時,getID()
方法返回的是SOME_ID,即依然保留着對變量的符號引用。這個看似不起眼的差異卻暗藏玄機,看下圖:
如圖,每一個AAR被接入後,都會跟隨App工程再經歷一次構建,即宿主工程的構建。而咱們知道,R文件是跟隨每次構建從新生成的(不論是AAR的構建仍是App的構建),而R.java中每一個域的值是由當前工程的資源集合作排列得出的,這意味着,假如工程的資源集合發生了變化,那麼R.java中域的值均可能發生變化。
對於AAR文件來講,自身工程的資源集合必然和宿主工程的資源集合不同,或者能夠這樣理解,R.java的值是一次性的,它只保證在當前構建結果下有效,此次生成的R文件,不保證在下次構建後可用,這也是每次構建都從新生成R文件的緣由。這樣咱們就找到了上面的崩潰緣由和拋出的前兩個問題的答案:AAR接入到App後跟隨App又經歷了一次構建,資源發生了重排列,因此手動打到AAR中的R文件ID值所有失效,沒法再索引到資源,因此運行時崩潰。
AAR中默認沒有R文件,由於帶上也徹底不能用。一樣由於重排列,AAR沒法預知自身資源接入到App後的ID值,因此Library工程生成的R.java中的域都不能用final修飾。
*:這裏的邏輯很刁鑽,但卻很重要,是理解後續操做的前提,務必要反覆品味...
那要怎麼辦呢?其實關鍵在主模塊身上:合併子模塊後,主模塊成了全部子模塊的代言人,App只需接入主模塊就等於接入了全部模塊。能力越大責任越大,咱們賦予了主模塊如此特殊的地位,那麼它也應該起到足夠特殊的做用,好比橋接子模塊到宿主的資源索引:
//com.sub.library public final class R { public static final class string { public static int some_string = com.main.library.R.string.some_string; } }
如代碼所示,咱們在把子模塊R文件放入classes.jar
以前,手動將其中的域指向主模塊R文件相同的域(正是由於這裏沒有final
修飾符,才能保留對主模塊域的符號引用,編譯後纔不會變成數值而沒法被更改),由於主模塊R文件會跟隨每一次App構建而從新生成,因此它的ID值老是新鮮可靠的,這樣子模塊就能夠經過這個橋接拿到正確的資源索引了。這時你可能會拍案而起,不對啊,主模塊沒有子模塊裏的資源文件,爲何R文件中會有跟子模塊相同的域呢?!
這其實就是上面提到的第三個問題:生成R文件流程的輸出無疑是R.java
,那麼輸入是什麼?這個問題也牽扯到本文剛開始賣的關子:那個默認包含子模塊內容的文件是誰?沒錯,就是R.txt
(這個看似老實的東西其實壞的很 XD)。
咱們知道,APK或AAR構建時會用Aapt編譯資源文件,這個R.txt
正是Aapt的產物,經過在aapt package
命令後面跟上--output-text-symbols
參數獲得。R.txt
中會記錄下-S
參數傳入的資源目錄下全部的資源文件信息,一個資源佔一行,格式爲int type name id
,好比: int string some_string 0x7f050000
(和R文件格式一致,因此猜到了吧,R.txt
就是R.java
的種子)。
在AAR的構建中,R.txt
天然記錄了Library庫內的資源文件信息,不過一個很不天然的事情是,構建時aapt package
命令的-S參數傳入的不是本工程/src/res
目錄,而是build/intermediates/res/merged/release
目錄,也就是合併子模塊資源後的/res目錄!因此,默認生成的R.txt
中就已經包含了全部子模塊的資源文件,而這個R.txt
偏偏就是構建生成R.java
的種子文件,腳本會根據R.txt
中每一行的內容生成R.java中對應的域。這也就解釋了爲何主模塊的R.java會包含全部子模塊的資源索引。終於,咱們知道了如何處理R文件:
task redirectRJava << { ... embeddedAarDirs.each { aarPath -> ... def sb = "package $subPackageName;" << '\n' << '\n' //將ID賦值爲主模塊包名下對應的值 sb << " public static $type $name = ${mainPackageName}.R.${subclass}.${name};" << '\n' ... //generated_rsrc_dir = "build/generated/source/r/release" mkdir("$generated_rsrc_dir/$packagePath") file("$generated_rsrc_dir/$packagePath/R.java").write(sb.toString()) } } //將R文件打成Jar包放入libs目錄 task embedRClass(type: org.gradle.jvm.tasks.Jar, dependsOn: collectRClass) { ... baseName "EmbedR" destinationDir file("$bundle_release_dir/libs") from base_r2x_dir }
解決完R文件的難題後,咱們就基本完成了AAR的合併工做,可是在實際業務中,會遇到另外一個棘手的問題:Support庫兼容問題。上面已經分析過,一個庫的R文件中會包含它依賴的全部子模塊的資源文件(還記得緣由麼),假如咱們的模塊依賴了Support庫(實際業務中很常見,好比用到了v4
的Fragment
或者RecyclerView
),那麼R文件或R.txt中就會包含Support庫內全部資源文件的信息,而當模塊被接入後,在App的構建流程中,會根據最終的Support庫版本(App依賴的Support庫版本不可知)對各個R.txt
裏的內容作過濾,只保留App工程中確實存在的資源文件,生成最終的R文件(源代碼見com.android.builder.symbols.RGeneration
)。
舉個例子,假如模塊使用的A版本的Support庫中有資源文件res1,而接入方App使用了更高版本的Support庫中沒有res1這個資源,那麼App構建生成的R文件中將沒有res1
這個資源索引,但是咱們打入AAR中的R文件還保留着對res1的符號引用,結果就是在運行時類初始化失敗(R.class的<cinit style="margin: 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; word-wrap: break-word !important; font-size: inherit; color: inherit; line-height: inherit;">錯誤):</cinit>
public final class R { public static final class string { //這裏的com.main.library.R.string.res1被過濾掉再也不存在,符號引用失效 public static int res1 = com.main.library.R.string.res1; } }
怎麼辦呢?第一反應是保證support庫版本一致(必須精確到小版本號),即針對每一個接入方構建出跟其Support庫一致的產物,這就帶來了不少隱藏成本(還須要手動作R.txt的過濾),更況且假如接入方某天更新了Support庫版本,直接就Runtime Crash了,這屬於對接事故,顯然不能接受。那麼,應該怎麼作呢?其實很簡單:禁止Support庫資源寫入R.txt便可(其實官方也明確提示過不要本身使用Support庫裏的資源),還記得R.txt是根據什麼生成的麼?
沒錯,是build/intermediates/res/merged/release
目錄下的資源合集,而這些文件又是根據遍歷依賴合併進來的,假如咱們能在合併時過濾掉Support庫的依賴項,就在源頭上過濾掉了Support庫資源。因此咱們只須要對合並Resources的Task稍作手腳:
task filterMergeResource << { def oldInputResourceSet = task_mergeResources.inputResourceSets List<ResourceSet> newInputResourceSet = new ArrayList(oldInputResourceSet) newInputResourceSet.removeAll { //過濾com.android.support包名下的依賴,避免support庫資源打入R.txt (null != it.libraryName) && (it.libraryName.contains("com.android.support")) } task_mergeResources.conventionMapping.map("inputResourceSets") { newInputResourceSet } }
最終咱們插入了以上幾個自定義任務到不一樣的構建節點中,完成了合併AAR的構建改造,但仍然有拓展空間,好比支持buildType和productFlavor,有興趣的同窗能夠本身嘗試下(其實就是找到對應的build目錄)。