前阵子 Android 端的线上崩溃比较多,热修复被提上日程。实现方案是 Tinker,Jenkins 打包,最后补丁包上传到 Bugly 进行分发。主要在 Jenkins 打包这一块爬了不少坑,现记录下来,供大家参考。
首先是本地实现,按照官方文档,只要一步一步按照文档来,这个步骤还是比较容易的,这里就不再赘述了,不懂的可以先参考官方文档:Bugly Android热更新使用指南、Bugly Android热更新详解。这里贴一下接入流程:
- 打基准包安装并上报联网(注:填写唯一的 tinkerId)
- 对基准包的 bug 修复(可以是 Java 代码变更,资源的变更)
- 修改基准包路径、修改补丁包 tinkerId、mapping 文件路径(如果开启了混淆需要配置)、resId 文件路径
- 执行 buildTinkerPatchRelease 打 Release 版本补丁包
- 选择 app/build/outputs/patch目录 下的补丁包并上传(注:不要选择 tinkerPatch 目录下的补丁包,不然上传会有问题)
- 编辑下发补丁规则,点击立即下发
- 杀死进程并重启基准包,请求补丁策略( SDK 会自动下载补丁并合成)
- 再次重启基准包,检验补丁应用结果
- 查看页面,查看激活数据的变化
这里说一下使用指南
中的第三步:初始化 SDK,我这里使用的是 enableProxyApplication = false
的方式,原本想用 enableProxyApplication = true
的这种比较灵活的方式,但是程序编译报错,没时间去深究报错的原因,加上直接继承的方式接入也没什么代价,就没管是为什么了,知道原因的可以顺手告知下。 ┑( ̄Д  ̄)┍
一通撸下来还是比较容易的,完成代码的接入后,先打个包(基准包),安装到手机上运行一遍,使程序联网上报到 Bugly。之后,再按照打基准包的基线版本,修改 tinker-support.gradle
文件中的 baseApkDir
参数,然后就可以打补丁包了。
先说明一下我司使用 Jenkins 打包 apk 的背景知识。Jenkins 打包 apk 使用的是 Ant 插件,打包脚本由于公司项目的原因,不方便展示出来,大家如果有疑问的话,可以在评论里说明,本人会私下里帮助大家解决。
下面爬坑 /(ㄒoㄒ)/~~
由于公司 Jenkins 的打包策略是,在构建之前,先执行 clean
命令,这也就意味着,像本地打包一样在 app/build/bakApk/app-xxxx-xx-xx-xx
目录下找到基准包已是不可能。那怎么办,没有基准包怎么打增量包?苦思良久,愚笨的我最终想到,在项目工程路径下创建一个文件夹,要打增量包时,将基准包拷贝到该文件夹,然后上传 SVN。这时,旁边同学来了句:可以找运维同学,双方约定一个目录,打基准包时将基准包由脚本拷贝过去,打补丁包时从约定的目录取就行((ಥ _ ಥ) 我咋就想不到…)。
然后屁颠屁颠的跑去找运维同学,沟通后发现,Jenkins 每次打包都会在 Jenkins 目录下的 /jobs/pipeline名称/builds/构建编号/archive/app/build/outputs/apk/kungeek/release/
保存一份 apk 文件的副本。路径中 构件编号
如图所示:
接下来,打补丁包时将 tinker-support.gradle
文件中的 baseApkDir
参数修改为 /jobs/pipeline名称/builds/构建编号/archive/app/build/outputs/apk/kungeek/release/
即可。代码如下:
/**
* 此处填写每次构建生成的基准包目录,注意变量要自定义
*/
def baseApkDir = "${rootProject.projectDir}/../../jobs/${pipeline名称}/builds/${baseApkBuildNumber}/archive/app/build/outputs/apk/kungeek/release"
由于构建基准包的同时生成的 mapping 文件(如果开启了混淆需要配置)、resId 文件在构建补丁包时也需要用到,所以,在构建基准包时,需要将这两个文件拷贝到 /jobs/pipeline名称/builds/构建编号/archive/app/build/outputs/apk/kungeek/release/
目录下,拷贝代码如下:
<copy todir="../../../../jobs/pipeline名称/builds/${env.BUILD_NUMBER}/archive/app/build/outputs/apk/kungeek/release/" flatten="true">
<fileset dir="${android.root}/app/build/bakApk/">
<include name="*/*" />
fileset>
copy>
注1:代码中相对路径问题读者有疑问的话,麻烦再评论去提问。
注2:代码中构建编码使用到了 Jenkins 的环境变量,需要先在 Ant 的构建脚本文件的
project
的标签下添加来导入。
这里遇到的坑是:因为 Tinker 构建的 apk 文件是存放在 app-xxxx-xx-xx-xx 目录下,所以需要使用通配符来辅助复制文件,运维同学原本是想将通配符加到 fileset
中形成以后完整的路径,经过一段痛苦的尝试以及百度后发现,通配符只能在 include
标签中使用。(ノへ ̄、)
踩过前面一个一个的坑,终于在 Jenkins 上打了基准包之后,/jobs/pipeline名称/builds/构建编号/archive/app/build/outputs/apk/kungeek/release/
目录下有了 基准包 apk 文件、mapping 文件、resId 文件。
接下来,我以为,只需要配置好基准包的构件编号等相关配置参数,再构建补丁包就没问题了。然后 Jenkins 在构建好补丁包 apk 文件后,展示成果时报出的 apk 文件未找到
给了我当头一棒,依然失败。挫败感油然而生~~~
之后,经运维同学确认,Jenkins 构建期间是有在 app/build/outputs/patch
目录下生成 patch_signed_7zip.apk
文件的,但是构建完成之后,又没了。然后我试着看了下构建过程中执行的命令,长这样的:
sh gradlew clean buildTinkerPatchRelease --stacktrace
sh gradlew checklist
执行了 buildTinkerPatchRelease 后,还执行的 checklist 任务,难道是执行 checklist 时把 patch 给清空了,之后我尝试把这个命令注释掉,再次打补丁包时成功。果然是这个 checklist 惹的事啊,事后发现,打补丁包后,再次执行 gradle task,基本都会清空 patch 目录,这是个坑,大家记得避免。
我们知道,在 Android Studio 中,一个 project 可以有多个 module,包括 application 类型的 module,一般情况下,执行 gradlew assembleRelease
任务会将所有的 APP 都打包,这里打基准包也没问题,但是打补丁包时就不行了,只能成功一个。
这里提供分开打包一个方案:在每个 application 的 build.gradle 中配置 productFlavors,且每个 application 的命名都得不一样,这样,针对不同的 APP 就会产生不同的构建 task,比如:在 A 的 build.gradle 中配置名为 a_app,则回产生一个名为 buildTinkerPatchA_appRelease 的 task,最终使用此 task 来打补丁包即可。
那么问题来了,最终打包的形式是什么呢?是这样?
sh gradlew buildTinkerPatchA_appRelease buildTinkerPatchA_appRelease
还是这样?
sh gradlew buildTinkerPatchA_appRelease
sh gradlew buildTinkerPatchA_appRelease
都不是,这两种方式其实和不配置 productFlavors 的打包方式是一样的,那么如何打包呢?
答案是在 Ant 的打包脚本中,执行多次打包,关键代码如下:
<exec dir="." executable="bash" failonerror="false">
<arg value="generated_apk_hotfix.sh"/>
<arg value="buildTinkerPatchApp_aRelease"/>
exec>
<exec dir="." executable="bash" failonerror="false">
<arg value="generated_apk_hotfix.sh"/>
<arg value="buildTinkerPatchApp_bRelease"/>
exec>
构建脚本 generated_apk_hotfix.sh 文件关键代码如下:
#!/bin/sh
command=$1;
# 增量包需分开打包,否则会失败
sh gradlew ${command} --stacktrace
上面说到的坑只有 4 点,但实际上也遇到过挺多小问题的,但那些就不用多说了,很容易解决。
最后,总结一下结合 Jenkins 构建补丁包的思路。
首先,约定好基线版本的基准包 apk 包、mapping 文件、R.txt 文件的存放路径,打基准包时将这三个文件存入该目录。如果跟本文一样存放在 Jenkins 的 pipeline 构建目录下的话,记得要调整 pipeline 的清理策略,否则等需要打补丁包的时候,发现基线版本 apk 包什么的被清理掉就尴尬了,我这里是考虑到重复利用空间,所以放入此目录下。
其次,通过约定的路径,找到基准包、mapping 文件、R.txt 文件,打补丁包。这里需要确定一个找到基准包的策略,比如,我这里是通过构建编号来匹配存放基准包的路径,然后通过固定命名格式(如:app_release_版本号.apk)来匹配基准包以及 mapping 文件和 R.txt 文件,如此下来,我只需要确定基线版本的版本号和构建编号即可。
最后,贴一下我最终的 tinker-support.gradle 文件代码内容,大家有需要的可以参考:
apply plugin: 'com.tencent.bugly.tinker-support'
def bakPath = file("${buildDir}/bakApk/")
/** 基准包的 Jenkins 构建编号*/
def baseApkBuildNumber = project.property("baseApkBuildNumber")
/** 基准包的版本号*/
def baseApkVersion = project.property("baseApkVersion")
/**
* 此处填写每次构建生成的基准包目录
*/
def baseApkDir = "${rootProject.projectDir}/../../jobs/Android_Trunk/builds/${baseApkBuildNumber}/archive/app/build/outputs/apk/release"
/** 基准包的 apk 文件名*/
def baseApkFileName = "app-v${baseApkVersion}"
/**
* 对于插件各参数的详细解析请参考
*/
tinkerSupport {
// 开启tinker-support插件,默认值true
enable = true
// tinkerEnable功能开关
tinkerEnable = true
// 指定归档目录,默认值当前module的子目录tinker
autoBackupApkDir = "${bakPath}"
autoGenerateTinkerId = true
// 打基准包时生成 R.txt、mapping.txt 文件名的前缀
// rootProject.ext.android_version 指打包时的版本号
targetFileNamePrefix = "app-v${rootProject.ext.android_version}"
// 是否启用覆盖tinkerPatch配置功能,默认值false
// 开启后tinkerPatch配置不生效,即无需添加tinkerPatch
overrideTinkerPatchConfiguration = true
// 编译补丁包时,必需指定基线版本的apk,默认值为空
// 如果为空,则表示不是进行补丁包的编译
// @{link tinkerPatch.oldApk }
baseApk = "${baseApkDir}/${baseApkFileName}.apk"
// 对应tinker插件applyMapping
baseApkProguardMapping = "${baseApkDir}/${baseApkFileName}-mapping.txt"
// 对应tinker插件applyResourceMapping
baseApkResourceMapping = "${baseApkDir}/${baseApkFileName}-R.txt"
tinkerId = "base-1.0.1"
// buildAllFlavorsDir = "${bakPath}/${baseApkDir}"
// 是否开启加固模式,默认为false
// isProtectedApp = true
// 是否开启反射Application模式
enableProxyApplication = false
supportHotplugComponent = true
}
/**
* 一般来说,我们无需对下面的参数做任何的修改
* 对于各参数的详细介绍请参考:
* https://github.com/Tencent/tinker/wiki/Tinker-%E6%8E%A5%E5%85%A5%E6%8C%87%E5%8D%97
*/
tinkerPatch {
//oldApk ="${bakPath}/${appName}/app-release.apk"
// tinkerEnable功能开关
tinkerEnable = true
ignoreWarning = false
useSign = true
dex {
dexMode = "jar"
pattern = ["classes*.dex"]
loader = []
}
lib {
pattern = ["lib/*/*.so"]
}
res {
pattern = ["res/*", "r/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]
ignoreChange = []
largeModSize = 100
}
packageConfig {
}
sevenZip {
zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
}
buildConfig {
keepDexApply = false
}
}
然后是维护在 gradle.properties 文件中的两个变量:
# 打增量包时基准包的 Jenkins 构建编号
baseApkBuildNumber = 1
# 打增量包时基准包的版本号
baseApkVersion = 1.0.0.197094