Android 携程动态加载框架的打包流程分析

最近携程开源了一套动态加载的框架,总的来说,该框架和OpenAtlas还是有一定的相似之处的,比如资源的分区。此外该框架也支持热修复。个人觉得该框架中携程做的比较多的应该在打包语句的编写上面,这篇文章主要用于记录自己学习该框架的一个过程,携程的打包语句是我见过最复杂的,所以还是非常值得借鉴的。在携程的github上的DynamicAPK上,给出的打包方法是命令行执行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文件的存在是因为我开启了混淆。

这里写图片描述

开启混淆的方式如下

buildTypes {
    ...
    release {
        ...
        minifyEnabled true
        ...
    }
}

gradlew bundleRelease

之后执行的就是bundleRelease,这个task最终目的是生成插件so(后缀为so,本质还是apk,这也是很多加壳的应用反编译不出来什么东西的原因)

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会生成插件的相关so文件到build-outputs目录,该目录在会在其依赖的task中事先创建好,首先会在插件模块的build目录中将dex.zip和resources.zip压缩文件中的文件(这两个文件的生成在其依赖的task中完成)作为输入文件,重新压缩为一个so文件,so的名字为包名.so,其中包名中的点修改为了下划线,见下图

这里写图片描述

这里写图片描述

这里写图片描述

该task需要依赖其他三个Task,依次为aaptReleasecompileReleasedexRelease

//初始化,确保必要目录都存在
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,该文件的定义如下

<?xml version="1.0" encoding="utf-8"?>
<ApkModules>
    <Module  packageName="ctrip.android.demo1" resourceId="0x31"/>
    <Module  packageName="ctrip.android.demo2" resourceId="0x36"/>
</ApkModules>

之后做的就是拼接命令行语句,执行生成资源就可以了。而拼接的命令行语句中,指定了很多参数,如-I、–apk-module、–public-R-path等等,具体意义在上文已经解释过了,最终的产物就是资源文件的压缩包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中,可以看出最终的产物应该是一些列的class类文件

dexRelease这个task的作用就是根据compileRelease生成的classes文件,调用dx命令行工具打包成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重新进行压缩打包的

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的产物如下

这里写图片描述

以上就是携程动态加载框架的打包流程分析,纯属个人看法,如有不正确的地方,请给予指正。

  • 9
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 13
    评论
评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值