李涛,腾讯 Android 工程师,14 年加入腾讯 SNG 增值产品部,期间主要负责手 Q 动漫、企鹅电竞等项目的功能开发和技术优化。业务时间喜欢折腾新技术,写一些技术文章,个人技术博客:www.ltlovezh.com 。
ApkChannelPackage 是一种快速多渠道打包工具,同时支持基于 V1 和 V2 签名进行渠道打包。插件本身会自动检测 Apk 使用的签名方法,并选择合适的多渠道打包方式,对使用者来说完全透明。
Github 地址: https://github.com/ltlovezh/ApkChannelPackage
众所周知,因为国内 Android 应用分发市场的现状,我们在发布 APP 时,一般需要生成多个渠道包,上传到不同的应用市场。这些渠道包需要包含不同的渠道信息,在 APP 和后台交互或者数据上报时,会带上各自的渠道信息。这样,我们就能统计到每个分发市场的下载数、用户数等关键数据。
既然我们需要进行多渠道打包,那我们就看下最常见的多渠道打包方案。
Gradle Plugin 本身提供了多渠道的打包策略:
首先,在 AndroidManifest.xml 中添加渠道信息占位符:
- "InstallChannel"android:value="${InstallChannel}"/>
然后,通过 Gradle Plugin 提供的
标签,添加渠道信息:
- productFlavors
- productFlavors {
- "YingYongBao" {
- manifestPlaceholders = [InstallChannel: "YingYongBao"]
- }
- "360" {
- manifestPlaceholders = [InstallChannel: "360"]
- }
- }
这样,Gradle 编译生成多渠道包时,会用不同的渠道信息替换 AndroidManifest.xml 中的占位符。我们在代码中,也就可以直接读取 AndroidManifest.xml 中的渠道信息了。
但是,这种方式存在一些缺点:
ApkTool 是一个逆向分析工具,可以把 APK 解开,添加代码后,重新打包成 APK。因此,基于 ApkTool 的多渠道打包方案分为以下几步:
经过测试,这种方案完全是可行的。
优点:
不需要重新构建新渠道包,仅需要复制修改就可以了。并且因为是重新签名,所以同时支持 V1 和 V2 签名。
缺点: 1. ApkTool 工具不稳定,曾经遇到过升级 Gradle Plugin 版本后,低版本 ApkTool 解压 APK 失败的情况。 2. 生成新渠道包时,需要重新解包、打包和签名,而这几步操作又是相对比较耗时的。经过测试:生成企鹅电竞 10 个渠道包需要 16 分钟左右,虽然比 Gradle Plugin 方案减少很多耗时。但是若需要同时生成上百个渠道包,则需要几个小时,显然不适合渠道非常多的业务场景。
那有没有一种方案,可以在添加渠道信息后,不需要重新签名那?
首先我们要了解一下 APK 的签名和校验机制。
在进一步学习 V1 和 V2 签名之前,我们有必要学习一下签名相关的基础知识。
数据摘要算法是一种能产生特定输出格式的算法,其原理是根据一定的运算规则对原始数据进行某种形式的信息提取,被提取出的信息就是原始数据的消息摘要,也称为数据指纹。
一般情况下,数据摘要算法具有以下特点:
著名的摘要算法有 RSA 公司的 MD5 算法和 SHA 系列算法。
数字签名和数字证书是成对出现的,两者不可分离(数字签名主要用来校验数据的完整性,数字证书主要用来确保公钥的安全发放)。
要明白数字签名的概念,必须要了解数据的加密、传输和校验流程。一般情况下,要实现数据的可靠通信,需要解决以下两个问题:
而数字签名,就是为了解决这两个问题而诞生的。
首先,数据的发送者需要先申请一对公私钥对,并将公钥交给数据接收者。
然后,若数据发送者需要发送数据给接收者,则首先要根据原始数据,生成一份数字签名,然后把原始数据和数字签名一起发送给接收者。
数字签名由以下两步计算得来:
这样,数据接收者拿到的消息就包含了两块内容:
接下来,接收者就会通过以下几步,校验数据的真实性:
因为私钥只有发送者才有,所以其他人无法伪造数字签名。这样通过数字签名就确保了数据的可靠传输。
综上所述,数字签名就是只有发送者才能产生的别人无法伪造的一段数字串,这段数字串同时也是对发送者发送数据真实性的一个有效证明。
想法虽好,但是上面的整个流程,有一个前提,就是数据接收者能够正确拿到发送者的公钥。如果接收者拿到的公钥被篡改了,那么坏人就会被当成好人,而真正的数据发送者发送的数据则会被视作脏数据。那怎么才能保证公钥的安全性那?这就要靠数字证书来解决了。
数字证书是由有公信力的证书中心(CA)颁发给申请者的证书,主要包含了:证书的发布机构、证书的有效期、申请者的公钥、申请者信息、数字签名使用的算法,以及证书内容的数字签名。
可见,数字证书也用到了数字签名技术。只不过签名的内容是数据发送方的公钥,以及一些其它证书信息。
这样数据发送者发送的消息就包含了三部分内容:
接收者拿到数据后,首先会根据 CA 的公钥,解码出发送者的公钥。然后就与上面的校验流程完全相同了。
所以,数字证书主要解决了公钥的安全发放问题。
因此,包含数字证书的整个签名和校验流程如下图所示:
默认情况下,APK 使用的就是 V1 签名。解压 APK 后,在
目录下,可以看到三个文件:MANIFEST.MF、CERT.SF、CERT.RSA。它们都是 V1 签名的产物。其中,
- META-INF
文件内容如下所示:
- MANIFEST.MF
它记录了 APK 中所有原始文件的数据摘要的 Base64 编码, 而数据摘要算法就是
。
- SHA1
文件内容如下所示:
- CERT.SF
主属性记录了
- SHA1-Digest-Manifest-Main-Attributes
文件所有主属性的数据摘要的 Base64 编码。
- MANIFEST.MF
则记录了整个
- SHA1-Digest-Manifest
文件的数据摘要的 Base64 编码。 其余的普通属性则和 MANIFEST.MF 中的属性一一对应,分别记录了对应数据块的数据摘要的 Base64 编码。例如:
- MANIFEST.MF
文件中 skin_drawable_btm_line.xml 对应的 SHA1-Digest,就是下面内容的数据摘要的 Base64 编码。
- CERT.SF
- Name: res/drawable/skin_drawable_btm_line.xml
- SHA1-Digest: JqJbk6/AsWZMcGVehCXb33Cdtrk=
- \r\n
这里要注意的是:最后一行的换行符是必不可少,需要参与计算的。
文件包含了对
- CERT.RSA
文件的数字签名和开发者的数字证书。
- CERT.SF
就是计算数字签名使用的非对称加密算法。
- RSA
V1 签名的详细流程可参考 SignApk.java,整个签名流程如下图所示:
整个签名机制的最终产物就是 MANIFEST.MF、CERT.SF、CERT.RSA 三个文件。
在安装 APK 时,Android 系统会校验签名,检查 APK 是否被篡改。代码流程是:
->
- PackageManagerService.java
,
- PackageParser.java
类负责 V1 签名的具体校验。整个校验流程如下图所示:
- PackageParser
若中间任何一步校验失败,APK 就不能安装。
OK,了解了 V1 的签名和校验流程。我们来看下,V1 签名是怎么保证 APK 文件不被篡改的?
首先,如果破坏者修改了 APK 中的任何文件,那么被篡改文件的数据摘要的 Base64 编码就和
文件的记录值不一致,导致校验失败。
- MANIFEST.MF
其次,如果破坏者同时修改了对应文件在
文件中的 Base64 值,那么 MANIFEST.MF 中对应数据块的 Base64 值就和
- MANIFEST.MF
文件中的记录值不一致,导致校验失败。
- CERT.SF
最后,如果破坏者更进一步,同时修改了对应文件在
文件中的 Base64 值,那么
- CERT.SF
的数字签名就和
- CERT.SF
记录的签名不一致,也会校验失败。
- CERT.RSA
那有没有可能继续伪造
的数字签名那?理论上不可能,因为破坏者没有开发者的私钥。那破坏者是不是可以用自己的私钥和数字证书重新签名那,这倒是完全可以!
- CERT.SF
综上所述,任何对 APK 文件的修改,在安装时都会失败,除非对 APK 重新签名。但是相同包名,不同签名的 APK 也是不能同时安装的。
由上述 V1 签名和校验机制可知,修改 APK 中的任何文件都会导致安装失败!那怎么添加渠道信息那?只能从 APK 的结构入手了。
APK 文件本质上是一个 ZIP 压缩包,而 ZIP 格式是固定的,主要由三部分构成,如下图所示:
第一部分是内容块,所有的压缩文件都在这部分。每个压缩文件都有一个
,主要记录了文件名、压缩算法、压缩前后的文件大小、修改时间、CRC32 值等。
- local file header
第二部分称为中央目录,包含了多个
(和第一部分的
- central directory file header
一一对应),每个中央目录文件头主要记录了压缩算法、注释信息、对应
- local file header
的偏移量等,方便快速定位数据。
- local file header
最后一部分是 EOCD,主要记录了中央目录大小、偏移量和 ZIP 注释信息等,其详细结构如下图所示:
根据之前的 V1 签名和校验机制可知,V1 签名只会检验第一部分的所有压缩文件,而不理会后两部分内容。因此,只要把渠道信息写入到后两块内容就可以通过 V1 校验,而 EOCD 的注释字段无疑是最好的选择。
既然找到了突破口,那么基于 V1 签名的多渠道打包方案就应运而生:在 APK 文件的注释字段,添加渠道信息。
整个方案包括以下几步:
这里添加魔数的好处是方便从后向前读取数据,定位渠道信息。 因此,读取渠道信息包括以下几步:
通过 16 进制编辑器,可以查看到添加渠道信息后的 APK(小端模式),如下所示:
是魔数,
- 6C 74 6C 6F 76 75 7A 68
表示渠道信息长度为 4,
- 04 00
就是渠道信息
- 6C 65 6F 6E
了。
- leon
就是 APK 注释长度了,正好是 15。
- 0E 00
虽说整个方案很清晰,但是在
这步遇到一个问题。如果 APK 本身没有注释,那最后 22 字节就是 EOCD。但是若 APK 本身已经包含了注释字段,那怎么确定 EOCD 的起始位置那?这里借鉴了系统 V2 签名确定 EOCD 位置的方案。整个计算流程如下图所示:
- 找到EOCD数据块
整个方案介绍完了,该方案的最大优点就是:不需要解压缩 APK,不需要重新签名,只需要复制 APK,在注释字段添加渠道信息。每个渠道包仅需几秒的耗时,非常适合渠道较多的 APK。
但是好景不长,Android7.0 之后新增了 V2 签名,该签名会校验整个 APK 的数据摘要,导致上述渠道打包方案失效。所以如果想继续使用上述方案,需要关闭 Gradle Plugin 中的 V2 签名选项,禁用 V2 签名。
从前面的 V1 签名介绍,可以知道 V1 存在两个弊端:
中的数据摘要是基于原始未压缩文件计算的。因此在校验时,需要先解压出原始文件,才能进行校验。而解压操作无疑是耗时的。
- MANIFEST.MF
正是基于这两点,Google 提出了 V2 签名,解决了上述两个问题:
关于第一点的耗时问题,这里有一份实验室数据(Nexus 6P、Android 7.1.1)可供参考。
APK 安装耗时对比 | 取 5 次平均耗时(秒) |
---|---|
V1 签名 APK | 11.64 |
V2 签名 APK | 4.42 |
可见,V2 签名对 APK 的安装速度还是提升不少的。
不同于 V1,V2 签名会生成一个签名块,插入到 APK 中。因此,V2 签名后的 APK 结构如下图所示:
APK 签名块位于中央目录之前,文件数据之后。V2 签名同时修改了 EOCD 中的中央目录的偏移量,使签名后的 APK 还符合 ZIP 结构。
APK 签名块的具体结构如下图所示:
首先是 8 字节的签名块大小,此大小不包含该字段本身的 8 字节;其次就是 ID-Value 序列,就是一个 4 字节的 ID 和对应的数据;然后又是一个 8 字节的签名块大小,与开始的 8 字节是相等的;最后是 16 字节的签名块魔数。
其中,ID 为
对应的 Value 就是 V2 签名块数据。
- 0x7109871a
V2 签名块的生成可参考 ApkSignerV2,整体结构和流程如下图所示:
、
- 数据摘要
和
- 数字证书
组装起来,形成类似于 V1 签名的 "MF" 文件(第二列第一行);
- 额外属性
、
- 类似MF文件
和
- 类似SF文件
一起组装成通过单个 keystore 签名后的 v2 签名块(第三列第一行)。
- 开发者公钥
上述流程比较繁琐。简而言之,单个 keystore 签名块主要由三部分组成,分别是上图中第二列的三个数据块:
、
- 类似MF文件
和
- 类似SF文件
,其结构如下图所示:
- 开发者公钥
除此之外,Google 也优化了计算数据摘要的算法,使得可以并行计算,如下图所示:
数据摘要的计算包括以下几步:
这样,每个数据块的数据摘要就可以并行计算,加快了 V2 签名和校验的速度。
Android Gradle Plugin2.2 之上默认会同时开启 V1 和 V2 签名,同时包含 V1 和 V2 签名的 CERT.SF 文件会有一个特殊的主属性,如下图所示:
该属性会强制 APK 走 V2 校验流程(7.0 之上),以充分利用 V2 签名的优势(速度快和更完善的校验机制)。
因此,同时包含 V1 和 V2 签名的 APK 的校验流程如下所示:
简而言之:优先校验 V2,没有或者不认识 V2,则校验 V1。
这里引申出另外一个问题:APK 签名时,只有 V2 签名,没有 V1 签名行不行? 经过尝试,这种情况是可以编译通过的,并且在 Android 7.0 之上也可以正确安装和运行。但是 7.0 之下,因为不认识 V2,又没有 V1 签名,所以会报没有签名的错误。
OK,明确了 Android 平台对 V1 和 V2 签名的校验选择之后,我们来看下 V2 签名的具体校验流程(
->
- PackageManagerService.java
->
- PackageParser.java
),如下图所示:
- ApkSignatureSchemeV2Verifier.java
其中,最强签名算法是根据该算法使用的数据摘要算法来对比产生的,比如:SHA512 > SHA256。
校验成功的定义是至少找到一个 keystore 对应的签名块,并且所有签名块都按照上述流程校验成功。
下面我们来看下 V2 签名是怎么保证 APK 不被篡改的?
综上所述,任何对 APK 的修改,在安装时都会失败,除非对 APK 重新签名。但是相同包名,不同签名的 APK 也是不能同时安装的。
到这里,V2 签名已经介绍完了。但是在最后一步 "数据摘要校验" 这里,隐藏了一个点,不知道有没有人发现?
因为,我们 V2 签名块中的数据摘要是针对 APK 的文件内容块、中央目录和 EOCD 三块内容计算的。但是在写入签名块后,修改了 EOCD 中的中央目录偏移量,那么在进行 V2 签名校验时,理论上在 "数据摘要校验" 这步应该会校验失败啊!但是为什么 V2 签名可以校验通过那?
这个问题很重要,因为我们下面要介绍的基于 V2 签名的多渠道打包方案也会修改 EOCD 的中央目录偏移量。
其实也很简单,原来 Android 系统在校验 APK 的数据摘要时,首先会把 EOCD 的中央目录偏移量替换成签名块的偏移量,然后再计算数据摘要。而签名块的偏移量不就是 v2 签名之前的中央目录偏移量嘛!!!,因此,这样计算出的数据摘要就和 "MF" 数据块中的数据摘要完全一致了。具体代码逻辑,可参考 ApkSignatureSchemeV2Verifier.java 的 416 ~ 420 行。
在上节 V2 签名的校验流程中,有一个很重要的细节:Android 系统只会关注 ID 为 0x7109871a 的 V2 签名块,并且忽略其他的 ID-Value,同时 V2 签名只会保护 APK 本身,不包含签名块。
因此,基于 V2 签名的多渠道打包方案就应运而生:在 APK 签名块中添加一个 ID-Value,存储渠道信息。
整个方案包括以下几步:
实际上,除了渠道信息,我们可以在 APK 签名块中添加任何辅助信息。
通过 16 进制编辑器,可以查看到添加渠道信息后的 APK(小端模式),如下所示:
就是我们的渠道信息
- 6C 65 6F 6E
。向前 4 个字节:
- leon
就是我们添加的 ID,再向前 8 个字节:
- FF 55 11 88
就是我们的 ID-Value 的长度,正好是 8。
- 08 00 00 00 00 00 00 00
整个方案介绍完了,该方案的最大优点就是:支持 7.0 之上新增的 V2 签名,同时兼有 V1 方案的所有优点。
那么如何保证通过这些方案生成的渠道包,能够在所有 Android 平台上正确安装那?
原来 Google 提供了一个同时支持 V1 和 V2 签名和校验的工具:apksig。它包括一个
命令行和一个
- apksigner
类库。其中前者就是 Android SDK build-tools 下面的命令行工具。而我们正是借助后面的 apksig 来进行渠道包强校验,它可以保证渠道包在 apk Minsdk ~ 最高版本之间都校验通过。详细代码可参考 VerifyApk.java
- apksig
目前市面上的多渠道打包工具主要有 packer-ng-plugin 和美团的 Walle。下表是我们的 ApkChannelPackage 和它们之间的简单对比。
这里我之所以同时支持 V1 和 V2 签名方案,主要是担心后续 Android 平台加强签名校验机制,导致 V2 多渠道打包方案行不通,可以无痛切换到 V1 签名方案。后续我也会尽快支持命令行工具。
目前 Gradle Plugin 2.2 以上默认开启 V2 签名,所以如果想关闭 V2 签名,可将下面的
设置为 false。
- v2SigningEnabled
- signingConfigs {
- release {
- ...
- v1SigningEnabledtruev2SigningEnabledfalse}
- debug {
- ...
- v1SigningEnabledtruev2SigningEnabledfalse}
- }
1. 在根工程的
中,添加对打包 Plugin 的依赖:
- build.gradle
- dependencies {
- classpath'com.android.tools.build:gradle:2.2.0'classpath'com.leon.channel:plugin:1.0.1'}
2. 在主 App 工程的
中,添加对 ApkChannelPackage Plugin 的引用:
- build.gradle
- apply plugin:'channel'
3. 在主 App 工程的
中,添加读取渠道信息的 helper 类库依赖:
- build.gradle
- dependencies {
- compile'com.leon.channel: helper: 1.0.1'
- }
4. 在 gradle.properties 文件中,配置渠道文件名称
- channel_file=channel.txt
其中 channel.txt 即为包含渠道信息的文件,需放置在根工程目录下,一行一个渠道信息。
5. 渠道包信息配置
若是直接编译生成多渠道包,则通过 channel 标签配置:
- channel { //多渠道包的输出目录,默认为new File(project.buildDir,"channel")
- baseOutputDir = new File(project.buildDir, "xxx") //多渠道包的命名规则,默认为:${appName}-${versionName}-${versionCode}-${flavorName}-${buildType}
- apkNameFormat = '$ {
- appName
- } - $ {
- versionName
- } - $ {
- versionCode
- } - $ {
- flavorName
- } - $ {
- buildType
- }'
- }
其中,多渠道包的命名规则中,可使用以下字段:
若是根据已有基础包生成多渠道包,则通过
标签配置:
- rebuildChannel
- rebuildChannel {
- baseDebugApk = 已有Debug APK
- baseReleaseApk = 已有Release APK//默认为new File(project.buildDir, "rebuildChannel/debug")debugOutputDir = Debug渠道包输出目录
- //默认为new File(project.buildDir, "rebuildChannel/release")releaseOutputDir = Release渠道包输出目录}
这里要注意一下,已有 APK 的名字必须包含
字符串,这样插件生成多渠道包时,会用当前的渠道替换
- base
字符串,形成新的渠道包。
- base
6. 生成多渠道包
若没有通过 Gradle Plugin 的
配置多渠道,那么通过以下 Task
- productFlavors
、
- channelDebug
分别负责生成 Debug 和 Release 的多渠道包。
- channelRelease
若是配置了
,那么对应的 Task 则是
- productFlavors
、
- channelFlavorXDebug
,FlavorX 表示在
- channelFlavorXRelease
中配置的渠道名称。
- productFlavors
除此之外,如果是根据已有基础包生成多渠道包,那么对应的 Task 则是
。
- reBuildChannel
7. 读取渠道信息
通过 helper 类库中的
类读取渠道信息。
- ChannelReaderUtil
- String channel = ChannelReaderUtil.getChannel(getApplicationContext());
来源: http://www.bubuko.com/infodetail-2052067.html