背景
APP包体积过大严重影响用户首次安装的体验,在iOS13以下的设备,移动网络下无法下载超过200M的APP,iOS13以上的设备需要在“设置“打开下载开关,才能在移动网络下下载超过200M的APP。
优化方向
本文主要从二进制文件、编译链接参数、Dead strip、无用资源图片删除、无用代码删除和资源动态下发等方式进行APP瘦身,累计瘦身京喜APP51M左右。
一、Bitcode优化
包含Bitcode并上传到App Store Connect的Apps会在App Store上编译和链接。包含Bitcode可以在不提交新版本App的情况下,允许Apple在将来的时候再次优化你的App二进制文件。在Xcode中,默认开启Bitcode。如果你的App支持Bitcode,App使用到的其他二进制形式也要支持Bitcode。
Bitcode是编译后生成汇编之前的中间表现:
底层编译流程:
二、链接时间优化(LTO)
Link Time Optimization (LTO) 链接时间优化是指:链接阶段执行模块间优化。在编译阶段,clang将发出LLVM bitcode而不是目标文件。链接器识别这些Bitcode文件,并在链接期间调用LLVM以生成将构成可执行文件的最终对象。接下来会加载所有输入的Bitcode文件,并将它们合并在一起以生成一个模块。LLVM的LTO机制是通过把LLVM IR传递给链接器,从而可以在链接期间执行整个程序分析和优化。
LTO有两种模式:
- Full LTO是将每个单独的目标文件中的所有LLVM IR代码组合到一个大的module中,然后对其进行优化并像往常一样生成机器代码。
- Thin LTO是将模块分开,但是根据需要可以从其他模块导入相关功能,并行进行优化和机器代码生成。
进行LTO而不是一次全部编译的优点是(部分)编译与LTO并行进行。对于完整的LTO(-flto=full),仅并行执行语义分析,而优化和机器代码生成则在单个线程中完成。对于ThinLTO(-flto=thin),除全局分析步骤外,所有步骤均并行执行。因此,ThinLTO比FullLTO或一次编译快得多。
使用的编译链接参数有:
clang:
-flto=<value> 设置LTO的模式:full或者thin,默认full。
-lto_library <path> 指定执行LTO方式的库所在位置。当执行链接时间优化(LTO)时,链接器将自动去链接libLTO.dylib,或者从指定路径链接。
在Xcode Build Setting中的设置为:
通过实例来分析一下:
--- a.h ---
extern int foo1(void);
extern void foo2(void);
extern void foo4(void);
--- a.c ---
#include "a.h"
static signed int i = 0;
void foo2(void) {
i = -1;
}
static int foo3() {
foo4();
return 10;
}
int foo1(void) {
int data = 0;
if (i < 0)
data = foo3();
data = data + 42;
return data;
}
--- main.c ---
#include <stdio.h>
#include "a.h"
void foo4(void) {
printf("Hi\n");
}
int main() {
return foo1();
}
进入终端运行:
1、将a.c编译生成bitcode格式文件
clang -flto -c a.c -o a.o
2、将main.c正常编译成目标文件
clang -c main.c -o main.o
3、通过LTO将a.c和main.c通过LTO方式链接到一起
clang -flto a.o main.o -o main
按照LTO优化方式:
- 链接器首先按照顺序读取所有目标文件(此时,是bitcode文件,仅伪装成目标文件)并收集符号信息。
- 接下来,链接器使用全局符号表解析符号。找到未定义的符号,替换weak符号等等。
- 按照解析的结果,告诉执行LTO的库文件(默认是libLTO.dylib)那些符号是需要的。紧接着,链接器调用优化器和代码生成器,返回通过合并bitcode文件并应用各种优化过程而创建的目标文件。然后,更新内部全局符号表。
- 链接器继续运行,直到生成可执行文件。
LTO整个的优化顺序为:
- 首先读取a.o(bitcode文件)收集符号信息。链接器将foo1()、foo2()、foo4()识别为全局符号。
- 读取main.o(真正的目标文件),找到目标文件中使用的符号信息。此时,main.o使用了foo1(),定义了foo4().
- 链接器完成了符号解析过程后,发现foo2()未在任何地方使用它将其传递给LTO。foo2()一旦可以删除,意味着发现foo1()里面调用foo3()的判断始终为假,也就是foo3()也没有使用,也可以删除。
- 符号处理完毕后,将处理结果传递给优化器和代码生成器,同时,将a.o合并到main.o中。
- 修改main.o的符号表信息。继续链接,生成可执行文件。
最后生成的可执行文件main的符号表信息:
链接完成之后,我们自己声明的函数只剩下:main、foo1和foo4。这个地方有个问题,foo4函数并没有在任何地方使用,为什么没有把它干掉?因为LTO优化以入口文件需要的符号为准,来向外进行解析优化。所以,要优化掉foo4,那么就需要使用一个新的功能dead strip。
三、 Dead strip 优化
链接器的-dead_strip参数的作用是:
Remove functions and data that are unreachable by the entry point or exported symbols.
简单来讲,就是移除入口函数或者没有被导出符号使用到的函数或者代码。现在foo4正是符合这种情况,所以,可以通过-dead_strip来删除掉无用代码。
放大到动态库,在创建动态库时可以使用-mark_dead_strippable_dylib:
Specifies that the dylib being built can be dead strip by any
client. That is, the dylib has no initialization side effects.
So if a client links against the dylib, but never uses any symbol
from it, the linker can optimize away the use of the dylib.
如果并没有使用到该动态库的符号信息,那么链接器将会自动优化该动态库。不会因为路径问题崩溃。 同时,你也可以在App中使用-dead_strip_dylibs获得相同的功能。
strip:移除指定符号。在Xcode中默认strip是在Archive的时候才会生效,移除对应符号。
strip -x:除了全局符号都可以移除 (动态库使用)
strip_x() {
if [ $CONFIGURATION == Release ]; then
strip -x $1
fi
}
for a_framework in `find ${destination} -name "*.framework"`; do
basename="$(basename -s .framework "$a_framework")"
binary="${destination}/${basename}.framework/${basename}"
if [[ "$(file "$binary")" == *"dynamically linked shared library"* ]]; then
strip_x "$binary"
fi
done
strip -S:移除调试符号(静态库使用) strip:除了间接符号表中使用的符号,其他符号都移除(上架App使用)
Strip Style设置, Release下为All Symbols
All Symbols: 去除所有符号,一般是在主工程中开启。
Non-Global Symbols: 去除一些非全局的 Symbol(保留全局符号,Debug Symbols 同样会被去除),链接时会被重定向的那些符号不会被去除,此选项是静态库/动态库的建议选项。
Debug Symbols: 去除调试符号,去除之后将无法断点调试。
Strip Linked Product:去除不必要的符号信息。Strip Linked Product 选项在 Deployment Postprocessing 设置为 YES 的时候才生效。
Strip Debug Symbols During Copy:将那些拷贝进项目包的三方库、资源或者 Extension的Debug Symbol 去除掉,Release下设置为YES。
Strip Swift Symbols:移除相应 Target中的所有的 Swift 符号,这个选项也是默认打开的。Swift标准库是会打进目标文件的。
四、 Code Generation Options
代码生成约定的选项:
1、None[-O0]不优化:
在这种设置下, 编译器的目标是降低编译消耗,保证调试时输出期望的结果。程序的语句之间是独立的:如果在程序的停在某一行的断点出,我们可以给任何变量赋新值抑或是将程序计数器指向方法中的任何一个语句,并且能得到一个和源码完全一致的运行结果。
2、Fast[-O1]大函数所需的编译时间和内存消耗都会稍微增加:
在这种设置下,编译器会尝试减小代码文件的大小,减少执行时间,但并不执行需要大量编译时间的优化。在苹果的编译器中,在优化过程中,严格别名,块重排和块间的调度都会被默认禁止掉。此优化级别提供了良好的调试体验,堆栈使用率也提高,并且代码质量优于None[-O0]。
3、Faster[-O2]编译器执行所有不涉及时间空间交换的所有的支持的优化选项:
是更高的性能优化Fast[-O1]。在这种设置下,编译器不会进行循环展开、函数内联或寄存器重命名。和Fast[-O1]项相比,此设置会增加编译时间,降低调试体验,并可能导致代码大小增加,但是会提高生成代码的性能。
4、Fastest[-O3]在开启Fast[-O1]`项支持的所有优化项的同时,开启函数内联和寄存器重命名选项:
是更高的性能优化Faster[-O2],指示编译器优化所生成代码的性能,而忽略所生成代码的大小,有可能会导致二进制文件变大。还会降低调试体验。
5、Fastest, Smallest[-Os]`在不显着增加代码大小的情况下尽量提供高性能:
这个设置开启了Fast[-O1]项中的所有不增加代码大小的优化选项,并会进一步的执行可以减小代码大小的优化。增加的代码大小小于Fastest[-O3]。与Fast[-O1]相比,它还会降低调试体验。
6、Fastest, Aggressive, Optimizations[-Ofast]与Fastest, Smallest[-Os]`相比该级别还执行其他更激进的优化:
这个设置开启了Fastest[-O3]中的所有优化选项,同时也开启了可能会打破严格编译标准的积极优化,但并不会影响运行良好的代码。该级别会降低调试体验,并可能导致代码大小增加。
7、Smallest, Aggressive Size Optimizations [-Oz]不使用LTO`的情况下减小代码大小:
与-Os相似,指示编译器仅针对代码大小进行优化,而忽略性能优化,这可能会导致代码变慢。
五、无用代码删除
Dead Code Stripping 对C/C++/swift等静态语言编译器会在link的时候移除未使用到的代码,OC动态语言却是无效的。
清理代码方案:
我们要清理的是:无用的类、无用的协议和无用的办法。我们采用曲线解决问题的办法:利用Mach-O文件找出无引用的类、协议和办法,然后根据LinkMap文件,将这些划分会不同业务,交给具体的业务进一步确认,确认无用后删除;
- 无引用的类 = __objc_classlist - (__objc_classrefs+__objc_superrefs)
- 无引用的方法 = __objc_classlist的 (instanceMethods - __objc_selrefs) + clasMethods - __objc_selrefs
- 无引用的协议 = __objc_protolist - (__objc_classrefs+__objc_superrefs) 的protocol_list
方案实践
直接使用已有工具Snake,需要简单设置下
- 将其中的snake拷贝到目录中,比如$HOME/custom-tool/bin目录下;
- 打开~/.bash_profile文件:vi ~/.bash_profile,在文件最上方加一行:export PATH=PATH,然后保存并退出
- 执行source ~/.bash_profile;
- 至此,Snake工具生效。 找到无引用类、方法和协议
snake -l app_name-LinkMap.txt app_name.app/app_name -c > app_name_unref_class.txt
snake -l app_name-LinkMap.txt app_name.app/app_name -s > app_name_unref_selector.txt
snake -l app_name-LinkMap.txt app_name.app/app_name -p > app_name_unref_protocol.txt
六、Objective-C Direct Methods
直接方法具有常规方法的外观,但是具有C函数的行为。 当直接方法被调用时,它直接调用它的底层实现,而不是通过objc_msgSend。
direct属性的用途
减少二进制文件大小:
- 去除了相关方法的Objective-C meta data
- 减少了调用oc方法时的胶水代码,减少指令数
提高方法调用效率:
- 减少了调用oc方法时的胶水代码,减少指令数
direct属性作用的对象
| 对象 | 声明方式 |
|---|---|
| 方法声明 | attribute ((objc_direct)) |
| 类实现、category、extensions | attribute ((objc_direct_memters)) |
| 属性 | @property (direct) |
实际操作2000多个Direct方法修改,二进制文件差不多有200K左右减少。
七、删除项目中未用到的图片
使用LSUnusedResources工具,导入项目查找无用图片,工具下载地址
找出无用图片后人工确认后删除。 注意点:一些拼接的图片名字的图片也是检测到没有被使用,需要人工二次确认后删除‘
八、压缩图片
- 将项目中的2x、3x图片全部放到Asset文件中,苹果会使用App Thinning方式针对不同机型下发图片,并对图片进行压缩。
- 将图片进行有损压缩,少量图片直接在tinypng进行压缩。
- 大量图片需要压缩则使用脚本进行批量压缩。 python压缩脚本如下:
def compressImages(uncompress_images):
for index in range(0,len(uncompress_images)):
pngDict = uncompress_images[index]
imagePath = pngDict['path']
source = tinify.from_file(imagePath)
os.remove(imagePath)
list = imagePath.split("/" + pngDict['name'])
source.to_file(os.path.join(list[0], pngDict['name']))
注意点:python的tinify库,每个免费key每月只能压缩500张图片,若有很多图片需要压缩,则多申请几个key
九、删除项目中重复图片
用脚本检测项目中重复的图片,若是使用组件化开发方式,各个组件中重复的图片将会变得非常多,用脚本方式查找出各个组件相同的图片,将不同组件中相同的图片删除,并在同一个组件中获取图片,达到瘦身效果,收益很高。
原理:使用ssim算法,计算两张图片的相似度在99%以上,则认为是同一张图片。 python对比代码:
#有99%相同认为是同一张图片
similarValue = 0.99
#采用SSIM进行对比,速度会较慢
def similarImg(image_path1,image_path2):
try:
img1 = Image.open(image_path1)
img2 = Image.open(image_path2)
if img1.size[0] != img2.size[0] or img1.size[1] != img2.size[1]:
return 0
im1 = io.imread(image_path1)
im2 = io.imread(image_path2)
im1_t = np.atleast_3d(img_as_float(im1))
im2_t = np.atleast_3d(img_as_float(im2))
# psnr_val = peak_signal_noise_ratio(im1_t, im2_t)
try:
ssim_val = structural_similarity(im1_t, im2_t, win_size=11, gaussian_weights=True, multichannel=True,
data_range=1.0, K1=0.01, K2=0.03, sigma=1.5)
except:
return 0
except:
return 0
if ssim_val > similarValue:
return 1
else:
return 0
十、资源动态下发
将本地的RN、H5等内置资源删除,使用动态下载的方式获取。将一些能动态下载的图片删除,使用网络下载的方式获取。收益很高。
十一、后续规划
做好各版本APP包大小监控,分析增量是否合理。只有做好每个版本监控,APP包才不会一下变成一个庞然大物。
iPA包增量分析:
对比两个版本iPA包资源文件,按照资源增量输出个各个版本包大小增量分析表。 对比python脚本如下:
def dir_compare(path1, path2):
files_in_path1 = filesizes_in_path(path1)
files_in_path2 = filesizes_in_path(path2)
filesubpaths = list(map(lambda x: x.replace(path1, ''), files_in_path1))
filesubpaths += list(map(lambda x: x.replace(path2, ''), files_in_path2))
filesubpaths = list(set(filesubpaths))
all_sizes = []
for filesubpath in filesubpaths:
fullpath1 = path1 + filesubpath
fullpath2 = path2 + filesubpath
size1 = files_in_path1.get(fullpath1, 0)
size2 = files_in_path2.get(fullpath2, 0)
all_sizes.append([filesubpath, size1, size2, size2 - size1])
all_sizes.sort(key=lambda x: x[3], reverse=True)
print('filepath, {}, {}, increase(Byte)'.format(path1, path2))
for a_size in all_sizes:
print('{}, {}, {}, {}'.format(a_size[0].encode('utf-8').decode('utf-8'), a_size[1], a_size[2], a_size[3]))
对比结果如下:
各组件模块监控:
分析各个组件二进制代码大小,找出增量组件,以组件大小粒度,输出各个组件增量大小
类文件分析:
分析增量组件中各个类文件的增量,以类大小粒度,输出增量较大的类。
对应的linkmap分析工具:LinkMap
参考文章: