货拉拉iOS包体积优化总结

2,316 阅读19分钟

一、前言

更小的包体积有助于拉新推广,试想一下,在推广APP时特别是在户外,是不是包体积小的更容易让用户下载呢?更小的包体积占用更小磁盘空间,下载更快,消耗数据流量也更少;此外,包体积是一个综合的性能指标,更小的包体积通常也意味着更快的APP启动速度,能提升用户体验。

我们平台就包体积问题已经做过多次优化,累计优化达到42MB+,截止到2.0.22版本,包体积是92.1MB,在行业同类APP中处于领先。包体积优化话题老生常谈,但随着苹果系统的不断迭代,优化方式也在变化,本文是基于当前的一些实践总结,下面就从统计口径、治理思路、具体的实践来讲述包体积工作如何开展。

image01.png

二、统计口径

2.1 统计口径

iOS工程从打包到生成ipa -》下载包 -》安装包的流程如下:

image02.png

  • IPA包大小: iOS工程打包后原始app压缩后的大小。上传App Store Connect后会进行APP Thinning处理,生成设备所需的特定架构和资源的变体版本
  • 下载大小: 是变体版本经过压缩的版本,是实际下载的大小。
  • 安装大小: 安装后占用的磁盘大小,也是在App Store中用户看到的大小。

综上,我们选择了安装大小作为优化统计的口径。此外,我们可以在App Store ConnectTestFlight -> 构建版本-> 构建版本元数据 -> APP文件大小中查看经过APP Thinning后的各个变体版本大小 。

2.2 苹果关于包体积的相关限制

1. APP文件大小的限制

  • 您的应用程序的未压缩总大小必须小于4GB,Apple Watch应用程序必须小于75MB。
  • iOS 9.0之后,每个二进制文件中所有__TEXT段的大小不超过500MB。

2.App Store OTA 下载大小限制

Appstore对使用蜂窝网络下载有限制,若下载大小超过限制,iOS 13 之前无法使用蜂窝网络下载 App,需通过 Wi-Fi 网络下载;iOS 13 之后默认会弹框让用户选择是否下载。如下为苹果历年来对 App 蜂窝网络下载限制的变化:

  • iOS 11 正式版后,OTA下载大小限制从 100 MB 提升至 150 MB。
  • iOS 12.4及后续版本,苹果把 OTA 下载大小限制放宽到 200MB。
  • iOS13 及以上用户可以使用流量下载超出 200MB(下载大小) 的 App, 但需要用户「设置」选择策略,默认为「超过 200MB 请求许可」(在设置->APPStore->APP 下载中可更改)。

image.png

image.png

三、治理思路

我们部门的APP是2021年起步,拉新是重要的工作之一。2017年Google Play的一个研究结果是包体大小每上升6MB,应用下载转化率就会下降1%, 因此我们的目标是跟行业头部的APP中对比维持较低的包体积。

由于包体积大小随着功能迭代中增加删除代码、图片、资源文件而发生变化的,所以包体积治理也是一个动态的过程。我们的治理思路是,对包体积情况分析分多期治理达到目标,并且在功能迭代中持续监控避免劣化,小幅的波动待累计后周期性做一次优化。

image.png

四、分析ipa包构成

在进行优化前,弄清楚ipa安装包里面有哪些内容很重要。首先对ipa包进行解压,解压后对文件进行归类统计,然后针对各个类型的文件发现和思考优化空间。如下是某个版本的各部分文件占比情况:

image.png

由上,结合包体积优化的一些资料,我们大体确定了以下几个思路:

image.png

包体积优化的方式有很多,以下是我们综合考虑ROI、风险、用户体验等因素的一些实践,从Mach-O优化、资源文件优化、编译设置优化3个方面讲述。

五 、Mach-O优化

安装包中包含的Mach-O文件包括主工程可执行文件、动态库Mach-O文件。Mach-O文件是根据代码生成的,所以从以下几个方向优化。

1. 无用代码清理

  • ABTest的代码在实验结束后及时下掉
  • 一些未使用的方法和类文件清理。
  • Debug下才使用的库和代码设置为Debug生效。Pod库在Podfile中配置为Debug下生效的方法如下:
# 1.Podfile中可以配置库只在debug下集成,
# 在release下打包时不会嵌入到安装包中,避免包体积增大
target 'User' do
pod 'UICompare',   '0.1.5',     :configurations => ['Debug']
pod 'FLEX',          '5.22.10',   :configurations => ['Debug']
pod 'SwiftLint',     '~> 0.43.1', :configurations => ['Debug']
...
end

# 2.如果有的库里面某个文件夹的内容没用到,也可以使用hook来删除
pre_install do |installer|
# 包体积优化去掉AFNetworking中没用到的UIKit+AFNetworking文件夹中内容
system("rm -rf ./Pods/AFNetworking/UIKit+AFNetworking")
end

2. 重复代码整合

  • 整合相同功能库,比如SDWebImageKingfisher可统一使用一个。
  • iOS工程组件化之后,不同Pod库使用了相同的功能类,导致重复。
  • 情况一:改了类名和文件名

比如Aspects库hook功能,有的库拷贝了一份,又因为要避免可能发生的符号冲突,将类名和文件名改写。

这种情况不容易查找,依赖开发者在需求开发过程中发现后先标记,在后续的包体积优化需求中考虑将文件下沉。

  • 情况二:未修改类名和文件名,放在动态库中

Swift工程中多个动态库依赖了相同的功能,各自把那些文件拷贝了一份在其内部。

注意📢:如果是OC文件,生成Pod动态库时也不会发生符号冲突;如果是Swift文件,有命名空间不会发生符号冲突。

这种情况下,如果是Swift文件,需要手动排查;如果是相同的OC符号在不同的Mach-O中(即主工程可执行文件和动态库的Mach-O文件),我们可以在调试运行时在Xcode控制台打印看到。推荐改法也是下沉到基础库中。

3. 动态库改静态库

  • 改法来由

我们可以在ipa包中的Frameworks文件夹中查看所有的动态库,动态库相比静态库会占用更大空间;另一方面过多的动态库数量也会影响启动速度,苹果官方的推荐是不超过6个,所以我们将一些库改为静态库。

"网易云音乐包体积优化"的一个case:在主程序中、动态库A中、动态库B中分别有一份OpenSSL的符号,这就造成了重复,占用二进制体积。这种问题的解决方案就是动转静,把动态库转化为静态库,都链接在主程序中,解除原来的依赖,都使用主二进制中的Symbol。

使用hopper打开动态库我们可以看到AFNetworkingSDWebImage动态库里面有关一些基础的使用方法存在重复Name,却不同的Address,比如这个dispatch_once

image.png

  • 修改方式

APP工程是组件化开发的,有100多个Pod库,经过分析后采用了两种修改的方式:

有的公司在全面改为XCFramework之后,看源码不方便,改代码后又需要生成新的XCFramework库版本,导致开发效率低,继而全面去除.....

综合考虑,我们这边的做法是:

  • 仅仅把版本相对固定的三方库生成XCFramework,这样可以享受到正向收益。通过脚本生成对应pod库的xcframework的Pod库版本,版本号约定为原先pod库的版本后加一个-xcbinary后缀,这样免去手动生成xcframework的麻烦。在需要看源码调试时,只需要将对应Pod库版本号后缀去掉。
  • 对于改动更频繁的二方库,我们保持源码版本的pod库,并且在Podspec中声明为s.static_framework = true , 编译生成产物为静态库,这样包体积相比动态库更小.

六、资源文件优化

  1. 无用资源清理

  • 使用LSUnusedResource工具进行未使用资源检查清理。

  • @1x图标是iPhone4以下设备才使用的,目前不需要,可以清理@1x图标。

  1. 重复资源

  • 相同的图标多个库都有一份可以考虑使用同一个。
  • Pod库中的资源生成方式

使用Cocoapods管理集成三方库时,如果Pod库的resources集成方式不对,会带来的图片重复合并问题。

 # podspec中资源生成的方式:

# 第一种:
# 是不应该使用的方式, iOS 优化IPA包体积(今日头条) 文章中说,
# * 如果是xcassets,会导致asset catalog中的图片,
# 既作为asset catalog被合并到主工程的asset.car中,
# 也会作为png被拷贝到安装包零散的存在中,导致其中一套图片白白占用了安装包空间。
s.resources = ['Home/Assets/**/*.{xcassets,png,xib}']

# 第二种:
# 应该使用的,并且图片推荐新建一个xcassets,因为这样从苹果的瘦身机制受益。
# 读取pod中图片的方式:
# 使用时是动态库,那么需要从mianBundle中获取Frameworks/Home.framework/Home.bundle然后读取
# 使用时是静态库,需要从mianBundle中获取Home.bundle然后读取
s.resource_bundles = {
  'Home' => ['Home/{Assets,Classes}/**/*.{xcassets,png,xib}']
 }
# 第三种
# 上述第二种中的方式跟下面这个方法是等价的,下面的方法是自己手动创建bundle
 s.resources = ['Home/Home.bundle']
  • ipa包中检查各个bundle里面,看图片、Xib是否打包后重复了。

# 经过排查,发现是PodSpec文件中资源的写法导致的
# 1、产生问题的写法
  s.resource_bundles = {
    'Home' => ['Home/Assets/**/*']
  }
# 2、改为写法就ok了
  s.resource_bundles = {
    'Home' => ['Home/{Assets,Classes}/**/*.{xcassets,png,xib}']
  }

3. #### 图片压缩

使用了ImageOptim工具来进行70%的微量有损压缩。

  1. 更低包体积格式图片格式

  • jpg:同样是需要@2x、@3x的图标,享受不到AppThinning

  • webP:享受不到AppThinning,且需要额外依赖webP.framework.

  • HEIC:iOS 12以上支持,将图片转为HEIC后放入到Asset Catalog中, 加载方式跟png图片一致,对于大图片效果很明显(75%左右)。但转为heic也要测试兼容性,特别是渐变透明色的图标,需要查看低版本系统显示是否有色差。

  1. Asset Catalog管理

有的图标可能会放在文件夹或者bundle中,但推荐把图标放到Asset Catalog管理,这样能从苹果App Store瘦身机制受益。注意,打包生成的ipa可能会变大,需要在App Store connect中查看变体版本的安装包大小才能看到实际收益。

  1. 资源按需加载

  • On-Demand Resource: 将图片资源设置为按需资源,不会打包进ipa,在使用时从App Store中下载资源(更适用于升级游戏应用,用户在使用APP时只需加载用户关联的几个等级的资源)。

  • 可以考虑在不影响用户体验的情况下,把本地的大图、动态图、Lotties动画放到服务器端。

七、编译设置优化

编译设置优化时,建议在Podfilepost_install函数中设置主工程和Pod的编译设置,这样能让各个Pod库也生效,也能做到仅仅设置打包环境是生效不影响编译调试耗时!

  1. GCC编译优化

通过GCC编译优化,产生体积更小的二进制产物,对OCCC++都有效果。

编译优化配置路径为:Build Settings -> Apple Clang - Code Generation -> Optimize Level

属性值大体说明
-O0不做优化
-O,O1编译器试图减少代码大小和执行时间,而不执行任何占用大量编译时间的优化。
-O2GCC执行几乎所有支持的优化,这些优化不涉及空间-速度折衷。与-O相比,此选项增加了编译时间和生成代码的性能。编译器不进行循环展开、内联函数和寄存器变量的重命名
-O3打开-O2指定的所有优化,开启内联函数和寄存器重命名选项。
-Os启用所有-O2优化,除了那些经常增加代码大小的优化。它还启用了-finline函数,使编译器根据代码大小而不是执行速度进行调整,并执行旨在减少代码大小的进一步优化。
-Ofast支持所有-O3优化。它还启用了并非对所有符合标准的程序都有效的优化。
-Oz针对大小而非速度进行积极优化。如果这些指令需要更少的字节来编码,则这可以增加执行的指令的数量-Oz的行为与-Os类似,包括启用大多数-O2优化。

我们使用-Oz,即release下设置GCC_OPTIMIZATION_LEVEL = z;后可以执行包体积上的优化。

-Oz进一步说明:在 Xcode 11 之后提供的编译优化参数 ,它通过识别单个编译单元中跨函数的相同代码序列来减少代码大小。这些序列在单个编译器生成的函数中被封装(Outlined)。每个原始代码序列都被替换为调用该 Outlined 函数。会减小相同代码存在多份问题,但是也会使得的函数调用存在更深的调用栈,对客户端性能较小。

  1. Swift 编译器的优化级别

SWIFT_OPTIMIZATION_LEVEL是一个 Swift 语言相关的构建设置,用于指定 Swift 编译器在编译 iOS 项目时应该使用的优化级别。

SWIFT_OPTIMIZATION_LEVEL设置大体说明
-Onone适用于Debug设置。无优化,保留所有的调试信息,不进行任何优化,以提供最佳的调试体验。
-O(默认值)适用于release设置。进行局部的优化包括循环优化、内联等,但不进行整个模块的优化。
-Osize适用于release设置。优化以减小生成的可执行文件的大小。

由上,将工程和pod库的编译设置改为-Osize

配合使用的还有Compliation Mode设置,可以在release设置为wholemodule

Compliation Mode设置大体说明
singlefile单个文件优化,可以减少增量编译的时间,并且可以充分利用多核 CPU,并行优化多个文件,提高编译速度。但是对于交叉引用无能为力。适合Debug.
wholemodule模块优化,最大限度优化整个模块,能处理交叉引用。缺点不能利用多核 CPU 的优势,每次编译都会重新编译整个 Module;适合Release.
  1. LTO-链接期优化参数设置

Link-Time Optimization(LTO)是一种编译优化技术,它在链接阶段对整个程序进行全局优化,而不仅仅是单个源文件。这意味着编译器能够在链接时看到整个程序的结构,从而进行更全面的优化。

具体来说,LTO 执行以下主要任务:

  1. 全局优化: LTO 允许编译器在整个程序级别上执行优化。这包括全局的循环优化、内联函数、减少冗余代码等。因为编译器能够看到整个程序的结构,它可以更好地理解程序的行为,从而进行更深入的优化。
  2. 内联: LTO 可以更好地进行函数内联。在链接时,编译器可以决定是否将函数的内容直接插入调用该函数的地方,而不仅仅是生成一个函数调用。这可以减少函数调用的开销,提高程序的性能。
  3. 消除未使用的代码: 编译器可以更好地识别和消除未使用的代码。在整个程序的上下文中,编译器能够判断哪些代码实际上没有被使用,从而减小生成的可执行文件的大小。
  4. 全局变量优化: LTO 也可以进行全局变量的优化,包括删除未使用的全局变量和优化全局变量的存储布局。

提供以下几个选项:

属性大体说明
No不开启链接期优化
Monolithic- 在这个模式下,整个程序的优化信息会被捆绑到一个单独的 LLVM Bitcode 文件中,通常使用 .lto 扩展名。
  • 这个单一的文件包含了整个程序的所有编译单元(模块)的优化信息。
  • 在链接时,编译器可以进行全局的、跨模块的优化。
  • 这种方式可以提供更高水平的优化,但生成的 .lto 文件通常较大。 | | Incremental | - 在这个模式下,优化信息被分散保存在多个小的 LLVM Bitcode 文件中,每个文件对应一个编译单元。
  • 这些小文件通常使用 .o 扩展名,并包含了相应模块的优化信息。
  • 在链接时,只有实际需要的模块会被重新优化。
  • 这种方式可以减小每次链接时需要处理的数据量,特别适用于大型项目。
  • Thin LTO 提供了一种折衷方案,旨在在提供一定程度优化的同时,减小完全 LTO 的一些开销。 |

我们在release下选择Incremental选项。开启这个优化后,一方面减少了汇编代码的体积,一方面提高了代码的运行效率。

  1. 主二进制对外暴露符号

一般情况下,iOS 应用程序的主要逻辑和功能都是在主二进制文件中实现的。为了确保应用程序的安全性和完整性,主二进制文件中的符号信息应该是内部的,不应该直接暴露给外部。这样可以防止恶意用户或攻击者直接访问和调用应用程序中的敏感函数或变量。

Xcode 中的 Symbols Hidden by Default控制是否将符号默认隐藏。设置为 Yes,则 Xcode 会在构建时使用 -fvisibility=hidden 选项来隐藏所有符号。这意味着主二进制文件中的符号会被限制在当前编译单元内部可见,不会被其他模块引用。这是一种常见的做法,以确保应用程序的符号信息不会被外部使用。

但实际查看主二进制文件可以看到主二进制还是暴露了一些符号,所以需要我们需要通过设置EXPORTED_SYMBOLS_FILE为一个空的文件来解决。

在设置为 "Symbols Hidden by Default" 为 "Yes" 后,可以使用命令行工具 nm(name list)来查看主二进制文件中的符号是否被限制在当前编译单元内部可见。

请按照以下步骤进行操作:

  1. 在终端中使用 cd 命令切换到主二进制文件的目录。例如:cd /path/to/YourApp.app
  2. 使用 nm 命令查看二进制文件的符号列表。例如:nm -gU YourApp

在输出结果中,你会看到不同类型的符号,例如函数、全局变量和局部变量。对于被隐藏的符号,可能会显示为 Uu,这意味着它们是未定义的或局部的符号。这些符号在当前二进制文件以外是不可见的。

相反,如果符号显示为 T(函数)或 D(数据),则表示它们是可见的全局函数或数据。这些符号可能是由于设置为 "No" 或其他配置,而允许在外部模块中访问的符号。

需要注意的是,这只能提供关于符号是否被隐藏的信息,而无法提供详细的函数或变量名称。如果需要查看更详细的符号信息,可以使用专门的反汇编工具(如 Hopper Disassembler)来分析和查看二进制文件的内容。

总而言之,使用 nm 命令可以查看被隐藏的符号,并判断其是否被限制在当前编译单元内部可见。

  1. Strip Linked Product

Strip Linked Product 设置为YES可以去除不需要的符号信息 需要注意Strip Linked Product 选项在 Deployment Postprocessing 设置为 YES 的时候才生效,而 Deployment Postprocessing 在 Archive 时不受手动设置的影响,会被强制设置成 YES。

结论:将Deployment Postprocessing设置为 NO,将Strip Linked Product设置为YES

八、总结

首先,要有个预研的阶段,可以从ipa构成的各个角度出发思考优化的方向,结合自己的想法,参考网上的一些做法多实践;其次,到真正运用到线上项目时,根据优化的指标目标,结合ROI、风险、用户体验等选择其中一些做法,这样对自己的APP来说才是合适的;最后,包体积优化时跟启动速度、编译速度指标也是有相关性的,在实践时需要考虑是否会影响其它指标。

参考文章:

  1. 应用的 APK 大小如何影响安装转化率
  2. What is app thinning? (iOS, tvOS, watchOS)
  3. 苹果关于__Text段大小的要求
  4. Reducing your app’s size
  5. iOS安装包大小优化笔记
  6. 抖音品质建设 - iOS 安装包大小优化实践篇
  7. 百度APP iOS端包体积50M优化实践(五) HEIC图片和无用类优化实践
  8. 如何让云音乐iOS包体积减少87MB
  9. iOS On-Demand Resources
  10. iOS 静态库和动态库对ipa包大小的影响
  11. 通过LinkMap查看各个类库链接到可执行文件中占的体积
  12. 关于Xcode编译性能优化的研究工作总结
  13. 查看ipa中的各部分大小的工具
  14. Mac上提取assets.car的图片