多渠道打包方案调研思考

apk学习系列

Posted by Cc1over on July 31, 2019

多渠道打包方案调研思考

前言

本来暑假应该是很忙很忙的时间,996的时间安排日子,但是没想到我们竟然出乎意料的闲,等着UI出图做项目,所以最近在闲暇时间之余写个apk系列的文章记录下项目里面临的问题以及整个调研思考的过程

业务情况

  • 我们的项目里面有个典型的业务就是多渠道打包,但是这种多渠道打包和传统意义上的多渠道打包有点不一致,我们项目里的多渠道打包,是要根据不同的渠道,有不同的apk名称,app名称,app的logo,以及多个键值对的渠道信息,而且打的包在40-50个左右,而且还在不断地增长中

  • 项目中原来使用的多渠道打包方案是Android官方提供的多渠道打包方案productFlavors,这种方式在使用的时候其实也并不是那么舒服,因为接近50个渠道信息,包括app名称,logo,3-5个与渠道相关的键值对信息,完全写在gradle里其实很难受,要定义好对应的sourceSet,以及map kv对应的数据,这样让gradle很臃肿,即便抽出一个独立的gradle apply进去,假如只是要打其中部分渠道的包又会相对比较麻烦

  • 最终在1.0版本是采取了xml配置文件的方式进行渠道信息的接入

<channels>
    <channel>
        <channelName>xxxx</channelName>
        <appName>xxxxxx</appName>
        <channelInfo1>channelInfo1</channelInfo1>
        <channelInfo2>channelInfo2</channelInfo2>
        <channelInfo3>channelInfo3</channelInfo3>
    </channel>
</channels>
  • 以这种xml的方式配置渠道信息,在打部分渠道包的时候把xml的其他不需要打包的渠道注释好就行了,这样其实灵活性不低,但是如果是刚接手项目的小伙伴刚拉下来,build的时候就会慢的飞起,而且这个文件也git管理起来了,这样就导致git管理上挺麻烦的
  • 而且productFlavors这种方式终归还是慢,实际测试过打50个我们项目的apk需要40-50min时间,然后发版的网站也没有批量发版的操作,只能一个渠道一个渠道的上传,麻烦的先不说,很容易就把渠道传错,然后就折腾个一番

思考及探索

由于在项目中遇到的坑太多,所以笔者才决定去找一套新的方案去解决上述的问题

当下流行的多渠道打包方案

  • productFlavors: 不予考虑

  • apktool: apktool是Android的一个逆向分析工具,原理其实就是把apk解开,添加渠道信息之后,重新打包成新的apk,具体步骤如下:

    • 复制一份新的apk
    • 通过apktool 工具,解压apk
    • 删除已有签名信息
    • 添加渠道信息
    • 通过apktool,重新打包生成新apk
    • 重新签名

    方案点评:这种方案的多渠道打包是切实可行的,但是一开始笔者忽略了这种方案主要是因为网上资料不多,评价也不咋地

  • Walle及VasDolly: 美团多渠道打包方案和腾讯的多渠道打包方案其实在V1签名的处理上有所差异,Walle是添加空文件,VasDolly是添加zipcomment,v2签名就就往signing block里添加id-value的数据

    方案点评:这个方案其实当时真的很想很想用在项目里,但是不管是walle还是vaslloy针对的情况都是无需重签名添加渠道信息的,和业务需求格格不入

得到启发

  • 当时其实是在apk知识这一块受到了资源混淆框架AndResGuard的影响和启迪,当时资源混淆这一块的设计对我影响很大,当时就萌生了一种想法,AndResGuard混淆的原理其实也是通过修改读取arsc资源表的内容加以操作,其实从AndResGuard中揭示了一个事实,数据存在在apk中,肯定是会有某种方式的,我们只要找到它存在的方式,就可以对它进行操作了
  • 因为便萌生了通过修改arsc文件实现符合我们业务情景的多渠道打包方案

技术落地与困难

基于上面的探索和原理,梳理一遍整个多渠道打包的流程

  • 解压apk
  • 读取manifest中信息,重点是读取application标签中的icon,roundIcon,label,并保存起来,由于manifest已经经过了编译,所以其实这个icon,roundIcon,label都是对应的resource.arsc文件的资源id
  • 读取resource.arsc文件的内容,修改label对应id的app名称,保存icon,roundIcon的资源id对应res目录下的资源
  • 修改上一步记录的res中的资源文件
  • 写回arsc文件
  • ps:写回arsc文件和修改res资源文件的两个操作并发执行
  • 7zip重压缩
  • 重签名

技术落地的过程其实借鉴了微信和美团的设计,其实整个arsc的读取操作都是根据apktool提供的jar包中对arsc文件读取操作实现的,而我自己便在此之上添加写回的操作,笔者认为整个多渠道打包工具的难点在于resource.arsc文件的读取,修改以及写回操作

困难1:resource.arsc读取

读取resource.arsc资源索引表的时候,会出现读多的异常

  • 问题原因:其实出现这个问题的原因就是因为笔者在书写读取操作的时候,其实并不想把太多东西保存在内存,而只是想把笔者想要的资源保存起来,所以其实并没有采取apktool中的读取流程,所以在读到最后一个TypeSpec中的Type的时候发现他会读多了
  • 解决方案:这个问题的解决方案有很多:
    • 直接catch EOF这个异常,因为如果遇到EOF异常就说明肯定读完,但是这样做的话,要把读过的数据保存在内存中,其实对于内存相对较低的情况不太友好
    • 还原apktool的做法
    • 自实现一种机制去检测读取的位置

困难2:修改resource.arsc中的字符串

  • 这里字符串的编码格式踩了坑,由于没注意编码格式导致了出现乱码的状况,最后分别对StringBlock中的utf-8和utf-16编码进行适配与转换

解决代码如下

private byte[] getUTF8Bytes(String s) {
        int n = s.length();
        byte[] utfBytes = new byte[3 * n];
        int k = 0;
        for (int i = 0; i < n; i++) {
            int m = s.charAt(i);
            if (m < 128) {
                utfBytes[k++] = (byte) m;
                continue;
            }
            utfBytes[k++] = (byte) (0xe0 | (m >> 12));
            utfBytes[k++] = (byte) (0x80 | ((m >> 6) & 0x3f));
            utfBytes[k++] = (byte) (0x80 | (m & 0x3f));
        }
        if (k < utfBytes.length) {
            byte[] tmp = new byte[k];
            System.arraycopy(utfBytes, 0, tmp, 0, k);
            return tmp;
        }
        return utfBytes;
    }
  • 重新写入工作的繁琐,由于是一个字节一个字节,其实容错率并不高,解决代码如下
public int overwriteString(int index, String change) {
        int diff = 0;
        if (index >= 0 && this.stringOffsets != null && index < this.stringOffsets.length) {
            int offset = this.stringOffsets[index];
            int stringOffset;
            int oldLength;
            int[] val;
            byte[] changeBytes;
            if (this.isUTF8) {
                val = getUtf8(strings, offset);
                stringOffset = val[0];
                oldLength = val[1];
                changeBytes = getUTF8Bytes(change);
            } else {
                val = getUtf16(strings, offset);
                stringOffset = val[0];
                oldLength = val[1];
                changeBytes = new String(
                        change.getBytes(StandardCharsets.UTF_16),
                        StandardCharsets.UTF_16).getBytes();
            }
            if (oldLength == changeBytes.length) {
                for (int i = stringOffset, j = 0; j < changeBytes.length; i++, j++) {
                    strings[i] = changeBytes[j];
                }
            } else {
                diff = changeBytes.length - oldLength;
                handleStringsSizeChange(offset, diff, index, oldLength, changeBytes);
            }
        }
        return diff;
    }

困难3:4的倍数

当时经过漫长的代码书写以及测试后发现,apk中的resource.arsc修改成功了,重签名也成功,用apktool解析也没有问题,但是就是安装不了

  • 当时解决这个问题,最初的想法其实是看PMS安装的源码和launcher显示图标的源码,分析写入操作到底是哪里出错
  • 然后读完了源码发现没有找到我要的答案,本想去读AssetManager读取资源的源码,但是,这个时候我在AndResGuard中再次得到了启发

AndResGuard中有一段很关键的源码

int size = (chunkSize - stylesOffset);
      if ((size % 4) != 0) {
        throw new IOException("Style data size is not multiple of 4 (" + size + ").");
      }

这段源码给了我问题的答案啊,其实不是写入有问题,只是没有把StringBlock中的字符串数目保持在4的倍数,导致安装失败

解决方案: 修改完resource.arsc文件的app label后判断一下长度,不是4的倍数便补0对齐

小结

笔者在项目中遇到的困难不止于此,只列举了3个耗时最长的案例

未来期望

  • 虽然实现了功能,外部调用也是采用gradle的方式,但是对于内部的设计笔者还是不太满意,想通过进一步学习wallevaslloy的源码加以改造原来的设计
  • 整个打包方案其实最耗时的是签名操作,所以笔者想添加上dex加壳,并尝试把壳dex中的注释信息删掉,尝试做到加固以及apk瘦身
  • 关于AndResGuard:其实真的从这个框架得到不少启发,甚是感谢,但是没有办法把这个框架导入到项目中,挺可惜的,因为项目推广的人员一直想在项目里添加换肤功能,如果把AndResGuard添加上去会比较蛋疼