一、背景
随着业务的快速发展与持续迭代,玩物得志APP的包体积也在不断增加,在仅仅四个月的时间,由V3.0.2的127.4M 增大到V3.5.0的174.5M,上涨了约37%,可想而知,如果不及时管控,包体积很快会突破200M。
安装包过大,将会影响下载转化率。google开发者大会上公布的统计数据显示:
包体大小每上升 6MB,应用下载转化率就会下降 1%,
而每当包体大小减少 10MB 的时候,平均下载转化率也会有 0.5-1.5% 的增长。
安装包大小有下载大小和安装大小两个概念。
下载大小:通过网络下载的压缩 App 大小。为了节省流量,用户下载的都是压缩包,而解压的过程也就是我们说的安装。
安装大小:为 App解压后将在用户设备上占用的磁盘空间大小。也就是在App Store上看到的大小,安装大小较大,通常会影响用户的下载意愿。
下载大小过大,苹果会限制用户使用蜂窝网络下载App。
2017 年 9 月,iOS 11 后,下载限制从 100 MB 提升至 150 MB
2019 年 5 月,下载限制从 150 MB 提升至 200 MB
2019 年 9 月,iOS 13 后,若下载大小超过 200 MB,用户可选择是否使用蜂窝网络下载,但iOS 13以下的系统仍然无法通过蜂窝网络下载
虽然苹果在逐渐放宽限制。但下载大小若超出 200 MB,可以肯定对APP下载成本,推广效率都会产生比较大的影响。
而安装大小过大,是会影响用户的留存率的,毕竟当用户手机内存不够用时,肯定是优先删除占内存比较大的App。
所以降低下载大小和安装大小就是我们的目的。
二、包大小分析
通过解压一个ipa文件,我们可以看到一个.app文件中主要包括三个部分:
资源文件:主要是图片、音频、视频、等资源。
可执行文件:程序的主体,是将我们的代码、静态库、动态库通过编译链接生成的文件。
bundle:工程中使用的三方或资源bundle。
不过.app的大小并不完全就是包体积的大小,在APP上传到 AppStore Connect 到之后,Apple 也会对安装包做一些处理,测试安装包的变化无法对应到真正的下载大小变化的变化。处理主要包括:
App Slicing 对于不同架构的裁剪,可执行文件只剩下单架构;
Asset.car 中图片只留下设备需要的特定尺寸和压缩算法的变体;
__TEXT 段加密;
这也是在不同设备上看到的包大小不同的部分原因。
通过分析可知,瘦身的途径主要还是针对可执行文件和资源的优化。
三、可执行文件优化
1、删除无用类
一般的无用代码筛查方式可以分为动态和静态两种方式。静态的方式主要是通过代码扫描、参与编译构建过程或者分析最终产物来确认哪些代码没有被用到。而动态的方式主要是靠插桩或者运行时信息来获取哪些代码没有执行。
1.1 动态查找
基于插桩的行级别代码覆盖率:
基于 GCOV 或者 LLVM Profile 二进制的插桩方案可以实现在运行时收集插桩数据来指导无用代码的删除。但插桩方案局限性也显而易见,插桩会劣化二进制本身的大小和性能,同时原生的插桩方案是无法过审上线。数据收集只能局限于线下。
基于 Runtime 的轻量级运行时「类覆盖率」方案:
Objc 的类首次调用类初始化时,+initialize 被执行,系统会自动标记已被调用,在 metaClass 中 data 的 flags 字段第 29 位就存着这个这个状态。可以使用 flags & RW_INITIALIZED 获取。
1.2 静态查找
Mach-O文件中,__DATA`` __objc_classrefs
中记录了引用类的地址,__DATA``__objc_classlist
中记录了所有类的地址,我们通过otool打印对应的信息,然后两者取差值,再进行符号化,就得到没有被引用的类信息。
通过
otool -v -s __DATA __objc_classrefs
获取到引用类(明确用到的)的地址。通过
otool -v -s __DATA __objc_classlist
获取所有类的地址。用所有类信息减去引用类的信息,此时我们可以拿到未使用类的地址信息。
通过
nm -nm
命令可以得到地址和对应的类名字。
通过otool -v -s __DATA __objc_classrefs
获取到引用类的地址。
def classref_pointers(path, binary_file_arch):
ref_pointers = set()
lines = os.popen('/usr/bin/otool -v -s __DATA __objc_classrefs %s' % path).readlines()
for line in lines:
pointers = pointers_from_binary(line, binary_file_arch)
ref_pointers = ref_pointers.union(pointers)
return ref_pointers
通过otool -v -s __DATA __objc_classlist
获取所有类的地址。
def classlist_pointers(path, binary_file_arch):
list_pointers = set()
lines = os.popen('/usr/bin/otool -v -s __DATA __objc_classlist %s' % path).readlines()
for line in lines:
pointers = pointers_from_binary(line, binary_file_arch)
list_pointers = list_pointers.union(pointers)
return list_pointers
用所有类信息减去引用类的信息,此时我们可以拿到未使用类的地址信息。
unref_pointers = classlist_pointers(path, binary_file_arch) - classref_pointers(path, binary_file_arch)
通过nm -nm
命令可以得到地址和对应的类名字。
def class_symbols(path):
symbols = {}
re_class_name = re.compile('(\w{16}) .* _OBJC_CLASS_\$_(.+)')
lines = os.popen('nm -nm %s' % path).readlines()
for line in lines:
result = re_class_name.findall(line)
if result:
(address, symbol) = result[0]
symbols[address] = symbol
return symbols
得到结果输出到txt中
由于是静态查找,对于动态生成的类,比如通过反射生成的类,会被认为没有被引用,所以查找出列表后,还需要人工检查一遍。
优化结果:删除无用类110个,收益0.5M。
2、编译选项优化
2.1 开启LTO
编译选项Link-Time Optimization优化
苹果官方介绍,开启LTO后会使在release下的运行速度提升10%,而且包体积会减小。
Apple uses LTO extensively internally
Typically 10% faster than executables from regular Release builds Multiplies
with Profile Guided Optimization (PGO)
Reduces code size when optimizing for size
但是有个缺点,debug时的编译速度慢了很多,而且二次编译时会全部编译,所以我们只是在release模式下开启了LTO。
2.2 Optimization Level
Optimization Level是指clang采用什么样的编译优化等级,在Clang的文档里 clang - Code Generation Options 可以查阅到主要有以下等级:
-O0
Means “no optimization”: this level compiles the fastest and generates the most debuggable code.
-O1
Somewhere between -O0
and -O2
.
-O2
Moderate level of optimization which enables most optimizations.
-O3
Like -O2
, except that it enables optimizations that take longer to perform or that may generate larger code (in an attempt to make the program run faster).
-Ofast
Enables all the optimizations from -O3
along with other aggressive optimizations that may violate strict compliance with language standards.
-Os
Like -O2
with extra optimizations to reduce code size.
-Oz
Like -Os
(and thus -O2
), but reduces code size further.
Xcode默认debug时为-O0
不优化,release时为-Os
。经过测试这里如果使用-Oz
会大约减小3M左右的包体积,但是在一些页面会出现crash, 经过排查是一些延迟释放导致的内存问题。出于安全考虑,目前采用的是-Os
这种优化等级。
2.3 符号相关
symbols是指程序中的所有的变量、类、函数、枚举、变量和地址映射关系,以及一些在调试的时候使用到的用于定位代码在源码中的位置的调试符号,符号和断点定位以及堆栈符号化有很重要的关系。
2.3.1 Strip Linked Product (STRIP_INSTALLED_PRODUCT
)
If enabled, the linked product of the build will be stripped of symbols when performing deployment postprocessing.
如果设置为yes,打包的时候会将symbols裁剪。
并不是所有的符号都是必须的,比如 Debug Map,所以 Xcode 提供给我们 Strip Linked Product 来去除不需要的符号信息(Strip Style 中选择的选项相应的符号),去除了符号信息之后我们就只能使用 dSYM 来进行符号化了,所以需要将 Debug Information Format 修改为 DWARF with dSYM file。
2.3.2 **Strip Debug Symbols During Copy **(COPY_PHASE_STRIP
)
Specifies whether binary files that are copied during the build, such as in a Copy Bundle Resources or Copy Files build phase, should be stripped of debugging symbols. It does not cause the linked product of a target to be stripped—use Strip Linked Product (STRIP_INSTALLED_PRODUCT) for that.
与 Strip Linked Product 类似,但是这个是将那些拷贝进项目包的三方库、资源或者 Extension 的 Debug Symbol 去除掉,同样也是使用的 strip 命令。这个选项没有前置条件,所以我们只需要在 Release 模式下开启,不然就不能对三方库进行断点调试和符号化了。
2.3.3 Symbols Hidden by Default (GCC_SYMBOLS_PRIVATE_EXTERN
)
When enabled, all symbols are declared private extern
unless explicitly marked to be exported using attribute((visibility("default"))) in code. If not enabled, all symbols are exported unless explicitly marked as private extern
.
意思就是设置为yes后,所有的symbols都会被申明为private extern,经过测试,确实可以减小包体积。
工程中的设置如下:
target.build_configurations.each do |config|
config.build_settings['COPY_PHASE_STRIP'] = 'YES'
config.build_settings['GCC_SYMBOLS_PRIVATE_EXTERN'] = 'YES'
config.build_settings['STRIP_INSTALLED_PRODUCT'] = 'YES'
编译选项优化结果:收益4.2M
3、__TEXT段迁移
iOS的可执行文件就是一个MachO文件,MachO结构主要分为 Header、Load Commands、Data三部分。
Header
包含该二进制文件的一般信息,字节顺序、架构类型、加载指令的数量等。使得可以快速确认一些信息,比如当前文件用于32位还是64位,对应的处理器是什么、文件类型是什么。Load Commands
是一张包含很多内容的表。 内容包括区域的位置、符号表、动态符号表等。它们描述了Data
在二进制文件和虚拟内存中的布局信息,有了这个布局信息就能够知道Data
在二进制文件中和虚拟内存中是怎样排布的。Data
存储了实际的内容,通常是对象文件中最大的部分,包含Segement的具体数据,如静态C字符串,带参数/不带参数的OC方法,带参数/不带参数的C函数。
以下是在MachOView中查看的结构:
Data的结构又可以分为多个Segment,主要有__PAGEZERO
、__TEXT
、__DATA
、__LINKEDIT
:
__PAGEZERO
是在可执行文件有的,动态库里没有。这个段开始地址为0(NULL指针指向的位置),是一个不可读、不可写、不可执行的空间,能够在空指针访问时抛出异常。__TEXT
是代码段,里面主要是存放代码的,该段是可读可执行,但是不可写。__DATA
是数据段,里面主要是存放数据,该段是可读可写,但不可执行。__LINKEDIT
段用于存放签名信息,该段是只可读,不可写不可执行。
其中的每一个Segment又可以分为一个或多个Section,而__TEXT
是Data中的一个Segment。
__TEXT
段迁移的方式:
一个Mach-O文件构建的构成主要包括 预处理
-> 编译
-> 汇编
-> 链接
等 4 个阶段。
我们通过在 Other Linker Flags
中添加参数可以在链接期移动Section。
-Wl,-rename_section,__TEXT,__cstring,__RODATA,__cstring
-Wl,-segport,__RODATA,rx,rx
其中 -Wl
的作用就是告诉 Xcode 它后面的参数是添加给 Ld
链接器的,这些参数将在链接阶段生效。
第一行参数会新创建一个 __RODATA
段,并把 __TEXT,__cstring
移动到 __RODATA,__cstring
。
第二行参数是给 __RODATA
赋予可读和可执行权限。
我们先来看移动__TEXT,__cstring
前的 Mach-O 文件:
构建完成后再来看一下移动__TEXT,__cstring
后的 Mach-O 文件:
这样就成功的移动了__TEXT
段中的一些Section。
facebook早期解决__TEXT
段大小限制问题就是使用的这种方式,具体参考: Analysis of the Facebook.app for iOS
Facebook avoids this limitation by moving some if the __TEXT
sections into the read only __RODATA
segment. Implementing this trick is really simple: you just need to add a linker flag to rename the chosen sections. And it appears you need absolutely nothing at runtime: the renamed sections will be found automatically. This linker flag is described in the ld man page:
-rename_section orgSegment orgSection newSegment newSection
Renames section orgSegment/orgSection to newSegment/newSection.
You could use it to rename the (__TEXT, __cstring)
section to (__RODATA, __cstring)
by simply adding this line into the Other Linker Flags (OTHER_LDFLAGS):
<pre data-language="plain" id="67Kfj" class="ne-codeblock language-plain" style="border: 1px solid rgb(232, 232, 232); border-top-left-radius: 2px; border-top-right-radius: 2px; border-bottom-right-radius: 2px; border-bottom-left-radius: 2px; background-color: rgb(249, 249, 249); padding: 16px; font-size: 13px; color: rgb(89, 89, 89);">-Wl,-rename_section,__TEXT,__cstring,__RODATA,__cstring</pre>
今日头条在减小下载大小时也采用了这种方式,
通过在Other Linker Flags中添加下面的参数,就可以达到这样的目的
这里的作用就是对__TEXT
段中的section移动到其他section,然后赋予读权限和可执行权限。
那么__TEXT
段迁移为什么会减小下载大小呢?
原因就是App在上传到App Store Connect后,苹果会对其进行加密,然后压缩成ipa。加密对可执行文件本身的大小几乎没有影响,但是却大大影响了压缩效率。而__TEXT
段又是加密段中最主要的一部分,通过减小__TEXT
段就可以减小加密范围,所以就可以将__TEXT
段中的一些Section迁移到其它Segment中。
优化结果:安装大小减小0.2M,下载大小减小25M。
4、三方库相关
1、推动直播SDK使用精简版,由于直播场景不需要实时音视频、超级播放器SDK以及 AI 特效组件的能力,所以修改了直播SDK。为了防止更换SDK出现问题,进行了两个版本的灰度观察,经过充分测试无误,才确定更换。
2、推动一些SDK的删除。删除了一些可以被取代的SDK。
优化结果:收益7M。
四、资源优化
资源优化主要是对图片资源和其他一些json、音频、视频等资源的优化。
1、PNG图片压缩
png压缩主要对比了两种方案:
TinyPNG
有损压缩,主要是使用Quantization的技术,通过合并图片中相似的颜色,通过将 24 位的 PNG 图片压缩成小得多的 8 位色值的图片,并且去掉了图片中不必要的 metadata,这种方式几乎能完美支持原图片的透明度。
ImageOptim
无损压缩,图片文件中往往包含一些注释、颜色 Profile 等多余信息,移除后图像质量不变,体积更小载入更快。ImageOptim 以此方式压缩图片,先分析图片,找到最优压缩参数,去除无关信息减小体积。
经过压缩测试,发现TinyPNG压缩效果远好于ImageOptim,TinyPNG压缩比约为65%,ImageOptim压缩比约为30%,并且肉眼看起来无差异。
使用过程中发现,一些png图片虽然压缩后变小了,但是打包后变化并不明显,有些甚至变大了。通过分析ipa中的png图片以及查阅资料了解到,由于苹果本身也会对png图片进行压缩,这个压缩过程是为了加快对图片的处理速度,将其转换为更方便处理的格式--CgBI格式。并且修改了存放实际的图像数据的IDAT数据块,改变了决定IDAT数据块大小的Filter方式、zlib的压缩方式。因为CgBI的IDAT是BGRA格式的,所以不管之前的IDAT是否有Alpha通道,在处理的时候,都会增加alpha通道,其次就是因为每一行数据的filter不同,苹果处理的时候,默认每一行都使用相同的filter,而原始文件则可以通过更好的算法,对不同的数据行使用不同的filter,为后面的数据压缩提供更容易压缩的数据。因此苹果对于png的优化可能会导致部分png图片变大的情况。
所以对于部分压缩后的png图片,我们也会采用转为WebpP的方式进行进一步处理。
优化结果:收益5M
2、PNG图片转为WebP图片
相较于PNG格式, WebP具有更加优秀的图像数据压缩算法,能带来更小的图片体积。所以会对一些较大的图片转换为WebP图片。
压缩采用的是: cwebp
-- Compress an image file to a WebP file
安装方式:brew install webp
使用方式:
cwebp [options] -q quality input.png -o output.webp
其中option可选:-loss(有损压缩,默认),-lossless(无损压缩)
-q:质量指数(压缩率),有损压缩有效,无损压缩忽略
input.png:待转换图片
-o:输入图片名称格式
#png 转换为webp
toWebp() {
filePath=echo $1 |sed 's/ /\ /g'
fileName={fileName##*/}
fileName=echo $fileName|sed 's/ /_/g'
fileName=${fileName%.*}
# 静默模式 转换时将不会打印转换日志
if [[ -e $LOCAL_CWEBP_PATH ]]; then
cwebp -quiet "$filePath" -o $newFilePath$fileName.webp
else $basedir/bin/cwebp -quiet "$filePath" -o $newFilePath$fileName.webp
fi
echo $filePath
printResult $? "${filePath##*/} ☑ $newFilePath$fileName.webp"
}
在转换时,本以为脚本将png图片转为webp图片,然后hook图片加载方式就可以读取webp图片了,但是发现png图片是由imageset管理的,代码中使用的图片名称和png图片名称可能不一致。
解决方法:脚本转换成webp的时候,不能直接使用png图片名称,而是要使用管理png的imageset名称。
解决完,run起来后又发现了另外一个问题,图片展示都放大了,经过排查,发现png有1x、2x、3x三种,一个60x60像素的3x图片生成的UIImage对象scale为3,size为20x20,但是转为webp再生成UIImage之后,UIImage的scale为1,size为60,所以显示时图片变大了。还好在SDImageCoder中找到了一个修改scale参数的转换方法。
/**
Decode the image data to image.
@note This protocol may supports decode animated image frames. You can use `+[SDImageCoderHelper animatedImageWithFrames:]` to produce an animated image with frames.
@param data The image data to be decoded
@param options A dictionary containing any decoding options. Pass @{SDImageCoderDecodeScaleFactor: @(1.0)} to specify scale factor for image. Pass @{SDImageCoderDecodeFirstFrameOnly: @(YES)} to decode the first frame only.
@return The decoded image from data
*/
- (nullable UIImage *)decodedImageWithData:(nullable NSData *)data
options:(nullable SDImageCoderOptions *)options;
但是另外一个问题又出现了,本想着统一由3x的png图片转为webp,然后scale参数传3,但是由于以前图片管理不规范,导致png图片有些只有1x图,有些只有2x或者3x图,所以这里还需要根据webp是哪种png图片转换来的,传对应的scale参数。
然后后面测试时又发现部分图片加载不出来,排查发现这些图片是在xib中读取的,而xib读取png的方式并不通过imageNamed
方法,然后首先第一个想法自然是hook xib读取png的方法,但是苹果并没有暴露给我们xib加载png的方法。也有一些资料说可以通过hook UINibDecoder的decodeObjectFotKey方法,但是觉得并不十分严谨,所以xib中的使用的png, 我采用了另外一种方式:在代码中将控件使用imageNamed
方法再读取一遍图片。
还有一点需要注意的是,一些可以支持区域拉伸的png图转为webp后拉升是会变形的。这部分图是不适合转为webp的。
优化结果:收益6M
3、修改组件库中图片管理方式
Asset Catalog,是Xcode提供的一项图片资源管理方式。每个Asset表示一个图片资源,但是可以对应一张或者多张PNG图,比如可以提供@1x
, @2x
, @3x
多张尺寸的图进行适配;
Asset Catalog中的图片,在编译时会被压缩,然后在App运行时,可以通过API动态根据设备scale factor来选择对应的真实的图片渲染,使用Asset Catalog管理的图片会在ipa包中生成一个Assets.car文件。
App Thing,是苹果平台上的一个用于优化App包下载资源大小的方案。在App包提交上传到App Store后,苹果后台服务器,会对不同的设备,根据设备的scale factor,重新把App包进行精简,这样不同设备从App Store下载需要的容量不同,3x设备不需要同时下载1x和2x的图。
但是,这套机制直接基于Asset Catalog,也就是说,只有在Asset Catalog中引入的图片,才能享受到App Thinning。直接拷贝到App Bundle中的散落图片,所有设备还是都会全部下载。
因此尽量提升Asset Catalog利用率,是一个很大的包大小优化点。
所以在使用cocoapods进行组件库管理时,组件库中的PNG图片也都使用Asset Catalog来管理。
除此之外还有一个资源引入方式的不同:pod中的资源引入方式有两种,resource_bundles和resources。
使用resources,会在主bundle中导入。这种方式读取图片不需要修改读取方式。
s.resources = ['ResourcesTest/Assets/*.xcassets']
使用resource_bundles,会在主bundle中生成一个自定义的bundle,bundle中存放着资源。读取资源时需要到对应bundle下读取。这种方式可以避免命名冲突。
s.resource_bundles = {
'ResourcesBubdlesTest' => ['ResourcesBubdlesTest/Assets/*.xcassets']
}
在修改的过程中,也有一些意外收获,发现项目中部分组件库引入资源方式存在问题,同时指定了resource_bundles和resources两种方式,这样会导致图片既存在main bundle中又存在resource_bundles生成的bundle中。
这里推荐使用resource_bundles + Asset Catalog的方式来管理组件库中的PNG图片。
优化结果:收益4.5M
4、删除无用PNG图片
通过工具筛查:LSUnusedResources
优化结果:删除图片56张,收益1.2M
5、压缩文本文件
玩物得志APP中有一些Lottie动画的json文件存放在本地,通过对这些文件的打包压缩也取得了一些优化效果。
将本地中较大的json文件放到一起压缩成zip;
启动时在异步线程解压zip,存放到沙盒中;
运行时从沙盒中读取json;
后续可以对更多的资源类型采用这种方式,比如音频、视频。
优化结果:收益1.2M
五、包大小监控
为了控制增量,我们也对每个版本做了包大小的监控。
针对可执行文件的变化,我们采用的是LinkMap来分析每个组件的大小变化并进行记录。
针对资源的变化,我们也会从每个版本的ipa包中分析出资源大小的变化并记录。
以后,我们计划对增量进行卡口,结合盘古打包平台进行包体积自动分析,在每次分支被合并到主分支之前,就能体现出增量大小,这样可以使得开发对自己开发的代码有更直观的感受,加强开发在日常编码中的瘦身意识,有意识的去对资源、代码进行清理。争取做到包体积的零增量。
六、效果
以上所有方案都在玩物得志APP中进行了实践和落地,经过一系列优化,玩物得志APP包体积整体收益如下:
下载大小由136.2M降为78.6M,减小57.6M
安装大小由174.5M降为140M,减小34.5M
下载大小最直观的体现就是下载时间的长短,以下对比了3.5.0和3.6.7两个版本的下载安装时长:
优化前:下载安装时长为64s
优化后:下载安装时长为43s
下载时长缩短32.8%
以下是记录的优化以来各个版本下载大小与安装大小的变化: