Flutter 基础体系 · 第 72/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 包体积优化:AOT、资源、字体、符号、拆分和分析
Flutter 包体积优化首先要区分三个经常被混用的概念:
- 构建产物大小:例如 APK、AAB、IPA、桌面应用目录或 Web 发布目录在磁盘上的大小。
- 用户下载大小:应用商店或 CDN 经过压缩、平台切分后,用户实际下载的数据量。
- 安装后占用:解压后的二进制、资源、缓存和运行时文件在设备上的空间。
这三个数字可能明显不同。一个包含多个 ABI 的通用 APK 会比单 ABI APK 大;AAB 本身可能较大,但 Google Play 会根据设备 ABI、屏幕密度和语言生成更小的 APK;IPA 上传包也不等于某个用户最终安装的 App Store 载荷。优化必须先确定要优化哪一个指标。
一、先建立包体积模型
可以把一个 Flutter 应用的发布体积粗略表示为:
其中:
- :Flutter Engine 及平台嵌入层;
- :Dart 编译后的 AOT 代码或 Web 代码;
- :Android、iOS、桌面平台的原生库;
- :图片、JSON、模型、音频、视频等资源;
- :应用打包的字体文件;
- :调试符号和映射信息;
- :清单、配置、资源索引等辅助文件。
这个公式不是构建系统的精确计费公式,因为压缩会跨文件产生效果,安装包也可能包含对不同设备架构的多份内容。但它适合建立因果关系:如果资源占包体的 60%,优化 Dart 代码通常不会改变总体积;如果 AOT 代码占主要部分,则图片压缩的收益有限。
还要区分压缩前后的体积。设某文件原始大小为 ,经过 ZIP、gzip 或 Brotli 后大小为 ,则压缩率可以写作:
已经压缩过的 JPEG、PNG、WebP、音频和视频通常很难通过再次 ZIP 获得明显收益;文本、JSON、未压缩字体和 JavaScript 则通常更容易压缩。因此,不能只看文件的原始字节数,也不能只看最终压缩包中的字节数。
二、AOT:发布模式为什么仍然包含大量代码
2.1 Debug、Profile 和 Release 的代码形态不同
Dart 支持 JIT 和 AOT 两类执行方式:
- JIT(Just-In-Time):运行时编译或解释代码,适合调试、热重载;
- AOT(Ahead-Of-Time):在构建阶段提前编译代码,适合发布。
Flutter 的常见模式可以概括为:
| 模式 | 主要用途 | 代码执行方式 | 是否适合比较发布包体 |
|---|---|---|---|
| Debug | 开发和热重载 | JIT,包含调试支持 | 否 |
| Profile | 性能分析 | 接近发布运行时 | 通常不用于最终发布包 |
| Release | 正式发布 | AOT | 是 |
因此,不能用 flutter run 生成的 Debug 应用大小判断线上包体。Debug 应用包含调试服务、热重载支持和更多运行时信息,结构与 Release 不同。
典型发布命令如下:
flutter build apk --release
flutter build appbundle --release
flutter build ios --release
flutter build macos --release
flutter build web --release
--release 是默认意图的明确表达,但显式写出有助于避免脚本或 CI 配置错误。
2.2 AOT 编译的大致路径
Dart 源码不会直接变成一个简单的机器码文件。发布构建可以抽象成以下路径:
flowchart LR
A[Dart 源码] --> B[Kernel 中间表示]
B --> C[静态分析与可达性分析]
C --> D[AOT 编译器]
D --> E[Dart AOT 代码与快照]
E --> F[Flutter Engine 与平台嵌入层]
F --> G[APK / IPA / 桌面产物]
其中:
- Dart 前端将代码转换为 Kernel 中间表示;
- 编译器分析入口、调用关系和类型信息;
- AOT 编译器生成目标平台代码及运行时所需的快照;
- Flutter Engine 负责启动 Dart isolate、加载快照并驱动渲染;
- Android、iOS 或桌面平台再将这些内容封装进最终应用。
AOT 的目标不是“把所有 Dart 文件原样打包”,而是保留运行时可能到达的代码。可以把保留条件近似写成:
这就是 tree shaking 的基本思想:不可达代码可以被删除。
2.3 Tree shaking 不是任意代码删除
如果代码通过普通静态调用连接到应用入口,编译器通常能够分析它。例如:
abstract interface class Formatter {
String format(Object value);
}
class JsonFormatter implements Formatter {
@override
String format(Object value) => '{"value":"$value"}';
}
class XmlFormatter implements Formatter {
@override
String format(Object value) => '<value>$value</value>';
}
final Formatter formatter = JsonFormatter();
在没有其他引用的情况下,XmlFormatter 的代码可能被 tree shaking 删除,因为程序的静态调用关系只使用了 JsonFormatter。
但以下场景会削弱这种效果:
final typeName = config['formatterType'];
// 根据字符串决定实例化哪个类
Dart 的 AOT 编译器不能仅凭任意运行时字符串推断“用户未来会传入什么值”。如果框架或插件需要通过反射、注册表、字符串名称或原生侧回调访问代码,这些代码可能需要显式保留。
Flutter/Dart 应用通常不依赖传统 Dart 反射,但插件注册、平台通道、代码生成框架和原生回调仍然可能构成额外入口。于是,“删除没有被直接调用的类”并不是安全的通用规则。
2.4 常量分支可以帮助编译器删除功能
编译期环境变量可以参与常量表达式:
const enableLegacyReport =
bool.fromEnvironment('ENABLE_LEGACY_REPORT', defaultValue: false);
void main() {
if (enableLegacyReport) {
runLegacyReport();
} else {
runModernReport();
}
}
构建时可以传入:
flutter build apk --release \
--dart-define=ENABLE_LEGACY_REPORT=false
如果这个值在编译期被确定为 false,编译器有机会删除旧功能及其只被该分支引用的代码。关键条件是:
- 必须使用
const环境值; - 条件必须出现在编译器可分析的代码路径中;
- 不能在运行时重新读取同一个配置;
- 依赖的资源不会因为 Dart 分支消失而自动从
pubspec.yaml中消失。
下面的写法不能达到相同效果:
final enableLegacyReport =
const String.fromEnvironment('ENABLE_LEGACY_REPORT') == 'true';
final 变量本身可以在运行时使用,但它不如直接的常量条件容易被编译器传播和折叠。更重要的是,任何资源只要仍然被声明为 Flutter asset,就不会因为业务代码未执行而自动从包中移除。
2.5 依赖包优化的真实边界
Dart 包的一个常见误解是“只要在 pubspec.yaml 中声明了整个包,包中所有代码都会进入 APK”。对于可被静态分析的 Dart 代码,通常会经过 tree shaking;但以下内容不能据此推断:
- 包内声明的 asset 是否被删除;
- 原生
.so、.framework、.xcframework是否被删除; - 通过插件注册或原生代码加载的模块是否被删除;
- 动态加载的文件是否被静态分析;
- 资源是否因不同平台配置而自动排除。
因此,添加一个依赖包后的体积变化应使用构建分析工具确认,而不是只看 Dart 源码行数。
三、资源:Flutter 会打包你声明的内容
3.1 pubspec.yaml 是资源进入包的主要入口
例如:
flutter:
assets:
- assets/images/logo.webp
- assets/config/
fonts:
- family: AppText
fonts:
- asset: assets/fonts/AppText-Regular.ttf
- asset: assets/fonts/AppText-Bold.ttf
weight: 700
这里的含义是:
logo.webp被作为单个资源打包;assets/config/下符合 Flutter 资源处理规则的文件会被纳入资源;- 两个字体文件注册为
AppText的不同字重。
修改后需要执行:
flutter pub get
flutter build apk --release
flutter pub get 会刷新依赖和工程生成状态;真正决定发布包体的是之后的目标平台 Release 构建。
Flutter 资源一般会被复制或封装到应用的 asset bundle 中。Dart 代码通过 rootBundle 读取它们:
import 'package:flutter/services.dart';
Future<String> loadConfig() async {
return rootBundle.loadString('assets/config/app.json');
}
这段代码只有在 assets/config/app.json 被 pubspec.yaml 声明后才成立。若资源未声明,常见失败表现是运行时抛出 Unable to load asset,而不是构建阶段自动提醒。
3.2 未使用资源不会像 Dart 代码一样自动 tree shake
下面的资源即使没有任何 Dart 代码读取,也可能进入应用:
flutter:
assets:
- assets/images/
Flutter 资源声明描述的是“要提供给运行时的资源集合”,而不是“根据代码引用自动推导的资源集合”。这和 AOT 对 Dart 代码的可达性裁剪不同。
因此,资源优化通常需要:
- 删除不再使用的文件;
- 缩小目录声明范围;
- 将开发素材和发布素材分离;
- 对构建产物中的资源进行审计;
- 避免把测试数据、设计源文件、原始视频放入应用目录。
如果某个功能只在特定产品版本中使用,可以用不同 flavor 或不同构建脚本生成不同资源集合,而不能期待运行时条件自动缩小安装包。
3.3 图片格式和尺寸必须与用途匹配
一张图片的内存和磁盘成本不是同一个问题。
例如:
- JPEG 适合照片和连续色调;
- PNG 适合需要无损压缩或透明通道的图形;
- WebP 通常适合希望同时获得较好压缩和透明支持的场景;
- SVG 适合图标、简单插画和需要缩放的矢量图,但运行时解析成本和兼容性需要评估;
- 大尺寸图片即使文件压缩后不大,解码后仍可能占用大量内存。
一个 4000×3000 的 RGBA 图片,未压缩像素内存近似为:
约为 45.8 MiB,尚未计算解码器和 Flutter 图片缓存的额外开销。它可能只有几 MiB 的 WebP 文件大小,但首次显示时仍会产生较大的内存压力。
包体优化不能把“文件变小”和“运行时内存变小”混为一谈。应同时考虑:
- 发布文件大小;
- 下载流量;
- 首次解码时间;
- 图片缓存占用;
- 不同屏幕密度下的清晰度。
3.4 分辨率变体不是免费机制
Flutter 支持基于目录约定的图片分辨率变体,例如:
assets/images/icon.png
assets/images/2.0x/icon.png
assets/images/3.0x/icon.png
并在 pubspec.yaml 中声明根目录:
flutter:
assets:
- assets/images/
运行时,AssetImage 会根据设备的设备像素比选择更合适的变体。这样可以改善显示质量,但这些变体通常都会被打包,因此会增加包体。
如果 1x、2x、3x 三个文件的大小分别为 20 KB、45 KB、90 KB,那么即使单台设备只使用其中一个,通用资源集合仍可能包含约 155 KB 的原始文件。Android App Bundle 可以进一步按屏幕密度切分,但本地 APK 或不具备商店切分能力的分发渠道不会自动获得这种收益。
3.5 JSON、模型和音视频常常是更大的来源
Flutter 应用中经常被忽略的资源包括:
- 本地化 JSON;
- 离线地图;
- OCR 或机器学习模型;
- 音效和背景音乐;
- 视频;
- HTML、Markdown 和模板文件。
对文本资源,压缩通常有效,但不能只依赖商店压缩。对模型和媒体文件,则应检查是否可以:
- 改为首次启动后下载;
- 按功能拆分;
- 使用针对推理框架的量化或裁剪格式;
- 降低音频采样率或视频码率;
- 只保留发布所需的语言和地区内容。
动态下载会把安装包体积转化为网络、缓存、版本管理和离线可用性问题。它不是无成本的优化。
四、字体:最容易被低估的资源类型
4.1 Flutter 字体配置包含三个维度
字体配置至少包含:
family:字体族名称;asset:字体文件路径;weight:字重;style:正常或斜体风格。
例如:
flutter:
fonts:
- family: BrandSans
fonts:
- asset: assets/fonts/BrandSans-Regular.ttf
weight: 400
- asset: assets/fonts/BrandSans-Medium.ttf
weight: 500
- asset: assets/fonts/BrandSans-Bold.ttf
weight: 700
- asset: assets/fonts/BrandSans-Italic.ttf
style: italic
使用时:
Text(
'账户余额',
style: const TextStyle(
fontFamily: 'BrandSans',
fontWeight: FontWeight.w700,
),
)
fontWeight 和 fontStyle 不会在运行时把一个普通字体神奇地变成真正的粗体或斜体文件。它们用于选择已注册的字体变体;如果不存在精确匹配,字体匹配系统可能选择相近变体,具体效果取决于字体和平台实现。
4.2 字体包体通常由字符集决定
拉丁字体可能只有几十到几百 KB,而包含大量汉字、日文或韩文字符的字体可能达到数 MB 甚至更大。字体大小的主要因素包括:
- 字符集合;
- 字形轮廓复杂度;
- hinting 和布局表;
- OpenType 特性;
- 是否包含多个字重和斜体;
- 是否是可变字体;
- 是否包含版权、名称和其他元数据。
如果应用只使用有限字符集,可以在确认授权允许的前提下进行字体子集化。常见做法是使用 FontTools 等外部工具生成只包含目标 Unicode 范围的字体,再将结果注册到 Flutter。
但“只保留当前文案中的字符”存在运行时风险:
- 服务端下发的新文案可能出现缺字;
- 用户输入的姓名、地址和聊天内容不可预测;
- 表情符号和组合字符可能不在子集内;
- CJK 字符覆盖范围估计错误会造成方框字;
- 字体 fallback 可能在不同平台上表现不一致。
所以字体子集化必须以产品输入边界为前提,而不是只根据当前几张页面截图生成字体。
4.3 系统字体和自带字体的取舍
使用系统字体通常可以减少应用自带字体,但会牺牲跨平台一致性:
- Android 不同厂商的系统字体可能不同;
- iOS 与 Android 的字形、字宽和 fallback 规则不同;
- 桌面平台的系统字体集合更不一致;
- Web 还受到浏览器和操作系统字体环境影响。
如果品牌排版或文档渲染需要严格一致,应保留应用字体;如果只是普通正文,系统字体可能更节省包体。这个决策属于视觉一致性和包体之间的取舍,而不是单纯的技术正确性。
4.4 Material 图标字体也会影响包体
当工程在 pubspec.yaml 中配置:
flutter:
uses-material-design: true
Flutter 可以使用 Material 图标相关资源。若应用大量使用 Material 图标,通常不应为了删除一部分图标而随意修改 Flutter SDK 内部资源;更可靠的做法是确认实际构建分析结果,再决定是否使用自定义图标字体、SVG 或少量矢量资源。
自定义图标字体也不是天然更小。一个字体文件可能包含大量未使用 glyph;而多个 SVG 文件虽然数量多,但经过压缩后可能更小。应以实际产物比较,而不是根据文件扩展名判断。
五、符号:调试符号、混淆和崩溃还原不是一回事
“符号”至少有三层含义,必须分开处理。
5.1 Dart 符号和混淆映射
Flutter 支持在发布构建时混淆 Dart 名称,并将还原所需信息输出到指定目录:
flutter build appbundle --release \
--obfuscate \
--split-debug-info=build/symbols/android
这里:
--obfuscate:对部分 Dart 符号进行混淆;--split-debug-info:将调试信息和符号映射从最终应用中分离;build/symbols/android:保存映射文件的目录。
这通常可以减少最终产物中可读的 Dart 符号,并提高反编译后的阅读难度,但它不是完整的安全边界。字符串、资源、业务逻辑结构和原生符号仍可能泄露信息,混淆也不能替代密钥管理和服务端权限控制。
最重要的运维条件是:每一次发布都必须保存对应的符号文件。符号文件要与构建版本、Git 提交、构建参数和发布渠道建立稳定关联。删除或覆盖错误映射后,线上堆栈可能无法恢复为源代码位置。
混淆后的 Dart 崩溃还原大致依赖:
线上 obfuscated stack
+
同一次构建产生的 mapping
↓
原始 Dart 类名、方法名和位置
如果使用了错误版本的 mapping,可能出现“还原成功但内容错误”的更危险情况。
5.2 原生调试符号
Android 的 .so、iOS 的 Mach-O 和桌面平台原生二进制还可能包含 C/C++ 或平台原生符号。它们与 Dart 混淆映射不同:
- Dart 符号用于还原 Dart 堆栈;
- ELF 符号或 Breakpad 符号用于还原 Android 原生崩溃;
- dSYM 用于还原 iOS 原生崩溃;
- 桌面平台根据操作系统使用对应的调试信息格式。
因此,仅配置 --split-debug-info 不能保证所有原生崩溃都可还原。Android NDK、Gradle 和崩溃平台还可能需要单独上传 native symbols;iOS 则要保存与归档对应的 dSYM。
5.3 Strip 与符号保管
Strip 通常表示从发布二进制中移除不必要的符号或调试信息。它可以减小包体,但风险是:
- 线上崩溃堆栈难以解析;
- native 崩溃分析缺少函数名;
- 不同 ABI 或不同构建版本使用了错误符号;
- 本地只保存了最终包,没有保存符号归档。
正确的流程不是“把符号全部删除”,而是:
- 从用户包中移除不必要的调试信息;
- 将完整符号保存到受控存储;
- 为每个版本和架构生成唯一记录;
- 在测试环境验证一次 Dart 和 native 崩溃还原;
- 发布后禁止覆盖历史符号。
六、拆分:减少的是某个用户获得的内容,不一定是构建总量
6.1 ABI 拆分
ABI 是应用二进制接口。Android 常见架构包括:
arm64-v8aarmeabi-v7ax86_64
如果生成一个包含多个 ABI 的通用 APK,每种架构的 native 库都可能被放入其中。可以使用:
flutter build apk --release --split-per-abi
通常会产生按 ABI 区分的 APK,例如:
app-arm64-v8a-release.apk
app-armeabi-v7a-release.apk
app-x86_64-release.apk
这些文件不是互相叠加安装的替代品,而是分别面向不同架构设备。直接比较其中某一个 APK 与通用 APK,才能判断 ABI 拆分对单用户下载量的影响。
风险包括:
- 侧载渠道必须选择正确 ABI;
- 某些设备或模拟器架构未覆盖;
- CI 需要为每种 APK 生成校验和和发布记录;
- native 插件必须确实提供对应架构。
6.2 AAB 与商店设备切分
Google Play 推荐 Android 使用 Android App Bundle:
flutter build appbundle --release
AAB 是交给 Google Play 的发布格式,不是用户直接安装的 APK。商店可以根据设备的 ABI、屏幕密度和语言生成设备相关 APK,从而减少用户下载不需要的内容。
这意味着:
- AAB 文件自身的大小不是用户下载大小;
- 本地用
unzip -l app-release.aab看到的是模块和资源全集; - 用户实际下载量要通过商店报告、内部测试轨道或 bundletool 模拟设备配置验证;
- 不能用通用 APK 的大小推断 AAB 用户体验。
如果需要在本地检查设备配置生成的 APK,可以使用 Android 官方 bundletool,示例:
bundletool build-apks \
--bundle=build/app/outputs/bundle/release/app-release.aab \
--output=build/app.apks \
--mode=universal
--mode=universal 会生成包含更多内容的通用包,便于测试但不代表商店给真实设备分发的最小包。要模拟特定设备,应提供对应的 --device-spec,并检查生成的 APK 集合。
6.3 iOS 的 App Store slicing
iOS App Store 也会根据设备和平台分发合适的架构与资源切片。IPA 上传文件可能包含多个架构和资源,不能简单等同于最终用户安装大小。
需要特别区分:
- 真机 Release 构建;
- Simulator 构建;
- App Store 归档;
- 企业签名或 Ad Hoc 分发;
- App Store 下载和安装后占用。
模拟器通常包含桌面架构,不能用它的 .app 目录大小评估真机包体。iOS 的 dSYM 通常随归档流程保留,但不应将其误认为需要随用户安装包分发的资源。
6.4 Flutter 代码延迟加载
Dart 支持 deferred import:
import 'package:flutter/material.dart';
import 'reports.dart' deferred as reports;
Future<void> openReports(BuildContext context) async {
try {
await reports.loadLibrary();
if (!context.mounted) {
return;
}
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => reports.ReportsPage(),
),
);
} on Object catch (error, stackTrace) {
// 记录 error 和 stackTrace,并向用户显示可恢复的错误状态。
debugPrint('Failed to load reports: $error\n$stackTrace');
}
}
被延迟导入的库不会像普通 import 一样直接参与初始代码路径。loadLibrary() 返回一个 Future,调用方必须处理加载中的 UI、失败重试和页面生命周期。
这段代码成立的前提是:
reports.dart中定义了可访问的ReportsPage;- 目标平台和当前 Flutter 工具链支持对应的 deferred loading 方式;
- 应用允许首次进入功能时产生额外加载延迟;
- 网络或动态模块加载失败时有错误处理。
平台差异很重要:
- Web 的 deferred loading 可以形成额外的代码分片,运行时按需下载;
- Android 可以结合 Android App Bundle 的动态功能模块实现更完整的按需交付,但需要 Play Feature Delivery、模块配置和发布渠道配合;
- iOS 对 Dart deferred loading 的实际交付方式存在平台限制,不能直接假设它一定产生独立的可下载模块;
- 桌面平台通常不会自动获得类似移动应用商店的动态模块分发。
因此,deferred import 是代码组织和加载语义,不等价于所有平台都能减少首包。必须检查目标平台构建产物和官方当前工具链支持情况。
按需加载还会引入新的故障路径:
sequenceDiagram
participant U as 用户
participant A as Flutter 应用
participant L as 延迟库加载器
participant N as 网络或动态模块服务
U->>A: 打开低频功能
A->>L: loadLibrary()
L->>N: 请求分片或动态模块
alt 加载成功
N-->>L: 返回代码模块
L-->>A: Future 完成
A-->>U: 创建功能页面
else 网络失败或模块不可用
N-->>L: 错误
L-->>A: Future 抛出异常
A-->>U: 显示重试或降级界面
end
把低频功能拆出去可以减小首包,但会增加首次进入延迟、缓存管理、版本一致性和灰度发布复杂度。登录、支付、核心导航等高频功能通常不适合仅为了包体而延迟加载。
七、不同平台的优化重点
7.1 Android
Android 重点通常是:
- 使用 Release AOT 构建;
- 使用 AAB;
- 通过 ABI 和资源切分减少单设备下载内容;
- 检查插件带入的 native
.so; - 对 Android 原生代码使用 R8/ProGuard 时验证反射和 JNI 配置;
- 分离和保存 Dart、NDK 符号。
R8 主要处理 Java/Kotlin 字节码,不会替代 Dart tree shaking,也不会自动压缩 Flutter asset。Android 资源收缩器同样不能安全地假设所有 Flutter 资源都可以依据 Java/Kotlin 引用关系删除,因为 Flutter asset 可能通过字符串在 Dart 中访问。
7.2 iOS
iOS 重点通常是:
- 使用真机 Release 归档;
- 检查 Framework、XCFramework 和插件带入的架构;
- 依赖 App Store slicing;
- 保存 dSYM;
- 用 App Store Connect 或实际归档结果判断下载大小和安装大小;
- 不用 Simulator 产物做线上包体结论。
iOS 的原生静态库、动态 Framework、资源 Bundle 和 Flutter Engine 组成方式受 Xcode 和插件配置影响。删除某个插件的 Dart import,不保证其原生部分已经从工程链接中消失;应检查最终归档中的 Mach-O、Framework 和资源。
7.3 桌面端
Windows、macOS 和 Linux 通常把 Flutter Engine、Dart AOT 代码、原生依赖和资源放进一个应用目录或安装包。它们一般没有移动商店那样的 ABI 和资源自动切片。
桌面端可以:
- 移除不支持平台的插件;
- 清理不需要的运行时资源;
- 使用安装包格式的压缩;
- 将大型可选数据放到首次使用时下载;
- 分别构建每个平台和架构,而不是把调试、符号和多个平台文件混在一起。
桌面应用更应该分别测量“安装包下载大小”和“安装目录大小”,因为安装器、压缩包和解压目录的数字不同。
7.4 Web
Flutter Web 的关键指标通常是:
- 首次 HTML、JavaScript/Wasm 和资源下载量;
- 首屏代码执行时间;
- 浏览器缓存命中;
- CDN gzip 或 Brotli 压缩后的大小;
- deferred loading 形成的分片数量和首包路径。
Web 的网络压缩对文本和 JavaScript 影响很大,但不会显著压缩已经是 JPEG、WebP、MP4 或压缩字体的内容。Web 代码分片还需要服务器正确配置缓存和 MIME 类型;如果 CDN 把每个分片都设置成不可缓存,拆分可能减少首包,却增加后续请求成本。
八、符号、资源和代码应该如何分析
8.1 使用 Flutter 的构建体积分析
Flutter 提供构建阶段的体积分析入口,例如:
flutter build apk --release --analyze-size
也可以对实际使用的发布目标执行相应命令,例如 AAB 或 iOS 构建。具体支持的目标和输出形式会随 Flutter 版本和平台工具链变化,应以当前 flutter build <target> --help 为准。
成功后通常会得到一个体积分析 JSON 文件或提示信息。将它导入 Flutter DevTools 的 App Size 工具后,可以按类别查看:
- Flutter Engine;
- AOT snapshot;
- Dart 包;
- 资源;
- 字体;
- 原生库;
- 其他构建产物。
命令成功并不等于分析结果已经上传或永久保存。CI 应该保留 JSON,并给每次构建记录:
- Flutter SDK 版本;
- Dart SDK 版本;
- 构建目标;
- ABI;
- flavor;
--dart-define;- Git commit;
- 是否启用混淆;
- 是否启用符号分离。
否则两个体积报告可能来自不同配置,差异无法归因。
8.2 直接检查 APK、AAB 和 IPA
Android APK 是 ZIP 容器,可以先观察条目大小:
unzip -l build/app/outputs/flutter-apk/app-release.apk \
| sort -k1,1n \
| tail -30
输入是 APK 路径;输出会列出归档内文件大小。末尾通常能看到最大的 .so、图片、字体或资源文件。这个结果是未解压条目大小,不一定等于设备安装后的磁盘占用。
也可以检查 ABI:
unzip -l build/app/outputs/flutter-apk/app-release.apk \
| grep -E 'lib/(arm64-v8a|armeabi-v7a|x86_64)/'
如果一个 APK 同时出现多个 ABI 目录,就要确认它是否是有意生成的通用包。
AAB 可以使用:
unzip -l build/app/outputs/bundle/release/app-release.aab \
| sort -k1,1n \
| tail -30
但 AAB 中存在模块结构和商店切分信息,不能把它的总大小直接报告为用户下载大小。最终判断应结合 bundletool 或商店测试轨道。
iOS 归档通常位于:
build/ios/archive/Runner.xcarchive
可以检查归档内容:
du -sh build/ios/archive/Runner.xcarchive
find build/ios/archive/Runner.xcarchive \
-type f \
-size +10M \
-print
这用于发现异常大的文件,不等价于 App Store 用户下载量。要分析 iOS 二进制的架构和符号,还需要使用 Xcode 命令行工具对归档内的 Mach-O 和 dSYM 进行检查。
8.3 用基线和差分找回归
单次分析只能告诉你“现在大在哪里”,不能告诉你“为什么变大”。更有效的方式是保留基线:
commit A:
assets 8.2 MB
fonts 6.4 MB
Dart AOT 4.7 MB
native 18.1 MB
commit B:
assets 19.6 MB
fonts 6.4 MB
Dart AOT 5.0 MB
native 18.1 MB
从这个假设结果可以推导出:
主要回归来源是资源,而不是 AOT 或 native 代码。若此时去优化 Dart 类名,只能影响很小的一部分。
CI 可以在超过阈值时失败,但阈值必须区分:
- 通用 APK;
- 单 ABI APK;
- AAB;
- Web 首包;
- Debug/Profile 产物;
- 是否含符号。
否则会出现“构建工具变更导致指标跳变,却错误阻断发布”的情况。
九、一个完整的诊断算例
假设某 Android Release 通用 APK 的分析结果如下:
| 类别 | 大小 |
|---|---|
| Flutter Engine | 18.0 MB |
| Dart AOT | 5.2 MB |
原生插件与 .so |
14.5 MB |
| 图片与其他资源 | 22.0 MB |
| 字体 | 9.0 MB |
| 其他 | 2.3 MB |
| 合计 | 71.0 MB |
现在完成三项修改:
- 删除未使用的设计源图片,资源从 22.0 MB 降到 13.0 MB;
- 将包含完整 CJK 字符集的字体子集化,字体从 9.0 MB 降到 2.5 MB;
- 使用 AAB 让用户只获得
arm64-v8a,但这一步不会改变 AAB 内部所有模块的总和。
对于通用 APK,修改后的理论大小为:
减少:
而 AAB 的用户下载大小还要进一步考虑:
- 设备只需要一个 ABI;
- 是否需要某些密度变体;
- 是否下载动态功能模块;
- 商店压缩和资源切分;
- 原生插件是否仍包含多架构内容。
因此,不能说“APK 减少 15.5 MB,所以所有用户下载都减少 15.5 MB”。正确结论只能是:在相同构建配置下,通用 APK 的构建内容减少了 15.5 MB;实际用户载荷还需对目标设备生成切分结果后验证。
十、常见误解和失败表现
10.1 用 Debug 包做体积比较
失败表现:开发机上的 Debug APK 很大,于是误以为 Release 包同样大。
原因:Debug 使用 JIT 和调试支持,不能代表 AOT 发布产物。
诊断:固定使用 flutter build ... --release,并记录目标平台、ABI 和 flavor。
10.2 只删除 Dart import,资源仍然留在包里
失败表现:删除页面代码后,图片、字体或 JSON 体积没有下降。
原因:Dart 代码可能被 tree shake,但 pubspec.yaml 中声明的资源仍是独立的 asset bundle 输入。
诊断:直接检查 APK/AAB/IPA 内的资源路径,并从资源声明中删除无用文件或缩小目录范围。
10.3 把字体 weight 当成字体压缩
失败表现:配置了 FontWeight.w700,但包体仍包含多个完整字体文件。
原因:weight 只是字体匹配信息,不会自动合并、子集化或压缩字体。
诊断:检查每个 .ttf、.otf、.woff 文件,并确认真实使用的字符集和变体数量。
10.4 启用 --obfuscate 后无法还原崩溃
失败表现:线上堆栈只有混淆后的类名,上传 mapping 后仍无法还原。
原因:mapping 与构建版本不匹配、文件被覆盖、构建参数不同,或者崩溃来自 native 层而不是 Dart 层。
恢复方式:从构建归档中找到同一次构建生成的 Dart mapping、Android native symbols 或 iOS dSYM;如果对应文件已经丢失,只能对未还原的堆栈进行有限分析,无法可靠恢复原始符号。
10.5 使用 deferred loading 后首包反而变大
失败表现:增加延迟库后,初始产物没有下降,甚至出现额外元数据或分片开销。
原因:目标平台可能不支持预期的动态交付;功能仍被初始入口引用;分片粒度过小;或构建工具将模块以其他形式封装。
诊断:
- 查看构建产物是否真的产生独立分片或动态模块;
- 检查入口代码是否在启动阶段调用了延迟库;
- 检查目标平台和商店分发能力;
- 比较用户首包和完整安装包,而不是只比较源代码结构。
10.6 只看文件总大小,不看压缩后下载量
失败表现:删除了一个 10 MB 的 JPEG,用户下载量却几乎没有变化。
原因:文件可能已经高度压缩,或统计的是解压后安装大小;也可能商店切片后该文件只影响少数设备。
诊断:同时记录归档条目大小、压缩后大小、设备实际下载大小和安装后占用。
十一、推荐的发布验证流程
一个可复现的体积验证流程可以如下:
flutter clean
flutter pub get
flutter build apk --release \
--split-per-abi \
--analyze-size
flutter build appbundle --release \
--obfuscate \
--split-debug-info=build/symbols/android
执行时应注意:
flutter clean会删除构建缓存,构建时间更长,但可以避免旧产物干扰;--analyze-size用于生成分析数据,不会自动修复任何体积问题;--split-per-abi适合比较单架构 APK,不应把它当作 AAB 的替代品;--split-debug-info的目录必须进入受控归档,而不是随临时构建目录一起删除;- 开启混淆后必须在测试环境验证崩溃还原;
- 还要对真正发布的 AAB 进行设备切分验证。
资源和字体变更后,应至少检查:
find assets -type f -printf '%s %p\n' | sort -nr | head -30
find assets/fonts -type f -printf '%s %p\n' | sort -nr
这两个命令用于快速定位原始目录中的大文件。它们不能代替最终产物分析,因为构建系统可能复制、重命名或压缩文件。
最终验收至少要回答以下问题:
- 这次体积增加来自 AOT、native、资源还是字体?
- 统计的是通用包、单 ABI 包、AAB 还是用户实际下载量?
- 删除资源后,最终归档内是否真的消失?
- 字体子集化是否覆盖了动态文本、用户输入和 fallback?
- 混淆后是否保留了同版本 Dart mapping?
- Android、iOS、桌面和 Web 是否分别使用了正确的判断标准?
- 延迟加载是否真的减少首包,并且具备失败、重试和缓存处理?
包体优化的核心不是让某个构建文件看起来更小,而是沿着完整链路确认:哪些代码被 AOT 保留,哪些资源被声明,哪些字体和符号被打包,哪些内容由平台切分,用户最终下载了什么,以及发生崩溃后还能否恢复诊断信息。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 内存泄漏:Controller、订阅、图片缓存、闭包和 DevTools
- 下一篇:Flutter Crash 诊断:错误捕获、符号化、版本、Breadcrumb 和隐私
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论