一、包大小概述
App在迭代过程中,随着新需求的不断丰富,包体积逐渐增大。早期功能较简单,优化空间有限,用户感知不明显,包大小问题未受重视。随着功能多元化,包大小水涨船高,大到一定值后,会产生一些消极影响。
1. App Store OTA 下载大小限制
- 2017年9月:100M
- 2019年5月:150M
- iOS13系统发布后:iOS13及以上用户可使用流量下载超出200MB的App,但需在「设置」中选择策略「超过200MB请求许可」,iOS13系统以下用户仍无法流量下载。
2. App __TEXT 段大小限制
- iOS 7之前:二进制文件中所有__TEXT段总和不得超过80MB
- iOS 7.x至iOS 8.x:每个特定CPU架构的__TEXT段不得超过60MB
- iOS 9.0之后:所有__TEXT段总和不得超过500MB
3. 其他影响
- 存储空间:占用用户磁盘空间,被卸载概率增大
- 性能影响:较大的App导致启动时间和响应时间增加,影响用户体验
- 市场适应性:用户可能会因App体积过大而选择轻量级的替代方案
二、包大小分析
1. Mach-O文件
作为应用程序的核心执行载体,承载着程序运行的关键要素
- 业务逻辑代码:Swift/Objective-C编译后的机器指令
- 静态库集成:编译时链接的第三方库和自定义库
- 系统调用接口:与iOS系统API的桥接代码
- 运行时支持:Objective-C/Swift运行时所需的元数据
2. 资源文件
丰富的多媒体和配置资源,构成应用程序的用户界面和交互体验
- 图片资源:包括Assets.car,Bundle文件,GIF,JPG,PNG,JSON等文件
- 静态WEB资源:JS,CSS,HTML
- 视图资源:XIB,Storyboard
- 音视频资源:mp4,mp3,caf等
- 其他资源:签名文件,字体扩展(TTF、OTF),PLIST文件,db文件,国际化资源文件,签名等等
3.Frameworks
模块化的功能扩展和依赖管理,提供丰富的系统能力和第三方功能
- 核心框架,如Foundation框架,Swift相关支持的系统库
- 第三方框架,二方动态库/三方动态库
接下来,我们将从编译器,Mach-O,资源文件,Frameworks目录四个方面介绍我们进行的包大小优化实践,当然有些实践方案是需要多部门配合才行,比如资源转动态下发,就需要后端提供接口能力才能实现,所以具体方案的可行性还需要根据实际情况考虑。
4.核心思路思维导图
以下是我们在App包大小优化方面,从以上四个方面的落地思路,可作参考
三、详细介绍
1.编译器优化
Xcode编译器为我们提供了很多的包大小优化的配置选项,这里我们主要列表以下几个较重要的配置进行介绍:
1.Optimization Level
- 选项None[-O0]:编译器不会优化代码,意味着更快的编译速度和更多的调试信息,默认在 Debug 模式下开
启;
- Fast[-O, O1]: 编译器会优化代码性能并且最小限度影响编译时间,此选项在编译时会占用更多的内存;
- Faster[-O2]:编译器会开启不依赖空间/时间折衷所有优化选项。在此,编译器不会展开循环或者函数内联。
此选项会增加编译时间并且提高代码执行效率;
- Fastest[-O3]:编译器会开启所有的优化选项来提升代码执行效率。此模式编译器会执行函数内联使生成的
可执行文件变得更大。一般不推荐使用此模式;
- Fastest Smallest[-Os]:编译器会开启除了会明显增加包大小以外的所有优化选项。默认在 Release 模式下开
启;
- Fastest, Aggressive Optimization[-Ofast]:启动 -O3中的所有优化,可能会开启一些违反语言标准的一些优
化选项。一般不推荐使用此模式。
2.Strip Link Product
Strip Link Product配置会受到Deployment Postprocessing配置的影响,只有当Deployment Postprocessing设置为YES时,此选项才会生效,当Strip Link Product为YES,会根据Strip Style中设置的选项进行不同程度的符号剥离
Strip type模式
- debugging 只移除调试符号 不影响包大小
- non-global| 移除非全局符号(隐藏内部类/方法) 保留全局符号,崩溃日志能显示类名/方法名 【Release环境 】
- all 移除所有符号(包括全局符号) 可减少包大小
3. Dead Code Stripping
此配置会在链接阶段移除未被引用的代码和符号,从而减少二进制文件的大小。
# Xcode配置方式:
DEAD_CODE_STRIPPING = YES # 开启DCS
OTHER_LDFLAGS = -Wl,-no_dead_strip # 禁用特定模块的DCS
# 测试项目: 10 万行代码,含 30 %未使用代码
+----------------+-----------------+------------------+
| 语言类型 | 开启DCS后体积减少 | 典型优化对象 |
+----------------+-----------------+------------------+
| Objective-C | 18-22% | 未调用的类/分类/静态函数 |
| Swift | 12-15% | 未引用的泛型/协议扩展 |
| C++ | 25-30% | 未实例化的模板类 |
| 纯C代码 | 20-25% | 未使用的全局函数/宏定义 |
+----------------+-----------------+------------------+
4.Link-Time Optimization
此配置在链接阶段所有模块统一调度,从全局的角度对符号进行去重处理,跨模块进行优化,结合Dead
Code Stripping深度清理。
# Xcode中LTO配置
OTHER_CFLAGS = -flto # 开启编译期LTO
OTHER_LDFLAGS = -flto # 开启链接期LTO
# 测试项目: 10 万行 Swift代码
+---------------------+------------+---------------+----------------+
| 配置 | 包体大小(MB) | 启动时间(ms) | 编译时间(min) |
+---------------------+------------+---------------+----------------+
| 无优化 | 45.8 | 450 | 8.2 |
| LTOOnly | 39.1 (-15%)| 420 (-6.7%) | 12.5 (+52%) |
| LTO+DCS | 34.7 (-24%)| 405 (-10%) | 13.1 (+60%) |
| LTO+DCS+Strip | 31.2 (-32%)| 390 (-13.3%) | 13.8 (+68%) |
+---------------------+------------+---------------+----------------+
5. Asset Catalog Compiler
在保证视觉效果的前提下,最大化压缩资源体积。建议发布构建时开启space模式。
# Xcode配置
ASSETCATALOG_COMPILER_OPTIMIZATION = space
2. 资源深度优化
1.包成分分析
对App包的资源进行优化之前,首先需要对包成分有一个整体的了解,从而做到有的放矢,优化处理占比较大的资源能起到事半功倍的效果,这里我们编写了IPA成分分析的脚本,将成分占比输出到我们的MDAP平台,效果如下:
通过终端也可以进行成分分析:
这里分享一下脚本中核心的逻辑,如按文件扩展名和特殊类型分类统计:
find "$APP_DIR" -type f | while read -r file; do
if [ -f "$file" ]; then
size=$(stat -f%z "$file" 2>/dev/null || stat -c%s "$file" 2>/dev/null || echo 0)
# 获取相对路径用于分类
rel_path=$(echo "$file" | sed "s|$APP_DIR/||")
# 分类逻辑 - 更精确的分类
case "$rel_path" in
$(basename "$APP_DIR" .app))
echo ".app/xxx $size"
;;
*.car)
echo ".car $size"
;;
*.dylib)
echo ".dylib $size"
;;
Frameworks/*.framework/*)
framework_name=$(echo "$rel_path" | sed 's|Frameworks/([^/]*).framework/.*|\1|')
case "$framework_name" in
*ImSDK*|*IMSDK*)
echo ".framework/ImSDK_Plus $size"
;;
*Lottie*)
echo ".framework/Lottie $size"
;;
*)
echo ".framework/$framework_name $size"
;;
esac
;;
*.framework/*)
framework_name=$(echo "$rel_path" | sed 's|/.*||' | sed 's|.framework$||')
case "$framework_name" in
*ImSDK*|*IMSDK*)
echo ".framework/ImSDK_Plus $size"
;;
*Lottie*)
echo ".framework/Lottie $size"
;;
*)
echo ".framework/$framework_name $size"
;;
esac
;;
*.rs)
echo ".rs $size"
;;
*.png)
echo ".png $size"
;;
*.sty)
echo ".sty $size"
;;
*.ttf|*.TTF|*.otf|*.OTF)
echo ".TTF $size"
;;
*.bmis)
echo ".bmis $size"
;;
*)
# 其他文件按扩展名分类
ext=$(echo "$rel_path" | sed 's/.*././' | tr '[:upper:]' '[:lower:]')
if [ "$ext" = "$rel_path" ]; then
echo "其他 $size"
else
echo "$ext $size"
fi
;;
esac
fi
done > "$temp_analysis"
统计每种类型的总大小并从大到小进行排序:
awk '
{
type = $1
size = $2
type_sizes[type] += size
total_size += size
}
END {
for (type in type_sizes) {
if (type_sizes[type] > 0) {
printf "%s|%d|%.2f\n", type, type_sizes[type], (type_sizes[type] / total_size * 100)
}
}
}' "$temp_analysis" | sort -t'|' -k2 -nr > "$TEMP_DIR/sorted_analysis.txt"
2. 未使用资源清理
未使用图片(GIF,PNG,JPG,webp等),音视频,Plist文件等,可以使用业内工具进行扫描。
- fdupes命令:查找项目中的重复文件,原理是对比不同文件的签名,签名相同的文件就会判定为重复资源,此方式可能存在误判,建议人工复核后进行删除。
- LSUnusedResources:一个可视化客户端工具。
- 编写Shell脚本:可根据需要对脚本匹配的正则进行修改来支持扫描识别出更多类型文件;扫描的文件可输出文件路径,用于再次确认;如项目采用的外部脚本的自动化构建打包,扫描脚本可移植到打包流程(一般用于优化外部三方库)中的资源文件;可继续对脚本进行拓展,将扫描的指定图片进行压缩,这一点也可以嵌入到CI流程中。
脚本扫描的方式在我们之前的包大小优化方案有比较详细的说明,这里不再阐述,如需要了解更多可以搜索货拉拉另一篇技术文档《iOS包大小优化探索与实践》。
3. 原工程图片压缩
项目中的图片资源最好用Asset Catalog进行管理,因为Xcode构建过程中,在执行到compile asset catalog时,会利用构建Asset Catalog的actool插件会对Asset Catalog中的png图片进行解码,得到Bitmap数据,然后再运用actool的编码压缩算法进行编码压缩处理。如果放入到Assets中的是JPG图片,最终也会转成png图片存放到Assets.car中。
actool的压缩算法有:lzfse,palette_img,deepmap2,deepmap_lzfse,zip。
需要注意的是,无损压缩是变换图片的编码压缩算法来减少大小,并未改变像素数据,所以无损压缩的图片不能优化Assets.car的最终大小,但有损压缩是可以的。
常见的图片压缩方式有:
- TinyPng:一个网页工具,有损压缩,需要联网,不支持批量处理。
- TingPNG4Mac:属于Mac的客户端工具,基于TinyPng实现的。
- ImageOptim:客户端工具,支持无损压缩和有损压缩两种形式,可自定义压缩方式。
- pngquant:命令行工具,针对png图片的有损压缩,编写脚本批量压缩,支持自定义压缩质量参数。
- Guetzli:针对数码图像和网页图像的JPEG编码器,支持JPG图片的有损压缩。
我们项目中主要的图片资源都是PNG格式,这里以PNG压缩为例,安装pngquant终端命令后:
$ pngquant --quality=20-30 文件名
当quality设置在20-30时,第一次压缩后,在不影响图片质量的情况下,大小一般可优化在60%左右,对于本身很小的图片也可以进行压缩(不过建议将压缩前后的对比图让UI设计师把关),这里也可以考虑使用脚本对项目中的大图进行批量处理(脚本批量压缩方式在我们之前的包大小优化方案有比较详细的说明,这里不再阐述),贴一下脚本批量处理流程图:
4. App打包流程中压缩
针对二方库或者三方库中的图片资源,有些库的图片大小没有做处理,当我们依赖这些库后图片会引入到我们工程中,影响我们的包大小,这里可以考虑将压缩脚本嵌入到打包流程中,在编译前对这些库中的大图进行压缩,不过压缩后最好对UI进行二次确认稳妥些。
- 对单张图片进行压缩
- 对某个目录下的图片进行压缩
以下是我们在打包流程中对依赖库增加的图片压缩处理:
5. 资源包动态下发
项目中有些资源并不是App启动就需要的,甚至有些是使用频率较低的,这类资源包括图片(大图,GIF),音频,字体文件,JSON文件,Plist等,对于这类资源,可以考虑将其统一托管到文件服务器上,在某个特定的时刻从服务器下载并解压到沙盒中,需要使用时从沙盒读取。
我们采用了架构组封装的二方下载库HDFileDownloader来实现资源的下载,需要考虑的是资源包的版本管理,下载前需要匹配本地资源包和服务器最新资源包的版本号来确保是否需要下载最新包,这里说明下下载库需要支持的点:
- 支持断点续传
- 支持资源包版本控制
- 资源包在前后端的安全性校验
- 下载成功才更新本地的资源包
1.字体资源下发
这里我们列举下我们在资源包动态下发的处理流程图,需要注意的是如果存在字体资源,需要在下载到字体资源后主动进行字体注册,否则字体将读取不到。
2. 其他类型资源下发
对于一般的资源文件,如PNG,GIF,JSON等,下载成功后解压到指定目录下,需要的时候进行读取即可
3. 异常情况兜底
由于资源文件存在在文件服务器,用户可能因为各种原因导致下载失败,无法加载指定的资源文件,这里我们考虑为保障用户体验,创建了一个类专门用于绘制相似图作为占位图进行展示,有些图无法直接代码绘制,这里需要考虑损失一点用户体验的,比如:
以下左边是原图,右边是我们通过代码绘制的图,整体来看还是能接受的。
6.资源转本地Zip包管理
前面我们介绍了大资源(如高清图片、JSON文件和动图等)托管至服务器进行动态下发,这在一定程度上减少了包体大小,然而,由于从服务器下载过程中可能遇到失败的风险,部分资源并不适合托管至服务器。现阶段,我们考虑将这些资源集中打包成Zip压缩文件,并存放在沙盒中,在应用启动后,首页展示前进行解压,通过提供方便使用的API进行资源访问,来进一步优化App的包大小。
以下是我们第一期转本地压缩包的资源文件,以及压缩前后的文件大小,本次由于文件数不多,优化0.7M左右,但App持续迭代过程中我们会增加新的各种资源文件,通过此方案管理资源能一定程度上控制App包大小的增量。
| 压缩前 | 压缩后 | 最终优化包大小 | |
|---|---|---|---|
| 23个文件 | 1.4M | 0.7K | 0.7K |
1.资源包版本管理
由于项目是多人开发,资源包如果手动管理容易出现文件,这里我们考虑通过脚本来管理资源包,在项目中增加一个目录HLLUnzipResource用于存在需要转ZIP管理的文件,注意这部分文件不需要依赖到工程中,在项目目录下我们有一个gen_zip_package.sh的脚本,当HLLUnzipResource目录有新增/删除时,执行脚本
// 给脚本增加执行权限
$ chmod +x gen_zip_package.sh
// 执行脚本
$ ./gen_zip_package.sh
当执行完脚本后,HLLUnzipResource目录下会生成一个zip资源包,此资源包则为最新资源包,如下:
当执行完脚本后,会在UnzipResource目录下生成一个MarkDown文件,自动用户记录每个版本的资源信息,如每种资源的个数及大小,还会记录具体生成时间和App对应版本:
同时,脚本会主动修改ResZipHelper.swift类中的zipVersion版本号,版本号通过时间戳拼接,这样App启动后就会使用最新的Zip资源文件了
最终我们会在App的沙盒看到如下资源包的目录结构:
2.资源解压流程图
在本地持久化了一个绑定了资源包版本号的标识,记录当前版本下的资源包是否已经解压成功,当资源包版本没有变化时不必重复解压。
3.资源读取流程图
为验证解压成功率,读取解压资源时,我们考虑先加一个配置开关,当资源加载不到时继续使用本地资源,当解压成功率我们能接受时,就可以去掉这些兜底资源文件
最后我们发现解压成功率在99%以上,所以我们去掉了本地的兜底资源文件:
3. Mach-O优化
Mach-O文件包含了最核心的业务代码以及项目依赖的静态库,要对Mach-O文件进行优化,我们从以下几个方面进行
1. 静态分析
一般采用静态分析工具,如
1. Clang静态分析器深度扫描
通过执行analyze命令对项目进行扫描,会生成一份HTML文档,我们可以根据HTML里面的提示对项目中的代码进行优化:
$ analyze -workspace Huolala.xcworkspace -scheme Huolala -
configuration Release -sdk iphoneos CLANG_ANALYZER_OUTPUT=plist-html CLANG_ANALYZER_OUTPUT_DIR="$(pwd)/clang_analysis"
HTML文档参考如下:
2.符号表交叉验证
这里提供三个命令,通过对App包提取符号表,找出未使用的符号,再手动进行排查
# 提取所有定义的方法符号
nm -u xxx.app/xxx | grep'OBJC_CLASS_$_' > defined_symbols.txt
# 提取所有被调用的方法符号
otool -v-s __TEXT__objc_methname xxx.app/xxx > used_symbols.txt
# 使用diff找出差异
comm-23<(sort defined_symbols.txt)<(sort used_symbols.txt) > unused_methods.txt
交叉验证产物如下
2. 动态分析
对于一些使用率较低的页面考虑下线转其他方式实现,如在App里每个主要功能入口加统计,跑几个版本后,将使用率低于5%的功能,采用H5页面或考虑下线
3.无用类清理
这里主要提供一个扫描工具WHC_ScanUnusedClass,通过执行这个工程生成一个插件对项目进行扫描,能支持OC类和Swift类,当然也需要人工二次确定,以防误删。
4. Framework优化
Framework目录存放着我们项目依赖的动态库,一般苹果建议不超过六个,由于我们的项目工程是组件化的,有些三方库,二方库通过Cocoapods引入时,是以动态库的形式引入的,太多的动态库不仅会影响启动速度,同等情况下包大小会增大许多,所以我们考虑对Frameworks目录下的动态进行优化。
1. 动态库合并
将多个动态库(.dylib或.framework)合并成一个,减少重复的代码和资源,从而减小整体大小。
动态库在应用中是单独存在的,每个动态库都有自己的头文件、符号表和资源。当应用中有多个动态库时,每个库都会有这些元数据,导致冗余。合并后,这些元数据可以共享,减少重复部分,链接器在合并时可能会优化代码结构,比如去除未使用的函数或数据,来进一步减小体积。 不过,合并动态库也可能带来一些问题,比如符号冲突,需要处理重命名或调整依赖关系。
- 提取各库目标文件
ar -x ../libA.dylib
ar -x ../libB.dylib
ar -x ../libC.dylib
- 合并所有.o文件
ld -r -o merged.o *.o
- 生成新动态库
clang -dynamiclib -olibAllInOne.dylib merged.o -install_name @rpath/libAllInOne.dylib
-compatibility_version 1 -current_version 1.0
2. 动态库转静态库
一般情况下,动态库转静态库后,App安装包都会减少,因为每个动态库都是一个独立的包,包含自己的头文件、符号表等元数据, 多个动态库会有重复的元数据开销,而静态库合并后,这些重复的部分会被优化掉,比如符号表合并,减少冗余。
另外,动态库需要额外的加载命令和依赖信息,比如在Mach-O文件中的LC_LOAD_DYLIB命令,这些在静态库中是不需要的。静态库在链接时会被整合到主二进制中,链接器可以更好地优化代码,比如去掉未使用的函数,而动
态库作为独立文件,可能无法做到这一点。
1. Pod插件打静态库
Pod插件是我们自研的cocoapods-mdapbox的Pod插件工具,通过gem安装插件
$sudo gem install cocoapods-mdapbox
创建发布配置文件 binary.yml
program:
pod_name: AFNetworking
pod_version: 4.0.1
repo: huolala-hll-wp-specs-ios
swift: true //(swift 专用参数)
xcframework: true //(默认打包是framework,如果配置了此参数则为构建xcframework,一般用于swift库配置此参数)
接下来就可以执行插件发布了
pod publish --binary
2.基于源码打静态库
基于源码打静态库,主要针对二方库或者三方库的源码在我们自己的Git服务器中,通过在在podspec文件修改配置如下,重新发布静态库即可
s.static_framework = true
3. 私有化Spec文件转静态库
对于一些三方库,比如Lottie-ios这个库,源码是在github上,我们没有直接进行源码管理,可以考虑只将官方库的podspec文件下载下来,修改配置后,推到我们自己的Podspec私有仓库中,这里需要注意的是:
- 版本目录结构要正确
- podspec文件中version字段定义我们自己的版本
- podspec文件中source源码要指向官方源码以及对应需要的官方源码的Tag
- 修改static_framework配置为true
最终通过Pod安装如下:
4.本地化Spec文件转静态库
对于一些三方动态库,也可以通过将其podspec文件直接拷贝我们的工程中,这种方式也会加快我们Pod的安装速度,修改配置后(配置修改参考上一步中的截图),在Podfile文件中修改依赖方式:
pod 'lottie-ios' , :podspec => '/Users/***/Desktop/lottie-ios/lottie-ios.podspec'
5.其他可行思路
除了以上的包大小优化方式外,我们还可以从别的角度进行优化
1. 按需依赖
动态库按需依赖,尽量将动态库按照需求拆分成多个子模块,能够支持接入方进行按需依赖,避免全量引入影响包大小, 通过建立subspec子模块进行导出,如下
2.去掉不需要的库
可以梳理整个项目的库依赖关系,找出进行了依赖但未使用库中Api的库进行删除,我们项目中经过梳理发现多依赖了Moya,Toast这两个库,直接进行了清理。
3. 去掉功能重复的库
有些库在功能上是重复的,可以考虑只取其一,经过梳理我们的项目发现:
- SDWebImage 和 Kingfisher都属于图片加载库,若两者都引用了,可以去掉其一
- AFNetworking和Alamofire都属于网络请求库,若两者都引用了,可以去掉其一
4. 勿将Debug环境依赖的库引入到App Store
有些工具库只需要在Debug环境中依赖,是不需要发布到App Store的,这类库可以考虑增加配置进行隔离
pod 'XXX', :configurations => ['Release']
5. 工程静态化
如果希望对项目依赖的动态库批量进行转静态库,而不是单独转静态库,可以考虑将工程进行静态化,所有的依赖库将被编译成一个单独的静态库,而不是动态库。
工程静态化,需要一下注意:
- 包大小是否真正的减少,需评估
- 符号冲突
- 编译时长,启动时长是否影响
- 功能的充分测试和验证
# 在Podfile文件中增加:
use_frameworks! :linkage => :static
四、防裂化方案
1. 持续优化
包大小优化是一个持续的过程,需要不断进行,同时也需要我们在开发过程中注意一些可能影响包大小的点,以及做好代码增量监控,我们可以从以下几个方面注意:
- 代码的封装性,避免大量的重复代码
- 业务需求图较大时,考虑压缩后再引入工程中
- 二/三方库管理 包括库大小以及每次库迭代的增量管控
- 持续考虑将增量资源转Zip管理或动态下发
- 定期清理项目中未使用资源文件,类文件
- 长时间为走的AB实验业务逻辑考虑推进下线
2. CI 监控
我们通过自研MDAP平台来支持包增量的检测,发布Release包前,可对本次包增量进行判别,超出预设值时,分析对应原因。
五、总结
本文聚焦货拉拉iOS客户端的安装包体积优化实践,从编译器优化、资源文件精简、图像压缩、代码结构扫描、框架治理、编码规范建议以及防劣化机制设计等多个维度展开阐述。其中不少优化思路具有普遍适用性,可作为多数App瘦身的参考方向。App包体积优化是一个逐步推进的过程,建议采用先简后难的策略,例如优先从资源文件入手——实践表明,这一环节往往能带来最显著的优化效果。此外,每次优化后务必进行全面的回归测试,以确保线上版本的稳定性。希望本文分享的经验能为同行在进行App包体积优化时提供有益的启发和参考。