从零把一个 Flutter 三方库搬到鸿蒙上,最难受的往往不是编译报错,而是你突然发现,你碰的不只是一个库,而是整整一套构建哲学。code_assets 就是这种“越底层越有价值”的典型——它把构建期的资产管理直接做成代码级逻辑,想在鸿蒙上接住它,就得把工程里的打包、链接、自动化和它一起重捋一遍。
这篇文章是我们在某跨平台设备应用(项目代号 X)中,做的一次完整适配记录。从资产生成管线怎么分叉、动态库怎么治理,到最后一键构建脚本长什么样,都会拆开讲。适合两类人看:一类是手里有存量 Flutter 代码、正在规划鸿蒙版本的团队;另一类是对构建期代码生成和动态链接感兴趣、想了解鸿蒙侧怎么和 Flutter 底层协作的开发者。看完你至少能少走两轮弯路。
1. 先搞清楚 code_assets 到底在解决什么问题
1.1 它本质上是把“资源”变成“代码”
用过 Flutter 的人都知道,普通资源打包是声明式的:在pubspec.yaml里写assets:,构建时 Flutter 工具链会把这些文件塞进安装包,运行时再通过AssetBundle去读。这个方案绝大多数场景下没问题,但一旦遇到“资产需要动态生成、需要索引、需要随构建过程参与编译”的情况,声明式方案就有点使不上劲了。
code_assets 的思路完全反过来:它允许你在构建期跑一段 Dart 脚本,自己决定要生成哪些文件、生成到哪里、以什么格式输出。听起来像 build_runner?方向有交集,但不完全一样。build_runner 更侧重源码生成和依赖注入,而 code_assets 关注的是资产和构建产物的生命周期管理。它可以帮你把一堆 JSON、二进制模板、国际化表,甚至加密后的配置块,直接转成 Dart 常量、静态字节数组或索引文件,后续原生层可以直接引用,运行时不再依赖文件 IO 去猜路径。
这就是标题里“代码级原生资产打包”的意思:资产不再只是“躺在包里的文件”,而是变成了编译产物的一部分。鸿蒙侧接这种库,最大的杀伤力在于它的产出物会同时出现在 Dart 侧、原生侧和资源目录里,任何一端路径对不上,构建能过,但运行时必挂。
1.2 鸿蒙化之后,问题从“一个库”变成“三条链路”
把 code_assets 从纯 Flutter 环境挪到鸿蒙环境,你会发现它牵扯到三套完全不同的链路:
- Dart 侧链路:生成脚本跑出来的
.dart文件要被flutter编译进libapp.so,这部分只要 Flutter 鸿蒙化 SDK 支持标准 dart compile,基本没问题。 - 原生侧链路:生成的资产索引如果要从 C++/ArkTS 侧读取,那就要处理 so 库符号导出、头文件路径、CMake 配置。这块是鸿蒙适配的重灾区,因为 Flutter 的鸿蒙嵌入层和安卓的 JNI 壳完全不是一套机制。
- 构建编排链路:原来一条
flutter build就搞定的事,鸿蒙化之后往往要拆成“生成资产 → 编译 Flutter → 编译鸿蒙 hap → 打包资源”四五个阶段,每个阶段之间还可能跨工具链,比如 dart 命令、hvigor、DevEco 的构建缓存,各自为政。
所以别看题目里写的是“适配一个库”,实际做的是“适配一套流水线”。我们当时第一步不是看代码,而是先把三套链路拉通,让它先能跑一个最小 Demo。
1.3 动态链接治理在这件事里扮演什么角色
“动态链接治理”听起来很宏大,落到 code_assets 的鸿蒙适配里,其实就是一件事:Flutter 引擎生成的 so、鸿蒙业务侧自己的 so、以及 code_assets 生成的原生桥接 so,三方共享同一个进程,符号和依赖但凡有一点混乱,启动阶段就会崩在 dlopen 里。
为什么普通 Flutter 安卓开发很少碰到这种问题?因为安卓有完整的 JNI 和 ClassLoader 体系兜底,so 之间的依赖关系相对固定。鸿蒙虽然兼容 POSIX 接口,但它的模块加载机制更接近“显式依赖”,对未定义符号和循环依赖的容忍度更低。再加上 code_assets 这类构建期生成库,很容易把“构建时才确定的路径”写死到原生代码里,造成运行期.so查找不到或符号被错误解析。
一句话总结:资产打包解决的是“东西生成得对不对”,动态链接治理解决的是“生成出来的原生代码跑不跑得起来”。缺一个,整体就是空中楼阁。
2. 适配前的整体方案:目标架构与工作量评估
2.1 两个目标,分了两个阶段
接手这个适配的时候,我给自己定了两个递进式目标:
- 阶段一:最小可用验证。不碰任何复杂原生逻辑,先用 code_assets 生成一份纯 Dart 资产索引,在鸿蒙模拟器里跑通 Flutter 页面读取索引的链路。这一步的价值在于确认“鸿蒙版 Flutter SDK 对 code_assets 的标准产出物没有额外限制”。
- 阶段二:完整链路。接入原生 C++ 读取生成的二进制资产,同时把 Flutter 引擎 so 和业务 so 的符号依赖关系理清,最后用一条脚本完成从资产生成到 hap 产出的全过程。
阶段一看起来简单,但非常关键。因为鸿蒙生态的 Flutter 分支版本跟社区主线有很多细微差异,某些 Dart 标准库在鸿蒙化 SDK 里的实现路径不一样。与其先啃原生代码,不如先确认纯 Dart 链路能否成立。我们当时拿到的鸿蒙 Flutter SDK 版本,对dart:io的某些文件操作有限制,code_assets 生成脚本只要碰了这些 API,就得提前做替换。这一步不跑,后面全白做。
2.2 目标架构:把产物拆到“两条通道”里
经过几轮试验,我最终把整个适配结构拆成两条通道:
- Dart 通道:code_assets 生成的纯 Dart 索引文件、常量映射表,直接由 Flutter 引擎编译进
libapp.so。适合放“运行时不需要和原生层交换”的数据,比如 UI 配置、页面路由表、国际化键值。 - 原生通道:code_assets 生成的原生二进制资产、加密文件,通过一个我们自研的
AssetNativeBridge统一管理。鸿蒙原生侧用 NAPI 暴露查询接口给 Dart,Dart 侧不直接碰文件路径,只通过 ID 读取。这样路径变化被限制在原生层,不会扩散到业务代码。
为什么要这样分?因为 code_assets 这类库最容易出的问题,就是“生成后的产物边界不清晰”。如果什么数据都往原生通道塞,桥接代码会膨胀,动态链接治理成本直线上升;如果什么数据都往 Dart 通道塞,遇到几十 MB 的模型文件或加密包,libapp.so会爆炸。两条通道边界定了,后续所有 CMake 和构建脚本都围绕这个边界展开。
2.3 工具链选型:不是越新越好
工具链这块我们没追新,稳定优先。开发环境用的是某公司内部发行版的 DevEco Studio 配套工具链,鸿蒙 SDK 版本选的是和 Flutter 鸿蒙化分支兼容性最好的一版,没有直接上最新。理由很简单:code_assets 是构建期库,它对 SDK 的依赖比运行期库更敏感,鸿蒙 SDK 一升级,构建缓存、编译参数全变,排查成本很大。
这里有个小建议:开始适配前,先把 Flutter 鸿蒙化 SDK 和 DevEco 的版本锁定,然后在整个适配周期内不升版。如果实在要升,单独开分支验证,不要在主线上混着升。我们后期遇到过两个问题:一个是 Flutter 引擎侧对libapp.so的导出符号规则变了,另一个是 hvigor 缓存目录结构和之前不一致,都是升级引发的,跟 code_assets 本身没关系,但排查起来极费时间。
3. 核心实现:从资产生成到原生读链路的完整落地
3.1 改造 pubspec 与生成脚本入口
code_assets 的接入方式,以我们使用的版本为例,需要先在pubspec.yaml里声明生成脚本入口:
name: project_x environment: sdk: ">=3.0.0 <4.0.0" flutter: uses-material-design: true code_assets: scripts: - tool/generate_asset_code.dart output_dir: .code_assets_gen注意,output_dir这个字段名在不同版本里可能不一样,有的版本叫output,有的叫buildDir,一定以你们安装版本的实际提示为准。我们当时在这里踩了个坑:本地开发机和 CI 机的 code_assets 版本不一致,结果生成目录分别落在.code_assets_gen和.code_assets,导致 Dart 侧import直接找不到文件。这种问题在改动代码前根本发现不了,最好的办法是在 CI 里锁定版本号。
生成脚本的核心逻辑长这样(示意):
// tool/generate_asset_code.dart import 'dart:io'; import 'package:code_assets/code_assets.dart'; Future<void> main(List<String> args) async { final builder = AssetBuilder( inputDir: Directory('assets/raw'), outputDir: Directory('.code_assets_gen'), ); await builder .scanFiles(extensions: ['.json', '.bin', '.dat']) .thenGenerateIndex() // 生成键值索引 .thenBundleBinary(); // 把二进制包转成可嵌入字节串 stdout.writeln('code assets generated: ${builder.statistics}'); }生成之后,你会得到类似这样的产出:
.code_assets_gen/ asset_index.dart asset_payload.bin native/manifest.h native/manifest.c.dart文件给 Flutter 侧用,.h/.c文件给原生侧用,这就是我前面说的“两条通道”的物理基础。
3.2 在鸿蒙原生侧架一个资产读取桥
Dart 侧怎么读资产索引我就不细说了,就是个普通的Map查询。难的是原生侧。我们当时在鸿蒙工程里加了一个 C++ 组件,叫AssetNativeBridge,职责很简单:加载 code_assets 生成的manifest.h,通过 CMake 编进一个独立的libasset_bridge.so里,对外暴露两个 NAPI 接口:
// native/asset_bridge.cpp #include "napi/native_api.h" #include "manifest.h" static napi_value LoadAssetById(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value argv[1]; napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr); char id[64]; size_t len = 64; napi_get_value_string_utf8(env, argv[0], id, len, &len); AssetSlice slice = LookupAssetById(id); // 把 slice.data 包装成 ArrayBuffer 返回 napi_value result; napi_create_arraybuffer(env, slice.size, (void**)&buffer_data, &result); memcpy(buffer_data, slice.data, slice.size); return result; }上层 ArkTS 侧直接调用:
import native from 'libasset_bridge.so'; const buffer: ArrayBuffer = native.loadAssetById("page_home_config") as ArrayBuffer;这套设计有几个好处:
- Dart 侧完全不感知文件路径,所有路径转换集中在原生桥里,后续沙箱路径变了只改一个文件。
- 资产数据以
ArrayBuffer形式返回,避免字符串编码问题,二进制资产也可以直接读。 - 桥接 so 和 Flutter 引擎 so 分离,动态链接治理时可以单独检查桥的符号依赖,不需要把业务代码全搅在一起。
这里提醒一句:NAPI 的函数签名在不同 SDK 版本里有细微差异,尤其是napi_create_arraybuffer这类接口,如果编译报错,优先去 DevEco 自带的头文件里确认签名,不要直接搜网上的旧代码。
3.3 动态链接治理:给 so 做“减负”和“隔离”
跑通资产读取之后,下一个大坑就是动态链接。我们项目 X 里除了 Flutter 引擎,还有自研的编解码库、加密库、以及 code_assets 桥接库,加起来近十个 so。最开始全量加载,冷启动阶段dlopen卡了快一秒,而且偶发 undefined symbol 崩溃。
治理思路做了三步:
第一步是“按需加载”。把启动必需的两个核心库保留在初始化阶段,其余库全部改为懒加载。具体到代码里,就是用一个LibraryRegistry统一管理:
void* LoadLibrary(const char* name) { void* handle = dlopen(name, RTLD_NOW | RTLD_LOCAL); if (!handle) { // 记录 dlerror 日志,不要静默失败 } return handle; }这里的RTLD_LOCAL很关键。如果不加,鸿蒙系统的全局符号表会被这个库的符号污染,后面加载另一个业务库时,一旦存在同名字符,解析结果就可能被前面的库抢走,造成“看起来编译正常,运行时方法调用错乱”的诡异 bug。
第二步是“符号版本隔离”。对我们自己的桥接库,开启-Bsymbolic强制内部符号优先,同时用 version script 只导出对外需要的接口:
set_target_properties(asset_bridge PROPERTIES LINK_FLAGS "-Wl,-Bsymbolic -Wl,--version-script=${CMAKE_SOURCE_DIR}/native/exports.map" )exports.map里只留:
{ global: loadAssetById; initAssetBridge; local: *; };这样libasset_bridge.so对外暴露的符号面非常干净,不容易跟 Flutter 引擎导出的符号打架。
第三步是构建后自动检查。每次构建完,脚本自动跑一遍符号扫描,如果发现预料外的 undefined symbol 或重复导出,直接 fail 构建。这一步看起来繁琐,但能避免大量“换了个库版本,启动就崩”的问题。扫描核心命令:
readelf --dyn-syms --wide out/libasset_bridge.so | grep " U " | grep -E "icu|ssl|crypto" && echo "FOUND_UNDEFINED" || echo "CLEAN"这里grep的关键字要看你们库的实际依赖,比如我们用到了libcrypto,就重点盯crypto相关符号。跑出来的报告会直接告诉你是哪个库把哪类符号带歪了。
3.4 把构建流程串成一条自动化流水线
前面的工作都属于“搭积木”,最后一步是把这些积木串成一条流水线。我们在项目根目录写了一个build_harmony.sh,整个流程分为五个阶段:
#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" STAGE_NAME="${1:-all}" echo "[1/5] 生成 code_assets 产物" dart run code_assets build \ --config pubspec.yaml \ --output .code_assets_gen echo "[2/5] 编译 Flutter 共享库" # 具体命令取决于你们接入的 Flutter 鸿蒙化 SDK flutter build hap --debug --target-platform ohos-arm64 echo "[3/5] 编译原生桥接库" hvigorw assembleHap \ --mode module \ -p product=default \ --no-daemon echo "[4/5] 校验 so 符号" if check_symbols.sh; then echo "符号检查通过" else echo "符号检查失败" exit 1 fi echo "[5/5] 打包最终产物" mkdir -p dist && cp -r build/ohos/arm64/*.hap dist/这条流水线的核心设计是:把 code_assets 的产出目录作为所有后续步骤的输入,要么不生成,要么全量重新生成,绝对不允许出现“原生桥编译用的是旧 manifest,Dart 侧已经用了新索引”的半新半旧状态。
以我们项目实际数据看,完整流水线跑一次在 CI 机器上大约 6 分半,其中 Flutter 编译占大头,code_assets 生成只占 20 秒左右。后来我们做了增量:只有当assets/raw目录下文件指纹发生变化时才重跑 code_assets,流水线时间进一步压缩到 4 分钟以内。
4. 实战中踩过的坑与排查实录
4.1 资产路径对不齐,运行期一片空白
最典型的坑是:code_assets 生成 index 时用的相对路径,跟鸿蒙应用沙箱里的实际路径对不上。比如在本地运行时资产在assets/raw/config.json,到了鸿蒙 hap 里,资源文件被解压到了ohos_assets/xxx/assets/raw/config.json,中间多了一层包名目录。Dart 侧如果直接按相对路径拼接,页面拿到的是空数据,没有任何报错。
这个问题的排查特别恶心,因为构建是成功的,日志也不报错,只有打开页面发现配置为空。后来我们在AssetNativeBridge里加了一个启动期自检函数,把当前应用的真实资产根目录打出来,跟 code_assets 生成的 manifest 里的根路径做一次自动比对,不一致直接输出 warning。
排查思路供参考:
- 先在鸿蒙侧调用
getContext().getFilesDir()或对应的沙箱接口,拿到真实根路径。 - 再去
.code_assets_gen/native/manifest.h里看生成时写死的路径前缀。 - 两者不一致时,优先在 code_assets 的生成脚本里把路径基准改成“运行期动态获取”,不要在原生侧强行拼接字符串。
4.2 动态库符号冲突,偶发崩溃比必现更难搞
我们遇到过一个非常隐蔽的崩溃:App 退到后台再回来,概率性 crash,log 指向libasset_bridge.so里的一个纯函数,看起来毫无规律。最后用readelf扫了所有 so 的符号,发现 Flutter 引擎里也导出了一个同名函数,两个库的符号版本不同,运行时解析到旧符号就崩了。
治理方案就是我前面说的版本脚本加-Bsymbolic。改完之后观察了一周,崩溃绝迹。特别注意:这种问题不要指望靠“换加载顺序”解决。你可以调顺序让本地测不出来,但用户手机的内存布局、触发时机不可控,只要存在符号冲突,早晚会炸。
4.3 构建缓存导致产物新旧不一
hvigor 和 flutter 都有自己的构建缓存。code_assets 生成的文件在.code_assets_gen目录下,如果这个目录被某次清理任务删掉,而原生侧编译缓存还留着旧的 object 文件,就会出现“链接成功但运行期找不到数据段”的怪问题。
我们的解决办法很简单粗暴:在 CI 流水线里,每次跑完整构建前清掉原生侧依赖manifest.h的相关目标缓存,同时把.code_assets_gen纳入 gitignore,保证任何机器都是从零生成。别迷信“接着上次继续编能省时间”,构建期代码生成类的项目,干净状态是最安全的。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 处理建议 |
|---|---|---|
| 页面读资产为空,无报错 | 路径基准不一致 | 启动自检打印真实根目录,和 manifest 对比 |
| 后台恢复偶发崩溃 | so 符号版本冲突 | 开启-Bsymbolic,导出符号白名单 |
| 构建产物在 CI 和本地不一致 | code_assets 版本未锁 | 锁定生成脚本和库版本,产物目录入库忽略列表 |
| 加载 so 报 undefined symbol | 依赖的系统库未打进 hap | 检查 CMake link 依赖,用readelf扫描未定义符号 |
| NAPI 接口返回 ArrayBuffer 后内容错乱 | 生命周期管理错误 | 确认napi_create_arraybuffer关联的引用在异步回调中未被提前释放 |
5. 适配后的收益与改进方向
跟很多“纯 API 替换”的适配不同,code_assets 的鸿蒙化一旦做通,收益是能被直接量化的。以我们项目 X 为例:原有 Flutter 页面里依赖 JSON 配置下发后再加载的逻辑,改造后启动阶段直接从二进制资产读取,冷启动阶段里那一长串文件 IO 全部消失。我们当时测了三种页面场景,启动 P95 从 812ms 降到了 526ms,包体因为去掉了冗余资源和重复的 so 依赖,也瘦了差不多 18%。
构建效率上,虽然完整流水线比单跑flutter build多了两三分钟,但因为整个流程从“人工手动拼步骤”变成“一键脚本”,研发同学再也不用自己记 DevEco 里那几个勾选按钮的顺序了。团队里一个只懂 Flutter 不懂鸿蒙构建的同事,也能照着脚本跑出完整 hap 包,这本身就是很大的隐性收益。
后面我们打算继续扩展三件事:
- 把 code_assets 的生成能力接到路由表自动生成上,新增页面不再需要手写路由映射。
- 把资产索引的一部分做成可热更新的独立
so,这样紧急修复配置时不用整包发布。 - 结合鸿蒙的方舟编译器,继续看是否能把部分运行时解析的逻辑提前到编译期。
但这些都是后话。如果你现在正卡在 Flutter 库鸿蒙化的某一个环节上,我最大的建议是:先把“生成产物边界”和“so 符号边界”这两件事画清楚。code_assets 这类库最怕的不是代码复杂,而是它生成的产物被多套构建系统各拿一份,最后互相不知道对方手里是什么。边界只要清晰了,剩下的事就是按部就班地填代码。