编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 性能优化实践

    • README
    • 公共方法论

    • 体系建设篇

    • 资源专项篇

    • 流水线专项

    • 业务专项篇

    • 交付防御篇

      • 崩溃捕获设计实践
      • 包体积与资源治理
        • 01.阅读说明
        • 02.贯穿案例
          • 2.1 案例背景
          • 2.2 经验派 6 周折腾
          • 2.3 方法派 10 天闭环
          • 2.4 上线效果
          • 2.5 案例串联全文
        • 03.包体积本质
          • 3.1 物理来源定义
          • 3.2 三部分组成
          • 3.3 用户感知定义
          • 3.4 反直觉问题清单
        • 04.包体积思想
          • 4.1 减拆分三大思想
          • 4.2 Tree Shaking 原理
          • 4.3 增量分发原理
          • 4.4 体积与转化关系
        • 05.度量与采集
          • 5.1 三类采集方案
          • 5.2 各方案的盲区
          • 5.3 跨平台采集对照
          • 5.4 数据可信度
        • 06.归因决策树
          • 6.1 包体积决策树
          • 6.2 资源占比归因
          • 6.3 代码占比归因
          • 6.4 ABI 与多分包
        • 07.Android 打包全链路
          • 7.1 编译流水线
          • 7.2 DEX 生成原理
          • 7.3 R8 优化原理
          • 7.4 资源打包原理
          • 7.5 AAB 分发原理
        • 08.iOS 打包全链路
          • 8.1 编译流水线
          • 8.2 Mach-O 结构
          • 8.3 LLVM 优化原理
          • 8.4 Asset Catalog
          • 8.5 App Thinning
        • 09.Web 打包全链路
          • 9.1 Bundle 流水线
          • 9.2 Tree Shaking 实战
          • 9.3 Code Splitting
          • 9.4 压缩传输链路
        • 10.Native 库全链路
          • 10.1 .so 体积构成
          • 10.2 strip 与符号
          • 10.3 ABI 选型
          • 10.4 编译期优化
        • 11.资源全链路
          • 11.1 图片格式演进
          • 11.2 字体子集化
          • 11.3 音视频压缩
          • 11.4 多语言按需
        • 12.跨端打包对照
          • 12.1 端到端流程对照
          • 12.2 优化手段对比
          • 12.3 统一启示
        • 13.治理一层删
          • 13.1 删除未用代码
          • 13.2 删除未用资源
          • 13.3 删除调试残留
          • 13.4 删除冗余依赖
        • 14.治理二层压
          • 14.1 代码压缩混淆
          • 14.2 现代图片格式
          • 14.3 资源压缩级别
          • 14.4 传输层压缩
        • 15.治理三层拆
          • 15.1 ABI 按需分发
          • 15.2 屏幕密度按需
          • 15.3 语言资源按需
          • 15.4 动态特性模块
        • 16.治理四层架构
          • 16.1 插件化架构
          • 16.2 在线化与小程序
          • 16.3 业务体积预算
        • 17.求证实验
          • 17.1 实验一:体积与转化
          • 17.2 实验二:R8 收益
          • 17.3 实验三:动态分发
          • 17.4 实验四:图片格式
          • 17.5 实验五:依赖审计
          • 17.6 五大实验启示
        • 18.实战案例
          • 18.1 跨端同构案例
          • 18.2 平台特异案例
          • 18.3 SDK 减肥案例
        • 19.防劣化体系
          • 19.1 三道防线总览
          • 19.2 编码期 Lint
          • 19.3 CI 与 SLO
        • 20.跨平台速查
          • 20.1 工具速查
          • 20.2 关键 API 速查
        • 21.总结与延伸
          • 21.1 五条核心原则
          • 21.2 五个常见误区
          • 21.3 一句话总结
      • 应用安全性能权衡
      • 弱网极端环境治理
  • 程序编程原理

  • 稳定性与可靠性

  • 工程化与运维

  • 方案设计思想

  • 专栏
  • 性能优化实践
  • 交付防御篇
杨充
2026-05-27
目录

包体积与资源治理

# 包体积与资源治理

📊 学习成本预估 | 难度:⭐⭐⭐⭐(4/5)| 阅读:约 40 分钟 | 实操:3 小时 🔗 前置阅读:卷四·01 | ➡️ 后续延伸:—

# 目录介绍

  • 01.阅读说明
  • 02.贯穿案例
    • 2.1 案例背景
    • 2.2 经验派 6 周折腾
    • 2.3 方法派 10 天闭环
    • 2.4 上线效果
    • 2.5 案例串联全文
  • 03.包体积本质
    • 3.1 物理来源定义
    • 3.2 三部分组成
    • 3.3 用户感知定义
    • 3.4 反直觉问题清单
  • 04.包体积思想
    • 4.1 减拆分三大思想
    • 4.2 Tree Shaking 原理
    • 4.3 增量分发原理
    • 4.4 体积与转化关系
  • 05.度量与采集
    • 5.1 三类采集方案
    • 5.2 各方案的盲区
    • 5.3 跨平台采集对照
    • 5.4 数据可信度
  • 06.归因决策树
    • 6.1 包体积决策树
    • 6.2 资源占比归因
    • 6.3 代码占比归因
    • 6.4 ABI 与多分包
  • 07.Android 打包全链路 ⭐
    • 7.1 编译流水线
    • 7.2 DEX 生成原理
    • 7.3 R8 优化原理
    • 7.4 资源打包原理
    • 7.5 AAB 分发原理
  • 08.iOS 打包全链路 ⭐
    • 8.1 编译流水线
    • 8.2 Mach-O 结构
    • 8.3 LLVM 优化原理
    • 8.4 Asset Catalog
    • 8.5 App Thinning
  • 09.Web 打包全链路 ⭐
    • 9.1 Bundle 流水线
    • 9.2 Tree Shaking 实战
    • 9.3 Code Splitting
    • 9.4 压缩传输链路
  • 10.Native 库全链路 ⭐
    • 10.1 .so 体积构成
    • 10.2 strip 与符号
    • 10.3 ABI 选型
    • 10.4 编译期优化
  • 11.资源全链路 ⭐
    • 11.1 图片格式演进
    • 11.2 字体子集化
    • 11.3 音视频压缩
    • 11.4 多语言按需
  • 12.跨端打包对照
    • 12.1 端到端流程对照
    • 12.2 优化手段对比
    • 12.3 统一启示
  • 13.治理一层删 ⭐
    • 13.1 删除未用代码
    • 13.2 删除未用资源
    • 13.3 删除调试残留
    • 13.4 删除冗余依赖
  • 14.治理二层压 ⭐
    • 14.1 代码压缩混淆
    • 14.2 现代图片格式
    • 14.3 资源压缩级别
    • 14.4 传输层压缩
  • 15.治理三层拆 ⭐
    • 15.1 ABI 按需分发
    • 15.2 屏幕密度按需
    • 15.3 语言资源按需
    • 15.4 动态特性模块
  • 16.治理四层架构 ⭐
    • 16.1 插件化架构
    • 16.2 在线化与小程序
    • 16.3 业务体积预算
  • 17.求证实验 ⭐
    • 17.1 实验一:体积与转化
    • 17.2 实验二:R8 收益
    • 17.3 实验三:动态分发
    • 17.4 实验四:图片格式
    • 17.5 实验五:依赖审计
    • 17.6 五大实验启示
  • 18.实战案例
    • 18.1 跨端同构案例
    • 18.2 平台特异案例
    • 18.3 SDK 减肥案例
  • 19.防劣化体系
    • 19.1 三道防线总览
    • 19.2 编码期 Lint
    • 19.3 CI 与 SLO
  • 20.跨平台速查
    • 20.1 工具速查
    • 20.2 关键 API 速查
  • 21.总结与延伸
    • 21.1 五条核心原则
    • 21.2 五个常见误区
    • 21.3 一句话总结

# 01.阅读说明

  • 本文卷归属:卷五 · 交付与防御 · 第 2 篇
  • 本文目标层级:L2 进阶 → L3 专家
  • 适用平台:Android(主) / iOS / Web / 嵌入式
  • 前置阅读:卷四·01 App 启动优化(包体积影响启动 / 安装转化)
  • 横联阅读:卷五·03 安全性能权衡(加固让包变大)
  • 本文核心命题:

    包体积是冷启动 + 安装转化 + 用户留存的瓶颈:每多 1MB 都让"下载到打开"漏斗多损失用户。 包体积 = 编译产物(代码 + 资源 + 元数据)的物理总和 + 分发策略的影响。 治理 = 思想(减 / 拆 / 分)+ 原理(Tree Shaking / 增量分发)+ 四层落地(删 / 压 / 拆 / 架构)。 关注用户实际下载,而不是构建产物大小——AAB/App Thinning 让两者差异巨大。


# 02.贯穿案例

本案例贯穿全文:§03 拿到本质、§04 拿到思想、§07-§11 各端打包原理、§13-§16 四层治理、§17 用实验复盘。

# 2.1 案例背景

某中型电商 App V8.0 的 Android 包从 V7.0 的 32MB 涨到 78MB,半年内涨了 2.4 倍,引发三个直接事故:

  • 印度市场安装转化率从 42% 跌到 28%——印度用户存储紧张,包大就放弃下载。
  • 应用商店警告:Google Play 提示"超过 50MB,建议优化"。
  • 用户卸载率:评论高频出现"占地方卸载了"。

PM 紧急要求"3 周内减到 30MB",研发组的反应是:"已经开了 R8,能减的都减了。"——这是经典的"工具迷信 + 不知道大头在哪"。

# 2.2 经验派 6 周折腾

周次 动作 结果
第 1 周 让 UI 同事换更小的图片资源 减了 2MB(50→48MB),杯水车薪
第 2 周 删除几个"看起来没用"的字符串 减了几 KB
第 3 周 把 PNG 全转 JPEG 部分图片质量明显下降,被 PM 打回
第 4 周 升级所有第三方库到最新版 反而多了 5MB(新版本含更多功能)
第 5 周 启用 R8 fullMode 上线后线上 1% 设备崩溃(反射类被误删)
第 6 周 紧急回滚 R8 fullMode 回到 78MB,6 周白干

复盘:六周折腾错在"凭感觉减"。真相是:没人做过组成分析——没人知道这 78MB 里代码占多少、资源占多少、单文件最大是哪个、哪些库可以替换。优化前必须先量化,否则就是盲人摸象。

# 2.3 方法派 10 天闭环

Day 1(§05 度量 + §06 归因):用 apkanalyzer 拆开看:

  • DEX 18MB(业务代码 + 第三方库)。
  • Native .so 32MB(含 4 个 ABI!其中 x86 / x86_64 占 16MB,但用户里 99.9% 是 ARM)。
  • 资源 22MB(图片 16MB,其中单张启动图就 3MB——iOS 设计师传错文件)。
  • 元数据 + 调试 6MB。

Day 2-3(§13 删 + §14 压):

  • 删 ABI:abiFilters 'arm64-v8a', 'armeabi-v7a',删了 16MB(x86 / x86_64)。
  • 删未用资源:shrinkResources + 手动审计,删了 3MB。
  • 压图片:PNG → WebP,省 4MB;启动图重新切,省 2.5MB。

Day 4-5(§15 拆):

  • 上 App Bundle:用户按设备下载,arm64 设备只下 arm64 那份 → 用户实际下载再砍一半。
  • 多语言按需:原本含 30 种语言全量,AAB 自动按用户语言下发。

Day 6-7(§16 架构 + §13 删依赖):

  • 审计第三方库:发现引了 3 个 JSON 库(GSON + Jackson + Moshi),统一到 Moshi 减 3MB。
  • 替换 OkHttp 旧版 + 多余 Glide 模块 → 减 2MB。

Day 8(R8 精确化):开 R8 fullMode 但只对自己代码,不混淆反射依赖的库,配 keep 规则 → 减 4MB 无崩溃。

Day 9-10(灰度 + §19 SLO):包大小阻断式 CI 卡口上线。

# 2.4 上线效果

指标 经验派 6 周后 方法派 10 天后 行业基准
构建产物(APK) 78 MB 42 MB < 50MB
用户实际下载(AAB) 78 MB 22 MB < 30MB
印度安装转化 28% 41% > 35%
Play Store 警告 触发 未触发 未触发
卸载率(30 天) 12% 6% < 8%

核心洞察:"构建产物 ≠ 用户实际下载"。经验派盯着 APK 大小做了 6 周,但 Play 用户其实根本没下载那个 APK——AAB 上传后用户下的是裁剪版。关注真正影响用户的指标才是包优化的起点。

# 2.5 案例串联全文

  • §03 包体积本质 ▶▶ 三部分组成 + 用户感知(印度敏感度)。
  • §04 包体积思想 ▶▶ 减 / 拆 / 分 三个思想恰好对应三周治理。
  • §07-§11 各端原理 ▶▶ 案例 78→42MB 减半的依据全在这里。
  • §13-§16 四层治理 ▶▶ 删 / 压 / 拆 / 架构——案例 10 天闭环逐层实施。
  • §17 求证实验 ▶▶ §17.2 R8 收益、§17.3 动态分发、§17.5 依赖审计 都在案例中变现。

# 03.包体积本质

包体积不是一个简单的"文件大小"——它是编译产物 + 分发策略 + 用户感知的复合产物。理解它的多层定义是后续所有治理决策的认知前提。

# 3.1 物理来源定义

包体积的物理来源 = 源码编译后的全部产物 + 配套元数据:

   源代码(.java / .kt / .swift / .ts ...)
        ↓ 编译
   字节码 / 机器码(.class / .o / .js)
        ↓ 链接 / 打包
   平台特定可执行体(DEX / Mach-O / Bundle)
        ↓ 加上资源
   原始安装包(APK / IPA / Bundle)
        ↓ 签名 / 加固 / 优化
   最终上架包(AAB / IPA / dist)
1
2
3
4
5
6
7
8
9

关键事实:

  • 包体积 ≠ 源码体积——编译后的字节码通常比源码大 3-5 倍(包含类型信息、调试信息、运行时元数据)。
  • 包体积 ≠ 安装后体积——APK 解压、Mach-O 加载、JS Bundle 解压后通常占用 2-3 倍于安装包的空间。
  • 包体积 ≠ 用户下载体积——AAB / App Thinning / Code Splitting 让用户实际下载远小于构建产物。

探索性思考:为什么"源代码 100KB 的 Hello World"打成 APK 也有几 MB? 因为最小 Android 应用必须包含:Android 框架的兼容库(AppCompat 等)、Kotlin 运行时、Material Design 资源、应用图标的多套密度版本、签名信息……这些"基础设施"是平台强制要求。这就是为什么"无论怎么减都减不到 < 1MB"——这是平台地板。

# 3.2 三部分组成

   总包体积 = 代码 + 资源 + 元数据 / 配置

   ┌──────────────────────────────────────┐
   │ ① 代码(30-60%)                       │
   │ - DEX / Java / Kotlin(Android)       │
   │ - Mach-O 可执行 / dylib(iOS)         │
   │ - JS Bundle(Web)                     │
   │ - .so / .a Native 库                   │
   ├──────────────────────────────────────┤
   │ ② 资源(30-60%)                       │
   │ - 图片(最大头)                       │
   │ - 字符串 / 配置                        │
   │ - 字体 / 音频 / 视频                   │
   │ - HTML / CSS                          │
   ├──────────────────────────────────────┤
   │ ③ 元数据 / 配置(5-10%)               │
   │ - Manifest / Info.plist                │
   │ - 签名 / 加固元数据                    │
   │ - 调试信息                             │
   └──────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

关键认知:

  • 优化前必须先做组成分析——不知道大头在哪就不知道往哪优化。
  • 不同应用大头不同:游戏 70% 资源;工具 60% 代码;图片应用 80% 资源。
  • 每减 1% 大头 ≈ 减 5-10% 小头——精力要花在大头上。

典型应用的组成分布:

应用类型 代码占比 资源占比 元数据占比 重点优化
重业务工具 60% 35% 5% R8 + 依赖审计
图片社交 30% 65% 5% 资源压缩 + 按需
游戏 25% 70% 5% 资源动态下载
小程序容器 20% 50% 30% 容器精简
嵌入式 80% 15% 5% 编译选项 + strip

# 3.3 用户感知定义

用户对"包大小"的感知不是绝对值,而是相对于使用场景:

场景 用户耐心 阈值
WiFi 下载新应用 较高 < 100MB 都能接受
4G 下载新应用 中 < 30MB 较好 < 50MB 还行
应用更新 高 增量更新 < 10MB 无感
打开应用看到"占用 500MB" 低 "卸载腾空间"

Google 数据(Android 官方研究):

  • APK 每减 6MB,安装转化 +1%(中位)。
  • 30-50MB 区间敏感度最高——每减 10MB 转化率 +2-2.5%。
  • < 20MB 后边际收益递减。
  • 印度 / 东南亚 / 拉美比北美 / 欧洲更敏感(设备存储小、流量贵)。

探索性思考:为什么"用户安装时不在乎大小、卸载时却很在乎"? 因为安装时是目标驱动("我要用这个应用")——大小只是阻力之一。 卸载时是清理驱动("我要腾空间")——这时用户会按"占用大小"逆序排序。 同一个 100MB 应用,装的时候用户不在乎,存了半年后会被第一个卸载。这是为什么"安装后体积"比"安装包体积"在长期更重要——前者决定了你能在用户设备里活多久。

# 3.4 反直觉问题清单

带着这些问题阅读:

  1. 包体积每多 1MB 转化率掉多少?
  2. ProGuard / R8 真的能减少 30% 吗?
  3. 多 ABI 包 vs 单 ABI 体积差多少?
  4. 动态分发(App Bundle)能减多少?
  5. WebP 替代 PNG 能省多少?
  6. AAB 上传后用户实际下载多大?
  7. iOS Bitcode 影响体积吗?
  8. 代码混淆能减小多少?

# 04.包体积思想

包体积治理不是"逐个文件减字节"——而是思想层的多种策略组合。理解这些核心思想,才能跳出"剪切贴照片"的低级优化层次。

# 4.1 减拆分三大思想

包体积治理的所有手段都可以归纳为三种思想:

┌─────────────────────────────────────────┐
│ 思想一:减(Reduce)                       │
│   不必要的内容根本不打包                    │
│   - 删未用代码(Tree Shaking / R8)         │
│   - 删未用资源(shrinkResources)           │
│   - 删调试信息(strip / minify)            │
│   - 删冗余依赖(Dependency Audit)          │
├─────────────────────────────────────────┤
│ 思想二:压(Compress)                     │
│   必要的内容用更小的格式                    │
│   - 代码混淆(短名 / 内联 / 常量折叠)       │
│   - 现代图片(WebP / AVIF / HEIC)          │
│   - 字体子集化                             │
│   - 传输压缩(gzip / brotli / zstd)        │
├─────────────────────────────────────────┤
│ 思想三:拆(Split)                        │
│   按需分发,用户只下载需要的                │
│   - ABI 拆分(按 CPU 架构)                │
│   - 屏幕密度拆分                           │
│   - 多语言拆分                             │
│   - 动态特性模块(按功能模块)              │
│   - Code Splitting(按路由)               │
└─────────────────────────────────────────┘

每种思想各有侧重,组合使用才能最大化收益
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

三种思想的本质差异:

维度 减 压 拆
作用对象 "不必要的" "格式低效的" "并非所有用户都要的"
改造成本 低 中 高
典型收益 -10-30% -20-50% -30-60%
需配套设施 静态分析 编解码器 分发渠道
风险 误删 兼容性 启动期下载

探索性思考:为什么"减"是最被低估的思想? 因为"减"的本质是承认浪费——团队心理上不愿承认"我们引入了 5MB 没用的代码"。所以工程师更倾向于"压缩"和"优化",听起来像"提升技术"。但实际上 30% 的包都是浪费(实测数据)——你引入的依赖里大部分函数没用、资源里大部分文件没用、第三方库的功能你只用了 10%。最大的优化收益往往来自删,不是来自压。

# 4.2 Tree Shaking 原理

Tree Shaking(摇树优化)是"减"思想的最重要技术——自动识别并删除未被引用的代码:

应用代码:                              依赖库:
   import { useDataA } from 'lib'        export function useDataA() { ... }
                                         export function useDataB() { ... }
                                         export function useDataC() { ... }
       │
       ▼
   Tree Shaking 分析:
   1. 扫描所有 import / require
   2. 构建函数引用图
   3. 找到根节点(main / 入口)
   4. 标记可达节点
   5. 删除不可达节点
       │
       ▼
   最终产物只含 useDataA + 依赖链
   useDataB / useDataC 被删除
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

Tree Shaking 能成立的两个前提:

① 静态可分析:import 必须是静态的,不能是动态字符串:

// 可以 Tree Shake
import { useDataA } from 'lib';

// 无法 Tree Shake
const lib = require(`./mod-${type}`);  // 动态路径
1
2
3
4
5

② 无副作用(side-effect-free):模块的代码不能有"导入即执行的全局副作用":

// ❌ 有副作用:哪怕没用 polyfillFn,polyfill 也会执行
window.someGlobal = "init";
export function polyfillFn() { ... }

// ✅ 无副作用:纯函数模块
export function pureFn() { ... }
1
2
3
4
5
6

package.json 的 "sideEffects": false 字段告诉打包器"这个模块没副作用,可以放心 Tree Shake"。

各端 Tree Shaking 实现:

端 工具 实现
Web JS Webpack / Rollup / esbuild / Vite 静态 ESM 分析
Android Java R8 调用图可达性分析
iOS Swift LLVM dead_strip 链接期 unreachable 剔除
Native C/C++ --gc-sections linker 段级别剔除

探索性思考:为什么 CommonJS(require)天然不 Tree Shake 友好? 因为 require 是运行时函数调用——require(name) 的 name 可以是任何字符串,编译期不可知。打包器只能"全部打进来"。ESM 的 import 是编译期声明——所有依赖关系在编译时就确定,可以做精确的可达性分析。这是为什么 ESM 的发明对包体积是革命性的。

# 4.3 增量分发原理

增量分发是"拆"思想的核心技术——用户只下载与自己设备/场景相关的部分:

传统打包(Fat APK):
   一个 APK 含全部 ABI / 屏幕密度 / 语言
        │
        ▼
   所有用户下载完全一样的包
        │
        ▼
   arm64 用户白下了 armv7 + x86 的代码
   1080P 用户白下了 720P 资源
   英文用户白下了中文资源

增量分发(App Bundle):
   开发者上传一个 AAB(含全部)
        │
        ▼
   Google Play 服务器拆解
        │
        ▼
   按用户设备裁剪:
   ┌─ arm64 用户 → 只下 arm64 代码
   ├─ 1080P 屏幕 → 只下高密度资源
   └─ 英文用户 → 只下英文字符串
        │
        ▼
   平均每用户下载 = 总包的 50%
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

增量分发的两种实现思路:

思路 A:服务端拆分(Google Play AAB / Apple App Thinning):

  • 开发者上传"全量包"。
  • 分发服务器为每种设备组合预生成"瘦身包"。
  • 用户请求时返回最匹配的瘦身包。

思路 B:客户端按需下载(Dynamic Feature / On-Demand Resources):

  • 安装包只含"核心功能"。
  • 进入特定功能时再下载该功能的代码 / 资源。
  • 例:Google Maps 安装包不含离线地图,使用时才下。

探索性思考:为什么"增量分发"对国内市场是个挑战? 因为国内市场依赖百花齐放的应用商店(华为 / 小米 / OPPO / 魅族 等等),它们都没有 Google Play 的"自动拆分"能力。开发者必须自己为每个市场打不同的 APK——多 ABI / 多密度 / 多语言的组合可能上百个。这是为什么国内大厂自研"动态化方案"(如阿里 Andromeda、腾讯 Tinker、小米的渠道差分包)——没有平台级支持,就要自己造基础设施。

# 4.4 体积与转化关系

包体积的最终目的是用户体验,必须量化它对业务指标的影响:

包体积 ↑
   │
   ├──▶ 下载时长 ↑ → 安装放弃率 ↑
   │
   ├──▶ 解压时长 ↑ → 安装失败率 ↑
   │
   ├──▶ 应用启动慢 ↑ → 留存 ↓
   │
   ├──▶ 用户存储占用 ↑ → 卸载率 ↑
   │
   └──▶ 应用商店警告 → 推荐降级
1
2
3
4
5
6
7
8
9
10
11

经典数据(§17.1 实验):

包大小区间 每减 10MB 转化率收益
80-50MB +2.5%
50-30MB +2%
30-20MB +1%
20-10MB +0.3%
< 10MB 边际递减

核心结论:重点投入区间是 30-60MB——这里 ROI 最高。已经做到 < 20MB 不必狂减,应该把精力投到其他地方。

探索性思考:为什么"包再小也不能等于零"? 三个原因: ① 平台基础设施有最小尺寸(Android 框架兼容包 ~3MB、iOS 系统库 ~5MB)。 ② 过度优化的代价:为减最后 1MB 可能要重写整套基建(如自实现 JSON 解析)——投入产出极低。 ③ 用户其实不在乎"5MB vs 8MB":心理学阈值在 30MB / 50MB / 100MB 这种"档位",不是连续的。

包体积治理的目标永远是"跨过下一个档位",不是"逼近物理零"。


# 05.度量与采集

# 5.1 三类采集方案

   ① 包大小总量(构建产物)
   ② 内部组成分析(apkanalyzer / Bundle Analyzer)
   ③ 用户实际下载(Play Console / App Store Connect)
1
2
3

① 包大小总量——每次构建后记录最终包大小,跟踪版本间变化。物理本质:构建产物的物理字节数。适用边界:CI 必备的最简监控;不知组成。

② 内部组成分析——用工具拆开包看每个文件 / 每个类的体积。物理本质:把"包"还原成"内容明细"。适用边界:定向优化必备;查找大头。

平台 工具
Android apkanalyzer / Android Studio APK Analyzer
iOS Xcode App Thinning Size Report
Web webpack-bundle-analyzer / source-map-explorer

③ 用户实际下载量——渠道数据反映用户最终下载体积。物理本质:分发后的真实数据,与构建产物可能差几倍。适用边界:评估真实用户感知。

三类方案的总览

方案 钩子位置 数据粒度 性能开销 跨端通用性 主要局限
① 总量 构建产物 文件级 极低 高 不知组成
② 组成 内部分析 类 / 资源级 极低 高 工具差异
③ 实际下载 渠道数据 用户级 渠道提供 中 数据滞后

组合定律:① 必备 + ② 优化前必做 + ③ 评估真实收益。

# 5.2 各方案的盲区

现象 方案 ① 方案 ② 方案 ③
包总大小 ✅ ✅ ✅(实际下载)
哪个资源大 ❌ ✅ ❌
哪个类大 ❌ ✅ ❌
哪个依赖库占比 ❌ ✅ ❌
用户实际下载 ❌ ❌ ✅
多 ABI 实际占比 ❌ 部分 ✅

# 5.3 跨平台采集对照

维度 Android iOS Web
总量 apk / aab 文件大小 ipa 文件大小 dist 文件夹大小
组成 apkanalyzer / AS APK Analyzer App Thinning Size Report bundle-analyzer
实际下载 Play Console "Download size" App Store Connect CDN 日志
增量分析 apkanalyzer compare 自定义 自定义 diff

# 5.4 数据可信度

数据 可信度 偏差来源
构建产物大小 高 文件系统精确
各资源占比 高 工具直接分析
实际下载(Play) 高 Google 直接统计
安装后大小 中 解压 + 缓存 + dex2oat 等差异

# 06.归因决策树

# 6.1 包体积决策树

包体积过大
   │
   ├── 代码占比 > 50% ──▶ 代码优化
   │                     ├─ 启用 R8 / Terser 全量
   │                     ├─ 删除未使用的库(§13.4)
   │                     └─ 评估 Native .so 大小
   │
   ├── 资源占比 > 50% ──▶ 资源优化
   │                     ├─ 图片格式(WebP / AVIF)
   │                     ├─ 大资源动态分发(§15.4)
   │                     └─ 压缩级别(aapt2 / Asset Catalog)
   │
   ├── ABI 占比 ──────────▶ ABI 优化
   │                     ├─ 删除 x86 / armv7(如已不支持)
   │                     └─ 用 App Bundle 自动按需
   │
   └── 元数据 / 调试 ───▶ 元数据优化
                       ├─ 删除调试符号(release)
                       └─ 删除未用 manifest 配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 6.2 资源占比归因

资源中典型分布:

类型 典型占比 优化方向
图片 40-60% 现代格式、下采样、按需分发(§11.1)
字体 5-15% 子集化、按需加载(§11.2)
音频 / 视频 视应用 低码率、按需下载(§11.3)
字符串 5-10% 多语言按需加载(§11.4)
其他 视情况 评估必要性

# 6.3 代码占比归因

代码中典型分布:

类型 占比 优化
业务代码 视项目 R8 / Terser 移除未用
第三方库 通常大头 评估必要性 / 替换轻量库
Native .so 视项目 strip / 分 ABI(§10)
编译产物 视编译选项 release 编译

# 6.4 ABI 与多分包

Android 多 ABI 的典型情况:

   只含 armv7:             ~10-30 MB
   armv7 + arm64:           ~20-60 MB
   armv7 + arm64 + x86 + x86_64: ~40-120 MB
1
2
3

App Bundle 后用户实际下载只含设备 ABI,节省 50%+。详见 §15.1。


# 07.Android 打包全链路

本章把 Android 打包从"源码"一路拆到"用户设备上的安装文件",回答四个核心问题:源码如何变成 DEX / R8 怎么减小代码 / 资源怎么打包 / AAB 怎么按设备分发。

# 7.1 编译流水线

Android 完整打包流程:

   Java/Kotlin 源码 (.java / .kt)
        ↓ javac / kotlinc
   Java 字节码 (.class)
        ↓ R8(混淆 + 优化 + DEX 转换)
   Dalvik 字节码 (classes.dex / classes2.dex ...)
        ↓
   ┌──────────────────┐    ┌──────────────────┐
   │  资源 (res/*)    │    │ Native (.so)     │
   │   ↓ aapt2        │    │  来自 jniLibs    │
   │  resources.arsc  │    │  按 ABI 分目录    │
   └──────────────────┘    └──────────────────┘
        │       │                    │
        └───────┴────────┬───────────┘
                         ↓
                   合并打包 + 签名
                         ↓
                  APK 或 AAB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

关键阶段:

  • DEX 生成:Java 字节码 → Android 专用的 Dalvik 字节码格式(详见 §7.2)。
  • R8 优化:混淆 + 压缩 + Tree Shaking + Inline(详见 §7.3)。
  • aapt2 资源处理:编译资源 + 生成资源 ID 表(详见 §7.4)。
  • AAB 签名分发:上传 Google Play 后按设备分发(详见 §7.5)。

# 7.2 DEX 生成原理

DEX 是为移动设备优化的字节码格式——比标准 Java .class 文件更紧凑:

Java .class(每个类一个文件):
   常量池在每个文件里重复
   多个类的字符串"hello"被存多次
   
DEX(多个类合并):
   全局共享常量池
   字符串只存一次
   方法/字段表全局合并
1
2
3
4
5
6
7
8

DEX 的体积优化设计:

  • 共享常量池:所有类共享一个字符串/类型/方法引用表。
  • 字段类型隐式编码:用最少字节表达类型信息。
  • 指令集精简:Dalvik VM 的指令比 JVM 字节码少(仅 256 种基本指令)。

结果:相同代码 DEX 比 .class 小 30-40%。

多 DEX 问题:单个 DEX 最多含 65536 个方法引用(method ref limit)。超过时必须拆 classes2.dex、classes3.dex...

小应用:   classes.dex (1 个)
中应用:   classes.dex + classes2.dex (主 + 次)
大应用:   classes.dex + classes2.dex + classes3.dex + ...
1
2
3

探索性思考:为什么"多 DEX 启动慢"? Android 5.0 之前,启动时只加载主 DEX,次 DEX 在第一次访问时才加载——第一次启动有"加载所有 DEX"的额外耗时(可达几秒)。Android 5.0+ 用 ART 后,所有 DEX 在安装时合并优化(dex2oat),启动期不再有这个问题。但 APK 总体积仍受影响——多 DEX 文件的元数据有重复。

# 7.3 R8 优化原理

R8 是 Android 的"四合一"优化器,干了 4 件事:

R8 = Tree Shaking(删未用代码)
   + Optimization(内联 / 常量折叠)
   + Obfuscation(短名混淆)
   + Dexing(生成 DEX)
1
2
3
4

① Tree Shaking(最大头):

入口(Application/Activity 等)
   ↓ 调用图分析
找到所有"可达"的类/方法/字段
   ↓ 反向
删除"不可达"的部分
1
2
3
4
5

② Optimization:

  • 方法内联:小方法直接展开到调用点 → 减少方法表 + 调用开销。
  • 常量折叠:if (BuildConfig.DEBUG) 在 release 永远是 false → 整个 if 块删除。
  • 死代码消除:if (false) { ... } 整段删除。

③ Obfuscation:

原始:UserListActivity.refreshData() 
混淆:a.b.c.a()
1
2

节省:每个名字从 30 字节 → 1-2 字节,大量类合计能减 5-10MB。

④ Dexing:直接生成最优化的 DEX,避免"先 .class 再转 DEX"的中间损失。

§17.2 实验 的实测:R8 全量启用 → Android 包减 30-35%。

探索性思考:为什么 R8 比 ProGuard 更安全? ① 更激进的优化:R8 的内联和死代码剔除比 ProGuard 强(基于 IR 而非字节码)。 ② 直接生成 DEX:ProGuard 后还要 D8 转 DEX,两步可能丢失优化机会;R8 一步到位。 ③ 更好的兼容性:R8 是 Google 官方维护,与 Android Gradle Plugin 深度集成。 这是为什么 AGP 4.0+ 默认禁用 ProGuard 切换 R8——没有理由还用旧工具。

# 7.4 资源打包原理

aapt2 是 Android 的资源编译器:

res/                                   resources.arsc + 编译后资源文件
├── drawable/icon.png                       ↓
├── drawable-hdpi/icon.png             ┌──────────────────────────┐
├── drawable-xhdpi/icon.png            │ 资源 ID 表(resources.arsc)│
├── values/strings.xml                 │  string/app_name = "..."  │
├── values-zh/strings.xml              │  drawable/icon = id 0x7f02...│
├── layout/activity_main.xml           └──────────────────────────┘
└── ...                                + 编译后的 .png / .xml 等
1
2
3
4
5
6
7
8

资源处理的关键优化:

  • PNG 压缩:aapt2 自动用 zopfli(更慢但更好的 deflate 实现)压缩 PNG。
  • 9-patch 切片:拉伸图只存最少像素 + 拉伸规则。
  • shrinkResources:基于 R8 的代码引用图,删除"代码里不再引用的资源"。

资源 ID 机制:每个资源(图片/字符串/布局)分配一个 32 位 ID,代码用 R.drawable.icon 这种符号引用。这套机制使得:

  • 同名不同密度的资源(icon.png in drawable-mdpi/hdpi/xhdpi)共享同一 ID,运行时按设备选择。
  • AAB 可以把"密度 = xhdpi 的所有资源"打成一个独立模块(详见 §7.5)。

# 7.5 AAB 分发原理

**AAB(Android App Bundle)**是 Google Play 的"全量上传 + 按需分发"机制:

开发者本地:
   生成一个 .aab 文件(含全部 ABI / 全部密度 / 全部语言)
        ↓
   上传到 Google Play
        ↓
Google Play 服务端:
   把 AAB 拆解成 N 个 split APK:
   ┌─ base.apk           (所有用户都需要)
   ├─ split_arm64_v8a.apk (arm64 用户)
   ├─ split_armeabi_v7a.apk (armv7 用户)
   ├─ split_xhdpi.apk    (高密度屏)
   ├─ split_zh.apk       (中文用户)
   ├─ split_en.apk       (英文用户)
   └─ ...
        ↓
   用户请求安装时:
   Play 根据用户设备返回匹配的 split:
   - 用户 A(arm64 + xhdpi + 简中)→ base + arm64 + xhdpi + zh
   - 用户 B(armv7 + hdpi + 英文)→ base + armv7 + hdpi + en
        ↓
   设备本地合并安装
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

实测收益(§17.3 实验):用户实际下载比 APK 少 50%+。

Dynamic Feature Module:把"非首屏功能"独立成模块,用户首次安装不下载,进入功能时按需下载:

base APK(10MB):核心功能
   + 模块 A "AR 试穿"(20MB):进入相机时下载
   + 模块 B "高级编辑"(15MB):付费用户才下载
1
2
3

探索性思考:为什么"AAB 是 Google Play 的政策武器"? Google Play 自 2021 年起强制新应用必须用 AAB——这不只是技术升级,更是商业策略:

  • Google 控制分发:开发者上传"全量",Google 决定"用户下什么"——开发者失去对最终包的完整控制。
  • AAB 文件 Google Play 独有:其他应用商店不接受 AAB(他们要 APK),形成 Google Play 的护城河。
  • 签名密钥托管:用 Play App Signing 后,签名密钥存在 Google——开发者切换到其他渠道更困难。

国内厂商面对这个问题时的方案:自己实现"渠道差分"机制(同 AAB 思想,但自主可控)。


# 08.iOS 打包全链路

本章拆解 iOS 打包流程,回答:Mach-O 与 ELF 有何不同 / LLVM 的 dead_strip 怎么减小代码 / Asset Catalog 凭什么比目录式资源更省 / App Thinning 自动到什么程度。

# 8.1 编译流水线

iOS 打包流程:

   Swift / Objective-C 源码
        ↓ swiftc / clang
   LLVM IR
        ↓ LLVM 优化(含 dead_strip)
   目标文件 (.o)
        ↓ ld 链接
   Mach-O 可执行 / 动态库 (.app/AppExe / .framework/*.dylib)
        ↓
   ┌──────────────────┐    ┌──────────────────┐
   │ 资源 (xcassets)  │    │ Storyboard       │
   │  ↓ actool        │    │  ↓ ibtool        │
   │ Assets.car       │    │ .storyboardc     │
   └──────────────────┘    └──────────────────┘
        │       │                    │
        └───────┴────────┬───────────┘
                         ↓
                  签名打包 (.ipa)
                         ↓
                上传 App Store Connect
                         ↓
              App Thinning(按设备分发)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

关键阶段:

  • LLVM 优化:跨整个项目的全局优化(详见 §8.3)。
  • Mach-O 生成:iOS 专用的可执行格式(详见 §8.2)。
  • Asset Catalog 编译:xcassets → Assets.car(详见 §8.4)。
  • App Thinning:服务端按设备裁剪(详见 §8.5)。

# 8.2 Mach-O 结构

Mach-O 是苹果系统的可执行格式,结构:

┌─────────────────────────────┐
│ Header(识别 + 架构信息)   │
├─────────────────────────────┤
│ Load Commands(段表)        │  ← 告诉 loader 如何加载
├─────────────────────────────┤
│ __TEXT 段(只读代码)        │
│  - __text(机器指令)        │
│  - __cstring(字符串常量)   │
│  - __const(常量)           │
├─────────────────────────────┤
│ __DATA 段(可读写数据)      │
│  - __objc_classlist(OC 类) │
│  - __objc_methname           │
│  - __data(全局变量)        │
├─────────────────────────────┤
│ __LINKEDIT 段                │
│  - 符号表 / 字符串表         │
│  - 动态链接信息              │
│  - 代码签名                  │
└─────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

iOS 与 Android 的对比差异:

维度 Android(DEX) iOS(Mach-O)
字节码 vs 机器码 字节码(运行时翻译) 机器码(直接执行)
加载方式 dex2oat 优化 dyld 加载
反编译难度 低 高(需要反汇编)
优化时机 运行时 + 安装期 编译期(LLVM)

Mach-O 体积构成(典型 iOS 应用):

段 占比 说明
__TEXT.__text 30-40% 实际机器指令
__TEXT.__cstring 10-15% 字符串常量
__DATA.__objc_* 5-15% OC runtime 元数据
__LINKEDIT 10-20% 符号 + 签名
Swift 元数据 5-15% Swift 反射信息

探索性思考:为什么 Swift 应用通常比同等 OC 应用大 5-10MB? ① Swift Runtime:早期 iOS 版本不内置 Swift Runtime,应用要自带(~5MB)。iOS 12.2+ 开始内置,新应用不再背这个包袱。 ② 泛型特化:Swift 编译期为每种泛型类型生成专门代码——通用性换体积。 ③ 元数据更多:Swift 的反射、协议见证表(Protocol Witness Tables)等运行时设施比 OC 多。

这就是为什么"纯 Swift 项目"通常比"纯 OC 项目"大——但 Swift 带来的开发效率提升通常值这个代价。

# 8.3 LLVM 优化原理

LLVM 是 iOS 的"R8"——做大量编译期优化:

优化 含义 体积收益
dead_strip 链接期删除未引用的符号 5-15%
Function Inlining 小函数直接内联 减小调用开销
Constant Folding 编译期计算常量表达式 微
Dead Code Elimination 删除 if (false) 类不可达分支 视项目
Link-Time Optimization (LTO) 跨文件优化(整个程序作为一个单元) 5-10%

dead_strip 的工作原理:

链接期所有 .o 文件 → 合并 → 找入口(main / __TEXT 入口)
   ↓
从入口反向分析"哪些符号实际被引用"
   ↓
删除未引用的符号(包括 OC class、Swift 函数、C 函数)
   ↓
最终的 Mach-O 不含死代码
1
2
3
4
5
6
7

关键 Build Settings(Xcode):

Dead Code Stripping = YES
Strip Linked Product = YES
Symbols Hidden by Default = YES
Link-Time Optimization = Incremental
1
2
3
4

探索性思考:为什么 iOS 没有"R8 反射陷阱"? Android 反射常用 Class.forName("com.app.SomeClass") 这种动态字符串——R8 不知道这个字符串值,无法判断 SomeClass 是否被引用,可能误删。

iOS(Swift / OC)反射通常用 NSClassFromString 或 Swift 的 String(describing:)——但 OC runtime 的所有类都被强引用(__objc_classlist 段保留所有类的引用),即使 dead_strip 也不会删 OC 类。Swift 的反射有更严格的限制,需要类标 @objc 或符合特定协议。

这是 iOS 比 Android 在"代码优化"上更安全的根本原因——平台层级保留了反射所需的元数据。

# 8.4 Asset Catalog

Asset Catalog(.xcassets)是苹果设计的资源管理系统——比传统目录式资源更省 30-50%:

传统目录式(Android 风格):
   res/drawable/icon.png        100KB
   res/drawable-hdpi/icon.png   200KB
   res/drawable-xhdpi/icon.png  400KB
   res/drawable-xxhdpi/icon.png 800KB
   总计:1500KB(每个文件独立 PNG)

Asset Catalog(iOS):
   icon.imageset/
       icon@1x.png  100KB
       icon@2x.png  200KB
       icon@3x.png  400KB
   ↓ actool 编译
   Assets.car(统一容器)
   总计:~600KB(共享元数据 + 更好压缩)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

Asset Catalog 的体积优化:

  • 统一容器:所有资源在一个 Assets.car 文件里,元数据共享。
  • 格式选择:自动选择最优编码(lossless PNG vs lossy WebP-like)。
  • App Slicing:构建时按设备 @2x/@3x 分别打包,分发时只发对应版本。
  • App Icon 自动处理:所有图标尺寸(20pt/29pt/40pt/60pt + 1x/2x/3x)一处管理。

性能附加收益:

  • 加载速度比读分散文件快 2-3×(一次 mmap)。
  • 内存占用更少(按需解码)。

# 8.5 App Thinning

App Thinning 是苹果版的 AAB——自动按设备分发:

开发者上传完整 .ipa(含全部架构 + 全部 @1x/@2x/@3x 资源)
        ↓
App Store Connect 处理
        ↓
拆分为多个变体:
   ┌─ iPhone 14 Pro(arm64e + @3x)→ App Variant A
   ├─ iPhone SE(arm64 + @2x)→ App Variant B
   ├─ iPad Pro(arm64 + @2x + iPad 资源)→ App Variant C
   └─ ...
        ↓
用户从 App Store 下载时:
   设备型号 → App Store → 返回匹配的 Variant
1
2
3
4
5
6
7
8
9
10
11
12

关键事实:

  • App Thinning 全自动:开发者无需配置。
  • iOS 9+ 全部支持:覆盖率不是问题。
  • 典型节省 30-50%:单设备下载量比"全量包"小一半。

On-Demand Resources(ODR):iOS 版的"动态特性"——把资源标记为 ODR 后安装时不下载:

let request = NSBundleResourceRequest(tags: ["level5"])
request.beginAccessingResources { error in
    // 资源已下载,可使用
    let image = UIImage(named: "level5_bg")
}
1
2
3
4
5

适用场景:游戏关卡、教育内容、不常用的功能资源。

探索性思考:为什么 iOS 包"看起来比 Android 大"? 因为 IPA 同时含 @1x/@2x/@3x 三套资源(构建产物),但用户实际下载经 App Thinning 后只有一套。

反观 Android:

  • 老式 APK 也含全部密度(mdpi/hdpi/xhdpi/xxhdpi/xxxhdpi——5 套!)。
  • AAB 后用户只下一套。

所以"iOS 大、Android 小"的印象其实是错觉——用户实际下载量两者差不多。开发者纠结"构建产物大小"是抓错了点。


# 09.Web 打包全链路

Web 打包与 Native 应用完全不同——没有"分发服务",每次访问就是一次"实时下载"。本章重点:Bundle 流水线 / Tree Shaking 实战 / Code Splitting / 传输压缩。

# 9.1 Bundle 流水线

现代 Web 打包流程:

   源码(.ts / .tsx / .vue / .css / 图片)
        ↓ 编译(Babel / SWC / esbuild)
   JS / CSS / 资源
        ↓ 打包(Webpack / Rollup / Vite / esbuild)
   ┌─────────────────────────┐
   │  main.[hash].js         │  ← 业务代码 chunk
   │  vendor.[hash].js       │  ← 第三方依赖 chunk
   │  runtime.[hash].js      │  ← Webpack runtime
   │  index.[hash].css       │  ← 样式
   │  fonts/icons.[hash].woff│  ← 资源
   └─────────────────────────┘
        ↓ 压缩(Terser / esbuild)+ 哈希
   dist/
        ↓ 部署到 CDN
   生产环境
        ↓ 浏览器请求时
   响应头 Content-Encoding: gzip / br
   浏览器解压执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

关键差异(与 Native 应用对比):

维度 Web Native
分发模式 实时请求 一次安装
体积关键指标 首屏加载量 安装包大小
增量更新 哈希文件名 + 浏览器缓存 应用商店增量
用户感知阈值 < 200KB Gz(首屏) < 50MB
Tree Shaking 必备 R8 / LLVM

# 9.2 Tree Shaking 实战

Web 的 Tree Shaking 比 Native 更"挑食"——必须满足严格条件:

条件 1:使用 ES Modules

// ✅ 可 Tree Shake
import { debounce } from 'lodash-es';

// ❌ 不可 Tree Shake(CJS 模块)
const _ = require('lodash');
const debounce = _.debounce;

// ❌ 不可 Tree Shake(整个导入)
import _ from 'lodash';  // 即使你只用 _.debounce
1
2
3
4
5
6
7
8
9

条件 2:模块标记 sideEffects: false

// package.json
{
  "name": "my-lib",
  "sideEffects": false,
  "main": "dist/index.js",
  "module": "dist/index.esm.js"  // ← ESM 入口供 Tree Shaking
}
1
2
3
4
5
6
7

条件 3:避免动态导入路径

// ❌ 打包器不知道实际会用哪个
const mod = await import(`./locales/${lang}.json`);

// ✅ 打包器能分析
const mods = {
    en: () => import('./locales/en.json'),
    zh: () => import('./locales/zh.json'),
};
1
2
3
4
5
6
7
8

典型救命场景:lodash 优化

// ❌ 引入整个 lodash(70KB)
import _ from 'lodash';
_.debounce(...)

// ✅ 只引入用到的(2KB)
import { debounce } from 'lodash-es';
debounce(...)

// 或:直接子路径导入
import debounce from 'lodash/debounce';
1
2
3
4
5
6
7
8
9
10

# 9.3 Code Splitting

Code Splitting 是 Web 版的"动态分发"——把 bundle 拆成多个 chunk,按需加载:

按路由拆分(最常见):

// React 示例
import { lazy, Suspense } from 'react';

const HomePage = lazy(() => import('./HomePage'));
const SettingsPage = lazy(() => import('./SettingsPage'));

function App() {
    return (
        <Suspense fallback={<Loading />}>
            <Routes>
                <Route path="/" element={<HomePage />} />
                <Route path="/settings" element={<SettingsPage />} />
            </Routes>
        </Suspense>
    );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

打包后:

dist/
├── main.[hash].js           ← 进首页就下
├── home.[hash].js           ← 进首页时下
└── settings.[hash].js       ← 进设置页时才下
1
2
3
4

按依赖拆分(分离不变的部分以利缓存):

// webpack.config.js
optimization: {
    splitChunks: {
        chunks: 'all',
        cacheGroups: {
            vendor: {
                test: /[\\/]node_modules[\\/]/,
                name: 'vendor',
                priority: 10,
            },
            common: {
                minChunks: 2,
                name: 'common',
                priority: 5,
            },
        },
    },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

效果:第三方依赖独立 chunk,业务代码每次发版只重下业务包,依赖包浏览器缓存命中。

# 9.4 压缩传输链路

Web 的真实"包大小" = 传输后的字节数,不是磁盘上的文件大小。

原文件 (main.js)         100KB
    ↓ minify (Terser)
压缩代码                 60KB    (混淆 + 删空白)
    ↓ gzip 服务端
传输字节                 18KB    (文本压缩比 ~3:1)
    ↓ brotli 服务端(更好)
传输字节                 14KB    (比 gzip 再好 15-25%)
1
2
3
4
5
6
7

三层压缩对比:

算法 压缩比 解压速度 浏览器支持 推荐场景
gzip 中 快 100% 兼容老浏览器
brotli 高 中 现代浏览器(90%+) 现代部署
zstd 高 快 部分(实验) 内部服务

关键工程点:

  • 服务器配置:Content-Encoding: br, gzip 让客户端按支持选择。
  • CDN 自动协商:腾讯云 / Cloudflare 等 CDN 自动启用 brotli。
  • 静态预压缩:构建时生成 .br 和 .gz 文件,避免运行时压缩 CPU 开销。

探索性思考:为什么"图片不该 gzip"? 因为 PNG/JPEG/WebP 等图片格式本身已经是压缩过的二进制——再用 gzip 压不仅没收益,反而:

  • 额外消耗服务端 CPU(运行时压缩)。
  • 可能让文件变大(gzip 算法对随机数据是负优化)。

现代 CDN 默认只对文本类型(HTML/CSS/JS/JSON/SVG)启用 gzip,对二进制不启用。这是 Content-Type 在压缩协商中的隐性作用。


# 10.Native 库全链路

Native 库(.so / .a / .dylib)是包体积的"重灾区"——单个 .so 几 MB 很常见。本章重点:.so 体积构成 / strip 与符号 / ABI 选型 / 编译期优化。

# 10.1 .so 体积构成

一个典型的 Android Native 库(.so)的内部结构:

ELF Header(识别 + 架构信息)
├── .text 段(机器指令)       ~ 60-70%
├── .data 段(已初始化数据)   ~ 5-10%
├── .rodata 段(只读数据)     ~ 10-15%
│   - 字符串常量
│   - 浮点常量
├── .bss 段(未初始化数据)    ~ 0%(运行时填零,文件中不存)
├── .symtab(符号表)          ~ 5-15%   ← strip 可删
├── .strtab(字符串表)        ~ 5-10%   ← strip 可删
├── .debug_*(DWARF 调试信息) ~ 30-200% ← strip 必删
└── .eh_frame(异常处理)      ~ 1-5%
1
2
3
4
5
6
7
8
9
10
11

关键事实:

  • 未 strip 的 .so 比 strip 后大 2-5 倍——主要是 .debug_* 段。
  • C++ 模板会膨胀代码:每实例化一种类型生成一份代码(同 Swift 泛型)。
  • 静态链接 vs 动态链接:静态链接把依赖库整个嵌入,体积大得多。

# 10.2 strip 与符号

strip 是 Native 库优化的关键步骤:

未 strip 的 .so:
   .text          5MB    ← 实际代码
   .symtab        2MB    ← 符号表(函数名/变量名)
   .strtab        500KB  ← 字符串表
   .debug_info   15MB    ← 调试信息(行号等)
   .debug_line    5MB
   ...
   总计:         28MB

strip 后的 .so:
   .text          5MB    ← 仍在
   .dynsym       100KB   ← 仅保留动态链接需要的符号
   ...
   总计:         5.2MB
1
2
3
4
5
6
7
8
9
10
11
12
13
14

strip 的两个版本:

命令 删除内容 副作用
strip --strip-debug 仅调试信息 仍可符号化崩溃栈(部分)
strip --strip-all 全部符号 + 调试 崩溃栈无法符号化

工程实践(呼应 卷五·01 §7.5):

开发产物:
   obj/local/arm64-v8a/libnative.so   ← 未 strip,含全部信息
        ↓ 归档到崩溃平台(用于符号化崩溃栈)
        ↓ strip --strip-all
打包到 APK:
   jniLibs/arm64-v8a/libnative.so     ← strip 后,体积小
1
2
3
4
5
6

release 包必须 strip——这是 Native 包优化的"零代价大收益"。

# 10.3 ABI 选型

Android 应用面对多种 CPU 架构(ABI):

ABI 用途 现状
arm64-v8a 64 位 ARM(现代手机) 必须(>95% 设备)
armeabi-v7a 32 位 ARM(老旧设备) 视支持范围(<5% 但量大)
x86_64 64 位 x86(少数模拟器/平板) 通常不需要
x86 32 位 x86(极少设备) 通常不需要

典型决策:

// build.gradle
android {
    defaultConfig {
        ndk {
            abiFilters 'arm64-v8a', 'armeabi-v7a'
            // 删了 x86 / x86_64,省 50% Native 代码体积
        }
    }
}
1
2
3
4
5
6
7
8
9

收益估算:

ABI 配置 Native 总体积(4MB / ABI 计)
全部 4 个 16MB
arm64 + armv7 8MB(-50%)
仅 arm64 4MB(-75%)

但要注意:删了 armv7 后,部分老旧 32 位设备无法运行——必须先看 BI 数据评估用户分布。

探索性思考:为什么"Google Play 不强制纯 64 位"? Google 已经强制新应用提供 64 位版本(不能仅含 32 位),但没有强制"必须只有 64 位"——因为:

  • 部分老设备只支持 armv7。
  • 在某些新兴市场,老设备占比仍高。

所以工程上的折中是:主推 arm64,保留 armv7——配合 AAB 让 64 位用户不下 32 位代码,两全其美。这是为什么 AAB 是个 game changer——它解放了"必须打多 ABI"的体积压力。

# 10.4 编译期优化

C/C++ 代码的编译期优化对体积影响巨大:

选项 含义 体积影响
-O0 无优化(debug) 大
-O2 标准优化 中等(默认)
-Os 优化体积 最小(推荐 release)
-Oz 极致体积优化(clang) 最小
-O3 极致性能 大(多内联、向量化)
-flto LTO 跨文件优化 -5-15%
-ffunction-sections + -Wl,--gc-sections 函数级 dead strip -10-30%
-fvisibility=hidden 默认隐藏符号 -5-10%

工程模板(CMake):

if(CMAKE_BUILD_TYPE STREQUAL "Release")
    set(CMAKE_C_FLAGS "-Os -fvisibility=hidden -ffunction-sections")
    set(CMAKE_CXX_FLAGS "-Os -fvisibility=hidden -ffunction-sections -fno-rtti -fno-exceptions")
    set(CMAKE_EXE_LINKER_FLAGS "-Wl,--gc-sections -flto")
endif()
1
2
3
4
5

关键点:-fno-rtti -fno-exceptions 在不用 RTTI 和异常的项目能省 10-20% C++ 代码——但需确认依赖库不依赖这些特性。


# 11.资源全链路

资源(图片 / 字体 / 音视频)通常占包的 40-60%——本章逐类拆解优化原理。

# 11.1 图片格式演进

图片是包体积的"绝对大头"。各格式对比:

格式 类型 体积 解码速度 透明 兼容性
PNG 无损 100% 快 ✅ 100%
JPEG 有损 30% 快 ❌ 100%
WebP 有损/无损 25-30% 中 ✅ Android 4.0+/iOS 14+
AVIF 有损 15-20% 慢 ✅ 现代浏览器/Android 12+
HEIC 有损 30% 中 ✅ iOS 11+
SVG 矢量 极小 快 ✅ 100%

优化决策树:

有透明 + 简单图形(icon)→ SVG(矢量,最优)
有透明 + 复杂位图        → WebP / AVIF(替代 PNG)
无透明 + 照片            → WebP / AVIF(替代 JPEG)
有动画                  → WebP 动图(替代 GIF,省 80%)
1
2
3
4

实测数据(§17.4 实验):

同张图(1080×1080) 体积 视觉差异
PNG 1.2MB -
JPEG(85% 质量) 280KB 几乎无
WebP(85% 质量) 180KB 几乎无
AVIF(85% 质量) 110KB 几乎无

优化清单:

  • 图标全部矢量化:SVG / Vector Drawable / Symbol。
  • 照片用 WebP 或 AVIF:替代 PNG / JPEG。
  • 下采样:1080P 屏幕的图不需要 4K 源。
  • 服务端响应式图片:HTML <picture> + 多源 srcset 让浏览器选最优。

# 11.2 字体子集化

字体文件是被忽视的大头——一个完整中文字体可达 20MB+:

完整字体文件(含全部 CJK 字符):    20MB
   ↓ 子集化(仅保留应用实际用到的字符)
应用专用字体子集:                    500KB
   ↓ WOFF2 压缩
最终下发:                           300KB
1
2
3
4
5

子集化工具:

  • Android:用 fonttools pyftsubset 离线生成。
  • iOS:同上。
  • Web:unicode-range CSS + 字体服务(如 Google Fonts)按需下发。

进阶:动态子集化——服务端按页面实际文字内容生成子集字体,每个页面只发该页面需要的字。

# 11.3 音视频压缩

音视频体积的关键是码率和分辨率:

类型 推荐
短音效(< 5s) AAC 64-128 kbps,单声道(mono)
背景音乐 OGG Vorbis 96-128 kbps
视频内嵌 H.264 / H.265 ≤ 720P,CRF 23-28
大量视频 不打包——服务端分发

核心原则:视频几乎不应该放在安装包里——大型视频(教程、广告、产品介绍)应该在线下载或 ODR。

# 11.4 多语言按需

字符串资源也能显著影响体积:

30 种语言 × 1000 条字符串 × 平均 50 字节 = 1.5MB

传统:全部打包(用户实际只用 1 种语言)
AAB:用户只下自己语言(节省 97%)
1
2
3
4

Android 配置(不用 AAB 时的退而求其次):

// build.gradle
android {
    defaultConfig {
        resConfigs "en", "zh", "zh-rCN"  // 只保留这几种语言
    }
}
1
2
3
4
5
6

注意:删除其他语言后,用户系统语言不在列表内会用 default(通常是英文)——可能有体验问题。


# 12.跨端打包对照

# 12.1 端到端流程对照

阶段 Android iOS Web
源码格式 .java / .kt .swift / .m .ts / .vue
编译器 javac / kotlinc / R8 swiftc / clang Babel / SWC / esbuild
中间产物 DEX Mach-O .o JS chunk
资源处理 aapt2 → resources.arsc actool → Assets.car bundler 内联
代码优化器 R8 LLVM Terser / esbuild
最终格式 APK / AAB IPA dist 目录
分发优化 AAB Split App Thinning Code Splitting
传输优化 无(本地安装) 无 gzip / brotli
典型大小 30-100MB 50-200MB 200KB-2MB(首屏)

# 12.2 优化手段对比

思想 Android iOS Web
Tree Shaking R8 LLVM dead_strip Webpack/Rollup TS
代码混淆 R8 短名 LLVM strip Terser
资源压缩 aapt2 zopfli + WebP actool 自动 imagemin
资源容器 resources.arsc Assets.car bundler 内联
多架构分发 AAB ABI 拆分 App Thinning 浏览器自动
多密度分发 AAB 密度拆分 App Thinning srcset / picture
多语言分发 AAB 语言拆分 iOS 14+ Asset Catalog 拆分 路由级 i18n
动态加载 Dynamic Feature On-Demand Resources Code Splitting / Dynamic Import
代码增量更新 Tinker 类热修复 不允许 哈希文件名 + CDN 缓存

# 12.3 统一启示

  • 三大思想跨端通用:减 / 压 / 拆——不同端只是工具不同,思想完全一致。
  • Web 是"实时分发",Native 是"一次安装":前者关注"首屏量",后者关注"安装包+长期占用"。
  • AAB / App Thinning 让"构建产物 ≠ 用户下载":永远以"用户实际下载"为准。
  • Native 端的最大优化空间在"拆"——单端体积有上限,按需分发是无上限的。
  • Web 端的最大优化空间在"压"——编辑期 Tree Shaking + 传输期 brotli 双重收益。

§13-§16 给出由浅入深的四层治理。


# 13.治理一层删

第一层治理 = 删除不必要的——这是最低成本、最高收益的一层。实测大部分包有 20-30% 是浪费,先把这部分清掉再谈其他。

# 13.1 删除未用代码

核心:让构建工具自动识别"代码里实际没用到的部分"。

端 工具 配置
Android R8(默认开) minifyEnabled true
iOS LLVM dead_strip Dead Code Stripping = YES(默认)
Web Webpack / Rollup mode: 'production'
Native linker --gc-sections -ffunction-sections + --gc-sections

关键陷阱:反射/动态调用会让 Tree Shaking 误删:

// 危险:R8 看不到对 SomeService 的引用
val cls = Class.forName("com.app.SomeService")
val instance = cls.newInstance()

// 必须配 ProGuard keep 规则
-keep class com.app.SomeService { *; }
1
2
3
4
5
6

审计反射依赖的工程方法:

// 方法 1:避免动态字符串引用
val cls = SomeService::class.java  // ✅ R8 能识别

// 方法 2:用注解处理器替代反射(Dagger / Moshi-Codegen)
// 编译期生成代码,运行时无反射,自然 Tree Shake 友好
1
2
3
4
5

# 13.2 删除未用资源

Android shrinkResources:

android {
    buildTypes {
        release {
            minifyEnabled true
            shrinkResources true  // 基于代码引用图删未用资源
        }
    }
}
1
2
3
4
5
6
7
8

工作原理:

R8 分析后得到"代码可达图"
   ↓
shrinkResources 看每个资源有没有被代码引用
   - R.drawable.icon 被引用 → 保留
   - R.drawable.unused_icon 没被引用 → 删除
   ↓
资源 ID 表也清理(resources.arsc 变小)
1
2
3
4
5
6
7

陷阱:通过字符串名字访问资源时无法识别:

// 危险:字符串引用 R8 不认识
val resId = resources.getIdentifier("dynamic_icon", "drawable", packageName)

// 必须配 keep.xml
<resources xmlns:tools="http://schemas.android.com/tools"
    tools:keep="@drawable/dynamic_icon" />
1
2
3
4
5
6

iOS 同等机制:Asset Catalog 自动剔除未引用资源(基于 imageNamed: 静态分析)。

Web 的资源管理:直接用 import 引用资源,未 import 的天然不进 bundle。

# 13.3 删除调试残留

Android 调试残留清单:

✗ debug 后端 URL(如 http://test-api.xxx)
✗ 测试账号 / 测试密钥
✗ Log 输出(详细日志暴露敏感信息)
✗ debug Activity(显式声明的 testActivity)
✗ Stetho、LeakCanary 等仅 debug 工具的 release 残留
1
2
3
4
5

正确做法:用 BuildConfig.DEBUG 隔离:

if (BuildConfig.DEBUG) {
    // 这里的代码 R8 在 release 构建时会整段删除
    Log.d(TAG, "verbose info")
    Stetho.initialize(...)
}
1
2
3
4
5

注意:直接删除 Log 调用本身不能减体积——日志字符串仍在常量池。要彻底清理,配 ProGuard 规则:

# 删除所有 Log 调用(包括字符串参数)
-assumenosideeffects class android.util.Log {
    public static int v(...);
    public static int d(...);
    public static int i(...);
}
1
2
3
4
5
6

# 13.4 删除冗余依赖

经典反例:项目里同时用了三个 JSON 库(GSON + Jackson + Moshi),各自带几 MB——其实任何一个就够。

审计方法:

# Android:用 dependency tree 分析
./gradlew app:dependencies > deps.txt

# 重点找:
# - 同类型多库(多个 JSON / 多个 HTTP / 多个图片库)
# - 大库只用了一个小功能(用 Apache HttpClient 只为发个请求)
# - 历史遗留的"曾经引入但忘删"
1
2
3
4
5
6
7

典型可优化清单:

问题 替换方案 节省
Apache HttpClient 仅发请求 用 OkHttp / 系统 HttpURLConnection 几 MB
Joda-Time(仅时间处理) Java 8+ java.time 几 MB
Guava(仅几个工具方法) 自己 copy 几行 几 MB
多个 JSON 库 统一一个 几 MB
Glide + Picasso 都引 选一个 几 MB
老版 protobuf-java protobuf-javalite 几 MB

§17.5 实验:典型项目依赖审计能省 8-15MB。

探索性思考:为什么"依赖审计"是被严重低估的优化? 因为依赖是"看不见的浪费"——你不会主动去看 jar 里有什么。但每个依赖背后可能是一个完整的库(含你完全不用的功能)。3 个 JSON 库 = 多余 6MB——但这种事很少有人去查。

工程实践:每次引入新依赖时填一份"依赖审批表"——评估它的体积、它解决的问题能否用现有依赖完成、是否有更轻量替代品。这是大型项目体积治理的"治本之道"。


# 14.治理二层压

第二层治理 = 用更小的格式表达同样的内容。这一层的工具大都"开关式"——开了就生效,难度低。

# 14.1 代码压缩混淆

各端代码压缩工具:

端 工具 收益
Android R8 -30-35% APK
iOS LLVM -10-20% Mach-O
Web Terser / esbuild -50-60% JS

Web Terser 的关键配置:

// webpack.config.js
optimization: {
    minimize: true,
    minimizer: [new TerserPlugin({
        terserOptions: {
            compress: {
                drop_console: true,    // 删除所有 console
                drop_debugger: true,
                pure_funcs: ['console.log'],  // 标记纯函数(无副作用,可删)
            },
            mangle: {
                toplevel: true,        // 顶层变量也混淆
            },
        },
    })],
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 14.2 现代图片格式

参考 §11.1。最高 ROI 的优化:

旧产物:PNG(无损)+ JPEG(有损)
新产物:WebP(同质量,体积 -25-30%)

切换成本:
- 编译期工具:cwebp / Squoosh / ImageMagick
- 运行时支持:Android 4.0+ / iOS 14+ 原生
- 旧设备:CDN 自动降级到 PNG/JPEG(按 Accept 头)
1
2
3
4
5
6
7

Android 工程模板:

# 批量转换
find app/src/main/res -name "*.png" -exec cwebp -q 85 {} -o {}.webp \;
find app/src/main/res -name "*.png.webp" -exec sh -c 'mv "$0" "${0%.png.webp}.webp"' {} \;
# 删原 PNG
find app/src/main/res -name "*.png" -delete
1
2
3
4
5

# 14.3 资源压缩级别

Android aapt2 自动用 zopfli——比标准 deflate 更慢但压缩比更好。

iOS Asset Catalog:

  • 自动选择最优编码(lossless 还是 lossy)。
  • 自动 PNG 优化(如 pngcrush 类无损优化)。

Web 的图片优化工具:

  • imagemin(构建期):自动批量压缩。
  • Squoosh(手动调优):人工挑选每张图最佳格式。
  • CDN 即时优化(Cloudinary / 自建):URL 参数控制格式与质量。

# 14.4 传输层压缩

仅 Web 适用——参考 §9.4。

核心配置(nginx 示例):

# 启用 gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_comp_level 6;

# 启用 brotli(更好)
brotli on;
brotli_types text/plain text/css application/json application/javascript;
brotli_comp_level 6;

# 静态预压缩(构建时生成 .gz/.br,避免运行时 CPU)
gzip_static on;
brotli_static on;
1
2
3
4
5
6
7
8
9
10
11
12
13

收益:JS/CSS 等文本资源的传输体积减 60-80%。


# 15.治理三层拆

第三层治理 = 按需分发——每个用户只下载与自己相关的部分。这一层需要平台支持,收益最大。

# 15.1 ABI 按需分发

AAB 自动拆分:开发者上传含 4 个 ABI 的 AAB,Google Play 给每个用户只发对应 ABI 的部分。

国内市场(无 AAB)的方案:

// 方法 1:每个渠道单独打包(多包变体)
android {
    splits {
        abi {
            enable true
            reset()
            include 'arm64-v8a', 'armeabi-v7a'
            universalApk false  // 不要全量包
        }
    }
}
// 输出:app-arm64-v8a-release.apk + app-armeabi-v7a-release.apk
1
2
3
4
5
6
7
8
9
10
11
12

收益:每个 APK 只含一种 ABI,比"全 ABI 包"小 50%+。

# 15.2 屏幕密度按需

AAB 自动拆分屏幕密度:mdpi / hdpi / xhdpi / xxhdpi 各自打包。

手动方案(不用 AAB):

android {
    splits {
        density {
            enable true
            reset()
            include "xxhdpi", "xxxhdpi"  // 主流密度
        }
    }
}
1
2
3
4
5
6
7
8
9

iOS:App Thinning 自动按设备 @2x / @3x 拆分(用户透明)。

# 15.3 语言资源按需

Android resConfigs 限制(不用 AAB 时):

android {
    defaultConfig {
        resConfigs "en", "zh", "zh-rCN", "zh-rTW"
    }
}
1
2
3
4
5

风险:用户系统语言不在列表内 → 显示 default(通常是英文)。国际化应用必须用 AAB,否则要么留全部语言(大)要么牺牲部分用户体验。

Web 的 i18n 拆分:

// 路由级 lazy load 每种语言
const loadLocale = (lang) => import(`./locales/${lang}.json`);
1
2

# 15.4 动态特性模块

Android Dynamic Feature:

基础 APK:核心功能(10MB)
   + Feature A "AR 试穿"(20MB):进相机时下载
   + Feature B "高级编辑"(15MB):付费用户才下载
   + Feature C "教程"(5MB):新用户引导时下载
1
2
3
4

配置:

// 模块的 build.gradle
plugins {
    id 'com.android.dynamic-feature'
}
1
2
3
4

用户感知:第一次进入该功能时显示"正在下载(5MB)...",下载完缓存到本地。

iOS On-Demand Resources:

// 标记资源为 ODR,安装时不下载
// 使用时按 tag 请求:
let request = NSBundleResourceRequest(tags: ["chapter-5"])
request.beginAccessingResources { error in
    if error == nil {
        // 资源已下载,可使用
    }
}
1
2
3
4
5
6
7
8

适用场景:游戏关卡、教育课程、付费功能、地区专属内容。

探索性思考:为什么"动态特性"听起来好但用得不多? 因为它的收益和成本都被低估了:

  • 收益小估:用户进入该功能时被"等下载"打断,体验差,部分用户因此放弃使用功能。
  • 成本大估:模块化改造让代码组织复杂(模块间不能任意引用)、CI / 测试覆盖范围扩大、本地调试更难。

实际数据:典型 App 引入 Dynamic Feature 后 包减 30%、但用户使用率降 5-10%。只有"明显非首屏 + 大体积"的功能才值得——核心功能强行拆只会两头不讨好。


# 16.治理四层架构

第四层治理 = 架构改造——前三层做到极致仍不够时,从架构层下刀。收益最大、改造成本也最大。

# 16.1 插件化架构

插件化:把整个应用拆成"宿主 + 插件",宿主小(< 5MB),各业务模块作为插件按需下载安装。

典型实现:

  • 阿里 Atlas:组件化框架(已开源)。
  • 滴滴 VirtualAPK:插件化框架。
  • 腾讯 Shadow:插件化框架。

优势:

  • 宿主极小(用户首次安装快)。
  • 业务可热更新(不发版即更新)。
  • 跨业务隔离(独立崩溃域)。

劣势:

  • 架构复杂度极高:跨插件调用、生命周期管理、签名校验都要自己解决。
  • 国内才适用:iOS / Google Play 不允许(违反 App Store Review Guidelines)。
  • 维护成本高:插件框架自身要持续投入。

# 16.2 在线化与小程序

思路:把"原本要打到包里的内容"放到云端:

传统:所有功能都打到 App 里
   ↓
小程序模式:宿主 App + 小程序容器
   ↓
具体功能:单个小程序按需加载(每个 1-3MB)
1
2
3
4
5

代表:

  • 微信 / 支付宝小程序:第三方业务作为小程序在宿主里运行。
  • 抖音 / 美团小程序:同样思想。
  • PWA(Progressive Web App):用 Web 技术做"类原生"体验。

对开发者的影响:

  • 某类业务(电商/服务)从 Native App 转向小程序,App 自身只保留核心功能。
  • 用户感知"功能没少",但 App 安装包从几百 MB 降到几十 MB。

# 16.3 业务体积预算

最后兜底:每个业务模块设定"体积预算",超了就要砍功能。

预算管理表:

模块 预算 实际 行动
用户中心 3MB 2.8MB OK
商品列表 5MB 6.2MB 超预算,砍图片或代码
直播 10MB 10MB OK
客服 2MB 1.5MB OK
总计 20MB 20.5MB OK(总预算未超)

预算执行:

  • 每次 PR 检查模块体积变化(CI 自动)。
  • 超过预算阻塞合并。
  • 大需求评审时必须给出"+多少 MB"估算。

探索性思考:什么时候需要走到"第四层架构"? 当前三层做满后包仍 > 100MB时——这通常意味着业务规模已经太大,靠"删压拆"无法压住增长曲线。这时只有:

  • 架构上"在线化"(小程序)。
  • 架构上"插件化"(动态加载)。
  • 业务上"砍功能"(合并主线、剥离非核心)。

架构改造永远是最后选项——它带来的复杂度提升有时比体积带来的损失更大。只有业务必要性证实时才动。


# 17.求证实验

# 17.1 实验一:体积与转化

Step 1 — 原始观察:工程师都听过"包小转化高",但到底相关性多大?

Step 2 — 提出疑问:包大小每减少 X MB,安装转化率提升多少?是线性还是有阈值?

Step 3 — 设计实验:某 App 在 6 个月内做了多次包优化,对应转化率变化(已脱敏):

版本 包大小 (MB) 安装转化率
v1.0 65 38%
v1.5 50 41%
v2.0 35 45%
v2.5 25 47%
v3.0 20 48%

Step 4 — 实测数据:

  • 65 → 35 MB:转化率 +7%(每减 10MB +2.3%)。
  • 35 → 20 MB:转化率 +3%(每减 10MB +2%)。
  • 20 → 15 MB:边际收益递减。

Step 5 — 提炼结论:

包大小 65 → 35 MB 区间内,每减 10MB 转化率 +2-2.5%。20 MB 以下边际收益递减。

Step 6 — 边界:

  • 重型应用(如游戏)有不同曲线(用户预期就是大)。
  • 一线品牌应用(用户已认知品牌)不那么敏感。
  • 印度 / 东南亚比北美 / 欧洲更敏感。

# 17.2 实验二:R8 收益

Step 1 — 原始观察:R8 是 Android 默认混淆压缩工具,实际收益多少?

Step 2 — 提出疑问:R8 各项功能(缩减 / 优化 / 混淆)分别贡献多少包减小?

Step 3 — 设计实验:

配置 描述
A R8 完全关闭
B 仅混淆(minifyEnabled, 不 shrink)
C minify + shrinkResources
D 全量(含 R8 fullMode)

Step 4 — 实测数据:

配置 DEX 大小 资源大小 总包 减小
A 关闭 18 MB 15 MB 38 MB -
B 仅混淆 14 MB 15 MB 33 MB -13%
C +shrinkRes 14 MB 11 MB 28 MB -26%
D 全量 11 MB 10 MB 25 MB -34%

Step 5 — 提炼结论:

R8 全量启用能让 Android 包减 30-35%。不启用 R8 是巨大的浪费。

Step 6 — 边界:

  • R8 可能与某些反射 / 动态加载库冲突,需配 keep 规则。
  • iOS 类似工具是 LLVM 优化(自动)。
  • Web 用 Terser / esbuild 类似收益。

# 17.3 实验三:动态分发

Step 1 — 原始观察:App Bundle 上传后用户实际下载多大?相比传统 APK 节省多少?

Step 2 — 设计实验:某 App 同一版本:

  • A 传统 APK(含全部 ABI / 屏幕密度 / 语言)。
  • B App Bundle(用户按设备下载)。

Step 3 — 实测数据:

设备 A APK B AAB 下载 节省
arm64 + 1080P + 简中 80 MB 38 MB 53%
armv7 + 720P + 英文 80 MB 32 MB 60%
平均 80 MB 36 MB 55%

Step 4 — 提炼结论:

App Bundle 平均节省 50%+ 用户下载量,是巨大的"白送"收益。

Step 5 — 边界:

  • 部分非 Play 渠道仍需 APK,需要单独打包(变体管理)。
  • 国内市场仍以 APK 为主,需要自己设计动态分发。
  • iOS 的 App Thinning 类似(自动)。

# 17.4 实验四:图片格式

Step 1 — 原始观察:常听说 WebP 比 PNG 小,但实际差多少?质量有损失吗?

Step 2 — 设计实验:某 App 资源 100 张图(混合:纯图、含透明、照片、icon),不同格式对比:

Step 3 — 实测数据:

格式 总体积 vs PNG
PNG 25 MB -
JPEG(85%) 9 MB -64%
WebP(85%, 含透明) 7 MB -72%
AVIF(85%, 含透明) 4.5 MB -82%

视觉评估(盲测):

格式 用户能分辨"哪张更好"
PNG vs WebP(85%) 不能分辨(人眼测试 80%+ 选错)
WebP vs AVIF(85%) 不能分辨
100% vs 85% JPEG 部分人能分辨(高对比图)

Step 4 — 提炼结论:

WebP 替换 PNG 节省 60-70% 资源体积,无视觉差异。AVIF 再节省 30%,但部分老设备不支持。

Step 5 — 工程意义:

  • Android 4.0+/iOS 14+ 直接用 WebP(覆盖率 99%+)。
  • AVIF 在 Android 12+/现代浏览器 全面支持,新项目可用。
  • 老设备 fallback:CDN 按 Accept 头返回 PNG/JPEG。

# 17.5 实验五:依赖审计

Step 1 — 原始观察:项目里引了几十个第三方库,真的都需要吗?

Step 2 — 设计实验:某 Android 项目(78MB)做完整依赖审计:

Step 3 — 实测数据:

问题 处置 节省
GSON + Jackson + Moshi 都引 统一到 Moshi -3MB
Apache HttpClient 仅发请求 改用 OkHttp -2MB
Joda-Time(仅时间) 改用 java.time -1.5MB
Glide 引了所有模块(含 GIF/视频) 仅引 core -1MB
老版 Protobuf-java 改 protobuf-javalite -2MB
Apache Commons Lang(仅 StringUtils 几个方法) 自实现 -800KB
重复引入的 androidx 多版本 exclude 老版本 -500KB
总计 -10.8MB

Step 4 — 提炼结论:

典型项目的依赖审计能省 8-15MB——比花几周做"图片压缩"高效得多。依赖是包体积治理的最大金矿。

Step 5 — 边界:

  • 替换依赖有兼容性风险,需要全量回归测试。
  • 某些依赖深度耦合,替换成本高于收益。
  • 自实现要权衡"减体积"vs"维护成本"。

# 17.6 五大实验启示

   体积 vs 转化     → 30-50MB 区间每减 10MB +2%        ─┐
                                                         │
   R8 收益          → -30-35% 包大小(必开)             │
                                                         │
   动态分发         → -50% 用户实际下载(白送)         ├─▶ 包优化 = 工具用足 + 动态分发 + 现代格式 + 依赖审计 + 关注用户分布
                                                         │
   图片格式         → WebP 替代 PNG 省 60-70%           │
                                                         │
   依赖审计         → 典型省 8-15MB(被低估)            ─┘
1
2
3
4
5
6
7
8
9

统一启示:

  • R8 + AAB + WebP 是必做基线:能减 50%+ 不开就是浪费。
  • 依赖审计是最大金矿:花一周省 10MB 比啥都强。
  • 关注用户分布:不同地区对包体积敏感度不同。
  • 数据驱动 ROI:每个优化都要量化转化率收益。
  • 关注"用户下载"而非"构建产物":AAB 让两者差几倍。

# 18.实战案例

# 18.1 跨端同构案例

背景:某应用 Android 80MB / iOS 120MB,希望大幅减小。

度量与归因:

  • Android:apkanalyzer 显示资源 50MB(含多 ABI / 多语言)。
  • iOS:Xcode size report 显示资源 70MB / 代码 50MB。

假设与求证:提出统一假设:"上动态分发 + 现代图片格式 + 移除未用"。

修复:

  • Android:上 App Bundle + 资源 WebP + 删除 x86 ABI。
  • iOS:Asset Catalog 集中 + ODR 按需下载 + LLVM 优化。

验证:

平台 优化前 优化后用户下载 降幅
Android 80 MB 32 MB -60%
iOS 120 MB 70 MB -42%

统一启示:包优化跨端通用法则 = 现代格式 + 动态分发 + 移除未用。

# 18.2 平台特异案例

背景:Android 应用启用 R8 后偶发 Class Not Found 崩溃。

现象:上线后 0.05% 设备崩溃,本地测试无问题。

度量与归因:崩溃栈显示 com.app.AClass not found。AClass 是动态反射调用的类,被 R8 误删。

修复:

  • 添加 ProGuard keep 规则保留反射类。
  • 长期:减少反射使用,改为编译期注解处理。

验证:崩溃消失,体积无回退。

边界:每次启用新 R8 优化时必须测试反射 / 动态加载场景。

# 18.3 SDK 减肥案例

背景:某创业团队的 App 引了 30+ 第三方 SDK(推送/统计/分享/支付/Crash 等),总包飙到 100MB+。

度量与归因:apkanalyzer 显示三方库占了 60MB——其中很多 SDK 重复实现了类似功能。

假设:SDK 整合——找一个"全功能 SDK"替代多个单功能 SDK。

修复:

  • 推送/统计/Crash 整合到一个综合 SDK(如 Firebase 全套)。
  • 删除多个分享 SDK,只用微信 + 微博 + 通用系统分享。
  • 删除测试期的几个 A/B 测试 SDK。

验证:

指标 优化前 优化后
总包大小 110 MB 65 MB
三方库占比 60 MB 18 MB
启动时长 2.8s 2.0s(SDK 减少 → 初始化更快)

统一启示:SDK 数量 ≠ 功能丰富度——很多团队为了"省研发成本"接了过多 SDK,但每个都自带 5MB 起步。整合或自实现关键功能才是治本之道。


# 19.防劣化体系

# 19.1 三道防线总览

开发期 ──▶ 编译期 / CI ──▶ 上线期 / 运行期
   │             │              │
   ▼             ▼              ▼
[Lint+预算]  [自动化基准]    [转化监控]
1
2
3
4

# 19.2 编码期 Lint

强制规则:

  • 引入 > 1MB 第三方库 → 警告(PR 必须有 Reviewer 同意)。
  • 资产中含未压缩 PNG / 大图 → 警告。
  • 单文件资源 > 阈值(图片 200KB / so 5MB)→ 错误。
  • 启用 Bitcode(iOS 已废弃)→ 警告。
  • 引入 minSdk 之外不需要的 ABI → 警告。

# 19.3 CI 与 SLO

CI 卡口:

  • 包大小基准对比上一版本,增长 > 5% 阻塞。
  • AAB / Asset Catalog 必须启用。
  • R8 / Terser 必须开 minify。
  • 模块级体积预算超出阻塞合并。

线上 SLO:

  • 总包大小 ≤ 阈值(按业务定)。
  • 用户实际下载(Play Console)≤ 阈值。
  • 安装转化率 ≥ 阈值。
  • 30 天卸载率 ≤ 阈值。

# 20.跨平台速查

# 20.1 工具速查

平台 总量 内部分析 优化工具 动态分发
Android apk / aab 大小 apkanalyzer / AS APK Analyzer R8 / shrinkResources App Bundle
iOS ipa 大小 Xcode App Thinning Size Report LLVM / strip App Thinning / ODR
Web dist 大小 webpack-bundle-analyzer Terser / Tree Shaking Code Splitting / Dynamic Import
Native size / objdump nm / objdump strip / --gc-sections N/A

# 20.2 关键 API 速查

操作 Android iOS Web
代码混淆 R8 (minifyEnabled true) LLVM 默认 Terser plugin
资源压缩 aapt2 自动 + WebP Asset Catalog imagemin / 服务端
多 ABI abiFilters App Thinning 自动 浏览器自动
动态加载 Dynamic Feature Module On-Demand Resources dynamic import()
删未用资源 shrinkResources true Asset Catalog 自动 bundler 自动
字体子集化 pyftsubset / 编译期 同 unicode-range
包体积 CI aapt2 dump apk-stats Xcode size report bundler stats

# 21.总结与延伸

# 21.1 五条核心原则

  1. 三组成分析:代码 / 资源 / 配置 各自治理(§3.2)。
  2. 三大思想组合:减 / 压 / 拆 协同(§4.1),缺一不可。
  3. 工具用足:R8 / AAB / Tree Shaking / WebP 必开(§13-14)。
  4. 关注用户实际下载:而非构建产物大小(§5.2 + §17.3)。
  5. 持续监控防劣化:CI 跟踪每个版本增量(§19)。

# 21.2 五个常见误区

  1. ❌ "删几张图就够了":错(资源不一定是大头,大头可能是依赖)。
  2. ❌ "R8 容易出 bug 不开":错(默认开 + 配 keep 即可,不开是浪费 30%)。
  3. ❌ "App Bundle 麻烦不用":错(上传一次自动分发,省 50%)。
  4. ❌ "包小到极致才好":错(< 20MB 边际收益小)。
  5. ❌ "用户不在乎包大小":错(数据证明 30-50MB 区间转化率敏感)。

# 21.3 一句话总结

包体积是冷启动 + 安装转化的瓶颈,每多 1MB 都让用户漏斗多损失。 三大思想(减 / 压 / 拆)+ 四层治理(删 / 压 / 拆 / 架构)——大部分团队的问题不是"减得不够狠",而是"工具没用足、依赖没审计、用 APK 而非 AAB"。 R8 + AAB + WebP + 依赖审计是 Android 必做基线,能减 50%+。 关注用户实际下载,不要被构建产物大小迷惑——优化的最终目的是用户体验,而非 CI 看板上的数字。

上次更新: 2026/06/07, 10:26:12
崩溃捕获设计实践
应用安全性能权衡

← 崩溃捕获设计实践 应用安全性能权衡→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式