[关闭]
@qidiandasheng 2021-06-22T11:24:45.000000Z 字数 11932 阅读 3800

包体积大小:瘦身

项目


官方 App Thinning

一般我们打出来的包是包含多个指令集的,当我们上传到App Store之后,苹果会通过App Thinning技术来对包进行瘦身,当用户下载时就只会下载一个适合自己设备的芯片指令集架构文件。

App Thinning 有三种方式,包括:App Slicing、Bitcode、On-Demand Resources。

查看包的大小

企业包

我们打企业包的时候有一个APP Thinning选择,我们如下图选择所有版本。然后我们就能看到打出来的包含有一个包含所有指令架构的总包和各种芯片指令集单独的一个包。

我们还能看到有一个App Thinning Size Report.txt文件,里面会有记录每一个包支持的手机及系统版本,以及压缩前压缩后的包大小(ipa本质是一个压缩包)。

屏幕快照 2020-02-06 下午1.57.52.png-293.7kB

屏幕快照 2020-02-06 下午2.02.29.png-346kB

  1. Variant: InHouse-137F49AB-508D-4843-96C2-D0B9F181BA16.ipa
  2. Supported variant descriptors: [device: iPhone5,4, os-version: 9.0], [device: iPhone5,1, os-version: 9.0], [device: iPhone5,2, os-version: 9.0], [device: iPod5,1, os-version: 9.0], [device: iPhone4,1, os-version: 9.0], and [device: iPhone5,3, os-version: 9.0]
  3. App + On Demand Resources size: 22 MB compressed, 64.9 MB uncompressed
  4. App size: 22 MB compressed, 64.9 MB uncompressed
  5. On Demand Resources size: Zero KB compressed, Zero KB uncompressed

App Store包

Apple Store的包在你打包时是没有App Thinning选项的,会在你上传到app store connect的时候苹果来帮你做。我查看的话进入app store connect选择自己的app,点击活动->所有构建版本->选择一个版本进入详情->App Store 文件大小

我们就能看到如下图所示的每种设备对应的安装包的下载大小和安装大小,其中安装大小就是我们在App Store上看到的app的大小。

屏幕快照 2020-02-06 下午2.23.17.png-287kB

安装包组成

将IPA包修改后缀名为ZIP,解压缩后,获取payload中的App文件,查看App文件的内容,你会发现该文件主要包含以下内容。

SwiftSupport

里面包含的是一些swift的标准库,也是打包到ipa包里面的。都是一些以libSwiftxxx开头的标准款。(好像是iOS12.2之后的的手机就不会打包进去了)

使用file命令查看标准库包含的指令集,这里以libswiftCore.dylib为例,如下包含了arm64、armv7、armv7s、arm64、arm64e,我看了一下大小为95.5M。如果单单只包含armv7的话是3.7M:

  1. ~ file /Users/dasheng/Desktop/AppStore\ 2020-02-06\ 13-22-53/AppStore/SwiftSupport/iphoneos/libswiftCore.dylib
  2. /Users/dasheng/Desktop/AppStore 2020-02-06 13-22-53/AppStore/SwiftSupport/iphoneos/libswiftCore.dylib: Mach-O universal binary with 4 architectures: [arm_v7:Mach-O dynamically linked shared library arm_v7] [arm64]
  3. /Users/dasheng/Desktop/AppStore 2020-02-06 13-22-53/AppStore/SwiftSupport/iphoneos/libswiftCore.dylib (for architecture armv7): Mach-O dynamically linked shared library arm_v7
  4. /Users/dasheng/Desktop/AppStore 2020-02-06 13-22-53/AppStore/SwiftSupport/iphoneos/libswiftCore.dylib (for architecture armv7s): Mach-O dynamically linked shared library arm_v7s
  5. /Users/dasheng/Desktop/AppStore 2020-02-06 13-22-53/AppStore/SwiftSupport/iphoneos/libswiftCore.dylib (for architecture arm64): Mach-O 64-bit dynamically linked shared library arm64
  6. /Users/dasheng/Desktop/AppStore 2020-02-06 13-22-53/AppStore/SwiftSupport/iphoneos/libswiftCore.dylib (for architecture arm64e): Mach-O 64-bit dynamically linked shared library arm64

基本上swift的标准库包含如下图这些,我算了下单独只包含armv7指令集的话大小一共7.2M左右,如果包含全部指令集的话就达到了160M。

屏幕快照 2020-02-06 下午3.57.37.png-153.9kB

优化

编译器优化

Xcode 支持编译器层面的一些优化选项,可以让我们介于更快的编译速度更小的二进制大小更快的执行速度之间自由选择想要进行的优化粒度;

去除符号信息

  1. All Symbols: 去除所有符号,一般是在主工程中开启。
  2. Non-Global Symbols: 去除一些非全局的 Symbol(保留全局符号,Debug Symbols 同样会被去除),链接时会被重定向的那些符号不会被去除,此选项是静态库/动态库的建议选项。
  3. Debug Symbols: 去除调试符号,去除之后将无法断点调试。

动态库

由于swift和OC混编使用了use_frameworks!,对应的库都会打包成动态库,造成包体积的增大。以及很多库的依赖问题导致的依赖库增加,比如我使用了一个swift的UI库DSTUserInterface ,造成了OC和swift的混编,以及引入了很多相关库,最后包体积从12M飙升到了36.3M。

现通过podfile里hook的方式,时动态库改为静态库,可显著减小包的体积。

注意:当OC工程引入swift的pod编译时可能出现Apple Mach-O Linker(Id) Error的报错,这是在OC主工程里面随意创建一个swift文件即可。好像是当都改为静态库时需要项目名-Swift.h这个文件进行一些编译链接的一个处理。而这个文件只有在主工程里有创建过swift文件后编译器才会自动生成。

  1. pre_install do |installer|
  2. installer.pod_targets.each do |pod|
  3. def pod.build_type;
  4. Pod::Target::BuildType.static_framework
  5. end
  6. end
  7. end
  8. //或者设置某几个库为动态库
  9. dynamic_frameworks = ['SwiftyJSON']
  10. pre_install do |installer|
  11. installer.pod_targets.each do |pod|
  12. if dynamic_frameworks.include?(pod.name)
  13. def pod.build_type;
  14. Pod::Target::BuildType.static_framework
  15. end
  16. end
  17. end
  18. end

图片资源优化

寻找无用图片

可以使用可视化工具:LSUnusedResources

图片资源压缩

对于 App 来说,图片资源总会在安装包里占个大头儿。对它们最好的处理,就是在不损失图片质量的前提下尽可能地作压缩。目前比较好的压缩方案是,将图片转成 WebP。WebP 是 Google 公司的一个开源项目。有损压缩模式下图片体积只有 jpeg 格式的 1/3,无损压缩也能减小 1/4,可以使用 cwebp 进行格式压缩转换,目前 SDWebImage、Kingfisher 都用支持该格式解析的拓展。无损压缩命令如下:

  1. // 语法
  2. cwebp [options] input_file -o output_file.webp
  3. // 无损压缩
  4. cwebp -lossless original.png -o new.webp

除了终端命令,还可以使用 iSparta 进行批量转换格式:

Xcode 本身提供压缩图片的编译选项

导出图片Thu Apr 22 2021 07_57_24 GMT+0800 (中国标准时间).png-30.7kB

删除重复文件

通过校验所有资源的 MD5,筛选出项目中的重复资源,推荐使用 fdupes 工具进行重复文件扫描,fdupes 是 Linux 平台的一个开源工具,由 C 语言编写 ,文件比较顺序是大小对比 > 部分 MD5 签名对比 > 完整 MD5 签名对比 > 逐字节对比

通过 Homebrew 安装 fdupes:

  1. brew install fdupes

查看目标文件夹下的重复文件:

  1. fdupes -Sr 文件夹 // 查看文件夹下所有子目录中的重复文件及大小
  2. fdupes -Sr 文件夹 > 输出地址.txt // 将信息输出到txt文件中

输出内容如下,一般情况下,相同文件仅保留一份,修改对应的引用即可。

  1. 4474 bytes each:
  2. Test/Images.xcassets/TabBarImage/tabBar_2.imageset/tabBar_2@2x.png
  3. Test/Resource/TabBarImage/tabBar_2@2x.png
  4. 3912 bytes each:
  5. Test/Images.xcassets/TabBarImage/tabBar_3.imageset/tabBar_3@2x.png
  6. Test/Resource/TabBarImage/tabBar_3@2x.png

图片资源放入.xcassets

尽量将图片资源放入 Images.xcassets 中,包括 pod 库的图片。Images.xcassets 中的图片加载后会有缓存,提升加载速度,并且在最终打包时会自动进行压缩(Compress PNG Files),再根据最终运行设备进行 2x 和 3x 分发。

无用代码优化

思路

LinkMap 结合 Mach-O 找无用代码

我们可以通过分析 LinkMap 来获得所有的代码类和方法的信息。获取 LinkMap 可以通过将 Build Setting 里的 Write Link Map File 设置为 Yes,然后指定 Path to Link Map File 的路径就可以得到每次编译后的 LinkMap 文件了。设置选项如下图所示:
下载.png-196kB

LinkMap 文件分为以下三部分:

得到了代码的全集信息以后,我们还需要找到已使用的方法和类,这样才能获取到差集,找出无用代码。所以接下来,我就先和你说说怎么通过 Mach-O 取到使用过的方法和类。

iOS 的方法都会通过 objc_msgSend 来调用。而,objc_msgSend 在 Mach-O 文件里是通过 __objc_selrefs 这个 section 来获取 selector 这个参数的。

所以,__objc_selrefs 里的方法一定是被调用了的。__objc_classrefs 里是被调用过的类,__objc_superrefs 是调用过 super 的类。通过 __objc_classrefs__objc_superrefs,我们就可以找出使用过的类和子类。

我们可以使用MachOView来查看 Mach-O 里的信息:

下载.jpeg-1814.4kB

  1. //main.m
  2. int main(int argc, char * argv[]) {
  3. @autoreleasepool {
  4. Boy *boy = [[Boy alloc] init];
  5. [boy say];
  6. return 0;
  7. }
  8. }
  1. //Boy.m
  2. #import "Boy.h"
  3. #import "Girl.h"
  4. @implementation Boy
  5. - (void)say{
  6. NSLog(@"hi there again!\n");
  7. }
  8. - (void)sayhi{
  9. [self sayhi];
  10. NSLog(@"say hi!\n");
  11. //会出现在__objc_classrefs中
  12. Girl *girl = [[Girl alloc] init];
  13. //不会出现在__objc_classrefs中
  14. Class Girl = NSClassFromString(@"Girl");
  15. id girl = [Girl new];
  16. }
  17. @end

如上我们main里面只初始化了Boy和调用了say方法,sayhi方法没有任何地方调用。但我们看下图的__objc_classrefs__objc_selrefs,里面显示出了Girlsayhi,所以这里目标文件中出现的是只要alloc过的类和调用过objc_msgSend的都会出现在这两个section里:

截屏2020-10-27 下午10.22.05.png-139.8kB
截屏2020-10-27 下午10.23.png-141.4kB

问题:
但是,这种查看方法并不是完美的,还会有些问题。原因在于, Objective-C 是门动态语言,方法调用可以写成在运行时动态调用,这样就无法收集全所有调用的方法和类。所以,我们通过这种方法找出的无用方法和类就只能作为参考,还需要二次确认。

通过 AppCode 找出无用代码

用 AppCode 做分析的方法很简单,直接在 AppCode 里选择 Code->Inspect Code 就可以进行静态分析:

下载 (1).png-1458.9kB

静态分析完以后,我们可以在 Unused code 里看到所有的无用代码,如下图所示:

下载 (2).png-796.7kB

无用代码的主要类型:

AppCode 静态检查的问题:

基于以上种种原因,使用 AppCode 检查出来的无用代码,还需要人工二次确认才能够安全删除掉。

运行时检查类是否真正被使用过

在 App 的不断迭代过程中,新人不断接手、业务功能需求不断替换,会留下很多无用代码。这些代码在执行静态检查时会被用到,但是线上可能连这些老功能的入口都没有了,更是没有机会被用户用到。也就是说,这些无用功能相关的代码也是可以删除的。

通过 ObjC 的 runtime 源码,我们可以找到怎么判断一个类是否初始化过的函数,如下:

  1. #define RW_INITIALIZED (1<<29)
  2. bool isInitialized() {
  3. return getMeta()->data()->flags & RW_INITIALIZED;
  4. }

isInitialized 的结果会保存到元类的 class_rw_t 结构体的 flags 信息里,flags 的 1<<29 位记录的就是这个类是否初始化了的信息。而 flags 的其他位记录的信息,你可以参看 objc runtime 的源码,如下:

  1. // 类的方法列表已修复
  2. #define RW_METHODIZED (1<<30)
  3. // 类已经初始化了
  4. #define RW_INITIALIZED (1<<29)
  5. // 类在初始化过程中
  6. #define RW_INITIALIZING (1<<28)
  7. // class_rw_t->ro 是 class_ro_t 的堆副本
  8. #define RW_COPIED_RO (1<<27)
  9. // 类分配了内存,但没有注册
  10. #define RW_CONSTRUCTING (1<<26)
  11. // 类分配了内存也注册了
  12. #define RW_CONSTRUCTED (1<<25)
  13. // GC:class 有不安全的 finalize 方法
  14. #define RW_FINALIZE_ON_MAIN_THREAD (1<<24)
  15. // 类的 +load 被调用了
  16. #define RW_LOADED (1<<23)

既然能够在运行中看到类是否初始化了,那么我们就能够找出有哪些类是没有初始化的,即找到在真实环境中没有用到的类并清理掉。

具体编写运行时无用类检查工具时,我们可以在线下测试环节去检查所有类,先查出哪些类没有初始化,然后上线后针对那些没有初始化的类进行多版本监测观察,看看哪些是在主流程外个别情况下会用到的,判断合理性后进行二次确认,最终得到真正没有用到的类并删掉。

精简重复代码

多人开发的项目可能存在大量复制粘贴代码,可以通过 PMD 扫描重复的代码片段,再结合实际逻辑重构代码。

PMD 是一个代码静态扫描工具,直接通过 brew 命令安装。

  1. brew install pmd

安装完成后,通过 PMD-CPD 即可得到重复代码信息,格式如下:

  1. pmd cpd --files 扫描文件目录 --minimum-tokens 70 --language objectivec --encoding UTF-8 --format xml > repeat.xml

其中,--files 用于指定文件目录,--minimum-tokens 用于设置最小重复代码阈值,--format 用于指定输出文件格式,支持 xml/csv/txt 等格式,这里建议使用 xml,方便查看 。
生成的 XML 文件内容如下,根据 file 标签信息就能定位到重复代码位置。

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <pmd-cpd>
  3. <duplication lines="87" tokens="565">
  4. <file column="1" endcolumn="3" endline="197" line="111"
  5. path="/Users/github/iOS-Develop-Tools/IOSDevelopTools/IOSDevelopTools/OCTimeConsumeMonitor/YECallMonitor.m"/>
  6. <file column="1" endcolumn="3" endline="152" line="66"
  7. path="/Users/github/iOS-Develop-Tools/IOSDevelopTools/IOSDevelopTools/OCTimeConsumeMonitor/YECallTraceShowViewController.m"/>
  8. <codefragment><![CDATA[}
  9. #pragma private
  10. - (NSUInteger)findStartDepthIndex:(NSUInteger)start arr:(NSArray *)arr
  11. {
  12. NSUInteger index = start;
  13. if (arr.count > index) {
  14. YECallRecordModel *model = arr[index];
  15. int minDepth = model.depth;
  16. int minTotal = model.total;
  17. for (NSUInteger i = index+1; i < arr.count; i++) {
  18. YECallRecordModel *tmp = arr[i];
  19. if (tmp.depth < minDepth || (tmp.depth == minDepth && tmp.total < minTotal)) {
  20. minDepth = tmp.depth;
  21. minTotal = tmp.total;
  22. index = i;
  23. }
  24. }
  25. }
  26. return index;
  27. }
  28. - (void)setRecordDic:(NSMutableArray *)arr record:(YEThreadCallRecord *)record
  29. {
  30. if ([arr isKindOfClass:NSMutableArray.class] && record) {
  31. int total=1;
  32. for (NSUInteger i = 0; i < arr.count; i++)
  33. {
  34. YECallRecordModel *model = arr[i];
  35. if (model.depth == record->depth) {
  36. total = model.total+1;
  37. break;
  38. }
  39. }
  40. YECallRecordModel *model = [[YECallRecordModel alloc] initWithCls:record->cls sel:record->sel time:record->time depth:record->depth total:total];
  41. [arr insertObject:model atIndex:0];
  42. }
  43. }
  44. ]]></codefragment>
  45. </duplication>
  46. </pmd-cpd>

只保留需要的指令集

没有开启 Bitcode 时,App 内的 Framework 会包含多个指令集,我们可以手动移除不需要的指令集。

这个存疑:官方的App Thinning应该也会对这种三方的Framework做一个指令集的拆分,具体实际情况需要验证。

移除 Framework 的未使用代码

具体可查看 删除 FrameWork 中无用 mach-O 文件

支持bitcode

关于bitcode的知识点可以看:关于bitcode, 知道这些就够了

支持bitcode之后,在上传到apple connect的时候苹果,苹果会做一次优化把bitcode中间码转换为对应架构的机器码。这期间符号地址之类的会变,所以原来编译的时候生成的符号表是没用的,需要重新要网站上下载。

GoogleProtobuf没有bitcode但能编译通过

原因是我并没有使用过GoogleProtobuf的方法或者类,xcode在编译的时候并不会链接进去,所以就算没bitcode也不会报错。但如果我在代码中使用了相关静态库的代码,则会在编译时链接进去,这时候发现静态库没有bitcode就会编译报错。

  1. #include <google/protobuf/descriptor.h>
  2. #include <google/protobuf/descriptor.pb.h>
  3. - (void)testGoogleProtobuf{
  4. std::string typeName;
  5. typeName = "Hello World";
  6. google::protobuf::Message *message = nullptr;
  7. const google::protobuf::Descriptor* desc = google::protobuf::DescriptorPool::generated_pool()->FindMessageTypeByName(typeName);
  8. }

编译时生成bitcode

我们设置了enable bitcode为YES正常build生成的静态库是有bitcode段,但没有内容的,需要设置Other C FlagsOther C++ Flags的值为-fembed-bitcode编译出来的静态库bitcode段才会有内容。

使用pod package打包静态库的话使用podspec里加入s.xcconfig = {'OTHER_CFLAGS' => '-fembed-bitcode'}即可加入bitcode

注:有出现源代码文件编的.o文件都有bitcode内容的情况,但pod库创建的dummy.m文件生成的.o文件没有bitcode内容,其实这个dummy.m没啥用,可以删除。pod加这个文件主要是因为Xcode的编译是依赖.m文件的,如果一个库里没有.m文件,将不会被编译,为了防止这种情况就会在每个库里增加一个空的.m文件。

取相同方法名

多个类中,可能有近义词的方法名,尽量方法名相同。因为MachO文件中__cstring__objc_methname这两个代码段记录的是方法名字符的ASCII码的十六进制表示。如果多个类中有相同的方法名,相同的方法名会进入link mapDead Stripped Symbols中,最后只留一个。

优化结果

以原包体积169.3M算,优化后为168.5M,实际优化1M不到

参考

应用 Swift 静态库的各种坑
深入探索 iOS 包体积优化
iOS 优化IPA包体积(今日头条)
今日头条 iOS 安装包大小优化—— 新阶段、新实践

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注