一次 iOS 包体优化的小记

36 阅读15分钟

摘要

包体优化表面上是在“减几 MB”,但真正做起来,背后牵扯的是资源治理、依赖收敛、分发策略、性能权衡和工程化机制。这里结合一套相对成体系的思路,梳理一下我对 iOS 包体优化的理解:先量化,再治理;先抓大头,再谈细节;最后把专项动作沉淀成持续能力。

一次 iOS 包体优化的小记

包体优化这个话题,很多时候容易被说成一些零散动作:压缩图片、删无用资源、开 -Osize、清理死代码。

这些动作当然都没错,但如果只停留在这一层,事情通常做不深。因为真实项目里的包体问题,往往不是某一个点造成的,而是长期迭代之后,资源、依赖、二进制、分发方式和工程机制一起堆出来的结果。

所以这几年我越来越倾向于把包体优化看成一个工程治理问题,而不是一个“技巧清单”。

如果让我概括这件事,我更习惯按下面四步来做:

  • 先量化,建立基线
  • 再按 ROI 排优先级,优先处理大头
  • 中间讲清楚 trade-off,避免负优化
  • 最后接入工程化机制,防止回涨

这篇随笔,主要就是顺着这条线,梳理一下我对 iOS 包体优化的一些实践理解。

先量化,而不是一上来就谈方案

我一般不会一开始就说“先压图”或者“先删资源”。

第一步一定是先回答两个问题:

  • 当前包体到底由什么构成
  • 这次优化的目标到底是什么

这两个问题看起来很基础,但其实很关键。

因为有时候业务真正关心的是 App Store 下载大小,比如要控制在某个下载门槛内,或者希望提升弱网场景下的首装转化;有时候更关心的是 安装大小,因为它会影响用户设备空间占用和长期体验。两者相关,但不完全相同,优化手段的优先级也会有差异。

在量化阶段,我通常会做几件事:

  • 拆 IPA,看资源、可执行文件、动态库、配置文件分别占多少
  • 用 LinkMap、xcrun size、otool -l 看二进制内部段分布和目标文件占比
  • 结合 App Store Connect 的下载大小和安装大小,明确优化目标
  • 对资源按类型聚类,区分图片、音视频、字体、多语言、配置文件

这一步做完之后,事情通常会变得很清楚。

因为很多时候,真正的大头并不是“代码写多了”,而是下面这几类:

  • 资源膨胀
  • 重依赖或历史依赖包袱
  • 编译与链接配置不够收敛

如果没有这一步量化,后面的优化就很容易变成拍脑袋。团队会忙很多动作,但收益未必集中。

资源治理,通常是 ROI 最高的一层

如果问我包体优化里最容易拿到明显收益的是什么,我通常会先看资源。

尤其是迭代时间比较久的业务项目,资源很容易在长期开发中自然膨胀。活动图、历史素材、重复资源、散落资源、不再使用的字体和视频,都会慢慢堆进主包。

图片:不是“全量压缩”,而是先抓大头

图片通常是第一个入口,但图片治理也最容易走偏。

真实项目里,图片优化不太适合“一刀切”地全量压缩一遍。更稳妥的方式通常是:

  • 导出图片清单,按大小排序,看 Top N
  • 看哪些图片没有进入 Asset Catalog
  • 看是否存在重复图片、历史活动残留图片、不同模块重复拷贝的素材
  • 按类型分层处理:图标、透明 UI 图、照片类、插画、大图背景分别采用不同策略

我比较倾向的一条原则是:资源治理优先做 大头 + 低风险 + 易落地 的项。

所以很多时候,第一波动作不是研究多复杂的格式,而是:

  • 把散落在 Bundle 里的图片收口到 Asset Catalog
  • 清理重复资源和无用资源
  • 清理历史活动图片
  • 对 Top 大图做抽样压缩和格式评估

图片进入 Asset Catalog 的意义,不只是资源管理更规范。更重要的是,它能更好地配合 App Slicing,让用户只下载当前设备需要的那部分资源,这对下载包大小会有比较直接的帮助。

至于图片格式本身,我一般不会只看“谁更小”,而会一起看这几个维度:

  • 是否支持透明通道
  • 画质是否能接受
  • 解码性能是否合适
  • 是否影响首屏和列表滚动
  • 工具链和设计导出流程是否可持续

也就是说,图片优化真正要平衡的是 包体、画质、性能、兼容性,不是单点追求最小。

音视频:收益明显,但更要关注体验边界

音视频资源的体积通常比较大,所以收益往往也更直接。

但这类资源的优化,通常不能只看“能不能放到服务端”,而要先看两个问题:

  • 它是不是首屏或主链路强依赖
  • 它是不是低频访问

如果是首屏依赖资源,就不太适合简单外放,更多还是要从编码格式、码率、时长、分辨率这些方向优化。
如果是低频、非核心资源,比如教程视频、引导动画、某些活动插画,那就可以评估 On Demand Resources 或服务端下发。

这里比较关键的一点是:资源外放并不是没有代价。

它能减轻首包压力,但也可能带来这些问题:

  • 首次进入页面需要等待下载
  • 弱网环境下拉取失败
  • 缓存和版本兼容变复杂
  • 离线场景不可用

所以这类优化更适合先分层、再局部试点,而不是全量铺开。

字体:经常被忽略,但常常很“值”

字体是一个很容易被忽视的点,尤其是中文字体文件,体积往往不小。

如果业务确实需要自定义字体,我通常会先问几个问题:

  • 这套字体是不是必须的
  • 是否所有页面都要用
  • 是否真的需要完整字集
  • 能不能做子集化
  • 能不能按语言或场景拆分

很多时候,一个品牌字体实际只用在少量标题或者局部视觉场景,这时候直接内置完整字库,其实性价比并不高。相对来说,做 subset 往往更符合收益预期。

无用资源清理:典型的高 ROI 动作

这类动作通常收益不小,而且改造风险相对可控。

常见做法包括:

  • 借助 LSUnusedResources、FengNiao 这类工具做初筛
  • 由工具先给候选集,而不是直接删除
  • 结合模块负责人确认动态引用、配置化引用、字符串拼接引用
  • 分批删除、分批回归

这里一个很现实的经验是:工具适合“发现问题”,不适合直接“裁决问题”。

因为 iOS 工程里常常会有反射、KVC、桥接调用、动态资源名等情况,静态扫描结果并不总是可靠。真正落地时,还是要有人做业务确认。

依赖和二进制,往往比想象中更集中

除了资源,另一个高收益点通常是依赖治理。

很多项目迭代久了之后,都会出现一些典型问题:

  • 为一个小功能引入整个重库
  • 传递依赖过多
  • 不同模块引入了重复能力
  • 历史库没人敢删,但其实已经很少使用

这类问题平时开发感知不一定强,但一旦结合 LinkMap 做聚合,往往很容易找到几个包体大户。

我自己更习惯把 LinkMap 当成体积归因工具,而不只是“看大小”的工具。它更重要的价值是帮助回答下面这些问题:

  • 哪个库最大
  • 哪个模块最大
  • 哪个目标文件异常大
  • 是不是某些符号或链接参数把整库都带进来了

例如 -ObjC 就是一个很典型的点。它在某些场景下确实必要,但也可能把静态库里的 ObjC 类和分类全量拉进最终产物,造成明显膨胀。所以更稳妥的做法通常不是“默认保留”,而是确认它到底是不是必须,有没有更精确的替代方式,比如局部 force_load。

至于编译和链接优化本身,我一般会把它理解成 Release 构建的基础卫生项,包括:

  • -Osize:以减小代码体积为目标的优化选项。相比更激进地追求运行时性能,它会更倾向于收敛生成代码大小,适合对包体敏感的业务 App。
  • Whole Module Optimization:把整个 Swift Module 放在一起做统一优化,而不是按单文件独立优化。它的价值在于让编译器获得更完整的上下文,做更有效的内联、冗余消除和代码收敛。
  • LTO:Link Time Optimization,也就是在链接阶段再做一轮跨文件、跨目标文件优化。它可以进一步消除冗余代码、合并重复逻辑,让最终产物更紧凑。
  • Dead Code Stripping:删除最终二进制里未被引用的符号和代码段,减少“编进来了但实际上没有被使用”的内容,是 Release 包体优化里很基础也很有效的一项。
  • Strip Linked Product:对最终产物做符号剥离,去掉不需要随 App 一起发布的符号信息,直接减小可执行文件体积。
  • Strip Debug Symbols During Copy:在拷贝阶段移除调试符号,避免无关的调试信息进入最终安装包,进一步压缩发布产物大小。

这些配置都应该开,而且长期来看是有收益的。
但如果从专项 ROI 来说,它们通常不是第一优先级,更像是资源和依赖治理之后的“收尾增强”。

它们的共同点,是都在做二进制层的“收敛”;但本质上解决的问题并不完全一样:有的是在编译阶段减少代码膨胀,有的是在链接阶段继续做全局优化,有的是把最终产物里不需要带上的符号和调试信息剥离掉。

不过这类优化也不是“开了就一定大幅下降”。尤其在 ObjC 工程里,动态派发、Category、反射以及 -ObjC 等链接参数,都会影响死代码剥离的实际收益。所以它们更适合作为基础收敛手段,而不是包体专项的唯一抓手。

换句话说,在大多数业务 App 里,真正收益最大的往往不是研究编译参数,而是先把资源和依赖的大头治理掉。

比“会列手段”更重要的,是讲清楚 trade-off

如果说包体优化最容易把人区分开,我觉得不是谁知道更多术语,而是谁更清楚每种方案的边界和代价。

动态库拆分,不等于包体优化

动态库拆分经常会被误认为是包体优化手段,但更准确一点说,它更偏向:

  • 架构边界治理
  • 按需加载
  • 模块解耦
  • 启动阶段负载拆分

它并不一定能减少总安装大小。相反,因为每个 Mach-O 都有额外的 header、load commands、符号表和 dyld 装载成本,拆得不合适时,体积和加载开销反而可能增加。

所以如果当前目标是“压安装包”,我通常不会把动态库拆分当成核心抓手。除非这个模块本身满足几个条件:

  • 访问频率低
  • 边界清晰
  • 能延迟加载
  • 不属于首屏强依赖
  • 拆出后不会带来新的复杂耦合

也就是说,动态库拆分不是不能做,而是要明确它优化的到底是什么。
如果目标是包体,就要谨慎;如果目标是模块治理和加载时机管理,它的价值就会更大。

图片压缩,也不能只看文件大小

图片优化里最常见的误区,就是只看“磁盘上更小”。

但客户端资源不是存下来就结束了,它最终还要经历下载、解码、渲染、缓存、重复展示这些过程。所以我更习惯把图片优化分成两个视角来看:

  • 存储侧:文件大小、下载包大小、安装包大小
  • 运行时:解码耗时、CPU、内存、首屏时间、滚动帧率

有些格式或者压缩策略,在磁盘上确实更小,但如果它刚好用于首屏或者高频列表里,可能会把解码压力放大。那从业务结果看,未必是真正的优化。

所以更稳妥的方式通常是:

  • 对 Top 大图做样本实验
  • 横向比较压缩率、画质、透明通道支持和解码表现
  • 先在低风险页面或低频场景试点
  • 观察页面渲染和弱网下载表现,再决定是否推广

很多时候,包体优化里的 trade-off,不是“我知道有风险”,而是“我知道该怎么用数据判断值不值得做”。

分发策略,优化的不只是包体数字

当我们把包体问题从“文件大小”扩展到“用户下载与安装体验”时,系统分发能力的重要性就会变得更明显。

App Thinning / App Slicing

这是一个很典型的系统能力。
如果资源管理方式不规范,很多切片收益其实是吃不到的。

所以,把图片和其他资源规范地收口到合适的资源体系里,本身就是一种“为分发优化做准备”的动作。它不一定像“删掉几十张图”那样立刻可见,但从下载包角度看,价值往往更长期。

On Demand Resources 和服务端下发

如果要简单区分两者适合什么场景,我一般会这样理解:

ODR 更适合:

  • 静态
  • 低频
  • 和版本强绑定
  • 可以接受按场景下载的资源

比如教程素材、关卡资源、AR 模型、某些低频功能页的大素材。

而服务端下发更适合:

  • 高频变化
  • 运营驱动
  • 需要灰度控制
  • 需要分地域或分人群策略的资源

比如活动 banner、节日皮肤、动态配置文案等。

两者都能降低首包压力,但本质不同:

  • ODR 更像系统层的分发能力
  • 服务端下发 更像业务层的灵活配置能力

它们都不是银弹。真正落地时,还是要一起看业务场景、弱网表现、离线要求和缓存策略。

工程化,才是防止包体回涨的关键

如果说前面的动作更偏专项治理,那工程化解决的是“后面怎么不再涨回来”。

我一直觉得,包体优化不应该只在“快超线了”时才被想起来,而应该变成一个持续被感知、被约束的工程指标。

比较常见的做法包括:

  • 在 CI 中产出每次构建的包体报告
  • 解析 LinkMap,做模块级、库级的体积 diff
  • 对大资源新增、重依赖新增做阈值告警
  • 在 MR 阶段把包体增量作为 review 维度之一

这样做的价值,不只是知道“又变大了”,而是能进一步知道:

  • 是哪个模块涨了
  • 是哪类资源涨了
  • 是不是某个三方依赖带进来的
  • 这次增长到底合不合理

只有把变化归因得足够清楚,团队才有可能形成相对稳定的治理习惯。

某种程度上说,前面的专项动作解决的是“这次怎么降”,而工程化解决的是“以后怎么稳”。

最后

回过头看,包体优化最难的地方,其实并不是缺少手段。

压图、删资源、裁依赖、开编译选项,这些都不难知道。
真正难的是三件事:

  • 有没有先量化再动手
  • 能不能分清优先级,先拿高 ROI
  • 会不会在优化过程中把首屏、弱网、离线这些体验做坏

所以如果再让我总结一次,我会更愿意把它描述成这样:

  • 包体优化的起点是数据,不是经验
  • 包体优化的核心是归因,不是罗列技巧
  • 包体优化的关键是 trade-off,不是单点极致
  • 包体优化的终点是工程化,而不是一次性的专项冲刺

它表面上看是在“减几 MB”,但真正做起来,背后牵扯的是资源体系、依赖治理、分发策略、性能权衡和团队机制。

从这个角度看,包体优化其实不只是一个性能题。
它更像是在逼着我们重新审视工程本身。