_带你了解腾讯开源的多渠道打包技术 VasDolly源码解析

byte[] comment = channel.getBytes(ChannelConstants.CONTENT_CHARSET);
Pair<ByteBuffer, Long> eocdAndOffsetInFile = getEocd(file);

if (eocdAndOffsetInFile.getFirst().remaining() == ZipUtils.ZIP_EOCD_REC_MIN_SIZE) {
System.out.println(“file : " + file.getAbsolutePath() + " , has no comment”);

RandomAccessFile raf = new RandomAccessFile(file, “rw”);
//1.locate comment length field
raf.seek(file.length() - ChannelConstants.SHORT_LENGTH);
//2.write zip comment length (content field length + length field length + magic field length)
writeShort(comment.length + ChannelConstants.SHORT_LENGTH + ChannelConstants.V1_MAGIC.length, raf);
//3.write content
raf.write(comment);
//4.write content length
writeShort(comment.length, raf);
//5. write magic bytes
raf.write(ChannelConstants.V1_MAGIC);
raf.close();
} else {
System.out.println(“file : " + file.getAbsolutePath() + " , has comment”);
if (containV1Magic(file)) {
try {
String existChannel = readChannel(file);
if (existChannel != null){
file.delete();
throw new ChannelExistException("file : " + file.getAbsolutePath() + " has a channel : " + existChannel + “, only ignore”);
}
}catch (Exception e){
e.printStackTrace();
}
}

int existCommentLength = ZipUtils.getUnsignedInt16(eocdAndOffsetInFile.getFirst(), ZipUtils.ZIP_EOCD_REC_MIN_SIZE - ChannelConstants.SHORT_LENGTH);
int newCommentLength = existCommentLength + comment.length + ChannelConstants.SHORT_LENGTH + ChannelConstants.V1_MAGIC.length;
RandomAccessFile raf = new RandomAccessFile(file, “rw”);
//1.locate comment length field
raf.seek(eocdAndOffsetInFile.getSecond() + ZipUtils.ZIP_EOCD_REC_MIN_SIZE - ChannelConstants.SHORT_LENGTH);
//2.write zip comment length (existCommentLength + content field length + length field length + magic field length)
writeShort(newCommentLength, raf);
//3.locate where channel should begin
raf.seek(eocdAndOffsetInFile.getSecond() + ZipUtils.ZIP_EOCD_REC_MIN_SIZE + existCommentLength);
//4.write content
raf.write(comment);
//5.write content length
writeShort(comment.length, raf);
//6.write magic bytes
raf.write(ChannelConstants.V1_MAGIC);
raf.close();

}
}

getEocd(file)的的返回值是Pair<ByteBuffer, Long>,多数情况下first为EOCD块起始位置到结束后的内容;second为EOCD块起始位置。

if为apk本身无comment的情况,这种方式属于大多数情况,从文件末尾,移动2字节,该2字节为注释长度,然后组装注释内容,重新计算注释长度,重新写入注释长度,再写入注释内容,最后写入MAGIC魔数。

else即为本身存在comment的情况,首先读取原有注释长度,然后根据渠道等信息计算出先的注释长度,写入。

3.3 gradle自动化

最后我们看下,是如何做到输入./gradle channelRelease就实现所有渠道包的生成呢。

这里主要就是解析gradle plugin了,如果你还没有自定义过plugin,非常值得参考。

代码主要在VasDolly/plugin这个module.

入口代码为ApkChannelPackagePlugin的apply方法。

主要代码:

project.afterEvaluate {
project.android.applicationVariants.all { variant ->
def variantOutput = variant.outputs.first();
def dirName = variant.dirName;
def variantName = variant.name.capitalize();
Task channelTask = project.task(“channel${variantName}”, type: ApkChannelPackageTask) {
mVariant = variant;
mChannelExtension = mChannelConfigurationExtension;
mOutputDir = new File(mChannelConfigurationExtension.baseOutputDir, dirName)
mChannelList = mChanneInfolList
dependsOn variant.assemble
}
}
}

为每个variantName添加了一个task,并且依赖于variant.assemble

也就是说,当我们执行./gradlew channelRelease时,会先执行assemble,然后对产物apk做后续操作。

重点看这个Task,ApkChannelPackageTask

执行代码为:

@TaskAction
public void channel() {
//1.check all params
checkParameter();
//2.check signingConfig , determine channel package mode
checkSigningConfig()
//3.generate channel apk
generateChannelApk();
}

注释也比较清晰,首先channelFile、baseOutputDir等相关参数。接下来校验signingConfig中v2SigningEnabled与v1SigningEnabled,确定使用V1还是V2 mode,我们上文中将v2SigningEnabled设置为了false,所以这里为V1_MODE。

最后就是生成渠道apk了:

void generateV1ChannelApk() {
// 省略了一些代码
mChannelList.each { channel ->
String apkChannelName = getChannelApkName(channel)
println “generateV1ChannelApk , channel = ${channel} , apkChannelName = ${apkChannelName}”
File destFile = new File(mOutputDir, apkChannelName)
copyTo(mBaseApk, destFile)
V1SchemeUtil.writeChannel(destFile, channel)
if (!mChannelExtension.isFastMode){
//1. verify channel info
if (V1SchemeUtil.verifyChannel(destFile, channel)) {
println(“generateV1ChannelApk , ${destFile} add channel success”)
} else {
throw new GradleException(“generateV1ChannelApk , ${destFile} add channel failure”)
}
//2. verify v1 signature
if (VerifyApk.verifyV1Signature(destFile)) {
println “generateV1ChannelApk , after add channel , apk ${destFile} v1 verify success”
} else {
throw new GradleException(“generateV1ChannelApk , after add channel , apk ${destFile} v1 verify failure”)
}
}
}

println(“------ p r o j e c t . n a m e : {project.name}: project.name:{name} generate v1 channel apk , end ------”)
}

很简单,遍历channelList,然后调用V1SchemeUtil.writeChannel,该方法即我们上文解析过的方法。

如果fastMode设置为false,还会读取出渠道再做一次强校验;以及会通过apksig做对签名进行校验。

ok,到这里我们就完全剖析了基于V1的快速签名的全过程。

接下来我们看基于v2的快速签名方案。

四、基于V2的快速签名方案

关于V2签名的产生原因,原理以及安装时的校验过程可以参考 VasDolly实现原理

我这里就抛开细节,尽可能让大家能明白整个过程,v2签名的原理可以简单理解为:

  1. 我们的apk其实是个zip,我们可以理解为3块:块1+块2+块3
  2. 签名让我们的apk变成了4部分:块1+签名块+块2+块3

在这个签名块的某个区域,允许我们写一些key-value对,我们就将渠道信息写在这个地方。

这里有一个问题,v2不是说是对整个apk进行校验吗?为什么还能够让我们在apk中插入这样的信息呢?

因为在校验过程中,对于签名块是不校验的(细节上由于我们插入了签名块,某些偏移量会变化,但是在校验前,Android系统会先重置偏移量),而我们的渠道信息刚好写在这个签名块中。

好了,细节一会看代码。

4.1 读取渠道信息

写入渠道信息,根据我们上述的分析,流程应该大致如下:

  1. 找到签名块
  2. 找到签名块中的key-value的地方
  3. 读取出所有的key-value,找到我们特定的key对应的渠道信息

这里我们不按照整个代码流程走了,太长了,一会看几段关键代码。

4.1.1 如何找到签名块

我们的apk现在格式是这样的:

块1+签名块+块2+块3

其中块3称之为EOCD,现在必须要展示下其内部的数据结构了:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图片来自:参考

在V1的相关代码中,我们已经可以定位到EOCD的位置了,然后往下16个字节即可拿到Offset of start of central directory即为块2开始的位置,也为签名块末尾的位置。

块2 再往前,就可以获取到我们的 签名块了。

我们先看一段代码,定位到 块2 的开始位置。

V2SchemeUtil

public static ByteBuffer getApkSigningBlock(File channelFile) throws ApkSignatureSchemeV2Verifier.SignatureNotFoundException, IOException {

RandomAccessFile apk = new RandomAccessFile(channelFile, “r”);
//1.find the EOCD
Pair<ByteBuffer, Long> eocdAndOffsetInFile = ApkSignatureSchemeV2Verifier.getEocd(apk);
ByteBuffer eocd = eocdAndOffsetInFile.getFirst();
long eocdOffset = eocdAndOffsetInFile.getSecond();

if (ZipUtils.isZip64EndOfCentralDirectoryLocatorPresent(apk, eocdOffset)) {
throw new ApkSignatureSchemeV2Verifier.SignatureNotFoundException(“ZIP64 APK not supported”);
}

//2.find the APK Signing Block. The block immediately precedes the Central Directory.
long centralDirOffset = ApkSignatureSchemeV2Verifier.getCentralDirOffset(eocd, eocdOffset);//通过eocd找到中央目录的偏移量
//3. find the apk V2 signature block
Pair<ByteBuffer, Long> apkSignatureBlock =
ApkSignatureSchemeV2Verifier.findApkSigningBlock(apk, centralDirOffset);//找到V2签名块的内容和偏移量

return apkSignatureBlock.getFirst();
}

首先发现EOCD块,这个前面我们已经分析了。

然后寻找到签名块的位置,上面我们已经分析了只要往下移动16字节即可到达签名块末尾 ,那么看下ApkSignatureSchemeV2Verifier.getCentralDirOffset代码,最终调用:

public static long getZipEocdCentralDirectoryOffset(ByteBuffer zipEndOfCentralDirectory) {
assertByteOrderLittleEndian(zipEndOfCentralDirectory);
return getUnsignedInt32(
zipEndOfCentralDirectory,
zipEndOfCentralDirectory.position() + 16);
}

到这里我们已经可以到达签名块末尾了。

我们继续看findApkSigningBlock找到V2签名块的内容和偏移量:

public static Pair<ByteBuffer, Long> findApkSigningBlock(
RandomAccessFile apk, long centralDirOffset)
throws IOException, SignatureNotFoundException {

ByteBuffer footer = ByteBuffer.allocate(24);
footer.order(ByteOrder.LITTLE_ENDIAN);
apk.seek(centralDirOffset - footer.capacity());
apk.readFully(footer.array(), footer.arrayOffset(), footer.capacity());
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
long apkSigBlockSizeInFooter = footer.getLong(0);

int totalSize = (int) (apkSigBlockSizeInFooter + 8);
long apkSigBlockOffset = centralDirOffset - totalSize;

ByteBuffer apkSigBlock = ByteBuffer.allocate(totalSize);
apkSigBlock.order(ByteOrder.LITTLE_ENDIAN);
apk.seek(apkSigBlockOffset);
apk.readFully(apkSigBlock.array(), apkSigBlock.arrayOffset(), apkSigBlock.capacity());

return Pair.create(apkSigBlock, apkSigBlockOffset);
}

这里我们需要介绍下签名块相关信息了:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图片来自:参考

中间的不包含此8字节,值得是该ID-VALUE的size值不包含此8字节。

首先往前读取24个字节,即读取了签名块大小64bits+魔数128bits;然后会魔数信息与实际的魔数对比。

接下来读取8个字节为apkSigBlockSizeInFooter,即签名块大小。

然后+8加上上图顶部的8个字节。

最后将整个签名块读取到ByteBuffer中返回。

此时我们已经有了签名块的所有数据了。

接下来我们要读取这个签名块中所有的key-value对!

V2SchemeUtil

public static Map<Integer, ByteBuffer> getAllIdValue(ByteBuffer apkSchemeBlock) {
ApkSignatureSchemeV2Verifier.checkByteOrderLittleEndian(apkSchemeBlock);

ByteBuffer pairs = ApkSignatureSchemeV2Verifier.sliceFromTo(apkSchemeBlock, 8, apkSchemeBlock.capacity() - 24);
Map<Integer, ByteBuffer> idValues = new LinkedHashMap<Integer, ByteBuffer>(); // keep order
int entryCount = 0;
while (pairs.hasRemaining()) {
entryCount++;

long lenLong = pairs.getLong();

int len = (int) lenLong;
int nextEntryPos = pairs.position() + len;

int id = pairs.getInt();
idValues.put(id, ApkSignatureSchemeV2Verifier.getByteBuffer(pairs, len - 4));//4 is length of id
if (id == ApkSignatureSchemeV2Verifier.APK_SIGNATURE_SCHEME_V2_BLOCK_ID) {
System.out.println("find V2 signature block Id : " + ApkSignatureSchemeV2Verifier.APK_SIGNATURE_SCHEME_V2_BLOCK_ID);
}
pairs.position(nextEntryPos);
}

return idValues;
}

首先读取8到capacity() - 24中的内容,即所有的id-value集合。

然后进入while循环,读取一个个key-value存入idValues,我们看下循环体内:

  1. pairs.getLong,读取8个字节,即此id-value块的size
  2. 然后pairs.getInt,读取4个字节,即可得到id
  3. size - 4 中包含的内容即为value

如此循环,得到所有的idValues。

有了所有的idValues,然后根据特定的id,即可获取我们的渠道信息了。

即:

ChannelReader

public static String getChannel(File channelFile) {
System.out.println("try to read channel info from apk : " + channelFile.getAbsolutePath());
return IdValueReader.getStringValueById(channelFile, ChannelConstants.CHANNEL_BLOCK_ID);
}

这样我们就走通了读取的逻辑。

我替大家总结下:

  1. 根据zip的格式,先定位到EOCD的开始位置
  2. 然后根据EOCD中的内容定位到签名块末尾
  3. 然后根据签名块中的数据格式,逐一读取出id-values
  4. 我们的渠道信息与一个特点的id映射,读取出即可

4.2 写入渠道信息

先思考下,现在要正视的是,目前到我们这里已经是v2签名打出的包了。那么我们应该找到签名块中的id-values部分,把我们的渠道信息插入进去。

大致的方式可以为:

  1. 读取出块1,签名块,块2,EOCD
  2. 在签名块中插入渠道信息
  3. 回写块1,签名块,块2,EOCD
4.2.1 读取出相关信息

V2SchemeUtil

public static ApkSectionInfo getApkSectionInfo(File baseApk) {
RandomAccessFile apk = new RandomAccessFile(baseApk, “r”);
//1.find the EOCD and offset
Pair<ByteBuffer, Long> eocdAndOffsetInFile = ApkSignatureSchemeV2Verifier.getEocd(apk);
ByteBuffer eocd = eocdAndOffsetInFile.getFirst();
long eocdOffset = eocdAndOffsetInFile.getSecond();

//2.find the APK Signing Block. The block immediately precedes the Central Directory.
long centralDirOffset = ApkSignatureSchemeV2Verifier.getCentralDirOffset(eocd, eocdOffset);//通过eocd找到中央目录的偏移量
Pair<ByteBuffer, Long> apkSchemeV2Block =
ApkSignatureSchemeV2Verifier.findApkSigningBlock(apk, centralDirOffset);//找到V2签名块的内容和偏移量

//3.find the centralDir
Pair<ByteBuffer, Long> centralDir = findCentralDir(apk, centralDirOffset, (int) (eocdOffset - centralDirOffset));
//4.find the contentEntry
Pair<ByteBuffer, Long> contentEntry = findContentEntry(apk, (int) apkSchemeV2Block.getSecond().longValue());

ApkSectionInfo apkSectionInfo = new ApkSectionInfo();
apkSectionInfo.mContentEntry = contentEntry;
apkSectionInfo.mSchemeV2Block = apkSchemeV2Block;
apkSectionInfo.mCentralDir = centralDir;
apkSectionInfo.mEocd = eocdAndOffsetInFile;

System.out.println("baseApk : " + baseApk.getAbsolutePath() + " , ApkSectionInfo = " + apkSectionInfo);
return apkSectionInfo;
}

  1. 首先读取出EOCD,这个代码见过多次了。
  2. 然后根据EOCD读取到中间目录的偏移量(块2)。
  3. 将中间目录完整的内容读取出来,
  4. 读取出块1

全部都存储到apkSectionInfo中。

目前我们将整个apk按区域读取出来了。

4.2.2 签名块中插入渠道信息

ChannelWriter

public static void addChannel(ApkSectionInfo apkSectionInfo, File destApk, String channel) {
byte[] buffer = channel.getBytes(ChannelConstants.CONTENT_CHARSET);
ByteBuffer channelByteBuffer = ByteBuffer.wrap(buffer);
//apk中所有字节都是小端模式
channelByteBuffer.order(ByteOrder.LITTLE_ENDIAN);

IdValueWriter.addIdValue(apkSectionInfo, destApk, ChannelConstants.CHANNEL_BLOCK_ID, channelByteBuffer);
}

将渠道字符串与特定的渠道id准备好,调用addIdValue

IdValueWriter

public static void addIdValue(ApkSectionInfo apkSectionInfo, File destApk, int id, ByteBuffer valueBuffer) {
Map<Integer, ByteBuffer> idValueMap = new LinkedHashMap<>();
idValueMap.put(id, valueBuffer);
addIdValueByteBufferMap(apkSectionInfo, destApk, idValueMap);
}

继续:

public static void addIdValueByteBufferMap(ApkSectionInfo apkSectionInfo, File destApk, Map<Integer, ByteBuffer> idValueMap) {

Map<Integer, ByteBuffer> existentIdValueMap = V2SchemeUtil.getAllIdValue(apkSectionInfo.mSchemeV2Block.getFirst());

existentIdValueMap.putAll(idValueMap);

ByteBuffer newApkSigningBlock = V2SchemeUtil.generateApkSigningBlock(existentIdValueMap);

ByteBuffer contentEntry = apkSectionInfo.mContentEntry.getFirst();
ByteBuffer centralDir = apkSectionInfo.mCentralDir.getFirst();
ByteBuffer eocd = apkSectionInfo.mEocd.getFirst();
long centralDirOffset = apkSectionInfo.mCentralDir.getSecond();
//update the offset of centralDir
centralDirOffset += (newApkSigningBlock.remaining() - apkSectionInfo.mSchemeV2Block.getFirst().remaining());
ZipUtils.setZipEocdCentralDirectoryOffset(eocd, centralDirOffset);//修改了apkSectionInfo中eocd的原始数据

RandomAccessFile fIn = new RandomAccessFile(destApk, “rw”);
long apkLength = contentEntry.remaining() + newApkSigningBlock.remaining() + centralDir.remaining() + eocd.remaining();
fIn.seek(0l);
//1. write real content Entry block
fIn.write(contentEntry.array(), contentEntry.arrayOffset() + contentEntry.position(), contentEntry.remaining());
//2. write new apk v2 scheme block
fIn.write(newApkSigningBlock.array(), newApkSigningBlock.arrayOffset() + newApkSigningBlock.position(), newApkSigningBlock.remaining());
//3. write central dir block
fIn.write(centralDir.array(), centralDir.arrayOffset() + centralDir.position(), centralDir.remaining());
//4. write eocd block
fIn.write(eocd.array(), eocd.arrayOffset() + eocd.position(), eocd.remaining());
fIn.setLength(apkLength);
System.out.println("addIdValueByteBufferMap , after add channel , new apk is " + destApk.getAbsolutePath() + " , length = " + apkLength);
}

首先读取出原本的id-values,代码我们前面已经分析过,与我们要添加的id-value放到一个map中。

然后调用V2SchemeUtil.generateApkSigningBlock重新生成一个新的签名块,这里不看了,其实就是根据上图的字节描述,很容易生成。

再根据新的签名块,和之前的中间目录偏移量,计算出新的偏移量,调整EOCD中的相关值。

最后,通过RandomAccessFile重新写入:

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则近万的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

img

img

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)

最后

最后这里放上我这段时间复习的资料,这个资料也是偶然一位朋友分享给我的,里面包含了腾讯、字节跳动、阿里、百度2019-2021面试真题解析,并且把每个技术点整理成了视频和PDF(知识脉络 + 诸多细节)。

还有 高级架构技术进阶脑图、高级进阶架构资料 帮助大家学习提升进阶,也可以分享给身边好友一起学习。

一起互勉~

《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门即可获取!

、讲解视频,并且会持续更新!**

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)

最后

最后这里放上我这段时间复习的资料,这个资料也是偶然一位朋友分享给我的,里面包含了腾讯、字节跳动、阿里、百度2019-2021面试真题解析,并且把每个技术点整理成了视频和PDF(知识脉络 + 诸多细节)。

还有 高级架构技术进阶脑图、高级进阶架构资料 帮助大家学习提升进阶,也可以分享给身边好友一起学习。

[外链图片转存中…(img-CP5HE94a-1711907776194)]

[外链图片转存中…(img-Bq909uZS-1711907776194)]

一起互勉~

《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门即可获取!
  • 3
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值