深度剖析 | 阿里热修复如何精简优化补丁资源?
    1970-01-01    万壑

 

阿里妹导读:Sophix 是阿里推出的史上首个非侵入式移动热更新解决方案,自去年推出已有一年的时间了。这一年来,阿里集团内外成千上万的app踊跃接入。由于接入简便,操作流畅,功能可靠,资源占用极小,Sophix得到了广大开发者的好评,网上也出现了大量开发者亲身实践的接入文章。

 

今天,我们选取了其中一个改进点——资源补丁的精简优化,来详细介绍一下 Sophix 背后的技术。

 

这一年,关于Sophix热修复我们陆续做了很多优化和改进,包括:

 

  • 兼容最新Android版本至Android P dp3

  • JIT混合编译的兼容

  • 第三方加固的全面兼容

  • 新增稳健接入方式

  • 三星低版本特殊机型的兼容

  • 补丁工具加速与初始化检查

  • 资源补丁深度优化

  • 其他稳定性和性能的改进

 

Sophix热修复中的资源修复我们在《深入探索Android热修复技术原理》书中已经有过介绍,主要思想就是将新增和修改的资源打包到补丁资源包中,以0x66的包名来重新编排这些资源。对比其他热修复需要替换完整资源包,Sophix的增量的资源补丁方案能做到资源补丁最小化,并且运行时无需合成完整资源,实现了性能与空间的最优化。

 

在此基础上,我们继续改进了资源补丁,对resources.arsc中的字符串池进行裁剪,在不损耗运行时性能的情况下让补丁包大小精简到了极致。

 

resources.arsc结构

 

resources.arsc文件集结了所有带id的资源项,其粗略概貌可以由以下这张图展现:

 

这里我们不需要太关注细节,只大致说明一下。每个arsc文件的开头是一个类型为RES_TABLE_TYPEResTable_header结构头,它指定了这个arsc文件所包含的其他结构,一般来说,只有一个全局字符串池和其他包资源块,通常情况下(Android Studio默认编译出来的)也仅有一个包,包id为0x7f,也就是说该包下的所有资源编号都是0x7fXXXXXX

 

我们发现,每个包中还有两个字符串池,分别是类型字符串池资源项字符串池,这两个字符串池和全局字符串池又有怎样的关系呢?

 

类型字符串池只表示类型对应的名称,像layout、string、color、integer等这些字符串,在arsc中只有一个类型id(比如0、1、2、3等)来表示他们。下面还有例子会详细解释。类型字符串池是比较独立的,而且所占空间很小,与其他结构也没有太大关联。

 

资源项字符串中存储的是字符串,与局字符串池中存储的是字符串相对应。这里的键和值就是我们通常理解中键值对(Key-Value)的。之所以值字符串放在全局,应该是Android在设计之初打算在一个resources.arsc中的各个包中进行资源值的复用,然而由于目前默认只有一个0x7f包,自然也没有复用这一说了。

 

只看这个结构会比较抽象,我们举个例子,对于以下这个字符串资源:

 

 

假设这个资源在编译进arsc之后,对应的id为0x7f010000

 

此时arsc中0x7f包中类型字符串池是

 

 

0x7f包中键字符串池是

 

 

arsc文件中的全局值字符串池是

 

 

那么,在解析这个资源项的时候,由于它的包id为0x7f,就会找到这个0x7f包中来解析,类型id为0x01,表示类型字符串池的第0x01个字符串,也就是这里的string类型,剩下的0x0000,表示该类型的第0个资源项。

 

我们从第0个资源项中解析出它是一个字符串类型的资源(这里省略解析过程),并且得到他的key值为0x1,value的值为0x3。而从前面列出的信息中可以看到,键字符串池第1个字符串为app_name,值字符串池的第3个字符串为MyDemo。由此就可以得到这个<string name="app_name">MyDemo</string>资源的完整信息了。

 

这里我们可以看出,一个资源中占空间最大的正是字符串池,其他结构只是一些索引数字,所占空间很小,因此如果能对字符串池进行精简,将节省很多空间。

 

字符串池的构造

 

首先,我们得先弄清字符串池的结构是怎样的,它的关键入口是ResStringPool_header这个结构头,系统会以通过这个结构头解析出完整的字符串池。

 

 

接下来我们从StringPool解析过程的系统源码入手,探寻其具体的构造。核心解析逻辑在ResStringPool::setTo,简单起见,以下代码去掉了与主流程无关的检查代码:

 

 

这里很清楚地展示了解析的过程,对ResStringPool的各个字段进行赋值。

 

 

其中有几个比较重要的字段:

 

  • mEntries:字符串偏移数组指针

  • mStringPoolSize:字符串个数

  • mStrings:字符串块的起始地址

  • mEntryStyles:样式偏移数组指针

  • mStylePoolSize:样式个数

  • mStyles:所有样式的存储的起始地址

 

 

 

mEntriesmEntryStyles保存是都是每个字符串在字符串块中的偏移,字符串块就是所有字符串的集合,以\0分割开,通过偏移可以获得具体的某个字符串值,这个过程体现在另一个ResStringPool::stringAt函数:

 

 

这里需要注意的一点是,字符串池中的字符串可以以UTF8或者UTF16编码来存储,不同编码中的保存偏移的方式有所不同。这里仅看UTF16的情况,参数idx表示我们要获取的第几个字符串,mEntries[idx/sizeof(uint16_t)可以获得第idx个字符串在字符串池中的偏移off,然后由mStrings+off就可以获得这个字符串实体的起始位置,接着就可以由decodeLength方法得到真正的字符串值。

 

style即表示字符串的样式,后面我们会详细讲到。

 

通过这个解析过程,我们可以得到这张结构图,其很好地体现出字符串池的构造:

 

精简思路

 

我们的资源补丁方案中,补丁中只包含新增和修改的资源,而生成补丁需要一个新包APK和一个旧包APK,毫无疑问,这两种加入补丁包的资源实际上都是属于生成补丁时的新包中的资源,因此直接拿新包APK中resources.arsc的完整字符串池就可以作为补丁的字符串池,我们最早的资源补丁就是直接采用这种方式。这么做有一个好处,就是新增和修改的资源用到的字符串索引完全不需要修改,就可以正常获取到字符串池的具体值。但是,由于字符串池是从完整的新包中直接拿过来的,因此,里面非新增和修改的资源所用的字符串也直接包含在了其中,而这些字符串对于补丁,是多余的。因此,我们需要精简去除的,正是这些无用的字符串。

 

具体来说,主要分为三个步骤:

 

首先,我们需要确定要留下的是哪些字符串。

 

接着,重新编排留下的有效字符串,使其紧凑对齐,并且重新计算各个字符串相对起始位置的偏移。

 

最后,修正所有引用字符串的地方,使得补丁资源可以正确地引用到重排过的字符串。

 

确定要留下的字符串

 

需要留下的字符串,无疑就是补丁资源中使用的字符串,而补丁资源中使用的字符串,就是我们通过比较新包和旧包,得到的新增和修改的资源所用到的字符串。具体来说,我们已经通过比较得到了一个映射表,里面记录了所有新包资源到补丁资源的id映射关系,如下所示:

 

 

这里需要处理两个字符串池,全局的值字符串池0x7f和包中的键字符串池,其中的无用的字符串和样式都需要去掉。

 

对于0x7f包中的键字符串,我们需要收集表中所有资源的键,也就是这些资源项的名称,得到一个字符串索引值的列表,这个时候得到的列表,由于是新包字符串池的索引,因此是零散分布的。

 

 

我们可以直接为每个收集到的键的字符串索引重新指定一个索引值,由此得到一张新包索引到补丁包索引的映射表:

 

 

对于全局值字符串池的处理也是类似,不同地方在于,我们需要进一步解析每个资源项,得到其对应的具体字符串值,仍然是以这个资源为例:

 

 

我们需要找到的,就是app_name在0x7f包键字符串的索引,以及MyDemo全局值字符串中的索引

 

另外,我们还需要处理样式。样式是字符串的特殊格式,比如下面的这个资源

 

 

这里的Demo字符串就拥有加粗的样式,而某个字符串对应的样式的在样式表中的索引值与这个字符串在字符串池中的索引值是一样的。aapt在编译的时候也会将带有样式的资源全部放到字符串池的最前面。比如有五个字符串具有样式,这五个字符串就会被默认放到字符串池的前五个,而样式表也只有五个样式,分别对应了这前五个字符串。而从第六个字符串以后,就没有样式了。

 

所以,这里我们还需要调整样式表,把收集到的字符串所对应的样式也一同移动到对应位置。此外,样式字符串,也就是例子中的b字符串实际上也是保存在字符串池中的,因此,当使用到某个样式的时候,还需要将该样式的字符串索引添加到我们的索引映射表中并重新编排。

 

重新编排与调整偏移值

 

我们用一张示意图来描述这个编排过程:

 

 

其中深色offset entry的表示补丁中实际有效的字符串所对应的偏移值,可以看到,其中的新包中entries按照前面安排的映射关系移动到了补丁entries的相应位置,并且entries的偏移值也根据新排布的字符串位置进行了调整。下方的字符串块strings和样式块styles的内容也只保留有效部分,这样,所有有效字符串紧贴在了一起,并去除了新包中其他无用的资源,大幅节省了空间。

 

最后需要重新构造字符串的头部ResStringPool_header结构,使得其中的各个字段(stringCount、styleCount、stringsStart、stylesStart等)填入正确的值。

 

这样,一个有效的补丁字符串池就完整构建好了。这个重排的过程对于键值两种字符串池是完全相同的。

 

修正资源引用处

 

字符串池构建完毕了以后,还需要对资源中使用到这些字符串的地方进行重新索引。显然,只需要根据这个映射表:

 

 

把原来的老索引值修正为新索引值就行了。具体来说,就是将资源文件结构中的ResTable_entry(代表资源项)和Res_value(代表具体资源的值)中,类型为ResStringPool_ref的字段的index值修正过来即可。

 

 

由于我们压缩优化的是resources.arsc中的字符串池,因此需要完整地遍历每个补丁资源项,把相应的index做替换。而xml中的资源不需要相应修改,因为xml中使用到的只有arsc里面的资源id,感知不到id对应的字符串是什么,所以只要在arsc中处理好,xml自然就能找到id所持有的正确的字符串。

 

总结

 

通过这三个步骤,便实现了字符串池的精简。当然处理过程中还有有很多零碎的问题,比如引用类型资源的处理、Map资源项和字符串池各个块的拼接等等,这些都需要十分细致地处理好,否则都会导致运行时解析格式失败而崩溃。本文没有述及这些繁琐的问题,也是为了不因为它们而扰乱了主要处理逻辑,当搞定了主干后,回头再收拾这些细枝末节就显得游刃有余了。

 

精简后效果是很明显的,不过具体还是取决于原始APK中资源字符串的数量以及补丁资源中实际有效的字符串的数量,如果资源字符串较多的话会有非常显著的优化。我们遇到最极端的一个例子是,精简之前带资源的补丁有4M大小,而精简之后直接变为23K!由此可见一斑。

 

目前Sophix最新版本打包工具的高级选项中已默认开启这个优化资源补丁选项,立刻使用就能为你的资源热修复补丁瘦身。

 

 

 

当然,还有一些其他选项开关,是为了打包的灵活性而设置的,其中有些强烈建议打开的选项我们已经默认开启了。

 

Sophix热修复中还有许多技术优化点,我们也在去年7月推出了《深入探索Android热修复技术原理》免费电子书,详细讲解了代码、资源、动态库的热修复实现。值此一周年之际,我们与电子工业出版社合作,计划在近期出版该书的印刷纸质版,并新增了一些篇章,以方便大家翻阅,敬请期待。

 

最后,手淘基础平台部EMAS平台诚招Android高级开发工程师/专家,欢迎各位优秀靠谱的小伙伴加入,职位详情可以点击文末“阅读原文”查看,或者发送简历至xiaolin.gxl@alibaba-inc.com。