写点什么

_ 带你了解腾讯开源的多渠道打包技术 VasDolly 源码解析,2021 移动开发者未来的出路在哪里

用户头像
Android架构
关注
发布于: 18 分钟前

if (length > 0) {index -= length;raf.seek(index);// read channel bytesbyte[] bytesComment = new byte[length];raf.readFully(bytesComment);return new String(bytesComment, ChannelConstants.CONTENT_CHARSET);} else {throw new Exception("zip channel info not found");}} else {throw new Exception("zip v1 magic not found");}} finally {if (raf != null) {raf.close();}}}


使用了 RandomAccessFile,可以很方便的使用 seek 指定到具体的字节处。注意第一次 seek 的目标是length - magic.length,即对应我们的读取魔数,读取到比对是否相同。


如果相同,再往前读取SHORT_LENGTH = 2个字节,读取为 short 类型,即为渠道信息所占据的字节数。


再往前对去对应的长度,转化为 String,即为渠道信息,与我们前面的分析一模一样。


ok,读取始终是简单的。


后面还要看如何写入以及如何自动化。

3.2 写入 v1 渠道信息

写入渠道信息,先思考下,有个 apk,需要写入渠道信息,需要几步:


  1. 找到合适的写入位置

  2. 写入渠道信息、写入长度、写入魔数


好像唯一的难点就是找到合适的位置。


但是找到这个合适的位置,又涉及到 zip 文件的格式内容了。


大致讲解下:


zip 的末尾有一个数据库,这个数据块我们叫做 EOCD 块,分为 4 个部分:


  1. 4 字节,固定值 0x06054b50

  2. 16 个字节,不在乎其细节

  3. 2 个字节,注释长度

  4. N 个字节,注释内容


知道这个规律后,我们就可以通过匹配 1 中固定值来确定对应区域,然后 seek 到注释处。


可能 99.99%的 apk 默认是不包含注释内容的,所以直接往前 seek 22 个字节,读取 4 个字节做下匹配即可。


但是如果已经包含了注释内容,就比较难办了。很多时候,我们会正向从头开始按协议读取 zip 文件格式,直至到达目标区域。


不过 VasDolly 的做法是,从文件末尾 seek 22 ~ 文件 size - 22,逐一匹配。


我们简单看下代码:


public static void writeChannel(File file, String channel) throws Exception {


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 fieldraf.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 contentraf.write(comment);//4.write content lengthwriteShort(comment.length, raf);//5. write magic bytesraf.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 fieldraf.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 beginraf.seek(eocdAndOffsetInFile.getSecond() + ZipUtils.ZIP_EOCD_REC_MIN_SIZE + existCommentLength);//4.write contentraf.write(comment);//5.write content lengthwriteShort(comment.length, raf);//6.write magic bytesraf.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 = mChanneInfolListdependsOn variant.assemble}}}


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


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


重点看这个 Task,ApkChannelPackageTask


执行代码为:


@TaskActionpublic void channel() {//1.check all paramscheckParameter();//2.check signingConfig , determine channel package modecheckSigningConfig()//3.generate channel apkgenerateChannelApk();}


注释也比较清晰,首先 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 = {apkChannelName}"File destFile = new File(mOutputDir, apkChannelName)copyTo(mBaseApk, destFile)V1SchemeUtil.writeChannel(destFile, channel)if (!mChannelExtension.isFastMode){//1. verify channel infoif (V1SchemeUtil.verifyChannel(destFile, channel)) {println("generateV1ChannelApk , {destFile} add channel success")} else {throw new GradleException("generateV1ChannelApk , {destFile} add channel failure")}//2. verify v1 signatureif (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("------ {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 EOCDPair<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 blockPair<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 fieldslong 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 orderint 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 idif (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. 读取出


《Android学习笔记总结+最新移动架构视频+大厂安卓面试真题+项目实战源码讲义》
浏览器打开:qq.cn.hn/FTe 免费领取
复制代码


块 1,签名块,块 2,EOCD2. 在签名块中插入渠道信息 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 offsetPair<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 centralDirPair<ByteBuffer, Long> centralDir = findCentralDir(apk, centralDirOffset, (int) (eocdOffset - centralDirOffset));//4.find the contentEntryPair<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 签名块中插入渠道信息

用户头像

Android架构

关注

还未添加个人签名 2021.10.31 加入

还未添加个人简介

评论

发布
暂无评论
_带你了解腾讯开源的多渠道打包技术 VasDolly源码解析,2021移动开发者未来的出路在哪里