Flutter APK 体积144MB到23MB:瘦身实战
- 发表于
- flutter
本文记录了一个真实 Flutter 项目(含 MediaKit 视频播放器、QuickJS 引擎、InAppWebView 等重量级插件)从 144MB 优化到 23MB 的全过程,涵盖 AGP 压缩策略、ABI 分包、代码混淆、Dart AOT 分析等多个维度。
背景
项目是一个多功能影视聚合应用,技术栈:
- Flutter 3.35.0 / Dart 3.9.0
- MediaKit(libmpv 视频播放)、QuickJS(JS 引擎)、InAppWebView
- AGP 8.6.1 / Kotlin 2.1.0 / Gradle 8.11.1
- 目标:通过 OTA 和仓库分发 APK
某次升级 AGP 和 Kotlin 版本后,APK 从 ~25MB 暴涨到 ~47MB——几乎翻倍。排查发现问题并非代码膨胀,而是 一个被忽视的 native library 压缩策略变更。
TL;DR 优化效果
| 阶段 | APK 大小 | 节省 |
|---|---|---|
| 初始(3 ABI 全包) | 144.3 MB | — |
| 单 ABI 构建 | 46.9 MB | -97.4 MB |
| 恢复代码混淆 | 44 MB | -2.9 MB |
| GBK 映射表去重 | 42.9 MB | -1.1 MB |
| 启用 useLegacyPackaging | 23.5 MB | -19.4 MB |
| highlight 按需导入 | 23.1 MB | -0.4 MB |
第一刀:单 ABI 构建(-97MB)
Flutter 默认构建包含多个 CPU 架构的 native 库。对于 APK 直接分发场景,没必要把三种架构塞进同一个包。
问题
| 1 2 3 4 | <em># 默认构建会包含 armeabi-v7a + arm64-v8a + x86_64</em> flutter build apk --release <em># 输出:144.3 MB</em> |
方案:通过
--target-platform
指定单 ABI
| 1 2 3 4 5 6 | <em># 32位 ARM(覆盖绝大多数设备)</em> flutter build apk --release --target-platform android-arm <em># 64位 ARM(新设备,性能更优)</em> flutter build apk --release --target-platform android-arm64 |
但
--target-platform
只控制 Flutter 引擎和 Dart AOT 产物的架构,不影响第三方插件 AAR 中打包的 native 库(如 libmpv.so)。需要在
build.gradle
中配合 ABI filter 和 packaging excludes:
| 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 | android { defaultConfig { ndk { <em>// 通过构建参数动态控制,默认 v7a</em> if (project.hasProperty('targetAbi')) { abiFilters project.property('targetAbi') } else { abiFilters 'armeabi-v7a' } } } <em>// 关键:排除插件 AAR 中非目标架构的 .so</em> packaging { jniLibs { def targetAbi = project.hasProperty('targetAbi') ? project.property('targetAbi') : 'armeabi-v7a' def allAbis = ['armeabi-v7a', 'arm64-v8a', 'x86_64', 'x86'] allAbis.findAll { it != targetAbi }.each { abi -> excludes += ["lib/${abi}/**"] } } } } |
为什么需要
packaging.jniLibs.excludes?ndk.abiFilters只控制 CMake/ndk-build 编译的产物。像 media_kit 这类通过 JAR 分发预编译 .so 的插件,其多架构库会通过 Gradle 依赖解析进入 APK,不受abiFilters约束。
第二刀:恢复代码混淆(-2.9MB)
问题
项目原先通过
gradle.properties
配置混淆:
| 1 2 | extra-gen-snapshot-options=--obfuscate |
升级 AGP 后此配置被注释掉,改为依赖构建命令行参数。但常常忘记加
--obfuscate
。
验证:Flutter Gradle 插件确实读取此属性
查看 Flutter SDK 源码
FlutterPlugin.kt
:
| 1 2 3 4 5 | val extraGenSnapshotOptionsValue: String? = project.findProperty("extra-gen-snapshot-options")?.toString() <em>// ...</em> extraGenSnapshotOptions = extraGenSnapshotOptionsValue |
方案:使用标准 Gradle 属性
比起旧的
extra-gen-snapshot-options
,Flutter 的 Gradle 插件还支持更语义化的属性:
| 1 2 3 4 | <em># gradle.properties</em> dart-obfuscation=true split-debug-info=build/debug-info |
这样无论构建命令是否带
--obfuscate
,混淆都会自动生效。
split-debug-info
配合使用可保留符号映射用于崩溃日志还原。
第三刀:GBK 映射表去重(-1.1MB)
问题
项目中 三个位置 各自嵌入了一份 23,943 条的 GBK-UTF16 映射表:
-
lib/utils/decode_body.dart(24,047 行) -
lib/utilsv2/decode_body.dart(24,051 行) -
package:fast_gbk(依赖库自带)
三份映射表在 AOT 编译后占用
libapp.so
约 6MB。
方案
统一使用
fast_gbk
包,删除两处嵌入的映射表:
| 1 2 3 4 5 6 7 8 9 10 11 12 | <em>// lib/utilsv2/decode_body.dart — 从 24,051 行精简到 92 行</em> import 'package:fast_gbk/fast_gbk.dart'; class DecodeBody { String decode(Uint8List bodyBytes, String? contentType) { if (_isGBK(contentType)) { return gbk.decode(bodyBytes, allowMalformed: true); } return utf8.decode(bodyBytes, allowMalformed: true); } } |
| 1 2 3 | <em>// lib/utils/decode_body.dart — 从 24,047 行精简到 3 行</em> export '../utilsv2/decode_body.dart'; |
第四刀:关键转折——
useLegacyPackaging
(-19.4MB)
这是本文最核心的优化,也是最容易被忽视的。
问题定位
通过
python3 + zipfile
分析 APK 内部压缩情况时,发现所有
.so
文件的压缩率竟然是 100%(即完全未压缩):
| 1 2 3 4 | libapp.so: 18.33MB raw -> 18.33MB compressed (100%) libmpv.so: 10.76MB raw -> 10.76MB compressed (100%) libflutter.so: 7.56MB raw -> 7.56MB compressed (100%) |
37.8MB 的 native 库占了 APK 的 86%,却一字节都没有压缩。
根因
AGP 8.x + minSdkVersion ≥ 23 时,默认行为变为
extractNativeLibs=false
:
-
.so文件以**未压缩、页面对齐(16KB aligned)**的方式存入 APK - Android 6.0+ 系统可直接从 APK mmap 加载 .so,无需解压
- 优势:安装后磁盘占用小(不需要 APK + 解压两份),安装速度快
- 劣势:APK 下载体积显著增大
旧版 AGP 8.3.2 +
minSdkVersion 21
时默认压缩 .so,升级 AGP 8.6.1 +
minSdkVersion
改为
flutter.minSdkVersion
(值为 24 ≥ 23)后,默认行为静默改变。
方案
在
build.gradle
中显式启用传统打包:
| 1 2 3 4 5 6 7 8 9 | android { packaging { jniLibs { <em>// 压缩 .so 文件,减少 APK 下载体积</em> useLegacyPackaging = true } } } |
在
AndroidManifest.xml
中同步声明:
| 1 2 3 4 | <application android:extractNativeLibs="true" ...> |
效果
| 1 2 3 4 5 6 7 | libapp.so: 18.33MB -> 6.25MB (34%, 节省 12.1MB) libmpv.so: 10.76MB -> 4.94MB (46%, 节省 5.8MB) libflutter.so: 7.56MB -> 4.24MB (56%, 节省 3.3MB) libqjs.so: 0.66MB -> 0.37MB (57%, 节省 0.3MB) ────────────────────────────────────────────────── Total .so: 37.8MB -> 16.0MB (节省 21.8MB) |
兼容性评估
| Android 版本 | API Level | 行为 | 兼容性 |
|---|---|---|---|
| 5.0-5.1 | 21-22 | 系统总是解压 .so,忽略
extractNativeLibs
| ✅ |
| 6.0-9.0 | 23-28 |
extractNativeLibs=true
是原生默认行为 | ✅ |
| 10.0+ | 29+ | 两种模式都支持,true 走传统解压路径 | ✅ |
-
useLegacyPackaging = true是 Android 从第一个版本就支持的传统打包方式 -
media_kit官方在 v1.0.2 CHANGELOG 中明确标注perf: enable extractNativeLibs - 唯一代价:安装后磁盘占用增大(需同时存储 APK 和解压的 .so),对现代设备的 128GB+ 存储不构成问题
选择策略建议
| 分发方式 | 推荐配置 | 原因 |
|---|---|---|
| APK 直接分发 / OTA |
useLegacyPackaging = true
| 下载体积优先 |
| Google Play AAB | 可用默认(false) | Play Store 有自己的增量分发和压缩机制 |
| 内部测试 |
useLegacyPackaging = true
| 传输效率优先 |
第五刀:highlight 按需导入(-0.4MB)
问题
package:highlight
的默认导入方式
import 'package:highlight/highlight.dart'
会注册全部 190 种语言定义,在 AOT 编译中占用 1.3MB。项目只用了 JavaScript 高亮。
方案
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | <em>// Before: 导入全部 190 种语言</em> import 'package:highlight/highlight.dart'; <em>// highlight.parse(code, language: 'javascript')</em> <em>// After: 只导入需要的语言</em> import 'package:highlight/src/highlight.dart'; import 'package:highlight/src/node.dart'; import 'package:highlight/languages/javascript.dart'; import 'package:highlight/languages/json.dart'; final _highlight = Highlight() ..registerLanguage('javascript', javascript) ..registerLanguage('json', json); <em>// _highlight.parse(code, language: 'javascript')</em> |
附:如何分析你的 Flutter APK 体积
方法一:Flutter 官方
--analyze-size
| 1 2 3 4 | flutter build apk --release \ --target-platform android-arm \ --analyze-size |
注意:
--analyze-size不能与--obfuscate/--split-debug-info同时使用。
会输出一份 JSON 报告,可用 Dart DevTools 可视化:
| 1 2 | dart devtools --appSizeBase=~/.flutter-devtools/apk-code-size-analysis_01.json |
方法二:Python 脚本分析 APK 压缩状况
上文的
useLegacyPackaging
问题就是用此方法发现的——官方工具只看到 raw size,看不出压缩率:
| 1 2 3 4 5 6 7 8 9 10 11 12 | import zipfile with zipfile.ZipFile('app-release.apk') as z: for info in z.infolist(): if info.filename.endswith('.so'): ratio = info.compress_size / info.file_size * 100 method = 'DEFLATED' if info.compress_type == 8 else 'STORED' print(f'{info.filename}: ' f'{info.file_size/1024/1024:.1f}MB -> ' f'{info.compress_size/1024/1024:.1f}MB ' f'({ratio:.0f}%) [{method}]') |
方法三:解析
--analyze-size
JSON 各包体积
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | import json with open('apk-code-size-analysis.json') as f: data = json.load(f) def find_node(node, name): if node.get('n') == name: return node for child in node.get('children', []): r = find_node(child, name) if r: return r def calc_size(node): total = node.get('value', 0) for child in node.get('children', []): total += calc_size(child) return total dart_aot = find_node(data, 'libapp.so (Dart AOT)') for pkg in sorted(dart_aot['children'], key=lambda x: calc_size(x), reverse=True)[:20]: size = calc_size(pkg) / 1024 print(f" {pkg['n']}: {size:.0f} KB") |
最终 APK 组成
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | APK Total: 23.1 MB Native Libraries (.so): 16.00 MB (73.1%) libapp.so (Dart AOT): 6.25 MB ← 你的 Dart 代码 libmpv.so (MediaKit): 4.94 MB ← 视频播放器(mpv) libflutter.so (Engine): 4.24 MB ← Flutter 引擎 libqjs.so (QuickJS): 0.37 MB ← JS 引擎 其他 media_kit .so: 0.20 MB Assets: 2.18 MB (10.0%) Java/Kotlin (.dex): 1.79 MB (8.2%) Resources (res/): 1.41 MB (6.4%) Resources (arsc): 0.37 MB (1.7%) Other: 0.15 MB (0.7%) |
libapp.so 内部各包体积 Top 15
通过
--analyze-size
报告(未混淆版本)递归计算:
| 包名 | 大小 | 占比 | 说明 |
|---|---|---|---|
| package:foxwlr | 4,554 KB | 20.2% | 项目自身代码 |
| package:flutter | 4,467 KB | 19.8% | Flutter Framework |
| package:fast_gbk | 2,259 KB | 10.0% | GBK 编解码映射表 |
| @unknown | 1,565 KB | 6.9% | 生成代码/内部 |
| package:highlight | 1,329 KB | 5.9% | 代码高亮(可优化) |
| @shared | 703 KB | 3.1% | 共享 stubs |
| dart:core | 478 KB | 2.1% | Dart 核心库 |
| package:pointycastle | 400 KB | 1.8% | 加密库(传递依赖) |
| dart:ui | 308 KB | 1.4% | UI 引擎绑定 |
| package:flutter_inappwebview | 295 KB | 1.3% | WebView |
| package:flutter_localizations | 287 KB | 1.3% | 国际化 |
| package:html | 266 KB | 1.2% | HTML 解析 |
| dart:io | 214 KB | 0.9% | IO 库 |
| package:image | 204 KB | 0.9% | 图像处理 |
| package:flutter_html | 186 KB | 0.8% | HTML 渲染 |
总结:优化检查清单
- 单 ABI 构建 —
--target-platform android-arm+packaging.jniLibs.excludes - 启用 native 库压缩 —
useLegacyPackaging = true+extractNativeLibs="true" - 代码混淆 —
dart-obfuscation=true写入gradle.properties - 去除重复数据 — 排查项目中嵌入的大数据表是否与依赖库重复
- 按需导入 — 检查 highlight、intl 等包是否加载了不需要的资源
- 使用分析工具 —
--analyze-size+ Python zipfile 脚本双管齐下 - 注意 AGP 升级的副作用 — 版本升级可能静默改变 native 库打包策略
最容易忽视且收益最大的是第 2 点。当你发现 APK 里的
.so
文件没有被压缩时,一行配置就能砍掉近一半体积。
本文基于 Flutter 3.35.0 / AGP 8.6.1 / Gradle 8.11.1 环境编写,数据来自实际项目构建。
原文连接
的情况下转载,若非则不得使用我方内容。