包体积与资源治理
# 包体积与资源治理
📊 学习成本预估 | 难度:⭐⭐⭐⭐(4/5)| 阅读:约 40 分钟 | 实操:3 小时 🔗 前置阅读:卷四·01 | ➡️ 后续延伸:—
# 目录介绍
- 01.阅读说明
- 02.贯穿案例
- 03.包体积本质
- 04.包体积思想
- 05.度量与采集
- 06.归因决策树
- 07.Android 打包全链路 ⭐
- 08.iOS 打包全链路 ⭐
- 09.Web 打包全链路 ⭐
- 10.Native 库全链路 ⭐
- 11.资源全链路 ⭐
- 12.跨端打包对照
- 13.治理一层删 ⭐
- 14.治理二层压 ⭐
- 15.治理三层拆 ⭐
- 16.治理四层架构 ⭐
- 17.求证实验 ⭐
- 18.实战案例
- 19.防劣化体系
- 20.跨平台速查
- 21.总结与延伸
# 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)
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 │
│ - 签名 / 加固元数据 │
│ - 调试信息 │
└──────────────────────────────────────┘
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 反直觉问题清单
带着这些问题阅读:
- 包体积每多 1MB 转化率掉多少?
- ProGuard / R8 真的能减少 30% 吗?
- 多 ABI 包 vs 单 ABI 体积差多少?
- 动态分发(App Bundle)能减多少?
- WebP 替代 PNG 能省多少?
- AAB 上传后用户实际下载多大?
- iOS Bitcode 影响体积吗?
- 代码混淆能减小多少?
# 04.包体积思想
包体积治理不是"逐个文件减字节"——而是思想层的多种策略组合。理解这些核心思想,才能跳出"剪切贴照片"的低级优化层次。
# 4.1 减拆分三大思想
包体积治理的所有手段都可以归纳为三种思想:
┌─────────────────────────────────────────┐
│ 思想一:减(Reduce) │
│ 不必要的内容根本不打包 │
│ - 删未用代码(Tree Shaking / R8) │
│ - 删未用资源(shrinkResources) │
│ - 删调试信息(strip / minify) │
│ - 删冗余依赖(Dependency Audit) │
├─────────────────────────────────────────┤
│ 思想二:压(Compress) │
│ 必要的内容用更小的格式 │
│ - 代码混淆(短名 / 内联 / 常量折叠) │
│ - 现代图片(WebP / AVIF / HEIC) │
│ - 字体子集化 │
│ - 传输压缩(gzip / brotli / zstd) │
├─────────────────────────────────────────┤
│ 思想三:拆(Split) │
│ 按需分发,用户只下载需要的 │
│ - ABI 拆分(按 CPU 架构) │
│ - 屏幕密度拆分 │
│ - 多语言拆分 │
│ - 动态特性模块(按功能模块) │
│ - Code Splitting(按路由) │
└─────────────────────────────────────────┘
每种思想各有侧重,组合使用才能最大化收益
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 被删除
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}`); // 动态路径
2
3
4
5
② 无副作用(side-effect-free):模块的代码不能有"导入即执行的全局副作用":
// ❌ 有副作用:哪怕没用 polyfillFn,polyfill 也会执行
window.someGlobal = "init";
export function polyfillFn() { ... }
// ✅ 无副作用:纯函数模块
export function pureFn() { ... }
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%
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 体积与转化关系
包体积的最终目的是用户体验,必须量化它对业务指标的影响:
包体积 ↑
│
├──▶ 下载时长 ↑ → 安装放弃率 ↑
│
├──▶ 解压时长 ↑ → 安装失败率 ↑
│
├──▶ 应用启动慢 ↑ → 留存 ↓
│
├──▶ 用户存储占用 ↑ → 卸载率 ↑
│
└──▶ 应用商店警告 → 推荐降级
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)
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 配置
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
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
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(多个类合并):
全局共享常量池
字符串只存一次
方法/字段表全局合并
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 + ...
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)
2
3
4
① Tree Shaking(最大头):
入口(Application/Activity 等)
↓ 调用图分析
找到所有"可达"的类/方法/字段
↓ 反向
删除"不可达"的部分
2
3
4
5
② Optimization:
- 方法内联:小方法直接展开到调用点 → 减少方法表 + 调用开销。
- 常量折叠:
if (BuildConfig.DEBUG)在 release 永远是 false → 整个 if 块删除。 - 死代码消除:
if (false) { ... }整段删除。
③ Obfuscation:
原始:UserListActivity.refreshData()
混淆:a.b.c.a()
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 等
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
↓
设备本地合并安装
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):付费用户才下载
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(按设备分发)
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 段 │
│ - 符号表 / 字符串表 │
│ - 动态链接信息 │
│ - 代码签名 │
└─────────────────────────────┘
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 不含死代码
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
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(共享元数据 + 更好压缩)
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
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")
}
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
浏览器解压执行
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
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
}
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'),
};
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';
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>
);
}
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 ← 进设置页时才下
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,
},
},
},
}
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%)
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%
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
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 后,体积小
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 代码体积
}
}
}
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()
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%)
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
2
3
4
5
子集化工具:
- Android:用 fonttools
pyftsubset离线生成。 - iOS:同上。
- Web:
unicode-rangeCSS + 字体服务(如 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%)
2
3
4
Android 配置(不用 AAB 时的退而求其次):
// build.gradle
android {
defaultConfig {
resConfigs "en", "zh", "zh-rCN" // 只保留这几种语言
}
}
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 { *; }
2
3
4
5
6
审计反射依赖的工程方法:
// 方法 1:避免动态字符串引用
val cls = SomeService::class.java // ✅ R8 能识别
// 方法 2:用注解处理器替代反射(Dagger / Moshi-Codegen)
// 编译期生成代码,运行时无反射,自然 Tree Shake 友好
2
3
4
5
# 13.2 删除未用资源
Android shrinkResources:
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true // 基于代码引用图删未用资源
}
}
}
2
3
4
5
6
7
8
工作原理:
R8 分析后得到"代码可达图"
↓
shrinkResources 看每个资源有没有被代码引用
- R.drawable.icon 被引用 → 保留
- R.drawable.unused_icon 没被引用 → 删除
↓
资源 ID 表也清理(resources.arsc 变小)
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" />
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 残留
2
3
4
5
正确做法:用 BuildConfig.DEBUG 隔离:
if (BuildConfig.DEBUG) {
// 这里的代码 R8 在 release 构建时会整段删除
Log.d(TAG, "verbose info")
Stetho.initialize(...)
}
2
3
4
5
注意:直接删除 Log 调用本身不能减体积——日志字符串仍在常量池。要彻底清理,配 ProGuard 规则:
# 删除所有 Log 调用(包括字符串参数)
-assumenosideeffects class android.util.Log {
public static int v(...);
public static int d(...);
public static int i(...);
}
2
3
4
5
6
# 13.4 删除冗余依赖
经典反例:项目里同时用了三个 JSON 库(GSON + Jackson + Moshi),各自带几 MB——其实任何一个就够。
审计方法:
# Android:用 dependency tree 分析
./gradlew app:dependencies > deps.txt
# 重点找:
# - 同类型多库(多个 JSON / 多个 HTTP / 多个图片库)
# - 大库只用了一个小功能(用 Apache HttpClient 只为发个请求)
# - 历史遗留的"曾经引入但忘删"
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, // 顶层变量也混淆
},
},
})],
}
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 头)
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
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;
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
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" // 主流密度
}
}
}
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"
}
}
2
3
4
5
风险:用户系统语言不在列表内 → 显示 default(通常是英文)。国际化应用必须用 AAB,否则要么留全部语言(大)要么牺牲部分用户体验。
Web 的 i18n 拆分:
// 路由级 lazy load 每种语言
const loadLocale = (lang) => import(`./locales/${lang}.json`);
2
# 15.4 动态特性模块
Android Dynamic Feature:
基础 APK:核心功能(10MB)
+ Feature A "AR 试穿"(20MB):进相机时下载
+ Feature B "高级编辑"(15MB):付费用户才下载
+ Feature C "教程"(5MB):新用户引导时下载
2
3
4
配置:
// 模块的 build.gradle
plugins {
id 'com.android.dynamic-feature'
}
2
3
4
用户感知:第一次进入该功能时显示"正在下载(5MB)...",下载完缓存到本地。
iOS On-Demand Resources:
// 标记资源为 ODR,安装时不下载
// 使用时按 tag 请求:
let request = NSBundleResourceRequest(tags: ["chapter-5"])
request.beginAccessingResources { error in
if error == nil {
// 资源已下载,可使用
}
}
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)
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(被低估) ─┘
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+预算] [自动化基准] [转化监控]
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 五条核心原则
- 三组成分析:代码 / 资源 / 配置 各自治理(§3.2)。
- 三大思想组合:减 / 压 / 拆 协同(§4.1),缺一不可。
- 工具用足:R8 / AAB / Tree Shaking / WebP 必开(§13-14)。
- 关注用户实际下载:而非构建产物大小(§5.2 + §17.3)。
- 持续监控防劣化:CI 跟踪每个版本增量(§19)。
# 21.2 五个常见误区
- ❌ "删几张图就够了":错(资源不一定是大头,大头可能是依赖)。
- ❌ "R8 容易出 bug 不开":错(默认开 + 配 keep 即可,不开是浪费 30%)。
- ❌ "App Bundle 麻烦不用":错(上传一次自动分发,省 50%)。
- ❌ "包小到极致才好":错(< 20MB 边际收益小)。
- ❌ "用户不在乎包大小":错(数据证明 30-50MB 区间转化率敏感)。
# 21.3 一句话总结
包体积是冷启动 + 安装转化的瓶颈,每多 1MB 都让用户漏斗多损失。 三大思想(减 / 压 / 拆)+ 四层治理(删 / 压 / 拆 / 架构)——大部分团队的问题不是"减得不够狠",而是"工具没用足、依赖没审计、用 APK 而非 AAB"。 R8 + AAB + WebP + 依赖审计是 Android 必做基线,能减 50%+。 关注用户实际下载,不要被构建产物大小迷惑——优化的最终目的是用户体验,而非 CI 看板上的数字。