Files
knowledge-kit/Chapter1 - iOS/1.60.md
2026-01-02 10:28:57 +08:00

42 KiB
Raw Blame History

App 瘦身之道

App 的包大小做优化的目的就是为了节省用户流量,提高用户的下载速度,也是为了用户手机节省更多的空间。另外 App Store 官方规定 App 安装包如果超过 150MB那么不可以使 OTAover-the-air环境下载也就是只可以在 WiFi 环境下载,企业或者独立开发者万万不想看到这一点。免得失去大量的用户。

同时如果你的 App 需要适配 iOS7、iOS8 那么官方规定主二进制 text 段的大小不能超过 60MB。如果不能满足这个标准则无法上架 App Store。

另一种情况是 App 包体积过大,对用户更新升级率也会有很大影响。

所以应用包的瘦身迫在眉睫。

App 瘦身一般指的是安装包IPA主要由可执行文件、资源组成

对于产物的分析,可以查看可执行文件的具体组成。

Xcode - Build Setting - Write Link Map File 设置为 YES。修改 Path to Link Map File 即可。

可借助第三方工具解析LinkMap文件 GitHub - huanxsd/LinkMap: 检查每个类占用空间大小工具

1. App Thinning

App Thinning 是指 iOS9 以后引入的一项优化,官方描述如下

The App Store and operating system optimize the installation of iOS, tvOS, and watchOS apps by tailoring app delivery to the capabilities of the users particular device, with minimal footprint. This optimization, called app thinning, lets you create apps that use the most device features, occupy minimum disk space, and accommodate future updates that can be applied by Apple. Faster downloads and more space for other apps and content provides a better user experience.

Apple 会尽可能,自动降低分发到具体用户时,所需要下载的 App 大小。其中包含三项主要功能Slicing、Bitcode、On-Demand Resources。

App Thinning 是苹果公司推出的一项改善 App 下载进程的新技术,主要为了解决用户下载 App 耗费过高流量的问题,同时还可以节省用户设备存储空间。

1.1 Slicing

Slicing

当向 App Store Connect 上传 .ipa 后App Store Connect 构建过程中,会自动分割该 App创建特定的变体variant以适配不同设备。然后用户从 App Store 中下载到的安装包,即这个特定的变体,这一过程叫做 Slicing。

Slicing 是创建、分发不同变体以适应不同目标设备的过程

而变体之间的差异又具体体现在架构和资源上。换句话说App Slicing 仅向设备传送与之相关的资源(取决于屏幕分辨率、系统架构等等)

其中2x 和 3x 的细分,要求图片在 Assets 中管理。Bundle 内的则会同时包含。

变体

1.2 Bitcode

Bitcode is an intermediate representation of a compiled program. Apps you upload to iTunes Connect that contain bitcode will be compiled and linked on the App Store. Including bitcode will allow Apple to re-optimize your app binary in the future without the need to submit a new version of your app to the App Store.

Bitcode 是一种程序中间码。包含 Bitcode 配置的程序将会在 App Store Connect 上被重新编译和链接,进而对可执行文件做优化。这部分都是在服务端自动完成的。所以假如以后 Apple 新推出了新的 CPU 架构或者以后 LLVM 推出了一系列优化我们不需要重新为其发布新的安装包了。Apple Store 会为我们自动完成这步。然后提供对应的 variant 给具体设备

对于 iOS 而言Bitcode 是可选的Xcode7 以后创建的新项目默认开启watchOS、tvOS 则是必须的。

开启位置Build Settings -> Enable Bitcode -> 设置为 YES

开启 Bitcode有这么2点需要注意

  • 全部都要支持。我们所依赖的静态库、动态库、Cocoapods 管理的第三方库,都需要开启 Bitcode。否则会编译失败

  • 奔溃定位。开启 Bitcode 后最终生成的可执行文件是 Apple 自动生成的,同时会产生新的符号表文件,所以我们无法使用自己包生成的 dYSM 符号化文件来进行符号化。

For Bitcode enabled builds that have been released to the iTunes store or submitted to TestFlight, Apple generates new dSYMs. Youll need to download the regenerated dSYMs from Xcode and then upload them to Crashlytics so that we can symbolicate crashes.For Bitcode enabled apps, ensure that you have checked “Include app symbols for your application…” so that we can provide the most accurate crash reports.

上面是 fabric 中关于 Downloading Bitcode dYSMs 的描述:

在上传到 App Store 时需勾选“Includ app symbols for your application...”。勾选之后 Apple 会自动生成对应的 dYSM然后可以在 Xcode -> Window -> Organizer 中,或者在 Apple Store Connect 中下载对应的 dYSM 来进行符号化

App Connect-dYSM

Xcode-dYSM

那么 Bitcode 会对 App Thining 有什么作用?

在 New Features in Xcode7 中有这么一段描述:

Bitcode. When you archive for submission to the App Store, Xcode will compile your app into an intermediate representation. The App Store will then compile the bitcode down into the 64 or 32 bit executables as necessary.

App Store 会再按需将这个 bitcode 编译进 32/64 位的可执行文件。 所以网上铺天盖地地说 Bitcode 完成了具体架构的拆分,从而实现瘦包

1.3 on-Demand Resources

on-Demand Resource 即一部分图片可以被放置在苹果的服务器上,不随着 App 的下载而下载,直到用户真正进入到某个页面时才下载这些资源文件。

on-DemandResources

应用场景:相机应用的贴纸或者滤镜、关卡游戏等

如需支持 iOS9 以下系统,那么无法使用这个功能,否则上传会失败

2 包体积

2个概念

  • .ipa (iOS Application Package)iOS 应用程序归档文件,即提交到 App Store Connect 的文件

  • .app Application应用的具体描述即安装到 iOS 设备上的文件

当我们拿到 Archive 后的 .ipa使用解压软件打开后Payload 目录下存放的就是 .app 文件,二者大小相当

包体积,评判标准是以 App Store 上看到的为准。但是上传到 App Store Connect 处理完后,会自动帮我们生成具体设备上看到的大小。如下:

App Store 包大小

这其中又可以分为2类 Universal 和具体设备 Universal 指通用设备,即未应用 App slicing 优化,同时包含了所有架构、资源。所以包体积会比较大

观察 .ipa 的大小和 Universal 对应的包大小相当,稍微小一点,因为 App Store 对 .ipa 做了加密处理

有时候下载 App 会提示“此项目大于 150MB除非此项目支持增量下载否则您必须连接至 WiFi 才能下载”。150MB 针对的是下载大小。

  • 下载大小:通过 WiFi 下载的压缩 App 大小
  • 安装大小:此 App 将在用户设备上占用磁盘空间的大小

所以我们要瘦包,关键在于减小 .app 文件的大小。

2.1 Architectures

如果不支持32位以及 iOS8 ,去掉 armv7 ,可执行文件以及库会减小,即本地 .ipa 也会减小

2.2 Resources

资源的优化也就是平时的细心与审查。

图片、内置素材、Bundle、多语言、Json、字体、脚本、Plist、音频

图片Assets.car Bundle: 非放在 Asset Catlog 中管理的图片资源。包括 Bundle散落的 png、jpg 等

瘦包具体的方式:

  • 无用资源的删除
  • 重复文件的删除
  • 大文件压缩
  • 图片管理方式规范
  • on-Demand Resource游戏的、前置关卡依赖、滤镜App 等的依赖资源,建议用这种方式动态下载图片资源)

2.2.1 无用文件的删除

无用文件主要包含:无用图片、无用非图片部分。

非图片部分:资源较少,使用方式固定。比如音频、字体。需要手动排查 图片部分:主要使用一个开源的 Mac App LSUnusedResources 进行冗余图片的排查。

删除无用的图片过程可以概括为下面6步

  1. 通过 find 命令获取 App 安装包中的所有资源文件
  2. 设置用到的资源类型。比如 gif、jpg、jpeg、png、webp
  3. 使用正则匹配出在源码中使用到的资源名,比如 pattern = @"@"(.+?)""
  4. 使用 find 命令找到篇所有资源文件再去源码中找到使用到的资源文件2个集合的差集就是无用资源了。
  5. 确认无用资源后可以使用 NSFileManager 进行文件的删除。

如果不想重新写一个工具,那么可以直接使用开源的工具 LSUnusedResources

但是存在一点问题。会出现误报,因为不同的项目,图片使用方式不一样。

- (BOOL)containsSimilarResourceName:(NSString *)name {
    NSString *regexStr = @"([-_]?\\d+)";
    NSRegularExpression* regexExpression = [NSRegularExpression regularExpressionWithPattern:regexStr options:NSRegularExpressionCaseInsensitive error:nil];
    NSArray* matchs = [regexExpression matchesInString:name options:0 range:NSMakeRange(0, name.length)];
    //...
}

源码中的正则表达式处理的情况并不是很准确。可以根据自己的情况修改正则即可

2.2.2 图片资源的压缩

删除了无用的资源,那么对于资源这块还是有操作的空间的,比如图片资源的压缩。目前压缩比较好的方案就是 WebP它是谷歌公司的一个开源项目。

WebP 的优势:

  • 压缩率高。支持有损和无损2种方式比如将 Gif 图可以转换为 Animated WebP有损模式下可以减小 64%,无损模式下可以减小 19%
  • WebP 支持 Alpha 透明和 24-bit 颜色数,不会像 PNG8 那样因为色彩不够出现毛边。

Google 公司在开源 WebP 的同时,还提供了一个图片压缩工具 cwebp。 压缩完之后使用 WebP 格式的图片还需使用 libwebp 进行解析,参考这个Demo

缺点WebP 在 CUP 消耗和解码时间上会比 PNG 高2倍所以我们做选择的时候需要取舍。

2.2.3 重复文件删除

重复文件,即两个内容完全一致的文件。但是文件命名不一样。

借助 fdupes 这个开源工具,校验各资源的 MD5。

fdupes 是 Linux 下的一个工具,它由 Adrian Lopez 用 C 语言编写并基于 MIT 许可证发行该应用程序可以在指定的目录及子目录中查找重复的文件。fdupes 通过对比文件的 MD5 签名以及逐字节比较文件来识别重复内容fdupes 有各种选项,可以实现对文件的列出、删除、替换为文件副本的硬链接等操作。

文件对比从以下顺序开始: 大小对比 > 部分 MD5 签名对比 > 完整 MD5 签名对比 > 逐字节对比

执行结束后会在命令行展示出来,所以需要我们人工将这些文件确认对比后删除掉。

2.2.4 大文件压缩

图片本身的压缩,建议使用 ImageOptim。它整合了 Win、Linux 上诸多著名图片处理工具的特色,比如 PNGOUT、AdvPNG、Pngcrush、OptiPNG、JpegOptim、Gifsicle 等。 Bundle 内的图片资源必须压缩,因为 Xcode 并不会对其进行压缩。所以做好将图片都用 Assets 管理。

Xcode 提供给我们2个编译选项来帮助压缩图像

  • Compress PNG Files: 打包的时候自动对图片进行无损压缩。使用的工具为 pngcrush压缩比蛮高。
  • Remove Text Medadata From PNG Files移除 PNG 资源的文本字符,比如图像名称、作者、版权、创作时间、注释等信息

2.2.5 图片管理方式规范

2.2.5.1 主工程中的图片管理

工程中所有使用的 Asset Catlog 管理的图片(在 .xcassets 文件夹下)最终都会输出到 Asset.car 内。不在 Asset.car 内的都归为 Bundle 管理。

  • xcassets 里面的图片。只能通过 imageNamed 加载。 Bundle 里面的图片还可以通过 imageWithContentsOfFile 等方式
  • xcassets 里面的 @2x、@3x 会根据具体设备分发不会同时包含。Bundle 都包含(不进行 App Slicing
  • xcassets 内可以对图片进行 Slicing即裁剪和拉伸、Bundle 不支持
  • Bundle 内支持多语言Images.xcassets 不支持

使用 imageNamed 创建的 UIImage 会被立即加入到 NSCache 中(解码后的 Image Buffer直到收到内存警告的时候才会释放不使用的 UIImage。而 imageWithContentsOfFile 会每次重新申请内存,相同图片不会缓存,所以 xcassets 内的图片,加载后会产生缓存

综上:常用的、较小的图建议存放在 Images.xcassets 内管理。大图放在 Bundle 内管理。

这里讲一个插曲了,曾经很多文章都在谈一个结论,那就是「图片放在 Images.xcassets 里面更加快速且节省空间,直接放在 bundle 里面会比较慢」。我做过实验,实验环境和结论如下。使用 Instruments 测量耗时。

点击展开
//实验1
NSMutableArray *images = [NSMutableArray array];
for (NSInteger index = 0; index < 10; index++) {
    UIImage *image = [UIImage imageNamed:@"icon-iOS"];
    [images addObject:image];
}
self.imageView.image = images.lastObject;
//实验2
NSMutableArray *images = [NSMutableArray array];
for (NSInteger index = 0; index < 10; index++) {
    NSString *imagePath = [[NSBundle mainBundle] pathForResource:@"iOS" ofType:@"png"];
    [UIImage imageNamed:@"icon-iOS"];
    UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
    [images addObject:image];
}
self.imageView.image = images.lastObject;

Timeprofile-imageNamedFromAssets Timeprofile-imageNamedFromAssets

TimeProfile-imageWithContentsOfFile TimeProfile-imageWithContentsOfFile

Timeprofile-UIImageNamedFromFolder Timeprofile-UIImageNamedFromFolder

Images.xcassets

  • 图片大小要精确,不要出现图片太大的情况
  • 不要存放大图,不然会产生缓存
  • 不要存 jpg 图片,打包会变大
  • 图片不需要额外压缩(有人做过实验,对放入 assets 里面的图片进行压缩后打包发现包体积反而增大,怀疑是 Xcode 的编译选项 Compress PNG Files 自动对图片进行压缩2种压缩起了冲突反而增大
2.2.5.2 各个 pod 库中的图片管理

CocoPods 中两种资源引用方式介绍下:

  • resource_bundles

    We strongly recommend library developers to adopt resource bundles as there can be name collisions using the resources attribute. 允许定义当前的 pod 库的最远包的名称和文件。用 hash 形式声明key 是 bundle 的名称value 是需要包含文件的通配 patterns CocoPods 官方强烈推荐该方法引用资源,因为 key-value 可以避免相同资源的名称冲突

  • resources

    We strongly recommend library developers to adopt resource bundles as there can be name collisions using the resources attribute. Moreover, resources specified with this attribute are copied directly to the client target and therefore they are not optimised by Xcode. 使用该方法引用资源,被指定的资源会被拷贝进 target 工程的 main bundle 中。

说说项目中的情况吧:在工程中之前是通过 resource_bundles 引用资源的。资源是放在 Resources 目录下的图片引用。查询资料后说「如果图片资源放到 .xcasset 里面 Xcode 会帮我们自动优化、可以使用 Slicing 等(这里不仅仅指的是 resource_bundle 下的 xcassets」。所以动手将各个 Pod 库里面的图片全都通过 Assets Catalog 的方式进行处理。

Pod组件库图片处理前后对比

步骤:

  • 在各个 Pod 组件库里面的 Resources 目录下新建 Asset Catalog 文件,命名为 Images.xcassets

  • 将 Resources 里面零散的图片资源拖进 Images.xcassets 里面

  • 修改每个组件库的 podspec 文件

    点击展开
    s.resource_bundles = {
        'XQ_UI' => ['XQ_UI/Assets/*.xcassets']
    }
    </details>
    
  • 主工程执行 pod install

话说 resourcesresource_bundles 都可以使用 Asset Catalog那么有何区别

  • resources 只会将资源文件 copy 到 target 工程,最后和 target 工程的图片资源以及同样使用该方式的 Pod 库的图片资源共同打包到一个 Assets.car 中。因此图片资源会有混乱的可能。
  • resource_bundles 会生成一个你在 podspec 中指定名称的 bundle且在 bundle 中也会生成一个 Assets.car。所以图片是肯定不会混乱的但是图片的访问方式需要注意。

解决方法:为每个 pod 新建一个图片的分类,比如 UIImage+XQUIModule。然后访问图片的时候通过 [UIImage xquiModuleImageNamed:@"pull"] 访问。

点击展开
#import "UIImage+XQUIModule.h"
#import <SDGBase/UIImage+Bundle.h>

@implementation UIImage (XQUIModule)

+ (nonnull UIImage *)xquiModuleImageNamed:(nonnull NSString *)name
{
    return [UIImage imageNamed:name inBundleName:@"XQ_UI"];
}
@end

//UIImage+Bundle.m
#import "UIImage+Bundle.h"

@implementation UIImage (Bundle)

+ (nullable UIImage *)imageNamed:(NSString *)name inBundleName:(nullable NSString *)bundleName {
    NSBundle *bundle = [NSBundle bundleWithURL:[[NSBundle mainBundle] URLForResource:bundleName withExtension:@"bundle"]];
    return  [UIImage imageNamed:name inBundle:bundle compatibleWithTraitCollection:nil];
}
@end

2.2.6 矢量图的使用

事实上,对于 App 里面的单色图标,比如左上角的返回按钮、底部的 tabBar等只要是单色的纯色图标都是可以使用矢量图代替的比如 PDF、ttf 字体图标等。这样就不需要添加 @2x、@3x 图标,节省了空间。

iOS 中如何使用 ttf 矢量图,可以查看这个 Repo

3. Executable file

3.1 编译选项优化

3.1.1 Generate Debug Symbols

Enables or disables generation of debug symbos. When debug symbols are enabled, the level of detail can be controller by the build 'Level of Debug Symbols' Setting.

调试符号是在编译时形成的。当 Generate Debug Symbols 选项为 YES 的时,每个源文件在编译成 .o 文件时,编译参数多了 -g 和 -gmodules 两项。打包会生成 symbols 文件。设置为 NO 则 ipa 中不会生成 symbol 文件,可以减少 ipa 大小。但会影响到崩溃的定位。保持默认的开启,不做修改。

3.1.2 Asset Catalog Compiler

optimization 选项设置为 space 可以减少包大小 默认选项,不做修改。

3.1.3 Dead Code Stripping

For statically linked executables, dead-code stripping is the process of removing unreferenced code from the executable file. If the code is unreferenced, it must not be used and therefore is not needed in the executable file. Removing dead code reduces the size of your executable and can help reduce paging.

删除静态链接的可执行文件中未引用的代码

Debug 设置为 NO Release 设置为 YES 可减少可执行文件大小。

Xcode 默认会开启此选项C/C++/Swift 等静态语言编译器会在 link 的时候移除未使用的代码,但是对于 Objective-C 等动态语言是无效的。因为 Objective-C 是建立在运行时上面的,底层暴露给编译器的都是 Runtime 源码编译结果,所有的部分应该都是会被判别为有效代码。

带来的好处:

  • App 可执行文件体积减少
  • 减少 App 分页(分页过多会容易引起内存引起缺页异常)

默认选项,不做修改。

3.1.4 Apple Clang - Code Generation

Optimization Level 编译参数决定了程序在编译过程中的两个指标:编译速度和内存的占用,也决定了编译之后可执行结果的两个指标:速度和文件大小。 Build Settings -> code Generation -> Optimization Level 默认情况下Debug 设定为 None[-O0] Release 设定为 Fastest,Smallest[-Os]。

  • None[-O0]。 Debug 默认级别。不进行任何优化,直接将源代码编译到执行文件中,结果不进行任何重排,编译时比较长。主要用于调试程序,可以进行设置断点、改变变量 、计算表达式等调试工作。

  • Fast[-O,O1]。最常用的优化级别,不考虑速度和文件大小权衡问题。与-O0级别相比它生成的文件更小可执行的速度更快编译时间更少。

  • Faster[-O2]。在-O1级别基础上再进行优化增加指令调度的优化。与-O1级别相它生成的文件大小没有变大编译时间变长了编译期间占用的内存更多了但程序的运行速度有所提高。

  • Fastest[-O3]。在-O2和-O1级别上进行优化该级别可能会提高程序的运行速度但是也会增加文件的大小。

  • Fastest Smallest[-Os]。Release 默认级别。这种级别用于在有限的内存和磁盘空间下生成尽可能小的文件。由于使用了很好的缓存技术,它在某些情况下也会有很快的运行速度。

  • Fastest, Aggressive Optimization[-Ofast]。 它是一种更为激进的编译参数, 它以点浮点数的精度为代价。

默认选项,不做修改。

3.1.5 Swift Compiler - Code Generation

Xcode 9.3 版本之后 Swift 编译器提供了新的 Optimization Level 选项来帮助减少 Swift 可执行文件的大小:

  • No optimization[-Onone]:不进行优化,能保证较快的编译速度。
  • Optimize for Speed[-O]:编译器将会对代码的执行效率进行优化,一定程度上会增加包大小。
  • Optimize for Size[-Osize]:编译器会尽可能减少包的大小并且最小限度影响代码的执行效率。

We have seen that using -Osize reduces code size from 5% to even 30% for some projects. But what about performance? This completely depends on the project. For most applications the performance hit with -Osize will be negligible, i.e. below 5%. But for performance sensitive code -O might still be the better choice.

官方提到,-Osize 根据项目不同,大致可以优化掉 5% - 30% 的代码空间占用。 相比 -0 来说,会损失大概 5% 的运行时性能。 如果你的项目对运行速度不是特别敏感,并且可以接受轻微的性能损失,那么 -Osize 是首选。

除了 -O 和 -Osize 还有另外一个概念也值得说一下。 就是 Single File 和 Whole Module 。 在之前的 XCode 版本,这两个选项和 -O 是连在一起设置的Xcode 9.3 中,将他们分离出来,可以独立设置:

Single File 和 Whole Module 这两个模式分别对应编译器以什么方式处理优化操作。

  • Single File逐个文件进行优化它的好处是对于增量编译的项目来说它可以减少编译时间对没有更改的源文件不用每次都重新编译。并且可以充分利用多核 CPU并行优化多个文件提高编译速度。但它的缺点就是对于一些需要跨文件的优化操作它没办法处理。如果某个文件被多次引用那么对这些引用方文件进行优化的时候会反复的重新处理这个被引用的文件如果你项目中类似的交叉引用比较多就会影响性能。

  • Whole Module 将项目所有的文件看做一个整体,不会产生 Single File 模式对同一个文件反复处理的问题,并且可以进行最大限度的优化,包括跨文件的优化操作。缺点是,不能充分利用多核处理器的性能,并且对于增量编译,每次也都需要重新编译整个项目。

如果没有特殊情况,使用默认的 Whole Module 优化即可。 它会牺牲部分编译性能,但的优化结果是最好的。

故,在 Relese 模式下 -Osize 和 Whole Module 同时开启效果会最好!

3.1.6 Strip Symbol Information

1、Deployment Postprocessing 2、Strip Linked Product 3、Strip Debug Symbols During Copy 4、Symbols hidden by default

设置为 YES 可以去掉不必要的符号信息,可以减少可执行文件大小。但去除了符号信息之后我们就只能使用 dSYM 来进行符号化了,所以需要将 Debug Information Format 修改为 DWARF with dSYM file。

Symbols Hidden by Default 会把所有符号都定义成”private extern”详细信息见官方文档。

Release 设置为 YESDebug 设置为 NO。

3.1.7 Exceptions

在 iOS微信安装包瘦身 一文中,有提到:

去掉异常支持Enable C++ Exceptions 和 Enable Objective-C Exceptions 设为 NO并且 Other C Flags 添加 -fno-exceptions可执行文件减少了27M其中 __gcc_except_tab 段减少了17.3M__text 减少了 9.7M,效果特别明显。可以对某些文件单独支持异常,编译选项加上 -fexceptions 即可。但有个问题,假如 ABC 三个文件AC 文件支持了异常B 不支持,如果 C 抛了异常,在模拟器下 A 还是能捕获异常不至于 Crash但真机下捕获不了。去掉异常后Appstore 后续几个版本 Crash 率没有明显上升。

个人认为关键路径支持异常处理就好,像启动时 NSCoder 读取 setting 配置文件得要支持捕获异常,等等

看这个优化效果,感觉发现了新大陆。关闭后验证.. 毫无感知,基本没什么变化。

可能和项目中用到比较少有关系。故保持开启状态。

潜在问题与解决方案

问题 1依赖异常的标准库组件失效

  • 现象:如 std::vector 在内存不足时直接崩溃。
  • 解决:
    • 使用 std::nothrow 分配内存。
    • 替换为无异常的容器实现(如自定义或第三方库)。

问题 2第三方库依赖异常

  • 现象:链接时报错(如库中未定义异常相关符号)。
  • 解决:
    • 重新编译第三方库,确保其也启用 -fno-exceptions
    • 隔离异常代码,通过 C 接口封装调用。

问题 3代码中残留 try/catch

  • 现象:编译错误 error: exception handling disabled
  • 解决:
    • 全局搜索并删除所有异常处理代码。
    • 使用宏或条件编译隔离异常代码(不推荐)。

替代方案:若需保留部分异常逻辑但优化性能,可考虑局部禁用异常:通过 #pragma clang exception_behavior disable 或函数级属性控制。

#pragma clang exception_behavior disable
void criticalFunction() {
    // 此函数内禁用异常处理
}
#pragma clang exception_behavior enable

Link-Time Optimization 是 LLVM 编译器的一个特性,用于在 link 中间代码时,对全局代码进行优化。这个优化是自动完成的,因此不需要修改现有的代码;这个优化也是高效的,因为可以在全局视角下优化代码。

苹果在 WWDC 2016 中明确提出了这个优化的概念Whats New in LLVM。并且说在苹果内部已经广泛地使用这个优化方法进行编译。

它的优化主要体现在如下几个方面:

  1. 多余代码去除Dead code elimination如果一段代码分布在多个文件中但是从来没有被使用普通的 -O3 优化方法不能发现跨中间代码文件的多余代码因此是一个“局部优化”。但是Link-Time Optimization 技术可以在 link 时发现跨中间代码文件的多余代码。

  2. 跨过程优化Interprocedural analysis and optimization这是一个相对广泛的概念。举个例子来说如果一个 if 方法的某个分支永不可能执行,那么在最后生成的二进制文件中就不应该有这个分支的代码。

  3. 内联优化Inlining optimization内联优化形象来说就是在汇编中不使用 “call func_name” 语句,直接将外部方法内的语句“复制”到调用者的代码段内。这样做的好处是不用进行调用函数前的压栈、调用函数后的出栈操作,提高运行效率与栈空间利用率。

在新的版本中,苹果使用了新的优化方式 Incremental大大减少了链接的时间。建议开启。

总结,开启这个优化后,一方面减少了汇编代码的体积,一方面提高了代码的运行效率。

3.2 代码瘦身

代码的优化,即通过删除无用类、无用方法、重复方法等,来达到可执行文件大小的减小。 而如何筛选出符合条件的无用类、方法则需要通过一些工具来完成fui

编写 LLVM 插件检测处重复代码,未被调用的代码。

扫描无用代码的基本思路都是查找已经使用的方法/类和所有的类/方法,然后从所有的类/方法当中剔除已经使用的方法/类剩下的基本都是无用的类/方法,但是由于 Objective-C 是动态语言,可以使用字符串来调用类和方法,所以检查结果一般都不是特别准确,需要二次确认。目前市面上的扫描的思路大致可以分为 3 种:

  • 基于 Clang 扫描
  • 基于可执行文件扫描
  • 基于源码扫描

先谈几个概念。

可执行文件就是 Mach-O 文件,其大小是油代码量决定的,通常情况下,对可执行文件进行瘦身,就是找到并删除无用代码的过程。找到无用代码的过程类比找到无用图片的思路。

  • 找到类和方法的全集
  • 找到使用过的类和方法集合
  • 取2者差集得到无用代码集合
  • 工程师确认后,删除即可

LinkMap 文件分为3部分Object File、Section、Symbols。

  • Object File包含了代码工程的所有文件
  • Section描述了代码段在生成的 Mach-O 里的偏移位置和大小
  • Symbols会列出每个方法、类、Block以及它们的大小

先说说如何快速找到方法和类的全集?

我们可以通过 LinkMap 来获得所有的代码类和方法的信息。获取 LinkMap 可以通过将 Build Setting 里面的 Write Link Map File 设置为 YES然后指定 Path to Link Map File 的路径就可以得到每次编译后的 LinkMap 文件了。 c

产出的 LinkMap 阅读起来比较累github 有个可视化项目 用来查看 LinkMap 文件。

3.2.1 基于 clang 扫描

基本思路是基于 clang AST。追溯到函数的调用层级记录所有定义的方法/类和所有调用的方法/类,再取差集。具体原理参考 如何使用 Clang Plugin 找到项目中的无用代码,目前只有思路没有现成的工具。

3.2.2 基于可执行文件扫描LinkMap 结合 Mach-O 找无用代码)

上面我们得知可以通过 LinkMap 统计出所有的类和方法,还可以清晰地看到代码所占包大小的具体分布,进而有针对性地进行代码优化。

LinkMap-Object file

LinkMap-Sections

LinkMap-Symbols

得到了代码的全集信息后,我们还需要找到已经使用过的方法和类,这样才可以获取差集,找到无用代码。所以接下来就谈谈如何通过 Mach-O 取到使用过的类和方法。

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

里是被调用过的类, objc_superrefs 是调用过 super 的类(继承关系)。通过 _objc_classrefs 和 _objc_superrefs,我们就可以找出使用过的类和子类。

那么Mach-O 文件中的 _objc_selrefs、_objc_classrefs、_objc_superrefs 如何查看呢?

  1. 使用 otool 等命令逆向可执行文件中引用到的类/方法和所有定义的类/方法然后计算差集。具体参考iOS微信安装包瘦身目前只有思路没有现成的工具。
  2. 使用 MachOView 查看。但是这个项目运行不起来,这个新的 Repo 可以运行起来。

下面举例说明:

前置条件:先运行项目,在生成的 Products 目录下的 BridgeLabiPhone.app 解压,取出对应的和工程同名的 BridgeLabiPhone。然后运行上面的 Github 项目。可以看到运行了一个 Mac App。点击顶部的菜单栏里面的 File->Open。选择电脑上的 BridgeLabiPhone.app 选择里面的 BridgeLabiPhone。见下图

Mach-O-inspect

由于 Objective-C 是一门动态语言所以检测出的结果仍旧需要我们2次确认。

3.2.3 基于源码扫描

一般都是对源码文件进行字符串匹配。例如将 A *a、[A xxx]、NSStringFromClass("A")、objc_getClass("A") 等归类为使用的类,@interface A : B 归类为定义的类,然后计算差集。

基于源码扫描 有个已经实现的工具 - fui但是它的实现原理是查找所有 #import "A" 和所有的文件进行比对,所以结果相对于上面的思路来说可能更不准确。

3.2.4 通过 AppCode 查找无用代码

AppCode 提供了 Inspect Code 来诊断代码,其中含有查找无用代码的功能。它可以帮助我们查找出 AppCode 中无用的类、无用的方法甚至是无用的 import ,但是无法扫描通过字符串拼接方式来创建的类和调用的方法,所以说还是上面所说的 基于源码扫描 更加准确和安全。

AppCode-code inspect

说明AppCode检测出了实际上需要的大部分场景的问题但是由于 Objective-C 是一门动态性语言,所以 AppCode 检测出无用的方法等都需要工程师自己再次确认后删除。(在我们的工程中有一些和 H5 交互的桥接方法,因此 AppCode 视为 Unused Method但是你删除的话那就自己哭去吧 😭)。实际经验告诉我,使用 AppCode 的时候如果工程比较大,则整个 code inspect 会非常耗时(给你打个预防针哦,笔芯)

  • 无用类Unused class 是无用类Unused import statement 是无用类引入声明Unused property 是无用的属性;
  • 无用方法Unused method 是无用的方法Unused parameter 是无用参数Unused instance variable 是无用的实例变量Unused local variable 是无用的局部变量Unused value 是无用的值;
  • 无用宏Unused macro 是无用的宏。
  • 无用全局Unused global declaration 是无用全局声明。

3.2.5 运行时真正检测类是否用过

通过上述手段找到并删除了无用代码。App 不断上线迭代蛮多代码都不会被调用了(业务被砍掉了)。这种方式下这些无用的代码也是可以被删除的。

通过 Objective-C 的 runtime 源码,我们可以找到如何判断一个类是否初始化过的函数。

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

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

// 类的方法列表已修复
#define RW_METHODIZED         (1<<30)

// 类已经初始化了
#define RW_INITIALIZED        (1<<29)

// 类在初始化过程中
#define RW_INITIALIZING       (1<<28)

// class_rw_t->ro 是 class_ro_t 的堆副本
#define RW_COPIED_RO          (1<<27)

// 类分配了内存,但没有注册
#define RW_CONSTRUCTING       (1<<26)

// 类分配了内存也注册了
#define RW_CONSTRUCTED        (1<<25)

// GCclass 有不安全的 finalize 方法
#define RW_FINALIZE_ON_MAIN_THREAD (1<<24)

// 类的 +load 被调用了
#define RW_LOADED             (1<<23)

既然可以在运行的期间知道类是否初始化了,那么就可以找出哪些类未初始化,即可以找到在真实环境里面没有用到的类并删除掉。

4. App Extension

App Extension 的占用,都放在 Plugin 文件夹内。它是独立打包签名,然后再拷贝进 Target App Bundle 的。 关于 Extension有两个点要注意

静态库最终会打包进可执行文件内部,所以如果 App Extension 依赖了三方静态库,同时主工程也引用了相同的静态库的话,最终 App 包中可能会包含两份三方静态库的体积。

动态库是在运行的时候才进行加载链接的,所以 Plugin 的动态库是可以和主工程共享的,把动态库的加载路径 Runpath Search Paths 修改为跟主工程一致就可以共享主工程引入的动态库。

所以,如果可能的话,把相关的依赖改成动态库方式,达到共享。

5. 静态库瘦身

项目中都会引入第三方静态库。通过 lipo 工具可以查看支持的指令集,比如查看微博 SDK 终端切换到微博 SDK 的目录下执行下面命令

  • 静态库指令集信息查看:lipo -info libname.a(或者libname.framework/libname)
lipo -info libWeiboSDK.a
//Architectures in the fat file: libWeiboSDK.a are: armv7 arm64 i386 x86_64

我们知道 i386、x86_64 是模拟器的指令集。所以我们可以模拟器版本的指令集。因为 armv7 也可以兼容 armv7s。所以 armv7s 也可以删除了。只保留 armv7 和 arm64

  • 静态库拆分:lipo 静态库文件路径 -thin CPU架构 -output 拆分后的静态库文件路径
  • 静态库合并:lipo -create 静态库1文件路径 静态库2文件路径... 静态库n文件路径 -output 合并后的静态库文件径
lipo libWeiboSDK.a -thin armv7 -output libWeiboSDK-armv7.a
lipo libWeiboSDK.a -thin arm64 -output libWeiboSDK-arm64.a
lipo create libWeiboSDK-armv7.a libWeiboSDK-arm64.a -output libWeiboSDK.device.a

通过上面的操作我们将静态库里面支持模拟器的指令集给去掉了,所以模拟器是无法跑代码的,如何解决?

  1. 平时使用包含模拟器指令集的静态库,在 App 发布的时候去掉
  2. 如果使用 Cocoapods 管理可以使用2份 Podfile 文件。一份包含指令集一份不包含,发布的时候切换 Podfile 文件即可。或者一份 Podfile 文件,但是配置不同的环境设置

补充2个说明

  1. dSYM 文件 符号表文件 .dSYM 文件是从 Mach-O 文件中抽取调试信息而得到的文件目录,实际用于保存调试信息的是 DWARF 文件
  • 自动生成。Xcode 会在工程编译或者归档的时候自动生成 .dSYM 文件,在 Buld setting 设置中有开关可以设置去关掉 .dSYM 文件

  • 手动生成。通过脚本从 Mach-O 文件中提取出来。

    $ /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/dsymutil /Users/wangzz/Library/Developer/Xcode/DerivedData/YourApp-cqvijavqbptjyhbwewgpdmzbmwzk/Build/Products/Debug-iphonesimulator/YourApp.app/YourApp -o YourApp.dSYM
    

    该方式通过 dsymutil 工具,从项目编译结果 .app 目录下的 Mach-O 文件中提取出调试符号表文件。Xcode 在归档的时候是通过它生辰的 .dSYM 文件

  1. DWARF 文件 DebuggingWith Arbitrary Record Formats 是 ELF 和 Mach-O 等文件格式中用来存储和处理调试信息的标准格式,.dSYM 文件中真正保存符号表数据的是 DWARF 文件。DWARF 文件中不同的数据都保存在相应的 section 中。

最后的一个对比效果图: 瘦身效果图

总结:瘦身技术常见操作就这些,但是维持应用包体积的瘦身却是一个观念,从日常开发到线上发布都需要有这个意识。这样当你在写代码的时候就会考虑同样一个效果,你的具体实现手段是怎么样的。比如为了一个稍微炫酷的效果就要引入一个很大的三方库,有了“瘦身”的意识,你很大可能就是自己动手撸一个代码。比如一些无用资源的管理方式、有用的图片资源的高效管理方式等等。有了意识,行动自然会往这个方面去靠。(😂大道理一套一套的。我也不想的毕竟是playboy

其中遇到了一个神奇的问题。lint 的时候看到一些未使用的依赖库。见 问题

By the way 如果在应用包瘦身方面有其他的做法,请告知,完善文章。

参考文章: