Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> 關於Android編程 >> Android 攜程動態加載框架的打包流程分析

Android 攜程動態加載框架的打包流程分析

編輯:關於Android編程

最近攜程開源了一套動態加載的框架,總的來說,該框架和OpenAtlas還是有一定的相似之處的,比如資源的分區。此外該框架也支持熱修復。個人覺得該框架中攜程做的比較多的應該在打包語句的編寫上面,這篇文章主要用於記錄自己學習該框架的一個過程。在攜程的github上,給出的打包方法是命令行執行gradle,如下

git clone https://github.com/CtripMobile/DynamicAPK.git
cd DynamicAPK/
gradlew assembleRelease bundleRelease repackAll

該命令行中執行打包的語句gradlew assembleRelease bundleRelease repackAll,之後就會在對應目錄下生成/build-outputs/appname-release-final.apk文件,這條打包語句可以分解為三條語句依次執行,即gradlew assembleReleasegradlew bundleReleasegradlew repackAll,我們依次來看這三個命令到底做了什麼。

gradlew assembleRelease

該命令定義在sample模塊的build.gradle文件中

//打包後產出物復制到build-outputs目錄。apk、manifest、mapping
task copyReleaseOutputs(type:Copy){
    from ($buildDir/outputs/apk/sample-release.apk) {
        rename 'sample-release.apk', 'demo-base-release.apk'
    }
    from $buildDir/intermediates/manifests/full/release/AndroidManifest.xml
    from ($buildDir/outputs/mapping/release/mapping.txt) {
        rename 'mapping.txt', 'demo-base-mapping.txt'
    }

    into new File(rootDir, 'build-outputs')
}

assembleRelease<<{
    copyReleaseOutputs.execute()
}

從上面的語句看到,在執行完assembleRelease的時候,還執行了copyReleaseOutputs這個task,而這個task所做的就是將sample目錄下的build目錄中生成的部分文件拷貝到build-outputs目錄中

第一個文件是生成的apk文件,並對其進行了重命名;該文件用於後續插件打包的時候資源的引用等。 第二個文件是android的清單文件AndroidManifest.xml,直接復制不進行重命名; 第三個文件是mapping.txt文件,並對其進行了重名名。其中第三個文件是和代碼混淆相關的,如果沒有開啟代碼混淆,該文件是不存在的。

該task執行後,目錄中生成的文件如圖所示,其中mapping.txt文件的存在是因為我開啟了混淆。

這裡寫圖片描述vcjnz8I8L3A+DQo8cHJlIGNsYXNzPQ=="brush:java;"> buildTypes { ... release { ... minifyEnabled true ... } }

gradlew bundleRelease

之後執行的就是bundleRelease

task bundleRelease (type:Zip,dependsOn:['compileRelease','aaptRelease','dexRelease']){
    inputs.file $buildDir/intermediates/dex/${project.name}_dex.zip
    inputs.file $buildDir/intermediates/res/resources.zip

    outputs.file ${rootDir}/build-outputs/${apkName}.so

    archiveName = ${apkName}.so
    destinationDir = file(${rootDir}/build-outputs)
    duplicatesStrategy = 'fail'
    from zipTree($buildDir/intermediates/dex/${project.name}_dex.zip)
    from zipTree($buildDir/intermediates/res/resources.zip)
}

該task會生成插件的相關文件到build-outputs目錄,該目錄在會事先創建好,首先會在插件模塊的build目錄中將dex.zip和resources.zip壓縮文件中(這兩個文件的生成見下面的task的分析)的文件作為輸入文件,重新壓縮為一個so文件,so的名字為包名.so,其中包名中的點修改為了下劃線,見下圖

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

該task需要依賴其他三個Task,依次為aaptReleasecompileReleasedexRelease

        if( module.@packageName==${packageName}) {
            resourceId=module.@resourceId
            println find packageName:  + module.@packageName +  ,resourceId: + resourceId
        }
    }
    def argv = []
    argv << 'package'   //打包
    argv << -v
    argv << '-f' //強制覆蓋已有文件
    argv << -I
    argv << $sdk.androidJar        //添加一個已有的固化jar包
    argv << '-I'
    argv << ${rootDir}/build-outputs/demo-base-release.apk
    argv << '-M'
    argv << $projectDir/AndroidManifest.xml    //指定manifest文件
    argv << '-S'
    argv << $projectDir/res                    //res目錄
    argv << '-A'
    argv << $projectDir/assets                 //assets目錄
    argv << '-m'        //make package directories under location specified by -J
    argv << '-J'
    argv << $buildDir/gen/r         //哪裡輸出R.java定義
    argv << '-F'
    argv << $buildDir/intermediates/res/resources.zip   //指定apk的輸出位置
    argv << '-G'        //-G  A file to output proguard options into.
    argv << $buildDir/intermediates/res/aapt-rules.txt
    // argv << '--debug-mode'      //manifest的application元素添加android:debuggable=true
    argv << '--custom-package'      //指定R.java生成的package包名
    argv << ${packageName}
    argv << '-0'    //指定哪些後綴名不會被壓縮
    argv << 'apk'
    argv << '--public-R-path'
    argv << ${rootDir}/sample/build/generated/source/r/release/ctrip/android/sample/R.java
    argv << '--apk-module'
    argv << $resourceId

    args = argv

} data-snippet-id=ext.d44f64e4ac50b2acc8c1ccd2ae41d5ef data-snippet-saved=false data-csrftoken=YvivjwzW-3pqMJ0IR7WfTXK1Nv488i9Hbv_c data-codota-status=done>//初始化,確保必要目錄都存在
task init << {
    new File(rootDir, 'build-outputs').mkdirs()

    buildDir.mkdirs()

    new File(buildDir, 'gen/r').mkdirs()

    new File(buildDir, 'intermediates').mkdirs()

    new File(buildDir, 'intermediates/classes').mkdirs()

    new File(buildDir, 'intermediates/classes-obfuscated').mkdirs()

    new File(buildDir, 'intermediates/res').mkdirs()

    new File(buildDir, 'intermediates/dex').mkdirs()

}

task aaptRelease (type: Exec,dependsOn:'init'){
    inputs.file $sdk.androidJar
    inputs.file ${rootDir}/build-outputs/demo-base-release.apk
    inputs.file $projectDir/AndroidManifest.xml
    inputs.dir $projectDir/res
    inputs.dir $projectDir/assets
    inputs.file ${rootDir}/sample/build/generated/source/r/release/ctrip/android/sample/R.java

    outputs.dir $buildDir/gen/r
    outputs.file $buildDir/intermediates/res/resources.zip
    outputs.file $buildDir/intermediates/res/aapt-rules.txt

    workingDir buildDir
    executable sdk.aapt

    def resourceId=''
    def parseApkXml=(new XmlParser()).parse(new File(rootDir,'apk_module_config.xml'))
    parseApkXml.Module.each{ module->
        if( module.@packageName==${packageName}) {
            resourceId=module.@resourceId
            println find packageName:  + module.@packageName +  ,resourceId: + resourceId
        }
    }
    def argv = []
    argv << 'package'   //打包
    argv << -v
    argv << '-f' //強制覆蓋已有文件
    argv << -I
    argv << $sdk.androidJar        //添加一個已有的固化jar包
    argv << '-I'
    argv << ${rootDir}/build-outputs/demo-base-release.apk
    argv << '-M'
    argv << $projectDir/AndroidManifest.xml    //指定manifest文件
    argv << '-S'
    argv << $projectDir/res                    //res目錄
    argv << '-A'
    argv << $projectDir/assets                 //assets目錄
    argv << '-m'        //make package directories under location specified by -J
    argv << '-J'
    argv << $buildDir/gen/r         //哪裡輸出R.java定義
    argv << '-F'
    argv << $buildDir/intermediates/res/resources.zip   //指定apk的輸出位置
    argv << '-G'        //-G  A file to output proguard options into.
    argv << $buildDir/intermediates/res/aapt-rules.txt
    // argv << '--debug-mode'      //manifest的application元素添加android:debuggable=true
    argv << '--custom-package'      //指定R.java生成的package包名
    argv << ${packageName}
    argv << '-0'    //指定哪些後綴名不會被壓縮
    argv << 'apk'
    argv << '--public-R-path'
    argv << ${rootDir}/sample/build/generated/source/r/release/ctrip/android/sample/R.java
    argv << '--apk-module'
    argv << $resourceId

    args = argv

}

可以看到輸出了一個resources.zip文件,這個文件就是bundleRelease 中用到的壓縮文件之一,總的來說該task就是拼接命令行參數生成文件。

aaptRelease是對插件資源文件的編譯,依賴於aapt命令行工具,在了解該Task之前,需要了解一下該命令的一些參數。

-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。

在對插件的編譯過程中,攜程主要用了三個參數。其中也不乏攜程自己改裝aapt增加的參數。如下

使用-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類中完整復制一份,這樣插件工程即可和之前一樣,完全不用在乎資源來自於宿主或者自身,直接使用即可。當然這樣做帶來的副作用就是宿主和插件的資源不應有重名,這點我們通過開發規范來約束,相對比較容易理解一些。

了解了這麼一些基礎的概念之後,回頭再來看看該task所做的工作。首先調用了task init進行一些目錄的創建,然後引入創建apk資源文件所有必要的文件,再通過檢查apk_module_config.xml文件,找到對應包名的resourceId,該文件的定義如下


    
    
 data-snippet-id=ext.390cbb0be7f7f8db4317a070cfae9f36 data-snippet-saved=false data-codota-status=done>

    
    

之後做的就是拼接命令行語句,執行生成資源就可以了。最終的產物就是resources.zip

compileRelease這個task的作用就是編譯java文件,會指定classpath目錄以及目標目錄等相關信息。

task compileRelease(type: JavaCompile,dependsOn:'aaptRelease') {
    inputs.file $sdk.androidJar
    inputs.files fileTree(${projectDir}/libs).include('*.jar')
    inputs.file ${rootDir}/sample/build/intermediates/classes-proguard/release/classes.jar
    inputs.files fileTree($projectDir/src).include('**/*.java')
    inputs.files fileTree($buildDir/gen/r).include('**/*.java')

    outputs.dir $buildDir/intermediates/classes
    sourceCompatibility = '1.6'
    targetCompatibility = '1.6'
    classpath = files(
            ${sdk.androidJar},
            ${sdk.apacheJar},
            fileTree(${projectDir}/libs).include('*.jar'),


            ${rootDir}/sample/build/intermediates/classes-proguard/release/classes.jar
        )

    destinationDir = file($buildDir/intermediates/classes)

    dependencyCacheDir = file(${buildDir}/dependency-cache)

    source = files(fileTree($projectDir/src).include('**/*.java'),
            fileTree($buildDir/gen/r).include('**/*.java'))
    options.encoding = 'UTF-8'
}

最終的生成文件會在build/intermediates/classes

dexRelease這個task的作用就是根據compileRelease生成的classes文件,調用dex工具打包成android專用的dex文件。

task dexRelease (type:Exec){
    inputs.file ${buildDir}/intermediates/classes
    outputs.file ${buildDir}/intermediates/dex/${project.name}_dex.zip
    workingDir buildDir
    executable sdk.dex

    def argv = []
    argv << '--dex'
    argv << --output=${buildDir}/intermediates/dex/${project.name}_dex.zip
    argv << ${buildDir}/intermediates/classes

    args = argv
}

這個task輸出了一個dex.zip,也是bundleRelease這個task中用到的一個壓縮包之一。

gradlew repackAll

這個task主要是調用了其他5個task

task repackAll(dependsOn: ['reload','resign','repack','realign','concatMappings'])

下面來一一分析這幾個task

reload的作用就是將最開始生成的宿主文件的apk的assets目錄中,添加插件so,而so正是前面幾個task生成的插件so文件,最終的產物是demo-release-reloaded.apk這個文件

//base apk的assets中填充各子apk
//輸入:Ctrip-base-release.apk
//輸出:Ctrip-release-reloaded.apk
task reload(type:Zip){
    inputs.file  $rootDir/build-outputs/demo-base-release.apk
    inputs.files fileTree(new File(rootDir,'build-outputs')).include('*.so')
    outputs.file $rootDir/build-outputs/demo-release-reloaded.apk

    into 'assets/baseres/',{
        from fileTree(new File(rootDir,'build-outputs')).include('*.so')
    }

    from zipTree($rootDir/build-outputs/demo-base-release.apk), {
        exclude('**/META-INF/*.SF')
        exclude('**/META-INF/*.RSA')
    }

    destinationDir file($rootDir/build-outputs/)

    archiveName 'demo-release-reloaded.apk'
}

apk文件發生了改變,需要對其進行重新簽名,resign這個task的目的就是這個,調用命令行簽名工具,添加證書的信息進行簽名,但是在簽名前會進行一次壓縮,repack 這個task就是進行這個操作,最後輸出的是demo-release-repacked.apk,打包完畢後便會進行簽名的操作,也就是resign這個task所做的工作


//對apk重新壓縮,調整各文件壓縮比到正確
//輸入:Ctrip-release-reloaded.apk
//輸出:Ctrip-release-repacked.apk
task repack (dependsOn: 'reload') {
    inputs.file $rootDir/build-outputs/demo-release-reloaded.apk
    outputs.file $rootDir/build-outputs/demo-release-repacked.apk

    doLast{
        println release打包之後,重新壓縮一遍,以壓縮resources.arsc

        def oldApkFile = file($rootDir/build-outputs/demo-release-reloaded.apk)

        assert oldApkFile != null : 沒有找到release包!

        def newApkFile = new File(oldApkFile.parentFile, 'demo-release-repacked.apk')

        //重新打包
        repackApk(oldApkFile.absolutePath, newApkFile.absolutePath)

        assert newApkFile.exists() : 沒有找到重新壓縮的release包!
    }
}
//對apk重簽名
//輸入:Ctrip-release-repacked.apk
//輸出:Ctrip-release-resigned.apk
task resign(type:Exec,dependsOn: 'repack'){
    inputs.file $rootDir/build-outputs/demo-release-repacked.apk
    outputs.file $rootDir/build-outputs/demo-release-resigned.apk

    workingDir $rootDir/build-outputs
    executable ${System.env.'JAVA_HOME'}/bin/jarsigner

    def argv = []
    argv << '-verbose'
    argv << '-sigalg'
    argv << 'SHA1withRSA'
    argv << '-digestalg'
    argv << 'SHA1'
    argv << '-keystore'
    argv << $rootDir/demo.jks
    argv << '-storepass'
    argv << '123456'
    argv << '-keypass'
    argv << '123456'
    argv << '-signedjar'
    argv << 'demo-release-resigned.apk'
    argv << 'demo-release-repacked.apk'
    argv << 'demo'

    args = argv
}

簽名完畢後會輸出簽名後的文件demo-release-resigned.apk

而repack這個task最終調用的是repackApk重寫進行壓縮打包的

        if(entryIn.directory){
            println ${entryIn.name} is a directory
        }
        else{
            def entryOut = new ZipEntry(entryIn.name)
            def dotPos = entryIn.name.lastIndexOf('.')
            def ext = (dotPos >= 0) ? entryIn.name.substring(dotPos) : 
            def isRes = entryIn.name.startsWith('res/')
            if(isRes && ext in noCompressExt){
                entryOut.method = ZipEntry.STORED
                entryOut.size = entryIn.size
                entryOut.compressedSize = entryIn.size
                entryOut.crc = entryIn.crc
            }
            else{
                entryOut.method = ZipEntry.DEFLATED
            }
            zos.putNextEntry(entryOut)
            zos << zipFile.getInputStream(entryIn)
            zos.closeEntry()
        }
    }
    zos.finish()
    zos.close()
    zipFile.close()
} data-snippet-id=ext.a351408c11907042f6379e18d8366690 data-snippet-saved=false data-csrftoken=CFCj3qB4-eUeOg6hcI2TJYMr4jASCdmeRJK4 data-codota-status=done>import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream

// 打包過程中很多手工zip過程:
// 1,為了壓縮resources.arsc文件而對標准產出包重新壓縮
// 2,以及各子apk的純手打apk包
// 但對於音頻等文件,壓縮會導致資源加載報異常
// 重新打包方法,使用STORED過濾掉不應該壓縮的文件們
// 後綴名列表來自於android源碼
def repackApk(originApk, targetApk){
    def noCompressExt = [.jpg, .jpeg, .png, .gif,
                         .wav, .mp2, .mp3, .ogg, .aac,
                         .mpg, .mpeg, .mid, .midi, .smf, .jet,
                         .rtttl, .imy, .xmf, .mp4, .m4a,
                         .m4v, .3gp, .3gpp, .3g2, .3gpp2,
                         .amr, .awb, .wma, .wmv]

    ZipFile zipFile = new ZipFile(originApk)
    ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(targetApk)))
    zipFile.entries().each{ entryIn ->
        if(entryIn.directory){
            println ${entryIn.name} is a directory
        }
        else{
            def entryOut = new ZipEntry(entryIn.name)
            def dotPos = entryIn.name.lastIndexOf('.')
            def ext = (dotPos >= 0) ? entryIn.name.substring(dotPos) : 
            def isRes = entryIn.name.startsWith('res/')
            if(isRes && ext in noCompressExt){
                entryOut.method = ZipEntry.STORED
                entryOut.size = entryIn.size
                entryOut.compressedSize = entryIn.size
                entryOut.crc = entryIn.crc
            }
            else{
                entryOut.method = ZipEntry.DEFLATED
            }
            zos.putNextEntry(entryOut)
            zos << zipFile.getInputStream(entryIn)
            zos.closeEntry()
        }
    }
    zos.finish()
    zos.close()
    zipFile.close()
}

簽名完畢後會對該apk進行4K對齊操作。


//重新對jar包做對齊操作
//輸入:Ctrip-release-resigned.apk
//輸出:Ctrip-release-final.apk
task realign (dependsOn: 'resign') {
    inputs.file $rootDir/build-outputs/demo-release-resigned.apk
    outputs.file $rootDir/build-outputs/demo-release-final.apk

    doLast{
        println '重新zipalign,還可以加大壓縮率!'

        def oldApkFile = file($rootDir/build-outputs/demo-release-resigned.apk)
        assert oldApkFile != null : 沒有找到release包!

        def newApkFile = new File(oldApkFile.parentFile,'demo-release-final.apk')

        def cmdZipAlign = getZipAlignPath()
        def argv = []
        argv << '-f'    //overwrite existing outfile.zip
        // argv << '-z'    //recompress using Zopfli
        argv << '-v'    //verbose output
        argv << '4'     //alignment in bytes, e.g. '4' provides 32-bit alignment
        argv << oldApkFile.absolutePath
        argv << newApkFile.absolutePath

        project.exec {
            commandLine cmdZipAlign
            args argv
        }

        assert newApkFile.exists() : 沒有找到重新zipalign的release包!
    }
}

最後還有一個task,就是concatMappings,這個task很簡單,做的就是合並一下mapping文件。


/**
 * 用來連接文件的task
 */
class ConcatFiles extends DefaultTask {
    @InputFiles
    FileCollection sources

    @OutputFile
    File target

    @TaskAction
    void concat() {
        File tmp = File.createTempFile('concat', null, target.getParentFile())
        tmp.withWriter { writer ->
            sources.each { file ->
                file.withReader { reader ->
                    writer << reader
                }
            }
        }
        target.delete()
        tmp.renameTo(target)
    }
}
//合並base和所有模塊的mapping文件
task concatMappings(type: ConcatFiles){
    sources = fileTree(new File(rootDir,'build-outputs')).include('*mapping.txt')
    target = new File(rootDir,'build-outputs/demo-mapping-final.txt')
}

最終repackAll這個task的產物如下

這裡寫圖片描述

以上就是攜程動態加載框架的打包流程分析,純屬個人看法,如有不正確的地方,請給予指正。

 

  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved