燃烧app的卡路里--app瘦身之路

引言

app随着需求增加,体积逐步增大,影响用户的安装意愿。所以需要对app进行瘦身,轻装上阵~

我们的原则就是:

  • “知己知彼”
    我们需要了解app由哪些部门构成,哪些体积加起来构成了最终臃肿的app?
  • “高效打击”
    其次我们要对不同组成部分进行剖析,精确重点地对重要的模块进行优先处理,提高处理效率。为什么叫“高效打击”,因为我们的瘦身需求不是一项研究论文,可以长期深入的探索,通常是一个要在指定周期内完成的工作,所以我们需要有效率,快速地提升,所以本文的方式就是从最大可处理文件开始,依次处理更小的文件。毕竟对大文件处理得到的收益是最明显的。

所以本文主要方法就是按大小排序待处理的文件,分析每个待处理文件如何构成的,然后对构成文件递归分析构成、排序......


IPA文件构成

我们给用户或者appstore上传的应用文件,其实就是ipa文件,通过xcode中的archive而来。所以对于应用瘦身我们就是首先要对ipa文件进行瘦身。那么ipa文件是什么,包含什么? 我们先给出总图,再逐步说明:


ipa解析

ipa文件其实就是一个压缩文件,我们通过解压缩看到内部情况:

ipa文件 = app文件 + 图片文件 + meta信息 + plist文件

其中后面三项都是无法优化改动,是打包时生成的。所以现在我们ipa瘦身就等同于对app文件瘦身。


app文件瘦身:

app文件其实就是我们xcode中run出来的产出,也是一个文件夹,我们通过“查看包内容”,可以查看内部情况,主要包含:
app文件 = 可执行文件 + codingsign文件 + nib文件 + Assets.car+ 图片 +视频 + framework +语言包+ rn/ js/html文件 ......

每个app都不尽相同,可能与上述有所不同,但这不是重点,重点是我们要逐个对这些文件进行分析处理。我们目标是高效打击,那就首先对这些文件按照大小进行排序, 按照排序后文件进行逐个击破!但是本文描述的顺序是按照处理的难易程度来排列的,便于读者阅读。

  • _CodeSignature:

文件的 hash 列表。里面有一个文件 CodeResources 是一个属性列表,包含 bundle 中所有其他文件的列表。用来判断一个应用程序是否完好无损。

  • Assets.car

Assets.xcassets 好处是不同分辨率的图片好管理\工程打包后会对图片进行压缩. 如果将图片直接放在工程目录下面,打包后文件散落在包里面,不会对图片进行压缩,而如果放在xcassets中,会将这些图片(AppIcon和LaunchImage是直接放在包中的)统一压缩成一个Assets.car的文件。获取Assets.car里面的图片需要用到一个命令行工具叫cartool:https://github.com/steventroughtonsmith/cartool

  • 语言字体包

对于一些定制设计的语言或者字体包,可以考虑是否有必要?是否可以通过下载形式?

  • framework

如果我们的工程引入动态framework,这些framework会被引入到app文件夹的一个framework文件中,直接占用整个app的体积,所以对于framework的瘦身我们通常可以按照可执行文件相似的原理来处理。

  • 大视频文件

视频文件我们首先要考虑是否可以通过网络下载来使用,然后再考虑是否可以换成其他压缩编码效率更好的类型文件。

  • 大音乐文件

例如wav文件这种无损音频格式,虽然质量好,但是文件很大,我们可以选择合适的有损音频格式来有效减小体积。

  • 动图

gif格式的动图,是直接将图片放在一起播放,并没有去除时间冗余度,所以可压缩空间很大,我们可以考虑换成其他格式的动图。

  • 图片

对于图片我们需要从两方面处理,删减+压缩。
首先使用LSUnusedResources(https://github.com/tinymind/LSUnusedResources)工具找到无用图片,要注意代码中可能包含拼接文件名字的图片。
然后我们需要考虑是否对某些大图进行压缩,例如考虑是使用jpg还是png,还是WebP,当然更高的压缩编码可能带来图片质量的下降,需要权衡。

  • RN/js/html代码

对于RN/js/html代码,我们需要定期清理不用的静态代码,并按照oc源码相似原理来清除无用和过载的代码。

  • 可执行文件

经过排序后,排在第一位的通常就是与app同名的这个没有后缀的文件,这个文件是什么呢?它是怎么构成的呢?


可执行文件瘦身

首先我们通过命令看一下:

file 可执行文件

这个命令会输出:

xxx可执行文件: Mach-O executable arm_v7

可见这个文件是只包含arm_v7的executable文件。这样便可得到这个文件的架构信息,通常一个可执行文件可能包含armv7 arm64等架构。

1. 这里我们首先有一个可优化点:可执行文件是否包含不需要的架构?

例如模拟器的X86_64 I386首先是不能包含在其中的,因为appstore不允许,并且也是不需要提供给用的。另外是否需要支持armv7 也可以根据业务和需求来决定,如果可执行文件的size包含n个架构,通常每个架构的体积占到size/n,所以处理掉不需要的架构,带来的体积减小是最客观的!

处理的方式,一种是配置xcode的build settings中architecture,然后重新编译打包;另一种是使用lipo -thin命令,好处是不需要重新编译打包。(由于涉及到签名,这种方式无法提交appstore)

2. 架构处理之后,我们需要对最终保留的指定架构的可执行文件进一步处理:


可执行文件

可执行文件是有工程中的所有代码编译生成,通常由几部分组成: mach header + load command + segment1 + segment2 ......

  • header
    我们可以通过otool的一些指令查看这个可执行文件,例如查看header部分:
otool -h 可执行文件

输出结果如下:

Mach header

magic cputype cpusubtype caps filetype ncmds sizeofcmds flags

MH_MAGIC ARM V7 0x00 EXECUTE 55 5644 NOUNDEFS DYLDLINK TWOLEVEL WEAK_DEFINES BINDS_TO_WEAK PIE

还可以使用otool -l xxx输出图中第二个部分的load command,其他命令可以通过otool命令自己查看。

  • 加载命令
    Mach-O中最重要的部分,它说明了操作系统应当如何加载文件中的各个segment数据。
  • 段数据(Segments)
    每一个segment定义了一些Mach-O文件的数据、地址和内存保护属性,这些数据在动态链接器加载程序时被映射到了app所属进程的虚拟内存中。
    1). __PAGEZERO: 空指针陷阱段,映射到虚拟内存空间的第一页,用于捕捉对NULL指针的引用;
    2). __TEXT: 包含了执行代码以及其他只读数据。 为了让内核将它 直接从可执行文件映射到共享内存, 静态连接器设置该段的虚拟内存权限为不允许写。
    3). __DATA: 包含了程序数据,该段可写;
    4). __OBJC: Objective-C运行时支持库;
    5). __LINKEDIT: 含有为动态链接库使用的原始数据,比如符号,字符串,重定位表条目等等。

综上可知,我们工程中的代码(包括常量、变量、字符串、代码)和动态库等都被链接到了这个可执行文件,所以这些元素都是我们的优化点。从哪入手呢?

工程配置

首先从工程配置入手:
我们通过对xcode的工程进行配置,也可以减小可执行文件:

  • Strip Link Product设成YES,通常默认开启;
  • 去掉异常支持,微信号称减重明显,但在我们的工程中不明显。配置过程中,遇到了坑,这里列出来:

Enable C++ Exceptions和Enable Objective-C Exceptions设为NO,并且Other C Flags添加-fno-exceptions。 这种配置之后如果你的工程中没有使用try 或者 throw语句,可以编译通过,但是使用了try 或者 throw语句将会报错,大意是含有这些语句的文件不支持异常捕获。 这时我们需要对这些文件的编译选项进行配置,在build phases中查找到编译的源文件,然后添加compile flags。 这里需要注意我们需要根据这个文件类型添加不同的compile flag,对于oc文件需要使用下图的-fobjc-exceptions,而对于c++文件,需要使用-fexceptions,添加不当的话,编译还是会报错。


build settings
compile flag

代码

LinkMap文件是Xcode产生可执行文件(Mach-O)的同时生成的链接信息,用来描述可执行文件的构造成分,包括代码段(__TEXT)和数据段(__DATA)的分布情况。首先需要在工程中打开:XCode -> Project -> Build Settings -> 搜map -> 把Write Link Map File选项设为yes,并指定好linkMap的存储位置

LinkMap里展示了整个可执行文件的全貌,分为三段,分别是:

  • 以# Object files:为分割标志,列出所有.o目标文件的信息(包括静态链接库.a里的),
  • 以# Sections:为分割标志,描述各个段在最后编译成的可执行文件中的偏移位置及大小,包括了代码段(__TEXT,保存程序代码段编译后的机器码)和数据段(__DATA,保存变量值),字段的含义在Mach-o中已详细介绍。
  • 以# Symbols:为分割标志,列出具体的按每个文件列出每个对应字段的位置和占用空间

有个LinkMap分析工具:https://github.com/huanxsd/LinkMap,可以排序列出当前链接的所有的.o文件,这些就是我们需要优化的地方。不管是三方库还是我们自己代码中的o文件,都是我们需要逐个考虑的,尤其是排序靠前的文件。主要包含两类问题需要处理:

1. 过载
引入某些三方库,我们可能只是用其中一个或几个类,而其他所有类都是不会使用的,但是作为一个静态库,也会被链接到可执行文件中,这时我就需要将这些不需要的类在编译的工程中移除,使其不进入到最终的静态库。例如某加密解密三方库,我们可能只使用其中一种加密解密的类,那么其他的类文件都可以从工程中删除。

2. 无用类
例如afnetworking三方库,如果我们的代码中只是简单一个http请求,用处很少,那就可以考虑将afnetworking移除,因为afnetworking涉及到的o文件总体积可能将近1M。 如果我们在某个子工程可能只用到一个http请求,并且不需要复杂的处理,那么完全可以自己使用NSUrlsession来实现请求,而不必引入afnetworking。

由于这里是对app的可执行文件所有o文件进行排序,会将所有三方库的o文件混杂一起,为了便于查到精确处理,我们通常可以将其中大的三方库单独处理,可以使用ar -x命令,将库解压缩成o文件的集合,然后按照大小排序,再按照上述方式进行处理。

ar -x  静态库

对代码的处理我们只做到这个粒度,如果时间允许,可以进行更小粒度的处理,例如找到无用的方法、重复的方法进行屏蔽。当然更小粒度意味着对代码处理的时间更多,收益没有文件级别大。微信使用了下面的方法:

1. 查找无用selector
结合LinkMap文件的__TEXT.__text,通过正则表达式([+|-][.+\s(.+)]),我们可以提取当前可执行文件里所有objc类方法和实例方法(SelectorsAll)。再使用otool命令otool -v -s __DATA __objc_selrefs逆向__DATA.__objc_selrefs段,提取可执行文件里引用到的方法名(UsedSelectorsAll),我们可以大致分析出SelectorsAll里哪些方法是没有被引用的(SelectorsAll-UsedSelectorsAll)。注意,系统API的Protocol可能被列入无用方法名单里,如UITableViewDelegate的方法,我们只需要对这些Protocol里的方法加入白名单过滤即可。

2. 查找无用oc类
通过otool命令逆向__DATA.__objc_classlist段和__DATA.__objc_classrefs段来获取当前所有oc类和被引用的oc类,两个集合相减就是无用oc类。

总结:方法很多,最重要是根据自己的需求、项目紧迫程度,选择合适的方法来进行,而不是盲目扎到某一方面。

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 140,134评论 20 594
  • 用两张图告诉你,为什么你的 App 会卡顿? - Android - 掘金 Cover 有什么料? 从这篇文章中你...
    hw1212阅读 8,631评论 2 46
  • 不断的开发迭代,产品经理不断的添加需求,引入的资源文件几乎是只加不减,猛然回首,iOS包已经100多m,看来iOS...
    码农甲阅读 2,882评论 9 29
  • 一篇意味开始的文章做为开始,感觉特别一些。 要如何开始,我们当初如何开始走路,如何开始唱歌,如何开始生活,开始点点...
    Dalzial阅读 37评论 0 0
  • 昨日写完同学聚会的几点想法,2200多字花了2个小时,满满都是自己的心得。睡觉前,自己觉得不甚满意,觉得这么多的文...
    身体棒棒阅读 18评论 0 2