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 应用的发布体积粗略表示为:

Spackage=Sengine+SDart+Snative+Sassets+Sfonts+Ssymbols+SmetadataS_{\text{package}} = S_{\text{engine}} + S_{\text{Dart}} + S_{\text{native}} + S_{\text{assets}} + S_{\text{fonts}} + S_{\text{symbols}} + S_{\text{metadata}}

其中:

  • SengineS_{\text{engine}}:Flutter Engine 及平台嵌入层;
  • SDartS_{\text{Dart}}:Dart 编译后的 AOT 代码或 Web 代码;
  • SnativeS_{\text{native}}:Android、iOS、桌面平台的原生库;
  • SassetsS_{\text{assets}}:图片、JSON、模型、音频、视频等资源;
  • SfontsS_{\text{fonts}}:应用打包的字体文件;
  • SsymbolsS_{\text{symbols}}:调试符号和映射信息;
  • SmetadataS_{\text{metadata}}:清单、配置、资源索引等辅助文件。

这个公式不是构建系统的精确计费公式,因为压缩会跨文件产生效果,安装包也可能包含对不同设备架构的多份内容。但它适合建立因果关系:如果资源占包体的 60%,优化 Dart 代码通常不会改变总体积;如果 AOT 代码占主要部分,则图片压缩的收益有限。

还要区分压缩前后的体积。设某文件原始大小为 UU,经过 ZIP、gzip 或 Brotli 后大小为 CC,则压缩率可以写作:

R=CUR = \frac{C}{U}

已经压缩过的 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 / 桌面产物]

其中:

  1. Dart 前端将代码转换为 Kernel 中间表示;
  2. 编译器分析入口、调用关系和类型信息;
  3. AOT 编译器生成目标平台代码及运行时所需的快照;
  4. Flutter Engine 负责启动 Dart isolate、加载快照并驱动渲染;
  5. Android、iOS 或桌面平台再将这些内容封装进最终应用。

AOT 的目标不是“把所有 Dart 文件原样打包”,而是保留运行时可能到达的代码。可以把保留条件近似写成:

保留符号    从程序入口沿静态可分析调用边可达\text{保留符号} \iff \text{从程序入口沿静态可分析调用边可达}

这就是 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.jsonpubspec.yaml 声明后才成立。若资源未声明,常见失败表现是运行时抛出 Unable to load asset,而不是构建阶段自动提醒。

3.2 未使用资源不会像 Dart 代码一样自动 tree shake

下面的资源即使没有任何 Dart 代码读取,也可能进入应用:

flutter:
  assets:
    - assets/images/

Flutter 资源声明描述的是“要提供给运行时的资源集合”,而不是“根据代码引用自动推导的资源集合”。这和 AOT 对 Dart 代码的可达性裁剪不同。

因此,资源优化通常需要:

  1. 删除不再使用的文件;
  2. 缩小目录声明范围;
  3. 将开发素材和发布素材分离;
  4. 对构建产物中的资源进行审计;
  5. 避免把测试数据、设计源文件、原始视频放入应用目录。

如果某个功能只在特定产品版本中使用,可以用不同 flavor 或不同构建脚本生成不同资源集合,而不能期待运行时条件自动缩小安装包。

3.3 图片格式和尺寸必须与用途匹配

一张图片的内存和磁盘成本不是同一个问题。

例如:

  • JPEG 适合照片和连续色调;
  • PNG 适合需要无损压缩或透明通道的图形;
  • WebP 通常适合希望同时获得较好压缩和透明支持的场景;
  • SVG 适合图标、简单插画和需要缩放的矢量图,但运行时解析成本和兼容性需要评估;
  • 大尺寸图片即使文件压缩后不大,解码后仍可能占用大量内存。

一个 4000×3000 的 RGBA 图片,未压缩像素内存近似为:

4000×3000×4=48,000,000 bytes4000 \times 3000 \times 4 = 48{,}000{,}000 \text{ bytes}

约为 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,
  ),
)

fontWeightfontStyle 不会在运行时把一个普通字体神奇地变成真正的粗体或斜体文件。它们用于选择已注册的字体变体;如果不存在精确匹配,字体匹配系统可能选择相近变体,具体效果取决于字体和平台实现。

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 或不同构建版本使用了错误符号;
  • 本地只保存了最终包,没有保存符号归档。

正确的流程不是“把符号全部删除”,而是:

  1. 从用户包中移除不必要的调试信息;
  2. 将完整符号保存到受控存储;
  3. 为每个版本和架构生成唯一记录;
  4. 在测试环境验证一次 Dart 和 native 崩溃还原;
  5. 发布后禁止覆盖历史符号。

六、拆分:减少的是某个用户获得的内容,不一定是构建总量

6.1 ABI 拆分

ABI 是应用二进制接口。Android 常见架构包括:

  • arm64-v8a
  • armeabi-v7a
  • x86_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

从这个假设结果可以推导出:

ΔS(19.68.2)+(6.46.4)+(5.04.7)+(18.118.1)=11.7 MB\Delta S \approx (19.6-8.2) + (6.4-6.4) + (5.0-4.7) + (18.1-18.1) = 11.7 \text{ 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

现在完成三项修改:

  1. 删除未使用的设计源图片,资源从 22.0 MB 降到 13.0 MB;
  2. 将包含完整 CJK 字符集的字体子集化,字体从 9.0 MB 降到 2.5 MB;
  3. 使用 AAB 让用户只获得 arm64-v8a,但这一步不会改变 AAB 内部所有模块的总和。

对于通用 APK,修改后的理论大小为:

18.0+5.2+14.5+13.0+2.5+2.3=55.5 MB18.0 + 5.2 + 14.5 + 13.0 + 2.5 + 2.3 = 55.5 \text{ MB}

减少:

71.055.5=15.5 MB71.0 - 55.5 = 15.5 \text{ MB}

而 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 后首包反而变大

失败表现:增加延迟库后,初始产物没有下降,甚至出现额外元数据或分片开销。

原因:目标平台可能不支持预期的动态交付;功能仍被初始入口引用;分片粒度过小;或构建工具将模块以其他形式封装。

诊断

  1. 查看构建产物是否真的产生独立分片或动态模块;
  2. 检查入口代码是否在启动阶段调用了延迟库;
  3. 检查目标平台和商店分发能力;
  4. 比较用户首包和完整安装包,而不是只比较源代码结构。

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

执行时应注意:

  1. flutter clean 会删除构建缓存,构建时间更长,但可以避免旧产物干扰;
  2. --analyze-size 用于生成分析数据,不会自动修复任何体积问题;
  3. --split-per-abi 适合比较单架构 APK,不应把它当作 AAB 的替代品;
  4. --split-debug-info 的目录必须进入受控归档,而不是随临时构建目录一起删除;
  5. 开启混淆后必须在测试环境验证崩溃还原;
  6. 还要对真正发布的 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 官方文档重新梳理;正文与示例由 WR BLOG 编写。