用户下载一个 App 的耐心通常只有几十秒,安装包每大 10MB,转化率就掉一截。而 Flutter 的 release 包默认并不「小」:调试符号、未使用的图标字体、所有 ABI 的原生库、没压缩的图片资源,随便一项就能让包体膨胀几十 MB。与此同时,启动速度决定了「点击图标到看到首屏」的体感——冷启动多 500ms,用户流失会明显上升。
包体积和启动速度是一对孪生问题,优化手段高度重叠:减少 Dart 代码、延迟加载模块、精简资源,既减小包体又缩短启动时的初始化时间。本文从「怎么测量」开始,逐项拆解构建参数(--split-debug-info、--obfuscate、tree-shake-icons)、产物格式(App Bundle vs APK、ABI 拆分)、资源优化(字体子集化、图片格式),再到启动阶段拆解与首帧优化。测量方法上,Android 的启动分析思路可参考 Android 启动优化
,Flutter 的分析工具链与之互补。
一、先测量:包体积与启动时间的量化
不做测量的优化都是玄学。包体积有两个口径:下载体积(用户在商店看到的大小,已压缩)和安装体积(解压后占用磁盘的大小)。两者差距可能达到 2~3 倍。
分析 APK/AAB 的构成:
# 构建产物
flutter build appbundle --release
# 用 Android SDK 自带的分析工具看 AAB 各模块体积
bundletool build-apks --bundle=build/app/outputs/bundle/release/app-release.aab \
--output=app.apks
bundletool get-size total --apks=app.apks --dimensions=ABI
# 查看 APK 内部各文件占用(需先 unzip)
unzip -l build/app/outputs/flutter-apk/app-release.apk | sort -k1 -n -r | head -20
Flutter 侧也提供了体积分析命令:
# 输出各部分的体积明细
flutter build apk --release --analyze-size --target-platform android-arm64
# 生成 JSON 报告,用 DevTools 可视化
flutter build appbundle --analyze-size
# 然后打开 DevTools → App Size 面板加载 JSON
--analyze-size 会给出「Dart AOT 快照 / 原生库 / 资源 / 字体」四大部分的占比。典型 Flutter 空工程的 release 包约 1520MB(arm64 单架构),其中 Dart AOT 快照约 58MB,Flutter 引擎原生库约 8~10MB。这两块是「地基」,压不动;能压的是资源、字体、多余 ABI、未使用代码。
启动时间测量用 flutter run --profile --trace-startup:
flutter run --profile --trace-startup --dart-define=ENABLE_LOGGING=false
它会输出各阶段时间戳(timeToFirstFrameRasterizedMicros 等),并生成 start_up_info.json。关键指标是首帧光栅化时间(Time to First Frame Rasterized),这是用户真正感知到的「App 打开了」。
体积与启动的常见基线对照:
| 指标 | 空工程基线 | 优化目标 |
|---|---|---|
| 下载体积(arm64) | 15~20MB | 依业务而定 |
| Dart AOT 快照 | 5~8MB | 剥离符号后 3~6MB |
| 冷启动首帧 | 800~1500ms | < 800ms |
| 安装体积 | 40~60MB | 视资源而定 |
测量工具的分工:
| 工具 | 测什么 | 输出 |
|---|---|---|
--analyze-size | 包体积构成 | JSON + DevTools 可视化 |
bundletool get-size | AAB 各维度体积 | 命令行汇总 |
--trace-startup | 启动各阶段耗时 | start_up_info.json |
| DevTools Timeline | 运行时帧率与卡顿 | 火焰图 |
建议把「体积与启动基线」写进 CI,每次发版自动对比上一个版本,涨了超过阈值就告警——否则优化成果会在几个迭代后被不知不觉地吃掉。
二、构建参数:三行配置省下几十 MB
Release 构建必须加的三个参数:
flutter build appbundle \
--release \
--split-debug-info=build/symbols \
--obfuscate \
--dart-define-from-file=config/prod.json
逐个解释:
--split-debug-info=build/symbols:把调试符号从 Dart AOT 快照里剥离,单独存到build/symbols目录。这一步通常能省 20%~30% 的 Dart 快照体积。剥离后符号文件必须妥善归档——线上崩溃堆栈要靠它还原(配合崩溃监控平台的符号上传)。丢了符号,崩溃日志就是一堆地址。--obfuscate:混淆 Dart 代码的类名、方法名,既减小体积又增加逆向难度。它必须与--split-debug-info一起用,否则无法还原堆栈。--dart-define-from-file:把环境常量注入,避免把配置打进运行时资源。
Android 侧还有 Gradle 的 R8 混淆与资源压缩:
// android/app/build.gradle
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
}
minifyEnabled(R8 代码压缩)和 shrinkResources(资源压缩)能剔除未引用的 Java/Kotlin 代码与资源。注意:R8 默认会误删反射调用的类,需要 proguard 规则保留:
# proguard-rules.pro
# 保留 Gson/Jackson 序列化的模型类
-keep class com.example.myapp.model.** { *; }
# 保留反射调用的注解
-keepattributes *Annotation*
更深入的 R8 优化与基线配置文件(Baseline Profile)见 Android 侧专题——基线配置还能显著改善启动速度,与包体积优化双赢。
三、产物格式:App Bundle 与 ABI 拆分
APK 是单一产物,AAB 是按需分发。Google Play 要求新应用用 AAB 上传,它会根据用户设备自动下发对应的 ABI(armeabi-v7a / arm64-v8a / x86_64)和分辨率资源。这一步通常能省 30%~50% 的下载体积,因为用户不再下载三种架构的原生库。
# 推荐:AAB(Play 分发)
flutter build appbundle --release
# 若必须用 APK(如国内渠道),手动按 ABI 拆分
flutter build apk --release --split-per-abi
# 产出 app-armeabi-v7a-release.apk / app-arm64-v8a-release.apk / app-x86_64-release.apk
架构选择的权衡:
| 架构 | 覆盖设备 | 体积 | 建议 |
|---|---|---|---|
| arm64-v8a | 现代 64 位设备 | 基准 | 必留 |
| armeabi-v7a | 老 32 位设备 | 相近 | 视用户分布决定 |
| x86_64 | 模拟器/Chromebook | 相近 | 可只在调试包保留 |
32 位设备占比已很低(通常 < 5%),如果用户画像偏新,可以只发 arm64,省一份原生库。但要注意 Google Play 对 64 位的要求,以及部分国内渠道仍有 32 位存量。
# 只构建 arm64 单架构,用于体积对比
flutter build apk --release --target-platform android-arm64
# 查看 ABI 拆分后的各包体积
ls -lh build/app/outputs/flutter-apk/*.apk
多架构的另一个收益是「安装更快」:用户只下载自己设备需要的原生库,安装时间也随之缩短。
四、资源优化:图标、字体与图片
资源往往是最容易被忽视的「体积黑洞」。
4.1 图标 tree-shaking
Material/Cupertino 图标字体默认包含上千个图标,实际用到的可能只有几十个。Flutter 的 --tree-shake-icons 会在构建时扫描代码里用到的 IconData,只保留用到的字形:
flutter build appbundle --release --tree-shake-icons
它默认在 release 下开启,但如果图标是通过变量动态构造的(如从网络下发 IconData 的 codePoint),tree-shaking 会误删。这类动态图标应放进单独的字体文件并排除在 tree-shaking 之外:
# pubspec.yaml
flutter:
uses-material-design: true
fonts:
- family: DynamicIcons
fonts:
- asset: assets/fonts/dynamic_icons.ttf
一个纯 Material 图标字体约 1.6MB,tree-shaking 后可能降到几十 KB。
4.2 字体子集化
中文字体动辄 5~10MB,全量打包极不划算。用 fonttools 做子集化,只保留应用实际用到的字符:
# 提取应用中出现的所有字符(需先扫描代码与 ARB 文件)
python3 -c "
import glob, re
chars = set()
for f in glob.glob('lib/**/*.dart', recursive=True) + glob.glob('lib/l10n/*.arb'):
chars.update(open(f, encoding='utf-8').read())
open('charset.txt', 'w', encoding='utf-8').write(''.join(sorted(chars)))
"
# 生成子集字体
pyftsubset assets/fonts/NotoSansSC-Regular.ttf \
--text-file=charset.txt \
--output-file=assets/fonts/NotoSansSC-subset.ttf \
--layout-features='*' --flavor=woff2
子集化后中文字体可从 8MB 降到 1~2MB。代价是:运行时动态生成的文字(用户输入、服务端下发内容)若不在子集内会显示为方块。所以子集应包含常用汉字全集(GB2312 的 6763 字)加上应用固定文案,或改用「按需下载字体子集」的方案。
4.3 图片格式
优先顺序:SVG(矢量)> WebP > PNG/JPEG
- 图标与插画用 SVG:
flutter_svg渲染,体积是位图的几分之一。 - 照片类用 WebP:同等质量下比 JPEG 小 25%~35%,Flutter 原生支持解码。
- 多分辨率用
2.0x/3.0x目录:Flutter 按设备像素比自动选择,但要注意只提供实际需要的倍率,别把所有倍率都打包进去。
# 批量把 PNG 转成 WebP(质量 80)
for f in assets/images/*.png; do
cwebp -q 80 "$f" -o "${f%.png}.webp"
done
用 flutter_launcher_icons 和 flutter_native_splash 生成启动图标与闪屏,避免手工放多套图。
4.4 资源审计脚本
每次发版前跑一遍资源审计,能及早发现「误提交的大文件」:
#!/usr/bin/env bash
# 找出 assets 下超过 200KB 的文件,按大小排序
find assets -type f -size +200k -exec ls -lh {} \; | \
awk '{print $5, $9}' | sort -rh
# 统计各类型资源总占用
find assets -type f | sed 's/.*\.//' | sort | uniq -c | sort -rn
一个常见的事故是:设计师给了一张 4K 的 PNG 用作背景,没压缩就提交了,直接让包体多出 10MB。审计脚本能在 CI 里拦住这类提交,超过阈值就失败。
五、延迟加载与拆分
包体积优化的进阶手段是「不把暂时用不到的东西打进主包」。
5.1 延迟组件(Deferred Components)
Flutter 支持 Android 的延迟组件(Deferred Components),把非核心功能拆成独立模块,用户首次使用时再下载:
// 声明延迟导入
import 'package:myapp/features/ar_viewer/deferred_ar.dart' deferred as ar;
// 使用时加载
Future<void> openArViewer() async {
await ar.loadLibrary();
ar.showArViewer();
}
# pubspec.yaml(Android 侧需配合动态特性模块配置)
flutter:
deferred-components:
- name: ar_module
libraries:
- uri: package:myapp/features/ar_viewer/deferred_ar.dart
延迟加载对首包体积帮助巨大,但配置复杂(需要 Play Feature Delivery、签名一致性等),且目前主要支持 Android。适合「AR/离线地图/大型游戏模块」这类体积大又非首屏必需的功能。
5.2 首屏减负
即使不做延迟组件,也能通过「首屏只加载首屏需要的」来加速启动:把非首屏的 Provider、路由、依赖注入注册推迟到首帧之后。
void main() {
WidgetsFlutterBinding.ensureInitialized();
// 首帧前只做最必要的初始化
runApp(const MyApp());
// 首帧后再做重活:埋点、缓存预热、远程配置拉取
WidgetsBinding.instance.addPostFrameCallback((_) {
Analytics.init();
RemoteConfig.fetch();
});
}
六、启动阶段拆解与首帧优化
冷启动的时间花在哪,必须拆开看。Flutter 冷启动分四个阶段:
| 阶段 | 内容 | 优化手段 |
|---|---|---|
| 原生启动 | Application 创建、引擎加载 | 减少原生初始化、用 Baseline Profile |
| 引擎初始化 | Dart VM 启动、AOT 快照加载 | 减小快照体积、延迟 isolate |
| Dart 首帧 | main() 到首次 build | 精简 main()、首屏 Widget 树瘦身 |
| 光栅化 | 首帧绘制到屏幕 | 减少首屏复杂度、避免首帧加载图片 |
main() 里能省则省:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 反面教材:首帧前 await 一堆异步初始化
// await Firebase.initializeApp();
// await SharedPreferences.getInstance();
// await Analytics.init();
runApp(const MyApp());
}
await Firebase.initializeApp() 这类调用会阻塞首帧,把启动时间拉长几百毫秒。正确做法是「用的时候再初始化」或「首帧后初始化」。首屏的 Widget 树也要瘦身:避免首帧就加载网络图片、避免复杂的 CustomPaint、用 const 构造函数减少重建。
// 用 const 减少首屏重建开销
const homePage = HomePage();
// 首屏图片用占位,避免首帧等待解码
Image.network(
url,
frameBuilder: (ctx, child, frame, _) =>
frame == null ? const PlaceholderBox() : child,
)
针对 Android,还可以配合 Baseline Profile(基线配置文件)把热点代码预编译,冷启动通常能再快 20%~30%。这套「用编译期产物换启动速度」的思路,与 Android 启动优化 里 Profile-guided 的优化一脉相承。
渲染相关的深入优化(减少重绘、RepaintBoundary、图层合并)见 https://plumephp.com/flutter-performance-optimization/,它与包体积优化共同构成「运行时 + 构建时」的完整优化面。
对于 Web 端,包体积优化的逻辑类似但手段不同:代码分割、tree-shaking、gzip/brotli 压缩。Flutter Web 的产物优化详见 https://plumephp.com/flutter-web-desktop/。
6.1 启动优化检查清单
按优先级排列的可操作清单:
[ ] release 构建带 --split-debug-info 与 --obfuscate
[ ] main() 中不 await 非必要依赖(Firebase/SharedPreferences 等)
[ ] 首帧后(addPostFrameCallback)再做埋点、远程配置、缓存预热
[ ] 首屏 Widget 树用 const 构造,减少重建
[ ] 首屏图片用占位,避免首帧等待网络解码
[ ] 用 --trace-startup 建立优化前基线
[ ] Android 侧启用 Baseline Profile
[ ] 每次优化后对比首帧时间数字
6.2 启动加速的常见误区
- 「把初始化都改成 Future.microtask」:微任务仍在首帧前执行完,不会真正延后。要延后到首帧,用
addPostFrameCallback。 - 「延迟初始化 = 懒加载」:真正的懒加载是在「第一次用到」时才初始化(如单例的
late final),而不是统一推迟到首帧后。 - 「减小包体一定加快启动」:包体影响的是「安装」和「加载快照」时间,首帧慢更可能是
main()里的同步初始化导致的,两者要分开优化。
小结
包体积与启动优化是一套「测量 → 削减 → 验证」的循环。四条优先级最高的动作:release 必加 --split-debug-info + --obfuscate(省 20%~30% 且防逆向);用 AAB 而非 APK(按 ABI 分发省 30%~50%);图标 tree-shaking + 字体子集化(资源大户,中文字体可省数 MB);首帧后置重初始化(main() 里不 await 非必要依赖)。测量工具是 --analyze-size 与 --trace-startup,每次优化都要有前后数字对比,否则很容易「优化了个寂寞」。最后切记:符号文件必须归档,否则崩溃堆栈无法还原。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。