写在前面
继昨天在 Android多渠道包生成最佳实践(一) 文章中介绍了两种多渠道生成包的方案:
- META-INF目录添加渠道文件
- Apk文件末尾追加渠道注释
今天来介绍最后一种方案:针对Android7.0 新增的V2签名方案的Apk添加渠道ID-value。在读这篇文章前,你需要对zip格式和V2签名等知识等有一定的了解:
正文
在实践前,我们来简单地了解下Google引入的新的签名方案。
签名方案V2 (Full APK signature)
Android7.0引入一项新的应用签名方案 Apk签名方案 V2,它是一个对全文件进行签名的方案,能提供更快的应用安装时间、对未授权APK文件的更改提供更多保护。
顾名思义,新的签名方案是对整个Apk文件进行签名校验的,对于 Android多渠道包生成最佳实践(一) 里介绍的两种多渠道生成包方案显然是不适用了,因为它们都对Apk文件进行了修改,所以用新的签名方案的Apk在安装校验是就会不通过。我们来对比下新旧两种签名方案有何区别:
V1和V2签名对比(图来自Google官方文档).png
可以看到,新的签名方案在Zip文件中新增了一个 APK Siging Block,而这个新增的数据块就是保存签名信息的。而Contents of ZIP entries、ZIP Central Directory、End of Central Directory是受保护的,在签名后任何对它们的修改都逃不过新的应用签名方案的检查。
以此看来,我们是无法对Contents of ZIP entries、ZIP Central Directory、End of Central Directory做任何修改的了,但能不能针对 APK Siging Block做些手脚呢?我们不妨先来看下 APK Siging Block的格式:
| 偏移 | 字节数 | 描述 |
|---|---|---|
| 0 | 8 | 签名块长度(本字段的长度不计算在内) |
| 8 | n | 一组ID=value(安卓的签名保存在此) |
| 8+n | 8 | 签名块长度(和第一个字段值一致) |
| 16+n | 16 | 魔数 APK Sig Block 42” |
我们注意到 ID-value,它由一个8字节的长度标示+4字节的ID+它的负载组成。V2的签名信息是以固定的ID值(0x7109871a)的ID-value来保存在这个区块中,也就是说它是可以有若干个这样的ID-value来组成:
| Length | ID | Data |
|---|---|---|
| ··· | ··· | ··· |
| 签名长度 | 0x7109871a | 安卓签名信息 |
| ··· | ··· | ··· |
另外,签名校验是不会校验时,会忽略除了安卓签名信息的其他ID-value的,那么我们就可以把渠道号添加到ID-value里,就能实现多渠道生成包了!
需要另外提醒的是, APK Siging Block是使用小端模式来保存字节的,我们读的时候也必须用小端模式来读,否则会出错。
小端模式不了解的童鞋看下百度百科怎么解释:
小端模式,是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中,这种存储模式将地址的高低和数据位权有效地结合起来,高地址部分权值高,低地址部分权值低。
也就是说,如 0x1234,用小端模式保存的话,就是:
byte[0] = 0x34 -- 低字节保存在低地址
byte[1] = 0x12 -- 高字节保存在高地址
下面我们来实践下。
方案三:针对Android7.0 新增的V2签名方案的Apk添加渠道ID-value
说明:文本使用的代码大部分来自于美团的开源项目 walle(这里称赞下美团的技术团队,阅读其代码时真是赏心悦目,值得大家参考借鉴!)。因为walle项目为了兼容性和适配性,做了很多处理,项目本身有很多个子项目。所以我把核心部分的代码抽出来,精简化。而我们这里只需探索如何针对Android7.0 新增的V2签名方案的Apk添加渠道ID-value的核心代码就可以了,知道了原理,就可以根据自己的项目需求来做针对性的修改,或使用walle时避免不必要的坑。
好了,我们目标很清晰,要添加包含渠道信息的ID-value的到Apk文件的 APK Siging Block里。我们先来理理思路:
- 寻找
APK Siging Block数据块 - 对ID-value进行扩展,写入包含渠道信息的ID-value
似乎难度也不大,我们一步一步来看。
1.寻找 APK Siging Block数据块
根据上面分析我们知道, APK Siging Block是在紧接着Contents of ZIP entries后,在ZIP Central Directory前,我们有什么办法找到它所在文件的具体位置呢?嗯,很简单,我们通过Zip的 End of central directory record (EOCD)可以知道ZIP Central Directory的具体位置,我们再来回顾下EOCD的格式:
| Offset | Bytes | Desctiption |
|---|---|---|
| 0 | 4 | End of central directory signature = 0x06054b50 |
| 4 | 2 | Number of this disk |
| 6 | 2 | Number of the disk with the start of the central directory |
| 8 | 2 | Total number of entries in the central directory on this disk |
| 10 | 2 | Total number of entries in the central directory |
| 12 | 4 | Size of central directory (bytes) |
| 16 | 2 | Offset of start of central directory with respect to the starting disk number |
| 20 | 2 | Comment length(n) |
| 22 | n | Comment |
可以注意到,EOCD中的 Offset of start of central directory with respect to the starting disk number 是记录了ZIP Central Directory的具体位置,也即是离文件头的偏移。而ZIP Central Directory是紧跟APK Siging Block的,所以我们可以通过ZIP Central Directory找到签名块的具体位置。
先找到ZIP Central Directory的位置:
public static long findCentralDirStartOffset(final FileChannel fileChannel, final long commentLength) throws IOException {
// End of central directory record (EOCD)
// Offset Bytes Description[23]
// 0 4 End of central directory signature = 0x06054b50
// 4 2 Number of this disk
// 6 2 Disk where central directory starts
// 8 2 Number of central directory records on this disk
// 10 2 Total number of central directory records
// 12 4 Size of central directory (bytes)
// 16 4 Offset of start of central directory, relative to start of archive
// 20 2 Comment length (n)
// 22 n Comment
// For a zip with no archive comment, the
// end-of-central-directory record will be 22 bytes long, so
// we expect to find the EOCD marker 22 bytes from the end.
final ByteBuffer zipCentralDirectoryStart = ByteBuffer.allocate(4);
zipCentralDirectoryStart.order(ByteOrder.LITTLE_ENDIAN);
fileChannel.position(fileChannel.size() - commentLength - 6); // 6 = 2 (Comment length) + 4 (Offset of start of central directory, relative to start of archive)
fileChannel.read(zipCentralDirectoryStart);
final long centralDirStartOffset = zipCentralDirectoryStart.getInt(0);
return centralDirStartOffset;
}
再根据ZIP Central Directory的位置,向上读APK Signing Block:
public static Pair<ByteBuffer, Long> findApkSigningBlock(
final FileChannel fileChannel, final long centralDirOffset) throws IOException, SignatureNotFoundException {
// Find the APK Signing Block. The block immediately precedes the Central Directory.
// FORMAT:
// OFFSET DATA TYPE DESCRIPTION
// * @+0 bytes uint64: size in bytes (excluding this field)
// * @+8 bytes payload
// * @-24 bytes uint64: size in bytes (same as the one above)
// * @-16 bytes uint128: magic
if (centralDirOffset < APK_SIG_BLOCK_MIN_SIZE) {
throw new SignatureNotFoundException(
"APK too small for APK Signing Block. ZIP Central Directory offset: "
+ centralDirOffset);
}
// Read the magic and offset in file from the footer section of the block:
// * uint64: size of block
// * 16 bytes: magic
fileChannel.position(centralDirOffset - 24);
final ByteBuffer footer = ByteBuffer.allocate(24);
fileChannel.read(footer);
footer.order(ByteOrder.LITTLE_ENDIAN); // 小端模式,高字节保存在高地址
// 是否存在V2签名魔数:APK Sig Block 42
if ((footer.getLong(8) != APK_SIG_BLOCK_MAGIC_LO)
|| (footer.getLong(16) != APK_SIG_BLOCK_MAGIC_HI)) {
throw new SignatureNotFoundException(
"No APK Signing Block before ZIP Central Directory");
}
// Read and compare size fields
final long apkSigBlockSizeInFooter = footer.getLong(0); // 签名块的总长度
if ((apkSigBlockSizeInFooter < footer.capacity())
|| (apkSigBlockSizeInFooter > Integer.MAX_VALUE - 8)) {
throw new SignatureNotFoundException(
"APK Signing Block size out of range: " + apkSigBlockSizeInFooter);
}
final int totalSize = (int) (apkSigBlockSizeInFooter + 8); // + 8 (签名块第一个Block长度字节数)
final long apkSigBlockOffset = centralDirOffset - totalSize;
if (apkSigBlockOffset < 0) {
throw new SignatureNotFoundException(
"APK Signing Block offset out of range: " + apkSigBlockOffset);
}
fileChannel.position(apkSigBlockOffset);
final ByteBuffer apkSigBlock = ByteBuffer.allocate(totalSize);
fileChannel.read(apkSigBlock);
apkSigBlock.order(ByteOrder.LITTLE_ENDIAN);
final long apkSigBlockSizeInHeader = apkSigBlock.getLong(0);
if (apkSigBlockSizeInHeader != apkSigBlockSizeInFooter) { // 再检验一次
throw new SignatureNotFoundException(
"APK Signing Block sizes in header and footer do not match: "
+ apkSigBlockSizeInHeader + " vs " + apkSigBlockSizeInFooter);
}
return Pair.of(apkSigBlock, apkSigBlockOffset);
}
2. 对ID-value进行扩展,写入包含渠道信息的ID-value
写ID-value就很简单了,我们要先拿出原来Apk已存在的ID-value,然后把我们自己的渠道信息保存在新的ID-value里,再把新的旧的ID-value一起写进Apk:
public static void writeApkSigningBlock(final File apkFile, final Map<Integer, ByteBuffer> idValues) throws IOException, SignatureNotFoundException {
RandomAccessFile fIn = null;
FileChannel fileChannel = null;
try {
fIn = new RandomAccessFile(apkFile, "rw");
fileChannel = fIn.getChannel();
// 获取注释长度
final long commentLength = ApkUtil.getCommentLength(fileChannel);
// 获取核心目录偏移
final long centralDirStartOffset = ApkUtil.findCentralDirStartOffset(fileChannel, commentLength);
final Pair<ByteBuffer, Long> apkSigningBlockAndOffset
= ApkUtil.findApkSigningBlock(fileChannel, centralDirStartOffset); // 获取签名块
final ByteBuffer oldApkSigningBlock = apkSigningBlockAndOffset.getFirst();
final long apkSigningBlockOffset = apkSigningBlockAndOffset.getSecond();
// 获取apk已有的ID-value
final Map<Integer, ByteBuffer> originIdValues = ApkUtil.findIdValues(oldApkSigningBlock);
// 查找Apk的签名信息,ID值固定为:0x7109871a
final ByteBuffer apkSignatureSchemeV2Block = originIdValues.get(ApkUtil.APK_SIGNATURE_SCHEME_V2_BLOCK_ID);
if (apkSignatureSchemeV2Block == null) {
throw new IOException("No APK Signature Scheme v2 block in APK Signing Block");
}
// // 获取所有 ID-value
final ApkSigningBlock apkSigningBlock = genApkSigningBlock(idValues, originIdValues);
if (apkSigningBlockOffset != 0 && centralDirStartOffset != 0) {
// 读取核心目录的内容
fIn.seek(centralDirStartOffset);
byte[] centralDirBytes;
centralDirBytes = new byte[(int) (fileChannel.size() - centralDirStartOffset)];
fIn.read(centralDirBytes);
// 更新签名块
fileChannel.position(apkSigningBlockOffset);
// 写入新的签名块,返回的长度是不包含签名块头部的 Size of block(8字节)
final long lengthExcludeHSOB = apkSigningBlock.writeApkSigningBlock(fIn);
// 更新核心目录
fIn.write(centralDirBytes);
// 更新文件的总长度
fIn.setLength(fIn.getFilePointer());
// 更新 EOCD 所记录的核心目录的偏移
// End of central directory record (EOCD)
// Offset Bytes Description[23]
// 0 4 End of central directory signature = 0x06054b50
// 4 2 Number of this disk
// 6 2 Disk where central directory starts
// 8 2 Number of central directory records on this disk
// 10 2 Total number of central directory records
// 12 4 Size of central directory (bytes)
// 16 4 Offset of start of central directory, relative to start of archive
// 20 2 Comment length (n)
// 22 n Comment
fIn.seek(fileChannel.size() - commentLength - 6);
// 6 = 2(Comment length) + 4 (Offset of start of central directory, relative to start of archive)
final ByteBuffer temp = ByteBuffer.allocate(4);
temp.order(ByteOrder.LITTLE_ENDIAN);
long oldSignBlockLength = centralDirStartOffset - apkSigningBlockOffset; // 旧签名块字节数
long newSignBlockLength = lengthExcludeHSOB + 8; // 新签名块字节数, 8 = size of block in bytes (excluding this field) (uint64)
long extraLength = newSignBlockLength - oldSignBlockLength;
temp.putInt((int) (centralDirStartOffset + extraLength));
temp.flip();
fIn.write(temp.array());
}
} finally {
if (fileChannel != null) {
fileChannel.close();
}
if (fIn != null) {
fIn.close();
}
}
}
private static ApkSigningBlock genApkSigningBlock(final Map<Integer, ByteBuffer> idValues,
final Map<Integer, ByteBuffer> originIdValues) {
// 把已有的和新增的 ID-value 添加到 payload 列表
if (idValues != null && !idValues.isEmpty()) {
originIdValues.putAll(idValues);
}
final ApkSigningBlock apkSigningBlock = new ApkSigningBlock();
final Set<Map.Entry<Integer, ByteBuffer>> entrySet = originIdValues.entrySet();
for (Map.Entry<Integer, ByteBuffer> entry : entrySet) {
final ApkSigningPayload payload = new ApkSigningPayload(entry.getKey(), entry.getValue());
apkSigningBlock.addPayload(payload);
}
return apkSigningBlock;
}
注意上面在写完ID-value后,因为APK Signing Block的长度变化了,相应的Apk文件大小和ZIP Central Directory的偏移也会变化,要同步更新。
至此,三种方案已经讲完了。建议还是下载demo看下细节,因为上面的代码只是截取部分来讲解,可能阅读起来有点头不接尾。但原理我们是清晰的了,只要知道了原理,就很容易实现,但还是希望大家能自己实践下,只有自己实践后,才能有更深刻的理解。
写在最后
介绍了三种多渠道生成包的方案,其中方案一和二是针对旧签名方案的,而方案三是针对新签名方案的。在实际开发中,如果我们无法确保Apk是采用哪种签名方案(如渠道包在后端生成,后端是无法知道前端用什么签名方案的),我们就需要组合方案来生成渠道包了。
正如 Android多渠道包生成最佳实践(一) 中介绍的,方案二(Apk文件末尾追加渠道注释)比方案一(META-INF目录添加渠道文件)性能更优,所以我们可以优先采取 方案二和方案三 的组合来生成渠道包。
好了,三种多渠道生成包的方案到此介绍完了,不知你有没收获呢?或者你有更好的方案,欢迎在评论区留言~
参考与DEMO
参考:
新一代开源Android渠道包生成工具Walle
Apk签名方案 V2 (需要科学上网)
ZIP文件格式分析
DEMO:
Demo项目结构说明.png










网友评论