前言
目前端针对包大小的优化方案,江河有鲫。 我们系列的上一篇文章介绍了字节码的优化。 本期我们将重点介绍资源文件。 从资源二进制文件的角度出发,提出了包大小优化的新思路。
在资源文件优化方面,通常的优化手段多集中在图片/文件压缩、资源文件名混淆、资源文件离线下载等方面,而我们的新思路是基于对常规思路的深入分析和思考。
一开始,我们从资源文件名混淆优化入手。 业界最熟悉的资源文件名混淆方案。 开源项目无疑是。 本项目的优化目标是资源文件目录res下的文件。 优化点如下:
对于重复的资源文件优化排名,使用计算md5值的方法判断是否重复,只保留一份; 缩短资源文件的名称,即名称混淆; 对APK中的内容采用7zip压缩优化;
按照这个项目进行优化,整体收益可以达到非常可观的MB级别。 但是这个项目优化完成后,资源文件的进一步优化就会遇到瓶颈。
为了在此基础上更好地优化资源大小,我们需要了解资源文件目录res的文件类型和大小分布。 以抖音为例,下表列出了子文件夹的名称、文件个数、压缩文件夹的大小,按照文件个数降序排列:
从上表可以看出:
可以看出目录中的布局文件大小与图片文件不相上下。 这么大的文件这部分,除了文件名的混淆优化,还有没有其他的优化方法? 还是它的文件名完全混淆了?
此外,APK 的解压缩 .arsc 文件大小为 7.3MB。 它包含了app的所有资源文件名和资源字符串值。 是否有多余的字符串?
对于布局文件,从近万个文件的数量和20+MB的文件大小来看,还是有探索的必要。 我们分析了资源文件的二进制文件格式,从正在使用的文件内容的角度分析抖音优化,发现有多余的内容可以删除。 经过对各种稳定性和打包兼容性问题的反复尝试和解决,最终开发出一套针对ARSC/XML文件格式的包大小优化方案,目前已经登陆抖音关键词优化,实现盈利2MB多。
接下来,本文将深入讲解该方案的实现细节。
APK资源格式优化
我们的核心思想是以缩短资源路径为优化起点。 在最终的APK文件中,从.arsc和文件的二进制文件格式入手,检查其内容结构,找到可以删除的无用字符串,并优化文件名。 或者文件中的字符串池。 主要分为以下两个优化点。
资源路径缩短 资源格式修改
访问后,资源文件res目录->r,里面的子文件夹和文件名也是乱七八糟的,即:
这是为了减少资源文件路径,从而减小包的大小。 自然,自然是可以进一步减少资源文件路径了? 显然,如果能把所有文件都放到r目录下,去掉中间的子文件夹,可以进一步减少资源文件路径和zip节点数,包体肯定有好处; 对了,可以去掉文件名的后缀,也可以减少文件路径,即:
由于修改资源文件名需要修改.arsc文件,这里对.arsc文件格式进行分析:
如您所见,它包含 3 个字符串池。
如果我们在res/anim目录下有一个资源文件.xml,.arsc文件中的3个字符串池中的信息如下:
可以看出有两个地方和资源文件名有关。 全局字符串池保存完整的文件路径名,关键字符串池保存文件名。 为了缩短资源路径,这两个地方需要同时修改。 即修改全局字符串池中的res/anim/.xml -> r/f,修改关键字符串池中的 -> f。 .arsc文件中有两个字符串池需要修改,如下图箭头所示:
但是,缩短资源路径后,发现包大小反而增加了160K+!
键常量池剪枝
我们知道文件名是混淆的,它的混淆名来自于一组符合文件名规范的混淆字符串,而且里面的字符串都是唯一的,不重叠的。 因此,字符串集的数量越大,最长字符串的长度就越长。 会更大。
在不缩短资源路径的情况下,可以每次从混淆字符串集合中重新选择不同子目录文件夹中使用的文件名,使其名称在关键字符串池中始终保持最短;
对应的文件名字符串集合为:[a, b, c, d, e]
在缩短的情况下,由于所有文件都包含在一个文件夹r中,所使用的文件名只能来自同一个混淆字符串集,这样名称在关键字符串池中会逐渐变长,同时它将使路径字符串变长,导致整体结果变大! 如下所示:
对应的文件名字符串集合为:[a, b, c, d, e, f, g, h, i, j]
因此,当所有文件都包含在一个文件夹r中时,不同子目录下的文件名不能重复使用。 因此,虽然路径缩短了,但全局字符串池会变小,但关键字符串池会变小。 大的。 这是因为键名默认需要和文件名保持一致。
猜测:在.arsc文件中,键名是需要和文件名保持一致,还是键名本身必须存在?
其实在编译之后,使用资源文件的地方会被替换成具体的id值,比如:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// => setContentView(0x7f0b001c); // 替换为id值
}
}
可见,资源文件名必须与整型id值一一对应。 这种一对一的映射关系可以关联到:是否可以只根据整型id值找到对应的文件路径名? 因为这个过程根本不涉及key字符串的引用。
基于这个想法,我们将所有的关键字符串池替换为单个值“_”,发现 APK 可以正常工作。 显然,去掉key pool似乎并不影响APK运行时根据整型id值查找文件路径。
那么,关键字符串池中的字符串有什么作用呢? 查看源码发现,只有通过类似“资源文件反射”的方式调用,才能获取key字符串池中的字符串值,例如:
// MainActivity.java
// 此处返回值为"_", 因为键字符串池已经全部替换为 "_"
String entryName = getResources().getResourceEntryName(R.layout.activity_main);
// 此处返回的id值为0, 因为找不到名为 "abc_fade_in",类型为"anim"的资源
int id = getResources().getIdentifier("abc_fade_in", "anim", "cn.pkg");
在目前的项目中,一般没有办法使用上述的“资源文件反射”来获取资源名称,所以可以将key字符串池替换为单值“_”; 目前已知的必须以这种方式使用资源文件的方法,主要是当插件不在宿主项目下时。 如果需要,您可以保留这部分字符串名称并配置白名单。
下图是.arsc文件中key pool的格式和内容示意图:
最后需要将.arsc文件中key字符串对应的数组的索引值,以及所有被调用的地方,替换为字符串“_”对应的数组的索引值0,使得原来的文件名字符串将被替换为“_”,关键字符串池中只剩下“_”字符串。
崩溃和兼容性问题
项目灰度实现出现崩溃,发现目录下的xml图片文件后缀勾选,如下图:
/基础/核心/java///res/.java
//创建drawable
private Drawable loadDrawableForCookie(@NonNull Resources wrapper, @NonNull TypedValue value, int id, int density) {
...
if (file.endsWith(".xml")) { //对xml文件解析并创建drawable
final String typeName = getResourceTypeName(id);
if (typeName != null && typeName.equals("color")) {
dr = loadColorOrXmlDrawable(wrapper, value, id, density, file);
} else {
dr = loadXmlDrawable(wrapper, value, id, density, file);
}
} else { //对.png等其他图片解析并创建drawable
final InputStream is = mAssets.openNonAsset(value.assetCookie, file, AssetManager.ACCESS_STREAMING);
final AssetInputStream ais = (AssetInputStream) is;
dr = decodeImageDrawable(ais, wrapper, value);
}
...
}
因此,我们不去掉目录中的.xml后缀。
上线后反馈部分手机6.x启动慢。 经排查,发现图片文件名后缀被删除优化,导致app在部分rom上启动缓慢。 排除这些兼容性问题,最后我们只保留了路径缩短和键常量池剪枝优化,没有去掉文件名后缀,即:r/a/a.xml -> r/a.xml,这部分资源路径压缩优化收益300K+。
优化
我们知道目录下的布局文件占用的包体积很大。 从前面的分析可以看出,.arsc文件中有几个字符串池,有些字符串池不用,可以删除。 二进制文件的格式是一致的,还有一个字符串池。 有没有类似的优化点? 对此,有必要探讨布局文件的文件格式和内容。 随意打开一个布局文件,其源代码和二进制文件格式如下:
从布局文件的文件格式我们可以看出布局文件有一个字符串池和一个数组。 为了说明它的作用,如果布局文件中有一个属性“”,则布局文件中包含的信息如下:
我们知道它是一个attr属性。 如果查看系统源代码中的.xml,您可以看到:
它的整型id值和上面文件中的值完全一样,也就是下面的整型id值,两者都是。 系统属性的id值是固定的,布局文件的属性是通过字符串名或者整型id值来唯一标识的,那么是否可以只通过id和属性的字符串名来标识属性可以删除吗?
猜测:每个属性都有一个字符串名称和一个整数 id 值。 出于性能考虑,在解析布局文件中每个节点的属性时,是根据整数id值而不是字符串名称来唯一标识的,据此获取该属性的值就足够了。
为了验证我们的猜想,只需修改字符串池中的一个属性字符串: -> ,即可验证成功。 从前面的描述可以看出,该目录下有近9K个文件,影响范围非常广泛。 如果可行抖音优化,预计收益会很大,需要更加谨慎。
通过查看源码,发现每个属性(attr)都包含一个对应的整型id值。 ()解析布局文件得到标签后,在获取其属性值时,会直接根据整型id值获取。 这是一段比较底层的代码,因为关系到性能,一般的rom seo 这里好像没有改动,可能不影响兼容性。
源码中解析布局文件,识别属性,获取属性值的代码如下:
/基地/核心/jni/.cpp
// 通过属性整型id值获取属性值
static jboolean android_content_AssetManager_applyStyle(...) {
...
while (ix < NX && curIdent > curXmlAttr) {
ix++;
curXmlAttr = xmlParser->getAttributeNameResID(ix); //获取属性id值
}
if (ix < NX && curIdent == curXmlAttr) { //通过id值来标识属性
block = kXmlBlock;
xmlParser->getAttributeValue(ix, &value); //获取属性值
...
}
...
}
uint32_t ResXMLParser::getAttributeNameResID(size_t idx) const {
int32_t id = getAttributeNameID(idx);
// mTree.mResIds 就是 Resids数组;返回值即属性id值
if (id >= 0 && (size_t)id < mTree.mNumResIds) {
return dtohl(mTree.mResIds[id]);
}
return 0;
}
具体实现上,这个思路主要有3点,整体收益1.9MB+。 详见下文分析。
属性字符串名称修改
首先,我们将所有能与数组对应的字符串一一替换为""字符串,处理所有目录下的文件,获利1.1MB+。
推动本次优化,优化资源目录res下所有后缀为“.xml”的文件,额外获得180K+收益。 这部分之所以没有盈利,是因为其他目录下的“.xml”后缀文件多为or anim目录下的文件。 这些文件没有数组或包含太多属性。
偏移数组修改
观察该目录下布局文件中字符串池的格式,发现其中包含一个偏移数组和一个字符串池。 每个节点根据偏移量数组从字符串池中读取一个字符串。 因此,这里只能修改偏移数组指向相同的字符串值,从而将空字符串合并为一个,减少字符串池,节省空间,如下图所示:
上图中,左图是将数组对应的所有字符串一一替换为""字符串,偏移数组会指向5个""字符串; 右图是修改偏移量数组,修改其值指向第一个""字符串,同时删除多余的4个""字符串。 该解决方案获得 300K+ 的利润。
名称空间删除
在解析布局文件获取属性值时,我们发现属性的命名空间字符串很长,例如:""。 并且每个布局文件至少有一个命名空间字符串,这种情况出现得相当频繁。 我们猜测,在获取属性值的时候,是不是没有解析属性的命名空间字符串呢? 前面我们说过,属性值的获取只需要属性id值来标识,并没有用到命名空间字符串。 将命名空间字符串替换为空字符串后,发现没有问题,本次优化获得了500K+。
最终优化后的形式如下:
Mark 1--属性字符串名称裁剪,将字符串池中的每个字符串替换为“”空字符串;
Mark 2--偏移数组修改,将字符串池中的所有""空字符串合并为一个;
Mark 3-- ,将字符串池中的字符串替换为“”空字符串。
应用程序兼容
以上优化均为通用方案,在国内APP上可以接入使用。 但是目前在国外的Play 上,App都是使用App文件格式(即AAB),其中目录下的.arsc文件和文件的格式与上述格式不同。 格式用于增强此文件格式中内容的可扩展性和健壮性。
一个AAB文件在转换成多个APK的时候,会有一个从格式到二进制xml格式的转换,这个转换过程是在Play上进行的,我们无法更改。 所以只能针对AAB文件格式中的资源文件格式进行优化。 通过App解析布局文件中的属性并不复杂,这里不再赘述。 将上述优化方案移植到AAB文件上,结果如下:
资源路径缩短:
/基地/////.cpp
//读取protobuf格式的资源文件
static bool DeserializePackageFromPb(...) {
...
for (const pb::ConfigValue& pb_config_value : pb_entry.config_value()) {
...
//FindOrCreateValue搜寻已存在的或者创建新的ResourceConfigValue,搜寻时会判断键字符串是否已存在
ResourceConfigValue* config_value = entry->FindOrCreateValue(config, pb_config.product());
if (config_value->value != nullptr) {//发现已存在config_value,返回错误
*out_error = "duplicate configuration in resource table";
return false;
}
...
}
...
}
优化:
所以,我们的优化方案最终可以在一款海外APP上实现600K+的整体收益。 完成AAB文件的优化,通过后得到base-.apk,查看里面的文件。 收益图如下:
Mark 1——属性字符串名称修剪,命名空间移除已优化;
Mark 2--数组的修改无法优化,所以还是有多个""字符串,不像APK中可以合并为一个。
总结
可见,在资源文件优化方面,我们还是可以另辟蹊径的。 可以进行许多常规优化。 综上所述,主要工作是查找和确认无用字符串。 通常,在编译好的二进制文件中,字符串的作用是:
后两点是查找冗余字符串和优化包大小的重点方向。
优化收入落地