Android 合併AAR踩坑之旅

背景

在輸出Android模塊時,有時會由於個別緣由(好比來自業務的不可抗力),要求將模塊打包成一個文件提供給接入方。這就意味着在輸出模塊由多個子模塊組成的狀況下,咱們須要把多個AAR(或JAR)合併成一個大AAR輸出,這個合併過程涉及到了不少有用的知識和難點,這篇文章就詳細解析下其中的內容。java

首先來直觀認識下AAR

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中定義了ProjectTask兩個概念,同時也將構建流程面向對象化(即構建是針對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文件
  • 每一個AAR的解壓目錄
  • 要合併的JAR文件

AAR和JAR分開收集是由於合併這兩種文件的操做不一樣,JAR只需歸入將其Class文件,而AAR須要合併更多內容。
下面針對AAR中的5和目錄和5個文件,逐個介紹合併Task,從最簡單的開始:

/assets目錄

合併assets內容的Task很簡單,只需將子模塊解壓後的assets目錄添加到主工程的assets.srcDirs中便可

task embedAssets << {
    embeddedAarDirs.each { aarPath ->
        android.sourceSets.main.assets.srcDirs += file("$aarPath/assets")
    }
}
/res目錄

經過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
}
/jni目錄

遍歷子模塊解壓目錄,將其中的so文件copy到主工程build目錄下

task embedJniLibs << {
    embeddedAarDirs.each { aarPath ->
        copy {
            from fileTree(dir: "$aarPath/jni")
            into file("$bundle_release_dir/jni")
        }
    }
}
proguard.txt

注意:這個文件與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.xml

合併AndroidManifest使用官方庫manifest-merger便可,這裏遇到了第一個難點:想要把子模塊AndroidManifest合併進來必須使用MergeTypeAPPLICATION的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();
  ...
}
classes.jar

這彷佛是最重要的文件,可是合併方法卻不難:對於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)找不到對應資源?

在解釋緣由以前,先拋幾個問題:

  • AAR裏默認有R文件麼?
  • 爲何Android Library工程生成的R.java裏的域不是final修飾的?
  • 生成R文件流程的輸出無疑是R.java,那麼輸入是什麼?

在回答這些問題以前,先複習下Java基礎知識,下面是一段簡單的Java代碼:

public class Test {
    private static final int SOME_ID = 0x07111111;
    private int getID(){
        return SOME_ID;
    }
}

假如咱們將SOME_IDfinal修飾符去掉,那麼編譯後的字節碼跟不去掉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庫(實際業務中很常見,好比用到了v4Fragment或者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目錄)。

更多資料分享歡迎Android工程師朋友們加入安卓開發技術進階互助:856328774免費提供安卓開發架構的資料(包括Fultter、高級UI、性能優化、架構師課程、 NDK、Kotlin、混合式開發(ReactNative+Weex)和一線互聯網公司關於Android面試的題目彙總。
相關文章
相關標籤/搜索