聊到 Flutter 包体积,老开发应该都吃过亏:APK 动不动就七八十兆,用户看一眼安装包直接劝退。这两年鸿蒙设备铺开之后,HAP 成了绕不开的新目标。flutter_app_size_reducer这个三方库在 Flutter 生态里本来就是做包体扫描和体积拆解用的,社区反馈不错,但问题在于它的原生侧实现默认跑在 Android 和 iOS 上,到了鸿蒙容器里,不改造的话拿到的报告永远是空的。这篇文章就把这个库鸿蒙化适配的完整过程拆开讲一遍:从 HAP 包体结构、插件通道替换,到用 ArkTS 重写扫描逻辑,最后基于报告做一轮真实瘦身,把 HAP 从 86MB 压到 42MB 左右。
适配思路其实不复杂,核心一句话:Dart 层 API 保持不动,把原本 Android/iOS 的原生实现换成鸿蒙侧的 ArkTS 实现。真正复杂的反而是你要先搞懂 HAP 里到底什么东西在占空间,以及鸿蒙侧的文件系统比 Android 更严格的沙箱限制。
1. 为什么 HAP 体积比 APK 更值得关注
1.1 空壳 Flutter 应用的 HAP 就已经不小了
很多团队第一次打出 HAP 包的时候都会愣一下:明明什么业务都没写,包怎么就这么大?其实拆开看就明白了。一个典型的鸿蒙 Flutter 应用 HAP 内部大概是这样的结构:
xxx.hap ├── libs/ │ └── arm64-v8a/ │ ├── libflutter.so │ ├── libapp.so │ └── libxxx.so ├── assets/ │ └── flutter_assets/ ├── resources/ │ ├── index │ └── media/ ├── ets/ │ └── modules.abc └── module.json这里面的体积大头通常是libs/arm64-v8a/下的libflutter.so和libapp.so。前者是 Flutter 引擎本体,后者是 Dart 代码 AOT 编译产物。你哪怕只写一个Hello World,这两个文件加起来也可能超过 25MB。再加上 ArkTS 壳工程编出来的modules.abc、resources索引、以及 Flutter 侧的assets,一个空包上 30MB 很常见。
别说用户了,就是开发者自己下载安装都嫌慢。鸿蒙生态对应用安装包的限制比 Android 更敏感,而且设备形态覆盖手机、平板、车机、电视,不同设备的存储和带宽差异很大。包体瘦身不是锦上添花,是上架前必须做的事。
1.2 flutter_app_size_reducer 的能力与鸿蒙化难点
这个库原本做的事情可以简单理解为三件事:扫描应用包体内各目录的体积分布、拉出体积 TopN 的大文件、给出可执行的优化建议。它相当于是给你的安装包做了一次 CT 扫描,告诉你哪里肥了、怎么减。
但它有一个绕不开的实现前提:它必须跑在应用运行时里,通过原生侧的文件访问能力去扫安装包目录。在 Android 上,它用 Java 层的文件遍历逻辑,能拿到 APK 解压后的目录;在 iOS 上,它走 NSBundle 路径。到了鸿蒙,这两个原生实现全都不能用了,平台通道调过去直接返回MissingPluginException。
鸿蒙化的难点不在 Dart 层,而在两个地方:一是鸿蒙侧的文件沙箱机制比 Android 更严格,不是所有目录你都能随手扫描;二是 Flutter 引擎跑在鸿蒙容器里时,插件注册机制和 Android 不完全一样,你得按鸿蒙插件工程的规范去实现原生侧的注册和通道响应。搞懂这两点,适配工作就成功了一半。
2. 鸿蒙化适配方案怎么选:三条路线对比
2.1 路线取舍:为什么保留 Dart API 最省心
我在动手之前先列了三种方案,最后选了最保守但成本最低的一条。
第一条路线是改 Dart 层,直接给库写一套鸿蒙专用 API。这种做法的坏处很明显:所有业务方都要改调用代码,而且以后库升级还得跟着维护两套接口。除非鸿蒙侧能力差异大到完全无法复用抽象,否则不推荐。
第二条路线是保留 Dart 层 API,把原生侧换成 ArkTS 重写。这是最推荐的方案,也是社区做鸿蒙适配的主流套路。业务代码完全不用动,只需要在鸿蒙工程里引入适配后的插件实现,MethodChannel的通道名和方法名保持一致,Dart 侧就能无缝衔接。
第三条路线是绕过插件,直接用命令行工具在构建后拆包分析。这种做法适合本地排查,但不适合集成到 App 里给普通用户或 QA 用。flutter_app_size_reducer本身定位是运行时工具,走这条路等于放弃它的核心场景。
三条路线的核心差异可以看这个表:
| 路线 | 兼容性 | 实现成本 | 适用范围 | 维护成本 |
|---|---|---|---|---|
| 改 Dart API | 业务代码需改动 | 中等 | 鸿蒙专用场景 | 高 |
| 原生层 ArkTS 重写 | 业务无感 | 较低 | 跨端 Flutter 工程 | 低 |
| 命令行拆包分析 | 无法集成运行时 | 最低 | 本地开发排查 | 中 |
我最后选了第二条。理由很简单:跨端工程最怕的就是平台分支越来越多,Dart 层统一入口、原生层各自实现,这种模式在 Flutter 生态里已经很成熟了,鸿蒙化只是把 Android/iOS 两个分支变成三个分支而已。
2.2 通信链路与插件工程结构设计
选了路线之后,接下来要定数据链路。Dart 侧还是用标准MethodChannel发起调用,ArkTS 侧注册同名通道并处理analyze方法。
Dart 侧接口保持原来的样子,类似这样:
import 'package:flutter/services.dart'; class AppSizeReducer { static const MethodChannel _channel = MethodChannel('dev.example.app_size_reducer'); static Future<Map<String, dynamic>> analyze({bool includeCache = true}) async { final Map<Object?, Object?> raw = await _channel.invokeMethod('analyze', {'includeCache': includeCache}); return Map<String, dynamic>.from(raw); } }ArkTS 侧的逻辑则是注册同名通道,核心扫描函数返回一个包含体积分布和 TopN 文件的数据结构。这里有个细节要注意:鸿蒙侧的通道回调如果涉及耗时的文件遍历,千万不要在 UI 线程直接同步执行,应该放到异步任务里,用 Promise 把结果返回给 Dart 侧。我在第一版适配时踩过这个坑,当时扫一个 80MB 的包,界面直接卡了好几秒,后来改成异步才解决。
工程结构上,我建议单独拉一个插件模块,不要直接改原库代码。这样原库升级的时候,你只需要把 Dart 层合并过来,ArkTS 侧实现可以长期独立维护。
3. 包体扫描与分析模块的实现要点
3.1 鸿蒙侧如何拿到 HAP 安装路径
这是鸿蒙化适配里最需要小心的部分。鸿蒙的应用沙箱机制比 Android 严格,你不能随便遍历系统目录,只能访问本应用沙箱内和当前 bundle 相关的路径。
我采用的是通过bundleManager获取当前应用安装信息的方式,核心代码如下:
import { bundleManager } from '@kit.AbilityKit'; import { fileIo as fs } from '@kit.CoreFileKit'; interface ScanResult { size: number; files: number; } async function getHapRootPath(): Promise<string> { const bundleInfo = await bundleManager.getBundleInfoForSelf( bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_HAP_MODULE, ); return bundleInfo.bundleCodePath; }注意,不同 SDK 版本对bundleCodePath的返回策略有差异,有些版本返回的是安装包解压后的根目录,有些场景下还需要你手动拼接libs、assets等子路径。我建议真机上先打一条日志确认返回的到底是什么,再决定后续拼接逻辑。
如果拿不到bundleCodePath,还有一个备选方案:用应用上下文提供的目录作为起点,再根据相对路径去定位libs和assets。这个方法更稳定,但是可读性差一点,代码里要做好路径常量管理。
3.2 递归扫描与体积统计核心逻辑
拿到根路径之后,剩下的就是标准的目录遍历了。ArkTS 的fileIo模块提供了listFileSync和statSync,可以同步遍历目录,但在运行时扫描这类场景里,同步遍历大量文件会卡顿,建议在异步任务里执行。
核心的递归扫描逻辑可以写成这样:
function walkDirectory(dir: string): ScanResult { let size = 0; let files = 0; const children = fs.listFileSync(dir); for (const name of children) { const fullPath = `${dir}/${name}`; const stat = fs.statSync(fullPath); if (stat.isDirectory()) { const sub = walkDirectory(fullPath); size += sub.size; files += sub.files; } else { size += stat.size; files += 1; } } return { size, files }; }统计口径上有一个容易忽略的坑:文件系统显示的目录大小和stat.size并不完全一致。目录大小是磁盘块占用,stat.size是文件逻辑大小。做包体对比的时候,前后两次报告必须用同一个口径,否则你优化了几个 MB 都看不出来。我在实现里统一用stat.size做逻辑大小统计,磁盘占用只在最终设备侧验证时参考。
除了按目录聚类,我还在扫描时按文件扩展名做了二次聚合。这个很有用,因为很多体积问题藏得比较深,按目录看不出异常,但按扩展名一看,一堆.ttf字体或者.png图片躺在那里,问题立刻暴露。
3.3 报告结构:从数据到可执行优化建议
扫描完之后的产物是一份 JSON 报告,我把它设计成三段式结构:
{ "totalSize": 86.4, "categories": { "libs": 52.8, "flutter_assets": 24.6, "ets": 6.8, "resources": 2.2 }, "topFiles": [ { "path": "libs/arm64-v8a/libflutter.so", "size": 21.6 }, { "path": "libs/arm64-v8a/libapp.so", "size": 6.2 } ], "suggestions": [ { "type": "ABI", "level": "high", "desc": "发现多个 ABI 目录,建议只保留 arm64-v8a" } ] }totalSize和categories用于宏观判断,topFiles用于定位具体文件,suggestions是规则引擎自动生成的优化建议。建议规则不需要做得很复杂,几条硬规则就够了:检测到多个 ABI 目录就提示收敛;PNG 文件超过一定体积就建议转 WebP;存在重复文件哈希就提示去重;.abc或libapp.so过大就提示检查是否忘了开混淆和裁剪。
报告本身只是第一步,真正有价值的是接住这些建议去执行优化。下面就用一轮真实的瘦身过程来看看效果。
4. 实战瘦身:把 HAP 从 86MB 压到 42MB 的完整记录
4.1 基线分析:一眼定位体积大头
我拿一个实际在做的跨平台 Flutter 工程来举例,内部代号就叫某跨平台项目。适配完成之后,我先跑了一次analyze,拿到基线报告:
| 模块 | 优化前体积 | 占比 |
|---|---|---|
| libs/ | 52.8 MB | 61% |
| flutter_assets/ | 24.6 MB | 28% |
| ets/ | 6.8 MB | 8% |
| resources/ | 2.2 MB | 3% |
| 总计 | 86.4 MB | 100% |
问题一眼就能看出来:libs占了六成以上,flutter_assets接近三成。这两个目录不动,包体永远瘦不下来。
所以优化目标就很明确:第一步干掉冗余 so 库,第二步压缩 Flutter 产物,第三步清理资源层。每一步的收益都能在当前报告上直观看到。
4.2 so 库与 ABI 收敛:最大的一刀
先看libs目录,里面居然同时存在armeabi-v7a、arm64-v8a和x86_64三个 ABI 目录。这在 Android 时代还能理解,但鸿蒙设备的现状基本是 arm64 一统天下,保留另外两个 ABI 纯粹是白占空间。
处理方式有两个层面。首先是工程配置层面,在鸿蒙构建工程的打包配置里限定只保留arm64-v8a,这样后续每次打包都不会再带多余的 ABI。其次是历史产物清理,如果之前已经打出了包含所有 ABI 的 HAP,可以手动把其他 ABI 目录删掉再重新打包:
find libs -type d \( -name "armeabi-v7a" -o -name "x86_64" \) -exec rm -rf {} +这一刀下去效果是最猛的。原来 so 库总量 52.8MB,收敛到单 ABI 后变成约 20.8MB,直接省了 32MB。
接着再做一步 native 库符号裁剪。用鸿蒙工具链自带的 strip 能力去掉 so 文件里的符号表,这一步能把 20.8MB 再压到 17.6MB 左右。需要注意,strip 必须用对应平台工具链,用错版本可能导致 so 加载崩溃。
4.3 Flutter 构建参数与 Dart 产物裁剪
解决完 native 层,接着看 Dart 层。libapp.so是 Dart AOT 产物,体积受编译参数影响非常大。如果你的构建脚本还是默认的 release 参数,那这部分的优化空间相当可观。
我在鸿蒙 Flutter 工程的构建命令里追加了这几个参数:
--release \ --obfuscate \ --split-debug-info=./debug_info \ --tree-shake-icons--obfuscate会对 Dart 代码做混淆,同时显著减小产物体积;--split-debug-info把调试符号单独抽出来放在工程目录里,不进 HAP;--tree-shake-icons会摇掉未使用的图标字体数据。这几个参数叠加后,libapp.so从 6.2MB 降到了 4.6MB 左右,省了约 1.6MB。
有一点必须提醒:开了--obfuscate之后,线上崩溃堆栈会变得不可读。所以--split-debug-info产出的那份 debug_info 文件一定要归档保存,否则出问题的时候你没法还原堆栈。
4.4 资源层优化:图片、字体与冗余文件
flutter_assets里的 24.6MB 是另一个大头。主要成分是图片、字体和语言包。这里面有一堆历史遗留的 PNG 大图,还有好几套完整的字体文件。
图片处理我统一走了一遍 WebP 转换,能转的全部转掉,肉眼几乎看不出差异,单个文件体积普遍下降 60% 以上。字体文件则是做子集化,只保留业务真正用到的字符集,一套动辄 3MB 的字体,子集化之后往往只剩几百 KB。
另外还发现了一个问题:多语言资源里塞了十几种语言包,但产品实际只发布了中文和英文。把多余语言包清掉之后,flutter_assets从 24.6MB 降到了 18.5MB。资源层的整体收益大概是 6MB 左右。
4.5 验证与回归:别只看体积数字
优化全部做完之后,重新打 HAP 再跑一次报告,前后对比如下:
| 模块 | 优化前 | 优化后 | 收益 |
|---|---|---|---|
| libs/ | 52.8 MB | 17.6 MB | 35.2 MB |
| flutter_assets/ | 24.6 MB | 18.5 MB | 6.1 MB |
| ets/ | 6.8 MB | 5.2 MB | 1.6 MB |
| resources/ | 2.2 MB | 1.9 MB | 0.3 MB |
| 总计 | 86.4 MB | 43.2 MB | 43.2 MB |
43.2MB 对 86.4MB,缩减率正好 50%。如果你愿意在架构层再做按需分发,把一些不常用的大资源挪到云端,最终压到 42MB 以内没有任何问题。
体积数字好看是一回事,功能正常是另一回事。这一步别偷懒,真机装一遍,把核心流程全部回归,特别关注 so 库加载、图片显示和字体渲染这几块。我在验证阶段就遇到过资源改名后图片丢失的问题,原因就是资源索引没有同步更新,重新打包后问题消失。
5. 适配与瘦身过程中的常见问题、排查与技巧
5.1 高频问题速查表
整个适配和瘦身过程里,踩过的坑不少,整理成速查表方便你对照:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| analyze 返回 null,原生侧无日志 | ArkTS 侧插件没有注册成功 | 检查插件注册文件,确认 MethodChannel 通道名与 Dart 侧完全一致 |
| bundleCodePath 为空 | 当前 SDK 版本返回策略差异 | 改用应用上下文目录拼接路径,或者真机打日志确认实际值 |
| 扫描过程卡顿明显 | 文件遍历执行在 UI 线程 | 移到 async 任务或 TaskPool,返回 Promise |
| 报告体积明显偏小 | 部分目录无法访问,异常被吞掉 | 遍历时捕获异常,把不可读目录记录到 skippedPaths 并整体展示 |
| 瘦身后 so 加载崩溃 | strip 用错工具链,或 ABI 混用 | 用鸿蒙工具链的 strip,回滚后重新验证 |
| 开启混淆后崩溃堆栈不可读 | 缺少 debug info 归档 | 用 --split-debug-info 保存符号,配套还原工具 |
| 资源改名后图片丢失 | 资源索引没有同步更新 | 重新打包验证 resources/index 引用关系 |
5.2 几个必须收藏的实操技巧
第一,用hdc从设备侧验证真实占用。很多时候报告显示瘦身成功了,但设备上实际安装占用可能因为磁盘块对齐等原因和你预期不符。我习惯用hdc shell进入设备,检查应用目录的真实体积,和报告交叉验证。
第二,给每次优化建立基线档案。我会把每次构建出来的 HAP 体积、报告 JSON、构建参数都归档,按时间排序记录。这样能清晰看到每一步优化贡献了多少,哪次改动产生了体积反弹也能第一时间发现。
第三,把体积卡点放进 CI。这不是加分项,而是必要项。在流水线里跑一次 analyze 报告,超过阈值直接失败,省得每次上线前手动检查。
第四,多语言裁剪必须留兜底。清理语言包的时候,一定要保留系统的 fallback 语言资源,不然设备切换语言时界面会出现大面积空白的诡异问题。
5.3 给 Flutter 鸿蒙工程的一个建议
最后分享一个我在实际适配中最深的感受:flutter_app_size_reducer的鸿蒙化只是一个起点,真正值得投入的是把它的报告能力和你的构建流程打通。我现在的做法是每次发版前固定跑一遍分析,把报告归档,然后对比上一版看体积是涨是跌。这轮适配做完之后,这个库从一个纯粹的开发工具变成了 CI 里的体积守门员。
如果你手里也有 Flutter 工程要上鸿蒙,建议先别急着堆业务功能,把包体分析这条基础设施铺好。等到 HAP 体积失控那天再想补课,代价会大得多。