找回密码
 会员注册
查看: 14|回复: 0

抖音Android包体积优化探索:资源二进制格式的极致精简

[复制链接]

2万

主题

0

回帖

7万

积分

超级版主

积分
73548
发表于 2024-10-1 10:51:39 | 显示全部楼层 |阅读模式
动手点关注干货不迷路前言目前,安卓端对于包体积的优化方案已经多如过江之鲫,我们系列的上一篇文章介绍了 Class 字节码的优化,本期我们将关注点聚焦到资源文件上,从资源二进制文件的全新角度,拓展出包体积优化的新思路。在资源文件优化方面,通常的优化手段多集中在图片/文件压缩、资源文件名称混淆、离线下载资源文件等方面,而我们的新思路基于对于常规思路的深度分析及思考。一开始,我们是从资源文件名称混淆入手优化,业界对于资源文件名称混淆方案,最为熟知的开源项目当属 AndResGuard,该项目优化目标为资源文件目录 res 内的文件,其优化点如下:对重复的资源文件,以计算 md5 值的方式来判断是否重复并只保留一份;对资源文件名称进行缩短,即名称混淆;对 APK 中的内容采取 7zip 压缩优化;按照此项目进行优化,总体收益可以达到非常可观的 MB 级别。但完成此项目的优化后,资源文件的进一步优化便达到瓶颈。为了在此基础上更好的实现优化资源大小,我们需要了解资源文件目录 res 所包含的文件类型及其大小的分布情况。以抖音为例,下表是对其包含的子文件夹名称、文件数量、将文件夹 zip 压缩后大小的梳理,以文件数量降序排序:子文件夹名称文件数量文件夹zip压缩后大小drawable-xxhdpi-v4605419.5MBlayout597012.2MBdrawable43884.6MBlayout-v1729858.5MBdrawable-night-xxhdpi-v8994...drawable-xhdpi-v4431...anim382...color152...从上表,可以看到:drawable-xxhdpi-v4 目录下文件数量最多有 6000+,压缩后文件大小约为 19.5MB。drawable 目录下文件数量排第三,有 4388 个,压缩后 4.6MB,同时包含图片和.xml 文件。文件数量排第二和第四的都是 layout 目录下的布局文件,分别有 5970,2985 个,其文件夹压缩后大小分别为 12.2MB,8.5MB。布局文件总数近 9K,文件大小约 20.7MB可见,layout 目录下的布局文件大小已经和图片文件不相上下。而这部分如此大的文件,除了有文件名称的混淆优化之外,是否还有其他优化方式?或者其文件名称混淆是否彻底?此外,APK 解压后的 resources.arsc 文件有 7.3MB 之大。其中包含了 app 所有资源文件名称和资源字符串值,其中是否也存在冗余字符串?对于 layout 布局文件,从近万份之多的文件数量及 20+MB 的文件体积来看,即存在值得探究的必要。我们通过对资源文件的二进制文件格式的解析,并从文件内容被使用的角度分析,发现存在可以删除的冗余内容。在反复尝试并解决了各种稳定性和打包兼容问题后,最终研发出了一套针对 Android ARSC/XML 文件格式的包体积优化方案,目前已经落地抖音,实现 2MB 以上的收益。接下来,本文将深入讲解该方案的实现细节。APK 资源格式优化我们的核心思路是,以资源路径缩短为优化出发点,在最终的 APK 文件里,从resources.arsc与 layout 布局文件的二进制文件格式着手,查看其内容结构,寻找可以删除的未使用字符串,优化文件名称或者文件里的字符串池。主要分为下面两个优化点。资源路径缩短资源格式修改接入 AndResGuard 后,资源文件 res 目录 -> r,其中的子文件夹和文件名也都被混淆,即:res/anim/abc_fade_in.xml -> r/a/a.xml这是为了减少资源文件路径,从而减少包体积,自然联想到,是否还能进一步减少资源文件路径呢?显然,如果能将所有文件都放在 r 目录下,将中间的子文件夹去掉,则可以进一步减少资源文件路径和 zip 节点数量,一定还有包体收益;顺便可以将文件名的后缀去掉,也可以减少文件路径,即:r/a/a.xml -> r/ar/a/b.png -> r/b由于修改资源文件名称需要修改resources.arsc文件,这里对resources.arsc 的文件格式分析下:可以看到,其中包含有 3 个字符串池。假如我们有一个资源文件abc_fade_in.xml在 res/anim 目录下,其在resources.arsc文件中 3 个字符串池里的信息如下:全局字符串池(字符串池 1):主要包含完整文件路径名,即res/anim/abc_fade_in.xml类型字符串池(字符串池 2):资源种类名(包括存储 res 目录下子文件目录名),即anim键字符串池(字符串池 3):文件名,即: abc_fade_in可以看到,与资源文件名相关的地方有两处,分别在全局字符串池保存着完整文件路径名,键字符串池保存着文件名,为了将资源路径缩短,需要同时修改这两处,即在全局字符串池中修改res/anim/abc_fade_in.xml -> r/f ,在键字符串池修改 abc_fade_in -> f。在resources.arsc文件中需要修改的两处字符串池,如下图箭头所示:然而,在完成资源路径缩短后,却发现包体积反而变大了 160K+ !键常量池裁剪我们知道,文件名称混淆,其混淆名称来源于符合文件名称规范的混淆字符串集合,其中的字符串都是唯一不重合的,所以,字符串集合数量越大,其最长字符串的长度也会越大。在资源路径未缩短情况,不同子目录文件夹下,其使用的文件名称每次都可以从混淆字符串集合中重新选取,使得其名称在键字符串池中始终保持最短;其对应的文件名字符串集合为:[a,b,c,d,e]而在缩短的情况下,由于所有文件都包含在一个文件夹r下,其使用的文件名称只能来自同一个混淆字符串集合,使其名称在键字符串池中会逐渐变长,同时也会使得路径字符串跟着变长,导致其整体结果反而变大!如下图所示:其对应的文件名字符串集合为:[a,b,c,d,e,f,g,h,i,j]因此,当所有文件都包含在一个文件夹r下时,无法使得不同子目录下的文件名得以复用,所以虽然路径缩短,会使得全局字符串池变小,但键字符串池反而会变大。这是因为键名默认需要和文件名保持一致。猜想:resources.arsc文件中,键名是否需要和文件名保持一致,更或者,键名本身是否有存在的必要?其实,在经过编译后,资源文件被使用的地方会被替换成特定的 id 值,比如:publicclassMainActivityextendsAppCompatActivity{@OverrideprotectedvoidonCreate(BundlesavedInstanceState){super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);//=>setContentView(0x7f0b001c);//替换为id值}}由此可知,资源文件名称必然与整型 id 值有着一一对应的关系,这种一一映射的关系可以联想到:是否只需要根据整型 id 值,就可以找到相应的文件路径名称?因为这个过程里完全不涉及键字符串的引用。基于这个想法,我们将键字符串池全部替换为单一值"_", 发现 APK 运行正常。显然,去掉键字符串池,似乎并不会影响 APK 运行期间根据整型 id 值去查找文件路径。那么,键字符串池中的字符串的作用是什么呢?翻看源码发现,只有使用类似“资源文件反射”的方式调用,才会获取键字符串池中的字符串值,比如://MainActivity.java//此处返回值为"_",因为键字符串池已经全部替换为"_"StringentryName=getResources().getResourceEntryName(R.layout.activity_main);//此处返回的id值为0,因为找不到名为"abc_fade_in",类型为"anim"的资源intid=getResources().getIdentifier("abc_fade_in","anim","cn.pkg");当前项目中,一般没有使用上述“资源文件反射”获取资源名称的使用方式,所以键字符串池可以全部替换为单一值"_";目前已知的必须要以这种方式使用资源文件的方式,大多是插件等不在一个宿主项目下的情况,如果需要,可以对这部分字符串名称进行保留,配置白名单即可。下图是resources.arsc文件中键字符串池的格式和内容示意图:偏移数组(标记 1),数组的值为指向键字符串池(标记 2)中每个字符串的偏移值由于需要将键字符串池中所有字符串替换为单一值"_",那么,键字符串池中就只有一个"_"字符串,偏移数组也将只有一个元素,其指向键字符串池中"_"字符串的起始偏移值 0。最后,还需将resources.arsc文件中,键字符串对应的偏移数组的索引值,所有被调用的地方,全部替换为字符串"_"对应的偏移数组的索引值0,这样原有文件名字符串都会换为"_",键字符串池就只剩"_"字符串了。崩溃和兼容性问题在项目具体实施灰度中出现了崩溃,发现在 drawable 目录下的 xml 图片文件有对其后缀的检查,如下图:frameworks/base/core/java/android/content/res/ResourcesImpl.java//创建drawableprivateDrawableloadDrawableForCookie(@NonNullResourceswrapper,@NonNullTypedValuevalue,intid,intdensity){...if(file.endsWith(".xml")){//对xml文件解析并创建drawablefinalStringtypeName=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等其他图片解析并创建drawablefinalInputStreamis=mAssets.openNonAsset(value.assetCookie,file,AssetManager.ACCESS_STREAMING);finalAssetInputStreamais=(AssetInputStream)is;dr=decodeImageDrawable(ais,wrapper,value);}...}因此,我们对 drawable 目录下的 .xml 后缀不做去除。上线后,有反馈 6.x 上部分手机启动慢的现象,经排查发现是其中图片文件名称后缀删除优化,导致的在部分 rom 上 app 启动慢。排除掉这些兼容性问题,最后,我们仅保留路径缩短和键常量池裁剪优化,而不做文件名后缀去除,即:r/a/a.xml-> r/a.xml,此部分资源路径压缩优化收益 300K+。layout 优化我们知道,layout 目录下的布局文件所占包体积很大,从之前的分析可知,resources.arsc文件中有好几个字符串池,有的字符串池并没使用可以删除,而 layout 布局文件与resources.arsc文件的二进制文件格式一致,其中也有字符串池,是否也存在类似的优化点呢?对此,有必要对布局文件的文件格式和内容探究一波,随意打开一个布局文件,其源代码和二进制文件格式内容如下:从布局文件文件格式上,可以看到,布局文件有一个字符串池 strPool 和一个数组 resMap,为了阐述其作用,假如布局文件中有一个属性"layout_width",其在布局文件中包含的信息如下:字符串偏移数组(标记 1),指向字符串池(标记 2),用于从字符串池中获取标签(如:"LinearLayout")或属性字符串(如:"layout_width")字符串池(标记 2),布局文件中的唯一字符串池,保存布局文件中标签或属性字符串名,即:"layout_width"属性数组Resids(标记 3),包含当前文件所有属性的整型 id 值,属性"layout_width"的 id 值:10100F4h。从数组中整型 id 值提示的属性名(类似:attr_layout_width(10100F4h)),可以看到其属性名与字符串池中名称一一对应。我们知道 layout_width 本身是一个attr属性,查看系统源码中的 public.xml,可以看到:其整型 id 值与上面 layout 文件中的值,即 attr_layout_width 后面的整型 id 值完全一致,都是0x010100f4。系统属性的 id 值是固定的,而且,一个布局文件的属性由字符串名称或整型 id 值来唯一标识,那么,这里是否只需要 id 就可以标识属性,而属性的字符串名可以删除?猜想:每个属性都有字符串名和整型 id 值,为了性能,在解析布局文件中每个节点的属性时,是根据整型 id 值而不是字符串名来唯一标识,并据此拿到该属性的值即可。为了验证我们的猜想,简单修改字符串池中的一个属性字符串:layout_width -> llyout_width,验证可以运行成功。由前面的叙述可知 layout 目录下文件有近 9K 个,影响范围很广,如果可行其收益预计会很大,同时也更需要谨慎。通过翻看源码发现,每个属性(attr)包含一个对应的整型 id 值,在parseXml()解析布局文件得到标签后,获取其属性值时果然会直接根据整型 id 值来获取。这里属于比较底层的代码,因为与性能相关,一般 rom 厂商似乎不会改到这里,其兼容性可能不会受影响。源码中解析布局文件,标识属性并获取属性值的代码如下:frameworks/base/core/jni/android_util_AssetManager.cpp//通过属性整型id值获取属性值staticjbooleanandroid_content_AssetManager_applyStyle(...){...while(ixcurXmlAttr){ix++;curXmlAttr=xmlParser->getAttributeNameResID(ix);//获取属性id值}if(ixgetAttributeValue(ix,&value);//获取属性值...}...}uint32_tResXMLParser::getAttributeNameResID(size_tidx)const{int32_tid=getAttributeNameID(idx);// mTree.mResIds 就是 Resids数组;返回值即属性id值if(id>=0&(size_t)idFindOrCreateValue(config,pb_config.product());if(config_value->value!=nullptr){//发现已存在config_value,返回错误*out_error="duplicateconfigurationinresourcetable";returnfalse;}...}...}layout 优化:属性字符串名称裁剪:可以实现,取得收益 400K+;偏移数组修改:无法实现,因为最后转换 protobuf 格式到二进制 xml 格式,这一步是在 Google Play 本地的 aapt2 命令环境中实现,无法修改;命名空间去除:可以实现,取得收益 200K+因此,我们的优化方案最终在某海外 App 上总体可以取得 600K+收益。在完成对 AAB 文件的优化后,通过 split 后获取其中的 base-master.apk,查看其中的 layout 布局文件,收益示意图如下:标记 1--属性字符串名称裁剪,命名空间去除已优化;标记 2--偏移数组修改,无法优化,因此还是存在多个""字符串,而不像 APK 里面可以合并为一个。总结可见,在资源文件优化方面,还是可以另辟蹊径,有不少通用优化可以做,总结起来,主要工作还是在对无用字符串的搜寻和确认上。通常,编译后的二进制文件中,字符串的作用有:代码执行需要。这类字符串是必须的,但可以考虑是否可以精简,即混淆;调试辅助功能。这类字符串不一定必须,可以去除,如果需要保留,可以做相应的 keep 功能;文件格式设计者当初为了格式完备性引入,已拓展后续功能。这类字符串可能是与性能相违背的,如未使用可以直接去除;后两个点便是搜寻冗余字符串和优化包大小的重点方向。优化收益落地App收益抖音2.16MB+飞书1.6MB+加入我们抖音 Android 基础技术团队是一个深度追求极致的团队,我们专注于性能、架构、包大小、稳定性、基础库、编译构建等方向的深耕,保障超大规模团队的研发效率和数亿用户的使用体验。目前北京、上海、杭州、深圳都有大量人才需要,欢迎有志之士与我们共同建设亿级用户 APP!可以点击阅读原文,进入字节跳动招聘官网查询「抖音基础技术 Android」相关职位,直接发送简历内推。也可以邮件联系:zhangzuqiao@bytedance.com 咨询相关信息! 点击“阅读原文”了解岗位详情!
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-13 07:40 , Processed in 0.945080 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表