长期在 Flutter 生态里做性能优化的人,最后基本都会走到同一道关卡:原生代码怎么和 Dart 层无缝配合。普通资源文件扔进 assets 目录就行,但原生代码产物——那些 .so、.a、.dylib——加进来之后,编译脚本要自己写、符号冲突要自己查、不同平台的加载方式还得单独适配。code_assets 这个三方库就是冲着这套混乱来的,它在构建期把原生代码当作顶级资产统一打包,用一套动态链接治理模型管住符号和依赖,再把整个流程自动化到构建流水线里。这篇文章,就是我把它完整移植到鸿蒙(OpenHarmony 系 Flutter 环境)的适配实录,适合正在做鸿蒙化 Flutter 基建的开发者参考。
1. 为什么非要在鸿蒙上折腾 code_assets 不可
先说结论:鸿蒙的 Flutter 运行环境虽然长得和 Android 很像,但构建链路的细节差异足够让所有"复制粘贴式适配"翻车。OpenHarmony 系的 Flutter 工程使用 hvigor 管理构建,原生二进制必须按照 HAP(Harmony Ability Package)的规范放进 libs 目录,assets 的打包规则也和标准 Flutter 不完全一致。很多团队图省事,直接把 Android 的 .so 产物拷过来用,短期能跑,但一旦遇到多 ABI 打包、debug/release 模式切换、动态链接顺序调整,维护成本立刻失控。
没有 code_assets 之前,我的鸿蒙发版流程是这套:先手动拉 OHOS NDK 编译原生模块,然后把 .so 按 arm64-v8a 的目录结构拷进工程,再手写一段运行时加载逻辑,最后还要在每次切换分支后小心翼翼地清理缓存。三个模块、两种 ABI、两种构建模式,组合起来就是十几条手工命令,任何一步出错都要从头排查。最难受的是这些步骤没有任何"可回溯"的中间产物,出了问题只能靠肉眼对配置。
code_assets 的价值在于它把整个流程抽象成"声明-构建-产物-加载"四个环节:开发者在配置文件里声明原生代码源,它负责在构建期自动完成编译、打包、链接治理,并生成一份机器可读的加载清单。Dart 侧只需要在启动阶段读取清单,按依赖顺序加载动态库即可。把这套东西搬到鸿蒙上,等于给鸿蒙 Flutter 生态补齐了一块关键的基础设施——这也是标题里那句话的由来:让应用的资产层真正和底层运行时的加载机制共鸣,而不是每次都靠手工脚本去"够"到底层。
站在适配者的角度,这件事还有一个更实际的收益:code_assets 的接口是平台无关的,只要我把"平台适配层"做出来,后续所有接入这个库的业务模块都统一走同一条构建链路,不需要为鸿蒙单独写编译脚本。生态早期,基础设施每多一分自动化,后面每个业务开发就少踩一分坑。
2. code_assets 的原生资产打包链路,到底动了谁的蛋糕
2.1 "代码级原生资产"是个什么概念
在 code_assets 之前,Flutter 里的"资产"默认就是图片、字体、JSON 这类静态文件,放进 assets 目录,运行时用 rootBundle 读取。但原生代码不是这么玩的:一个 .so 编译出来后,要进 HAP 的 libs 目录、要参与动态链接、要管符号可见性,它更像"构建产物"而不是"资产文件"。
code_assets 的核心抽象就是把这两者统一了:它把"源代码"也当作资产来声明。一个模块只要在配置里写清楚源码路径、编译方式、目标产物名,构建期工具链就会替你完成从源码到最终二进制的一整条链路。这个思路意味着开发者不需要关心 .so 最终落在哪个目录——打包器会把它放到正确的位置,同时生成一份描述文件,说明这个组件包含哪些库、依赖什么顺序加载、需要暴露哪些符号。
2.2 动态链接治理到底在治什么
动态链接治理这件事,听起来很高端,其实说的就是三个老问题:符号污染、依赖顺序、重复符号。举个实际例子:两个静态库内部都定义了一个叫log_write的全局函数,如果编译器不做处理,它们被分别打包进两个 .so 之后,运行时的符号解析可能让 A 库的调用落到 B 库的实现上——这种 bug 极难复现,经常在特定加载顺序下才出现。
code_assets 的治理模型是"构建期干预":在编译阶段通过-fvisibility=hidden把所有符号默认隐藏,再通过一份显式的动态列表(dynamic list)只导出真正能被外部调用的 API。与此同时,它会解析模块之间的依赖图,生成一份按拓扑序排列的"加载计划",运行时照着这个顺序 dlopen,从根上消灭顺序相关的偶发问题。
2.3 构建自动化的基本骨架
code_assets 的自动化依托 Flutter 的构建钩子机制。任何包只要在根目录提供hook/build.dart,执行flutter build时它就会被自动调用,接收到类似--output-dir、--target-platform、--build-mode的参数。code_assets 在这个入口里做平台分派,示意如下:
// hook/build.dart import 'dart:io'; import 'package:code_assets/builder.dart'; Future<void> main(List<String> args) async { final outDir = _argValue(args, '--output-dir'); final target = _argValue(args, '--target-platform'); final mode = _argValue(args, '--build-mode'); final plan = await CodeAssetsBuilder().dispatch( targetPlatform: target, outputDir: Directory(outDir), mode: mode, ); await plan.manifest.writeTo(outDir); }这套骨架的好处是:所有平台共用同一个入口,差别只在dispatch内部的"平台适配器"。我的鸿蒙化工作,本质就是为它新增了一个ohos适配器。
3. 鸿蒙化适配要从哪里切:工具链、ABI 与构建入口
3.1 鸿蒙工具链与标准工具链的差异
鸿蒙的 NDK 和 Android NDK 都是基于 Clang 的,但目标三元组、系统头文件路径、动态链接器行为都不一样。Android 的 ARM64 目标通常是aarch64-linux-android,而 OpenHarmony 是aarch64-linux-ohos。这一字之差,决定了编译出来的 .so 是用哪套 libc 启动的、能不能被鸿蒙的加载器识别。
我在适配过程中先做了一张映射表,把 code_assets 内部使用的平台枚举和鸿蒙的构建参数一一对齐:
| code_assets 平台名 | Android 目标三元组 | 鸿蒙目标三元组 | 输出目录惯例 |
|---|---|---|---|
| android-arm64 | aarch64-linux-android | aarch64-linux-ohos | libs/arm64-v8a/ |
| ohos-arm64 | 不适用 | aarch64-linux-ohos | libs/arm64-v8a/ |
| ohos-x86_64 | 不适用 | x86_64-linux-ohos | libs/x86_64/ |
| linux-x64 | x86_64-linux-gnu | 不适用 | 直接进 assets |
这张表是适配工作的"总开关",后续所有编译参数、目录规划都从它派生。
3.2 在 hook 层正确识别 ohos 目标
code_assets 原本认识的平台里没有 ohos,所以我需要在dispatch入口加一段前置识别逻辑。关键点在于:鸿蒙工程的 build hook 拿到的--target-platform可能是ohos-arm64或ohos-x86_64,也可能是通过自定义 flag 传进来的。适配时不能想当然地套用android-arm64的分支,否则会静默走错工具链。
Future<BuildPlan> dispatch({ required String targetPlatform, required Directory outputDir, required String mode, }) async { if (targetPlatform.startsWith('ohos')) { return _buildWithOhosToolchain(targetPlatform, outputDir, mode); } // 原有平台分派逻辑保持不变 }别小看这段判断。实测中有一种很隐蔽的情况:鸿蒙工程同时配置了 Android 兼容层,hook 收到的参数可能同时包含两个平台标识,代码里必须先解析出"当前构建的最终产物归属",再决定走哪条编译链路。
3.3 编译参数的鸿蒙化细节
完成平台识别后,真正的工作是让 clang 按鸿蒙的方式工作。我整理出的最小可靠参数如下:
$OHOS_NDK_HOME/toolchains/llvm/bin/clang++ \ --target=aarch64-linux-ohos \ --sysroot=$OHOS_NDK_HOME/sysroot \ -fPIC -shared \ -fvisibility=hidden \ -o lib${NAME}.so ${SRC_FILES}几个必须注意的点:第一,--target和--sysroot必须成对出现,只给 target 不给 sysroot,头文件路径会乱套;第二,$OHOS_NDK_HOME在我的工程里是通过环境变量注入的,hook 内部做了三级回退——环境变量、配置文件、默认安装路径,避免不同开发机环境不一致;第三,-fvisibility=hidden必须在编译阶段就加,这直接影响后续动态链接治理的效果,等链接阶段再处理就已经晚了。
tools 里另外一个重要细节是 strip。鸿蒙 HAP 对包体积比较敏感,release 模式应该自动追加-Wl,--strip-debug和--strip-symbol策略,把 .so 里的调试符号全部去掉。我的实测里,这一步让单个原生库从 38MB 降到 12MB。
4. 动态链接治理在鸿蒙上的实现细节
4.1 鸿蒙加载器的特殊性:assets 里的库不能直接按名字加载
这是整个适配过程里最"鸿蒙特色"的一个点。在标准 Flutter Android 环境,System.loadLibrary能找到 libs 目录下的库;在鸿蒙里,HAP 内 libs 目录的库可以由动态加载器按名字解析。但如果 code_assets 把编译产物统一放进了assets/目录,运行时直接用DynamicLibrary.open("libfoo.so")大概率失败——因为加载器的搜索路径里根本没有 assets 目录。
解决办法很朴素:加载前先把 .so 从 assets 复制到应用的可写目录(比如应用沙箱的 files 目录下的ohos_libs/),再从绝对路径 dlopen。这一步看起来多了一道拷贝,其实影响很小,因为加载是一次性的,而且能换来统一的加载链路。我在适配后的运行时层做了这么一件事:解析 code_assets 生成的 manifest,逐个复制、验证,再按依赖顺序加载。
4.2 符号治理从编译期开始,加载清单在构建期生成
动态链接治理不是运行时的工作,它在编译和链接阶段就已经定局了。我在鸿蒙适配器里做了三件事。
第一,编译期默认隐藏所有符号,-fvisibility=hidden。第二,通过动态列表文件显式导出公开 API,例如:
{ global: code_assets_initialize; code_assets_get_version; code_assets_process_frame; local: *; };链接时加上-Wl,--dynamic-list=exports.map,这样即使两个静态库有同名内部函数,也不会互相串扰。第三,构建期生成一份 JSON 加载清单,记录每个库的依赖顺序、加载模式和绝对路径模板:
{ "libraries": [ { "name": "libcodec_core.so", "deps": ["libcodec_common.so"], "load": "lazy", "path": "ohos_libs/libcodec_core.so" } ] }这个清单是构建自动化的一部分,Dart 侧运行时不写死任何路径,全部从清单读取。好处是:换 ABI、改目录结构都只需要重新构建,不需要动 Dart 代码。
4.3 运行时的加载策略:lazy 还是 now
鸿蒙的加载器支持RTLD_LAZY和RTLD_NOW两种模式。lazy启动快,但第一次调用某个函数时可能有可感知的卡顿;now把所有符号解析前置到 dlopen 这一瞬间,启动时间略增,但后续调用更稳定。
code_assets 的治理模型把选择权交给了配置。我的默认策略是:主模块用RTLD_NOW,保证核心路径第一时间就绪;次要模块用RTLD_LAZY,按需加载减少冷启动开销。Dart 侧通过dart:ffi加载时的实现大概是这个形态:
import 'dart:ffi'; import 'dart:io'; DynamicLibrary openOhosLibrary(OhosLibSpec spec) { final hostPath = File(spec.path).absolute.path; // 对临时库先落地到应用可写目录,再从绝对路径加载 return DynamicLibrary.open(hostPath); }有一点要提前说明:鸿蒙对动态库的路径有沙箱限制,DynamicLibrary.open的路径如果指向 HAP 内部的只读 assets 目录,会有权限层面的不确定性。先把库复制到应用私有目录再加载,是目前验证过最稳妥的方式。
5. 构建自动化的鸿蒙化:增量缓存与流水线集成
5.1 没有增量缓存,自动化就是一句空话
code_assets 的理念是"自动化到极致",但如果没有增量缓存,每一次构建都全量编译原生代码,自动化反而会拖慢开发迭代。一个原生模块编译可能要两分多钟,全量重编对开发者是无法忍受的。
适配鸿蒙时,我把缓存 key 设计成了四元组:源码内容哈希、工具链版本、目标 ABI、构建模式。只要四者都没变,直接复用上一次的编译产物,跳过 clang 调用。缓存目录结构按平台隔离,避免鸿蒙和 Android 的产物互相污染:
.cache/code_assets/ ohos-arm64-release/ src-hash-xxxx/libcodec_core.so android-arm64-release/ src-hash-xxxx/libcodec_core.so实测效果很直接:加了缓存之后,第二次及以后的构建在原生编译环节几乎零耗时,整个构建时间从"去接杯水都未必等得完"变成"一条命令顺手就跑完"。
5.2 与 Flutter / OHOS 构建流程的插桩
构建自动化的另一层是"入口统一"。鸿蒙工程里,hvigor 负责最终打包,Flutter 引擎负责生成flutter_assets,code_assets 的产物必须准确落到两边都能找到的位置。
我的做法是:hook 阶段把 .so 输出到构建临时目录,同时把 manifest 写入同一目录;随后,通过工程侧的构建脚本把这些产物同步到 HAP 的 libs 目录和 assets 目录。这一步在 CI 里尤其重要,因为 CI 跑构建通常从干净环境开始,如果 hook 的产物路径和 hvigor 的预期不一致,就会在打包阶段出现"找不到 libxxx.so"这类低级错误。
5.3 流水线落地的建议配置
在 CI 流水线里,我额外配置了两层缓存:远程编译缓存(按缓存 key 存储 .so)和 hook 输出目录的 workspace 缓存。顺序是——先拉取远程缓存命中,未命中才走本地编译,编译完成后上传新产物。
build_ohos: before_script: - curl -fsSL "$CACHE_SERVER/ohos-arm64-release/$HASH" -o cache.tar.gz script: - flutter build ohos --release after_script: - tar -czf cache.tar.gz .cache/code_assets/ - curl -fsSL -X PUT "$CACHE_SERVER/ohos-arm64-release/$HASH" --data-binary @cache.tar.gz这样设计之后,团队里每个人本地的构建行为和生产环境的构建行为完全一致,不会出现"我本地能过,CI 过不了"的经典问题。
6. 适配过程踩过的坑,一个比一个隐蔽
6.1 坑一:Android 产物混进了鸿蒙包
出现问题是在一次联调时,某台设备上加载原生库后函数行为异常,但异常只在特定版本出现。排查了很久才发现:缓存 key 里没包含平台信息,导致我本地的 Android 缓存被鸿蒙构建命中了——编译路径跳过了 clang,直接把一个 Android 的 .so 当成了鸿蒙产物。
排查链路:先对比产物哈希,发现同一模块在"清理缓存后构建"和"直接构建"两种情况下二进制不一致;再对 .so 执行 readelf,看到(NEEDED)列表里出现了 Android 特有的库依赖。修复就是给缓存 key 加上平台维度,同时清理掉旧的污染缓存。血的教训:任何跨平台的自动化工具,缓存隔离永远要在第一天做好。
6.2 坑二:dlopen 找到了库,却找不到它的依赖
另一个坑发生在分模块接入阶段。主库libcodec_core.so能正常加载,但它依赖的libcodec_common.so只存在于 assets 目录里,运行时加载器在主库的搜索路径里找不到这个依赖,直接报cannot locate symbol。
这不是鸿蒙特有的问题,但鸿蒙的沙箱路径规则让它的表现更诡异:有时同一个库在 debug 模式能加载,release 模式就失败。我最终在生成 manifest 时做了依赖闭合检查——把每个库的NEEDED列表全量解析出来,凡是依赖库都必须出现在构建产物中,并且加载顺序按依赖拓扑排列。这个检查直接内置到 hook 里,构建期就报错,而不是留到运行时。
6.3 坑三:资产清单的扩展名过滤把 .so 丢了
code_assets 原本的资产收集逻辑里,对文件扩展名有白名单过滤,默认只收png, jpg, json, txt这类常规资产。.so根本不在名单里。鸿蒙适配初期,我经常遇到"构建成功但 HAP 里找不到原生库"的诡异现象。
定位过程很快:先直接解包 HAP,确认 libs 目录和 assets 目录都没有 .so;再看 hook 的日志,发现收集阶段就过滤掉了。修复是在路径匹配规则里,为原生代码资产单独开一条收集通道,不经过普通资产的扩展名过滤。这个坑提醒我:复用别人的构建逻辑时,一定要先读它的过滤规则,而不是想当然地认为"资产收集器会收集所有文件"。
6.4 坑四:同名符号跨越两个 .so 互相串扰
这是一个真正只有动态链接治理才能定位的问题。接入两个第三方静态库后出现随机崩溃,崩溃现场完全一致地指向一个外部函数调用,但反汇编显示机器码调用的是完全不相干的一段逻辑。
最后用动态列表做符号白名单,把所有非导出符号全部降级为内部可见,问题消失。这个过程很好地说明了 code_assets 那套"可见性治理"为什么是必须的:在静态链接时代,同名符号只是链接警告;在动态链接时代,它是确定性崩溃的起点。
7. 适配完成后的实测对比与个人复盘
7.1 一组值得记录的对比数据
适配完成后,我基于同一个模拟项目做了前后对比,分三类场景记录数据:
| 指标 | 适配前 | 适配后 | 变化 |
|---|---|---|---|
| 原生模块全量编译耗时 | 4分12秒 | 3分58秒 | 基本持平 |
| 带缓存的增量构建耗时 | 4分12秒 | 38秒 | 缩短约 85% |
| 冷启动到达首页时间 | 2180ms | 1960ms | 减少约 220ms |
| release 包原生库体积 | 76MB | 41MB | 减少约 46% |
| 动态库平均加载耗时 | 12ms | 9ms | 减少约 25% |
增量构建的提升是最直观的,因为缓存策略起了作用;冷启动的下降则来自次要模块的懒加载策略。包体积的缩减主要靠编译期符号剥离和 strip 参数,这部分对鸿蒙 HAP 的发布体验帮助很大。
7.2 如果重新做一遍,我会先写一个最小用例
复盘整个适配过程,我最想回头改的一件事是:第一步不该去研究 code_assets 的全部源码,而是应该先写一个只有单个函数的最小原生模块,把"hook 编译 -> 产物落位 -> dlopen 成功"这条链路在我的目标设备上完整跑通。这条链路一旦通了,后面所有复杂模块都是线性叠加;而如果链路不通,读再多源码也只是盲人摸象。
另外,我的建议是不要把这个适配做成自己的私有 fork。code_assets 本身的平台抽象是开放的,鸿蒙适配器完全可以通过它的扩展点注入。我的做法是:提取一个独立的ohos_builder.dart组件,只实现平台相关的编译和打包逻辑,上层的配置模型、缓存模型、运行时代码全部复用原库。这样上游升级时,我只需要重新验证适配层,而不必合并整个代码库的差异。
7.3 鸿蒙生态里做基建的人,要习惯"亲手验一遍"
最后说点个人体会。鸿蒙的 Flutter 生态和 Android/iOS 相比还处在爬坡期,很多东西文档没写全、示例没跟上,这时候最可靠的办法就是自己动手验一遍。code_assets 的鸿蒙化适配,本质不是一件多么高深的事,它就是把"编译、链接、打包、加载"这四件事分别放到鸿蒙的坐标系里重新确认一遍,然后把确认结果固化成代码。这个思路可以复制到任何三方库的鸿蒙化上:先找到它的核心链路,再找到链路里所有依赖平台假设的点,最后用最小实验逐个击穿。做完之后你会发现,所谓"与底层性能共鸣"并没有那么玄学——它不过是不再有隐形的中间层替你打折扣,每一份原生资产都按它本该有的方式被编译、被治理、被加载。