iOS 裁包大作战 —— JOOX Music 如何瘦身40MB

2022-12-14 16:58:32 浏览数 (2)

本文转载自内部同事分享yancywang(汪洋)

JOOX Music 是腾讯面向海外市场发布的音乐 App,目前在其发布的五个国家和地区均是排名第一的音乐服务应用。JOOX Music 从2014年发布至今,经历了大小数十个版本的迭代,功能不断的完善和丰富。而它的体积在 v3.5 版本时达到了有点惊人的 124MB!而东南亚是 JOOX Music 的主要发行地区,这里的网络环境相对较差,存在大量老旧的小容量 iOS 设备,而 App Store 的下载也不太稳定。因此,对 JOOX Music 的裁包大作战已势在必行。

这场在 JOOX Music v3.5 版本时爆发的裁包行动,经过两个版本成功的将 JOOX Music 从 124MB 的大家伙减到了 84MB,而裁包行动还远远没有结束......

应用包大小应用包大小

本文主要介绍了 JOOX Music (后文简称 JOOX) iOS App 在裁包过程中所总结的一些经验和方法 (Android版请收看兄弟篇 《Android APK瘦身-JOOX Music项目实战》),由浅入深,分为裁包基础篇、进阶篇以及可持续化篇。从最简单有效的方法开始说起,Let's begin!

1. iOS 裁包基础篇

1.1 删除无用的切图、字体等资源文件

随着版本不停的迭代,功能模块不断的丰富和删改,时间久了,项目中难免会遗留一些无用的切图,而切图往往会占用较大的存储空间,因此裁包的第一步也是最简单但收益很大的一步:删除无用的切图资源。

普遍的做法是写个脚本扫描代码和切图,将代码中没有出现的切图找出来然后进行删除。例如这个脚本:unused-image.sh。不过我比较推荐使用 LSUnusedResources 这个开源工具,简单好用,而且支持切图名的规则匹配,例如 icon_%d 匹配 icon_1,然后它还有个UI界面,哈~ 懒人福音。

JOOX 是一款面向海外的 App,自然少不了国际化,JOOX 中一些带文字的切图,就需要不同的地区使用不同的切图,JOOX 通过把不同地区的切图名配置在对应地区的 .strings 文件里,这样可以通过一个 key 取到不同地区所对应的切图名。而当时 LSUnusedResources 还不支持扫描 .strings 文件,因此我进行了添加,并提交了 Pull request,目前已被合入主线,也算是为 LSUnusedResources 做出了一点小小的贡献(开心~)。

不扫不知道,JOOX 竟然扫出了近 8MB 的无用切图......领导看不见......领导看不见......

除了切图外,无用的字体、音视频、配置文件、多语言等资源也需要删除,这些操作很简单,但收益往往很大。

1.2 删除 2x 切图

JOOX 目前已经不支持 iOS8 以下的设备了。从 iOS8 开始,其实我们可以只提供 3x 的切图。可能有同学会担心这会影响性能,而经我们实践,影响真的非常非常小。如果十分介意,可以在运行时代码裁切 2x 切图进行缓存。而删除 2x 切图,可以轻松裁掉几 MB,这种好事,做还是不做?

1.3 对切图、音视频等资源进行压缩

1.3.1 对切图进行无损压缩

对于图片的压缩,推荐使用 ImageOptim 这个开源工具,压缩图片时,它会运行多个图片压缩工具,包括 MozJPEG, pngquant, Pngcrush, SVGO, Google Zopfli 等,然后比较他们的压缩结果,选出压缩率最佳的那一个。ImageOptim 主打无损压缩。所以我们基本可以放心大胆的使用这个工具,也可以利用它开发自动化压缩脚本,而有损压缩这种事,还是拜托设计师做吧。

经我们实践,结合压缩效果、压缩时间等因素,我们采用如下设置进行压缩:(如果时间充足,全部勾上也是可以的咯)

应用图片压缩设置应用图片压缩设置

JOOX 用这个工具对切图进行了无损压缩,压掉了 5.5MB,对于 ipa 包的影响是减小了 900KB(因为打包本身也是一种压缩,所以切图压掉的多,但是对 ipa 包的大小影响却没有那么大)。 

1.3.2 PNG 转 JPG

对于那些可以不使用 alpha 通道的 PNG 切图来说,把 PNG 格式转成 JPG,会发现效果大大的好,切图的size 一下小了很多!如下图:(我真的只是用PS打开,另存为最高品质的 JPG 格式。如果稍微降低一点点品质(反正我是看不出差别),你会发现,JPG 格式小得有点恐怖)

但这还不是极致,Google 2010年发布的 WebP 图片文件格式,拥有更小的体积和更好的性能,目前 JOOX 中已经开始逐步推广 WebP 。

1.3.3 简单的切图用代码绘制

一些比较简单的切图,例如一个虚线框、一条波浪线,就可以用代码来实现。稍微复杂一点的,可以用 Paincode 来生成代码,不过代码不一定比切图省空间,还要具体情况具体分析。

1.3.4 对切图内容进行优化与压缩

我们和产品、设计童鞋一起找出了几张 JOOX 中曝光率极低,但体积较大的切图。设计师把这些切图从内容上进行了精简以减小体积。

例如下图是 JOOX 链接 Ford 车载系统时用的背景图,可以看到上面有丰富的纹理。产品和设计童鞋权衡了曝光率、切图大小以及视觉等方面的因素,决定把这张切图换成全黑。这下连切图都省了,一行代码搞定。

应用背景图应用背景图

JOOX 中还发现了几张背景相同,只是前景文字不同的切图,这也是应该避免的,应该只切一张背景图,文字单独切或是用代码实现。

1.3.5 对音视频进行压缩

音频尽可能使用AAC或者MP3格式,可以使用一个相对较低的码率。视频可以使用 FFmpeg 进行压缩,压缩的量其实是起决于产品和设计童鞋啦。不过,我们能做的就是,尽可能的把这些资源文件改为按需下载。

1.4 删除无用的代码文件以及第三方库

这句话又来了,随着版本不停的迭代,有些代码早已消逝在风里。但由于 Objective-C 的动态特性,可以在运行时通过类名、方法名去反射得到类和方法并进行调用,所以那些没有被用到的代码,只要是在项目里,也会被编译器编进可执行文件里。 因此那些历时较长,历史遗留代码较多的项目,就需要清理一下这些无用的代码文件啦。

一般的做法是写个脚本,扫描项目里的代码,找出那些没有被 import 或 include 的代码文件,然后进行清理。或者是扫描代码提取类名,然后看类名是否在代码里出现过来进行判断。这里有一个现成的工具: fui 。不过这个工具扫描耗时非常长,而且扫描结果的误报也很多,所以扫描后还需再手工检查一下。

而对于 JOOX 这样大的项目,使用 fui 就有点难受了,扫描出近一千个结果,还有不少的误报,这就需要我们一个一个文件的进行确认,这个可执行性真是太低了,所以我自己动手写了一个小工具,力图结果准确,宁可漏掉一些,也不希望有误报,因为这样我就可以放心的删啦!就像老罗在锤子坚果Pro发布会里说的那样,不用人工二次确认绝对是质的提升。小工具在这里: 暂且叫 JXUnusedFilesFinder吧,之前赶工写的,代码稍乱,现稍加整理放到 github 上,有需要的童鞋可自取。它的优点是扫描速度快,扫描和分析 JOOX 这样大的项目只需要一分钟左右(fui 需要15分钟左右),结果相对保守和准确。不过它的缺点:一是它只针对 JOOX 的代码进行了优化,并不保证适用于所有的项目。 二是它是以文件为单位进行的检查,只进行了简单的文件名匹配,并不能精确到类或者方法(想更进一步?请收看后续进阶篇)。

还有第三方库,一个个那么大,没用了肯定要赶快删掉啊。

1.5 避免使用 -all_load 加载静态库

通常情况下,我们在项目中使用静态库,在编译的时候,链接器只会把静态库中被我们使用到的部分加载进来,没有用到的部分并不会导致我们的包变大。可往往我们拿到的静态库里有 Category 啊!这就导致我们需要使用 -all_load 、-force_load 这样的 link flag 告诉链接器把静态库中的所有内容加载进来。这无疑会导致我们的包变大。

这里想强调的是,-all_load 会把所有静态库中的所有内容都加载进来,而 -force_load 则可以单独指定哪些静态库需要全量加载。所以更好的做法是使用 -force_load 来标记那些需要全部加载的静态库,那么其他没有被标记的静态库就不会被全部加载了。

也建议当我们写静态库给别人用的时候,最好就不要使用 Category 啦。

1.6 使用 Dynamic Frameworks

从 iOS8 开始,我们可以在项目中制作自己的动态库了,它主要是为了能在各种 Extension 和 主工程之间共享代码,所以我们应该使用它,避免重复代码。

1.7 编译选项优化

1.7.1 编译优化

在项目的 Build Settings -> Optimization Level 里有几个编译优化选项,Release 版本应该选择 Fastest, Smalllest 。

1.7.2 去除符号信息

符号信息应该在 Release 包里去掉。可在项目的Build Settings 中将 Strip Linked ProductDeployment PostprocessingStrip Debug Symbols During Copy 等选项在 Release 版本时设为 Yes,如下图,这样可以去除不必要的符号信息。

JOOX 通过去除符号信息,本地打出来的 ipa 包减小了 8.5MB,对上传 App Store 后的包影响大概在 20MB 左右!效果简直不要太好!(因为 App Store 会对我们上传的包进行一次加密后重压缩打包,由于加密会导致压缩率下降,所以上传后的包变大了很多,这也是裁剪代码和符号信息收益较大的一个原因)

而我有些担心的是,把符号信息裁掉了,会不会影响 Crash 堆栈信息的上报以及还原呢?你想啊,如果发出去的版本奔溃了你还还原不了堆栈,你说你 leader 想不想灭了你。额,关于不砍人的 leader 来一车这个梗,欢迎收看本文的兄弟篇:《Android APK瘦身-JOOX Music项目实战》。虽然 JOOX 的 Release 包是使用外置的符号文件在 RDM/Buggly 上进行的还原,理论上是不会受影响的,但为了保险起见,我们还是和 QA 童鞋一起做了一些测试,例如使用 RDM/企业签 打包然后主动触发 Crash 并观察 RDM 是否可以正确还原堆栈等。测试结果也表明:使用外置符号文件进行还原确实不会受到影响,可以正确的还原出 Crash 堆栈的类名、方法名等。但如果在 App 里尝试打印堆栈信息,就无法还原了,不过这个的影响不大,所以以上设置是可行的!

1.7.3 去除不需要的架构

如果你的项目已经不支持 armv7 架构的设备了,也就是不支持 iPhone5、iPad4 以下的设备,那就可以去除对 armv7 架构的支持啦。毕竟 iPhone4S 已经是6年前的产物了。

JOOX 去掉 armv7 后,本地打出的包小了 16MB 左右!上传到 App Store 后的结果简直不敢想!不过 JOOX 主要面向东南亚市场,这里的老旧设备还比较多,因此 JOOX 目前还是支持 armv7 的。

1.8 基础篇总结

是的,基础篇结束了,基础篇总结了一些比较简单易用,但是收益很高的方法,用这些方法,可以花比较少的时间就裁掉很多的 size,如果不是追求极致,一般完成以上的裁剪工作就差不多了。因为我们接下来要探索的进阶裁剪,可能每一步都需要花很多的时间和精力,但收益往往不是很大。不过呢不过呢,如果你还是有兴趣,那我们来一起探索一下其他的可能性。

2. iOS 裁包进阶篇

2.1 删除无用的类以及方法

前文提过一些简单的用于检测无用代码文件以及类的方法,现在我们来探索一下有没有其他的方法可以更加方便和精准,甚至是可以去除无用的方法。

2.1.1 删除空方法

一些空的方法,可以通过如下正则表达式进行匹配,然后将其去除,虽然可能量不多,但再少也是爱啊!

  • [ |-]s*([w|d|_]**?)[^{] {s*(n*s*//[^/|n]*n)*s*}
  • -s*([w|d|_]**?)([w|d|_] )s*((:)([w|d|_] s**?)([w|d|_] s?))?s*{s*(n*s*//[^/|n]*n)*s*[s*supers (1)s*((3)s*(4)s*)?]s*;s*(n*s*//[^/|n]*n)*s*}
2.1.2 通过反编译获取无用的类以及方法
2.1.2.1 Mach-O

我们先来简单了解一下我们编译出来的可执行文件,iOS 上的可执行文件都是 Mach-O 格式。Mach-O 是一种用于记录可执行文件、对象代码、共享库、动态加载代码和内存转储的文件格式。典型的 Mach-O 通常主要由文件头(Header)、加载命令(Load Commands)以及具体数据(Segment & Section)组成。

  • Header : 头部包含该二进制文件的一般信息,例如字节顺序、cpu 类型、加载指令的数量等。
  • Load Commands : 加载指令告诉加载器如何处理二进制数据。
  • Segment & Section : 主要包含代码段、程序数据段,内含我们需要的 OC类列表、被引用类列表、被引用方法列表等信息。

用 MachOView 打开一个可执行文件查看到它的结构。( 附 :LinkMap 也是一个了解可执行文件构成的好方法,具体可参见 《iOS APP可执行文件的组成》)

IOS APP 可执行文件的组成IOS APP 可执行文件的组成

可以看到 __DATA,__objc_classlist__DATA,__objc_classrefs__DATA,__objc_selrefs 等正是我们需要的信息,他们分别是类列表、被引用的类、被引用的方法,惊不惊喜!开不开心!那如何能得到这些信息呢?答案是 otool。

2.1.2.2 otool

otool 是一个反编译工具,可以提取并显示 iOS 可执行文件的相关信息,包括头部,加载命令,各个段,动态库等。

获取无用的类: 运行 otool -v -o 可执行文件路径 | open -f 命令可以得到如下信息:

通过提取 __DATA,__objc_classlist 以及 __DATA,__objc_classrefs 中的类名,我们可以得到两个集合,将这两个集合相减,就可以得到项目中无用的类列表。

获取无用的方法: 通过刚才的命令,注意到 __DATA,__objc_classlist 中的类信息还包括了该类的方法,于是我们也可以从这里通过正则表达式提取项目中的所有方法列表。然后通过 otool -v -s __DATA __objc_selrefs 可执行文件路径 | open -f 命令得到被使用过的方法列表,然后两个集合相减,就可以得到无用的方法列表啦!恩??!!发现 Github 上还有一个现成的 脚本!大家有福了。

然!经实践发现,该方法也有不足:如果一个 方法A 在 方法B 中被调用,而 方法B 并没有被调用,但是你会发现 方法A 还是出现在了 __DATA,__objc_selrefs 中,感不感动?

我们再换一种思路吧,关于找出无用方法这个事,我认为比较好的方法是使用脚本去扫描代码,将没有被调用的方法注释掉,然后再递归进行之前的操作,直到没有扫到无用的方法为止。需要注意的是,每次扫描代码进行匹配时,需要剔除注释。

那还有没有其他更好的办法呢?一直在想,如果有一个代码分析工具或者是编译器,可以分析代码间的调用关系或是生成一个语法树,这样就可能知道哪些方法是真的被调用了。

2.1.2.3 clang插件

一种解决思路是使用 clang 插件,clang 作为 LLVM 提供的编译器前端,将用户的源代码(C/C /Objective-C)编译成语言、目标设备无关的 IR(Intermediate Representation) 实现。还提供良好的插件支持,允许用户在编译时,运行额外的自定义动作。如此,我们便可利用 clang 生成的语法树来判断哪些方法是可以被程序主入口访问到的。

具体的实现方法可参考:《基于clang插件的一种iOS包大小瘦身方案》

2.2 删除静态库中无用的 Mach-O 文件

前文提到:编译的时候,链接器通常只会把静态库中被我们使用到的部分加载进来,除非我们配置了某些 link flag。

现在我们来假设一个情景:我拿到一个静态库,发现它功能众多,体积有 5、6 MB 之大,而其实我只用它里面一个很小的功能模块,但我又必须配置 -force_load 来让链接器全量加载它以免 crash。这一下子就把包搞大了很多啊!烦!

我们先看下静态库的结构。新建一个静态库工程,里面有两组文件,如下图左,然后把生成的静态库拖入 MachOView 中查看它的结构。可以看到静态库中有了两个对应的 .o 文件 TestClassA.o 和 TestClassB.o。所以我们可以认为静态库是一种由多个 Mach-O 组成的文件。(附 :同理我们试一下动态库,会发现动态库只有一个 Mach-O 文件)

那么那么,假设我知道只有 TestClassA.o 是我需要的,那有没有办法把 TestClassB.o 从静态库中去掉呢?答案是还真有!

我们可以使用命令 (ar -d 静态库可执行文件路径 TestClassB.o) 去除库中的 TestClassB.o。然后我们再把静态库拖进 MachOView 中,可以看到 TestClassB.o 已经被移除了。

我们再把这个裁剪后的静态库放到测试工程中,发现可以正常的运行和打包!而且使用裁剪后的静态库打出来的包,体积减小了很多,基本和不使用 -all_load 强制链接器加载 TestClassB 时打出来的包的大小一致(为了试验方便,我把 TestClassB 弄得比较大,这样体积变化比较明显)。

接下来还有一个问题是我们如何确定哪些 Mach-O 是可以删除的呢?简单的方法是主观分析判断,然后删掉后编译试试看,如果编译报错了,那就是删错了。不过如果存在通过反射去调用某些被删掉的类以及方法,编译阶段就无法发现了,这个就只能靠 QA 童鞋了。

2.3 protobuf 精简改造

protobuf 是 Google 推出的一种轻量高效的结构化数据存储格式,JOOX 用它进行网络协议序列化。但 Google 默认工具生成的代码比较冗余,像序列化、反序列化、计算序列化大小等方法都生成在具体的 pb 类里,每个类的实现大同小异。通过代码分析以及结合 protobuf 原理,可以把这些方法抽象到基类,派生类提供每个字段相关信息就够了。

这是一种从代码结构上的优化,也是节省包 size 的一种思路。具体实现参见 《iOS微信安装包瘦身》

2.4 长文本、数据移到外部文件

通过对 Mach-O 的了解,可以得知代码里的字符串常量是放在可执行文件的 __cstring 段,如果有特别长的字符串、数据等,建议从代码中移除,抽离保存成静态文件,因为 App Store 对可执行文件加密导致压缩率低,特别长的字符串抽离成静态资源后压缩率会比在可执行文件里高很多。

2.5 向 H5 转化

那些曝光率极低但 .o 文件体积较大的界面,可以考虑用 h5 实现。

2.6 进阶篇总结

我没骗你吧,进阶篇就是给爱折腾的人准备的,很多东西深入研究下去还是很有意思的!

3. iOS 裁包可持续化篇

裁包最痛的是什么?不是删了一下午,而包只小了几百 KB, 也不是手工核对成百上千的切图和代码到头晕眼花,而是我刚裁好的包,你丫又给我搞大了 ??!!每张一两 M 的切图你也敢往项目里扔 ??!!

这裁包何时是个头啊......

所以我们计划进行如下行动:

针对切图等资源文件

  • 基于 ImageOptim 开源软件定制一个自动化无损压缩 JOOX 切图的工具。
  • 开发脚本,将每天 DailyBuild 打出来的包拉取下来,对比昨天的包分析新增内容,发现较大新增文件时发出邮件告警。并持续监控 ipa 包的大小变化趋势。
  • 用脚本每周一、周三拉取 SVN 提交切图的记录,检查是否可以进行无损压缩,如可以,则发送邮件提醒或直接提 BUG 单给相关开发童鞋。

针对代码

  • 每个版本提交全测前,使用 JXUnusedFilesFinder 这个工具扫描并删除无用的代码文件。
  • 计划开发一个扫描无用类方法的自动化工具,同样在每个版本提交全测前进行扫描和删除。

PS :因为 JXUnusedFilesFinder 和计划开发的无用方法检测工具,都是以保守、准确、不需要人工二次确认为目标的,所以一般扫描出来后可直接删除,然后编译一下看是否有错,即使有错,一般情况下也不会多,而且也比较容易解决,所以这一步并不会花费大家太多的时间。

我们希望能通过以上的这些方法,把裁包这件事分散到日常的开发周期里,从源头上把好关,避免集中的事后补救。

4. The end

最后,希望本文能对大家有所帮助,这也是对自己裁包工作的一个总结。我们可以利用基础篇里提到的方法很轻松的进行一个大幅度的瘦身,也可以利用进阶篇里的方法更进一步的缩小包的 size,而且非常重要的是,我们应该建立一套适合自己的可持续化保证包 size 可控的方案。

最后最后,终于写完啦,我写的辛苦,大家也看的辛苦,要不裁包这种事,大家轮着来试试?没时间解释了,我去减肥了,土豆白~

推荐工具:

  • 扫描无用切图:LSUnusedResources
  • 切图压缩工具:ImageOptim
  • 扫描无用代码文件:JXUnusedFilesFinder
  • 扫描无用方法:objc_cover.py
  • Mach-O查看工具:MachOView
  • 统计项目 .o & .a 文件体积:linkmap.js

参考文献:

  • iOS微信安装包瘦身
  • iOS可执行文件瘦身方法
  • iOS APP可执行文件的组成
  • 基于clang插件的一种iOS包大小瘦身方案
  • Xcode中和symbols有关的几个设置
  • Mach-O可执行文件
  • 解读 Mach-O 文件格式
  • 减小ipa体积之删除frameWork中无用mach-O文件
  • WebP 探寻之路
  • Android APK瘦身-JOOX Music项目实战

0 人点赞