正文
前段时间APP要做资源压缩,需要把项目中使用的所有图片资源进行压缩,以减小APP安装包体积。想着既然压缩APP资源是为了缩小APP体积,那么来一遍APP整体瘦身流程并做一下总结吧。
整个过程分三步:
1.瘦身前分析
2.瘦身策略制定并实施
3.结果对比
瘦身前分析
安装包分析
iOS安装包有两种状态:一种是提交给Apple市场的多架构综合包;另一种是用户下载的单架构特定包;
提交给Apple市场的多架构综合包是Xcode构建出的产物ipa包,它里面包含了多个架构的产物,多套尺寸的图片资源,当上传成功后苹果会进行裁剪和二次分发,转化为供用户下载的具体架构的App Store下载ipa包。
用户下载的单架构特定包是用户从App Store下载的包。它里面包含了当前手机架构的静态库二进制文件、动态库、asset.car(单尺寸)、其他资源文件。它是针对当前手机的架构的特定下载包。里面的资源都是单份的。
对于多架构综合包可以使用工具对特定架构进行拆分
使用lipo工具拆分单架构
lipo "originalExecutable" -thin arm64 -output "arm64Executable"
使用assetutil工具拆分不同倍图的asset
xcrun --sdk iphoneos assetutil --scale 3 --output "$targetFolder/Assets3.car" "$sourceFolder/Assets.car"
源代码分析
iOS工程结构:iOS工程由主工程和Pod模块组成,模块有静态库和动态库两种类型。
主工程的构成有主Target,里面有源代码文件(OC的.h和.m、Swift)、xcassets、nib、bunlde、多语言文件、各种配置文件(plist、json)。
模块内部的构成有源代码文件(OC、C、C++的.h和.m、Swift)、xcassets、nib、bunlde、多语言文件、各种配置文件(plist、json)。
源码->IPA产物
iOS工程项目由源码到IPA包的核心的变化是:编译和文件拷贝。源码会被编译链接为MachO可执行文件,xcassets文件夹会被转化为Assets.car,其他都可以简单理解为文件拷贝。
IPA包产物分析
对从App Store下载的IPA包解压后是一个文件夹,内部主要包括MachO可执行文件、.framework(动态库)、Assets.car、.strings(多语言)、.bundle、nib、json、png...。
可以根据产物资源体积由大到小进行优化,比如:MachO可执行文件(包含所有静态库)、动态库、assets.car、bundle、nib、音频、视频。
瘦身策略
瘦身总体方向可以参下面5个方面入手:组件治理,资源治理,编译优化,代码治理,运行时下载。
组件治理[删除淘汰业务代码]:0代码覆盖率组件,无用组件,重复功能组件。
资源治理[正在用的压缩,不用的删除,太大的网络下载,相似的使用iconfont共用]:大资源,可以有损压缩资源,无用资源,重复图标,iconfont,多语言文案
编译优化[修改编译策略]:精简编译产物,剥离符号表Strip Linked Product,删除未引用的C/C++/Swift代码,Asset Catalog Complier,C++导出必要符号, Symbols Hidden by Default, LTO优化跨模块调用代码
代码治理[删除无用类]: 运行时未加载类,编译时无用类,业务重构
运行时下载[大资源网络下载]:大资源,多语言文案
组件治理[删除淘汰业务代码]
主要看对应模块的业务是否是线上正常运行,还是已经被废弃,或者被其他方式(RN, H5, Flutter)所取代,如果实际不在使用,则删除到此组件。
资源治理[正在用的压缩,不用的删除,太大的网络下载,相似的使用iconfont共用]
是APP瘦身效果非常好的一个方向,通常会有不错的收益。
这里需要注意使用Asset Catalog管理切图资源的包体积反弹问题,因为Xcode在编译时,会使用actool工具对Asset Catalog下的图片资源优化,压缩,这可能导致之前的压缩过的图片被重写变大。
1.有损压缩
XCode构建时会做“compile asset catalog”,会重新对图片进行无损压缩。因此使用imageoptim等工具进行无损压缩效果不明显,其中压缩png图片没有效果,压缩jpg图片有一定效果。
根据实践经验,icon做有损压缩并不影响视觉体验,压缩率可以达到70%~80%。业界有不少png压缩工具,我们使用到的有tinypng、pngquant、pngcrush、optipng(无损)、advpng。
2.删除无用图片 - 未被使用的图片资源
先解析出所有拷贝到构建产物的资源文件,再解析出代码中实际引用到的资源文件,两者的差集就是无用资源。
第一步获取全量资源文件。
a.可以通过在Cocoapos工程中,“Pods-targetName-resource.sh”脚本负责拷贝Pod里的文件资源到构建产物,包括所有文件类型bundle、xcassets、json、png。解析该脚本可以得到每个Pod模块都拷贝了哪些图片资源。
// Pods-targetName-resource.sh
install_resource "${PODS_ROOT}/APodName/APodName.framework/APodName.bundle"
install_resource "${PODS_ROOT}/BPodName/BPodName.framework/BPodName.xcassets"
install_resource "${PODS_ROOT}/BPodName/BPodName.framework/xxx.png"
b.也可以通过编写shell脚本,扫描项目源文件,找出所有的png,jpg资源文件。
因为我们项目中所有的图片都放在了Asset中进行管理,所以这里只扫描Asset下面的图片资源特征
targetPicture=".imageset"
function searchOneDir(){
cd $1
path=`pwd`
for item in `ls`; do
if [[ -d "${path}/${item}" ]] && [[ ! "${filterList[@]}" =~ "${item}" ]]
then
if [[ "${item}" =~ "${targetPicture}" ]]
then
echo "${path}/${item}"
echo "内部:$item"
itemSub="${path}/${item}"
appContainPictureList+=(${itemSub})
else
if [[ -d ${item} ]]
then
searchOneDir "$item"
fi
fi
fi
done
cd ..
path=`pwd`
echo "path2: $path"
return 0;
}
详细脚本见如下地址:
https://github.com/zhfei/ios-scripts/blob/main/tool_project_batchZipPicture.sh
c.或者从IPA包中直接查看
iOS开发中,如果使用了Images.xcassets管理图片,打包的时候会生成一个Assets.car文件,所有的图片都在这里面。
如果想查看里面包含的图片,则需要工具来解压将Assets.car文件解包到指定文件夹,可使用的工具有:cartool,AssetCatalogTinkerer。
第二步,获取代码中实际引用到的资源文件。OC代码中引用资源文件都是以字符串字面量的形式声明,构建后存放在Mach-O文件"__cstring" section。
利用strings解析framework的二进制文件就可以得到代码中所有的字符串声明,进行过滤。
这里有个注意点,对于Swift项目中使用的切图在strings打印的符号表中并不能直接查出,这个问题暂时没有明白为什么。
targetAssetDir=".xcassets"
targetPicture=".imageset"
appContainPictureList=()
UnUsedPictureList=()
UsedPictureList=()
function checkMachOFile() {
res=`strings $1`
for assertName in ${appContainPictureList[@]}; do
if [[ ! "$res" =~ "$assertName" ]]
echo "$assertName"
then
UnUsedPictureList[${#UnUsedPictureList[*]}]=$assertName
else
UsedPictureList[${#UsedPictureList[*]}]=$assertName
fi
done
}
详细脚本见如下地址:
https://github.com/zhfei/ios-scripts/blob/main/tool_project_findUnUsePicture.sh
3.重复图标
解决方案是在构建时计算资源的哈希值,去重相同哈希资源,并保留源文件名和哈希值的映射表。运行时Hook 资源加载的”imageNamed“方法,根据映射表替换资源名称。
4.iconfont
iconfont支持缩放、修改颜色,它size小,适合用于箭头、占位图等图标场景,使用iconfont可以减少包大小也能提高开发视觉体验的统一性。对相似的场景进行限制,禁止随意添加切图。
5.多语言文案
6.运行时下载
编译优化[修改编译策略]
1.精简编译产物Oz:Optimization Level
Optimization Level多个级别,-Oz比-O3的编译产物体积小10%左右。设置-Oz以后,XCode会优化连续的汇编指令,从而减少二进制大小,但副作用是执行速度会变慢。C++工程建议都开启。
主工程Release
Optimization Level :-Oz
Framework工程
Optimization Level :-Oz
2.Strip Linked Product - 剥离符号表
在Xcode中,"Strip Linked Product"是一个构建设置选项,用于控制在构建过程中是否剥离(strip)可执行文件中的符号信息。
符号信息是一个标识符或者函数名称,在可执行文件或动态链接库中可用于调试和符号化。剥离符号信息可以减小可执行文件的大小,同时也可以防止他人通过分析符号信息来获取敏感信息。
Strip Linked Product设置会剥离特定的符号,Debug环境不要设置YES,否则调试时看不到符号。
在Xcode中,"Deployment Postprocessing"是一个构建设置选项,它用于控制构建完成后是否执行部署后处理。
部署后处理是指构建过程完成对生成的可执行文件或应用程序包进行额外的处理。它可以包括诸如符号剥离、代码签名、资源压缩等操作。
主工程Release
Deployment Postprocessing :YES
Strip Linked Product :YES
Strip Style :All Symbols(剥离所有符号表和重定向信息)
Framework工程
Deployment Postprocessing :YES
Strip Linked Product :YES
Strip Style :Non-Global Symbols(剥离包括调试信息等非全局的符号,保留外部符号)
说明:
a.静态库不能将Strip Style 设置为All Symbols,因为剥离了所有符号的静态库是无法被正常链接的
b.去除符号不影响 dSYM 文件中的符号信息,查看崩溃日志时,可以从 dSYM 文件中找对应符号
3.Symbols Hidden by Default
Symbols Hidden by Default用于设置符号默认可见性,如果设置为YES,XCode会把所有符号都定义为”private extern”,包大小会略有减少。动态库设置为NO,否则会有链接错误。
主工程Release
Symbols Hidden by Default :Yes
Framework工程 静态库/动态库
Symbols Hidden by Default :NO
4.剔除未引用的C/C++/Swift代码:Dead Code Stripping
Dead Code Stripping开启后会在链接时移除未使用的代码,它对静态语言C/C++/Swift有效,对动态语言OC无效。
主工程
Dead Code Stripping :Yes
5.Asset Catalog Compiler
Optimization有三个选项,空、time和Space,选择Space可以优化包大小
主工程
Asset Catalog Compiler->Optimization设置为space
6.C++导出必要符号 - 动态库复用主二进制静态库(用的不多,因为很多项目不会嵌入动态库)
C++动态库经常用到一些基础库比如openssl、libyuv、libcurl,他们一般是静态库。如果动态库引用了静态库,它编译时默认会内嵌静态库的所有符号。
虽然我们可以在动态库中设置只导出需要用到的静态库符号,但是有可能多个动态库都用到了同一个基础库,这样还是会造成基础库的冗余。
比如openssl大小1MB,如果A、B两个动态库依赖了openssl,APP也引用了openssl,最终ipa包实际有3个openssl,有2MB大小是冗余的。
这种场景下,最佳解决方案是共享符号表,让动态库可以调用主二进制的基础库符号,从而可以去掉内置的静态库。只要修改XCode的Link配置,无需额外的代码开发。
动态库工程:
1设置当遇到未定义的函数时,动态查找APP主二进制符号表。
2 关闭bitcode
Other Linker Flags -> -undefined dynamic_lookup
Enable Bitcode -> No
3导出动态库需要调用的外部符号,写到一个文件exported_symbols内
nm -u xxx.framework/xxx > exported_symbols.txt
APP工程:
1配置需要导出exported_symbols文件内的所有符号,避免编译时动态库需要用到的符号被strip掉。
2关闭bitcode。
// exported_symbols.txt是需要被导出的符号文件路径
EXPORTED_SYMBOLS_FILE -> exported_symbols.txt
Enable Bitcode -> No
7.C++只导出必要符号:Symbol Visibility (用的不多,因为很多项目没有涉及自己构建C++静态库和动态库)
用到哪部分导出哪部分,没有使用的不导出。
C++的静态库和动态库都只导出必须的符号,默认设置为隐藏所有符号,然后用Visibility Attributes单独控制需要导出的符号。
8.默认隐藏所有C++符号
Other C++ Flags->添加-fvisibility=hidden
设置需要导出的符号
__attribute__((visibility("default"))) void MyFunction1() {}
__attribute__((visibility("default"))) void MyFunction2() {}
代码治理[删除无用类]
删除无用OC类 - 运行时Objc类覆盖率
ObjC的类第一次被使用时会调用+initialize方法,类被加载过后cls->isInitialized会返回True。isInitialized方法读取了metaClass的data变量里的flags,如果flags里的第29位为1,则返回True。
// objc-class.mm
Class class_initialize(Class cls, id inst) {
if (!cls->isInitialized()) {
initializeNonMetaClass (_class_getNonMetaClass(cls, inst));
}
return cls;
}
// objc-runtime.h
#define RW_INITIALIZED (1<<29)
bool isInitialized() {
return getMeta()->data()->flags & RW_INITIALIZED;
}
删除无用OC类 - 编译时未被引用的类
iOS编译的产物是Mach-o格式的,文件里 __DATA __objc_classrefs 段记录了所有引用过的类的地址,__DATA __objc_classlist段记录了所有类的地址,两者Differ可以得到未被引用类的地址。
然后将地址符号化,就可以得到未被引用类信息。因为Objc是动态语言,如果使用runtime动态调用某个class,这种情况扫描不出来。(比如Target Action和 JS Core)
otool -v -s __DATA __objc_classrefs xxxMainClient #读取__DATA Segment中section为__objc_classrefs的符号
otool -v -s __DATA __objc_classlist xxxMainClient #读取__DATA Segment中section为__objc_classlist的符号
nm -nm xxxMainClient
运行时下载[大资源网络下载]
大资源
多语言文案
瘦身结果
项目中切图通过压缩少了7M,Xcode编译产生的APP包少了1.2M。
项目切图资源小了7M,实际打包只少了1.2M的原因为:
对应Asset Catalog下管理的切图资源,Xcode打包编译时会用actool工具处理图片,优化图片大小。对于压缩后的产物,在Xcode编译打包后,被压缩的切图都会重新变大,包括:pngquant有损压缩和imageoptim无损压缩。
解决方法
1.关掉Xcode的PNG优化开关(设置Targets->Build Settings->Compress PNG Files为YES)。
2.可以采用将压缩后的切图放在一个单独的目录下,脱离Asset Catalog的管理,避免被压缩后的切图重新被优化大。
3.修改压缩策略,将Asset Catalog Compiler->Optimization设置为space
另外
为了后续的内存变化检测,可以为每个模块做增量变化统计,静态库和动态库计算的原理不同。
对于静态库,先解析linkmap数据,计算出模块代码大小,在解析Pods-targetName-resource.sh的资源拷贝代码,计算出拷贝到Pod模块的资源大小。
对于动态库,先使用lipo拆分动态库的二进制文件,计算出单架构的代码大小,然后再计算动态库framework内的资源文件,得到动态库的资源文件大小。
参考文章:
https://developer.aliyun.com/article/981881