Dart Native Assets 使用方法
本文背景部分人工编写, 其余部分由 AI 结合实际可执行项目编写
背景
dart 很早就支持 ffi, 允许加载其它语言编写的链接库. 但一直以来存在一个问题, 如果使用纯 dart, 你需要手动构建链接库. 如果只是在单个项目中使用, 这不存在问题, 只需要编写一个构建脚本即可. 但如果想写一个 package 给其它项目使用, 就不行了. flutter 提供了一个功能, 允许自动构建 ffi package, 因此以往 dart ffi 强绑定 flutter.
Dart Native Assets 改变了这一情况. 它允许编写自定义的 hook 来构建链接库, 并且提供接口用于加载链接库. 现在 ffi package 不再只是用于 flutter 项目, 纯 dart 项目也能方便地使用 ffi.
Dart Native Assets 还能解决一个问题: 以往如果编写一个第三方 c/c++ 库的 binding, 需要在项目中完整存放 c/c++ 代码, git submodule 也不被 flutter 支持, 这导致维护成本较高. 现在可以在 hook 中下载第三方库的源码, 构建后注册到 dart 中.
native_toolchain_cmake
https://github.com/rainyl/native_toolchain_cmake
此项目提供了使用 cmake 构建代码的能力, dart 官方只提供了 c 代码编译工具. 本文使用此工具构建代码.
创建项目与添加依赖
使用 dart create -t package 创建一个普通的 dart package 即可
pubspec.yaml 中需要添加如下依赖:
hooks: 提供 build hook 入口code_assets: 用于向 Dart 注册 native assetnative_toolchain_cmake: 用 CMake 构建本地动态库ffi: Dart FFI 基础能力
一个最小示例如下:
dependencies:
ffi: ^2.2.0
hooks: any
code_assets: ^1.0.0
native_toolchain_cmake: ^0.2.5
基本目录结构
一个最小项目通常会长这样:
your_package/
hook/
build.dart
lib/
your_package.dart
src/
ffi.dart
src/
CMakeLists.txt
ffi.cpp
ffi.h
pubspec.yaml
职责划分建议保持清晰:
hook/build.dart: 构建第三方源码, 调用 CMake, 注册 native assetsrc/CMakeLists.txt: 决定如何编译动态库src/ffi.cpp/src/ffi.h: 导出给 Dart 的 C ABIlib/src/ffi.dart: Dart 侧 FFI 绑定与运行时加载
整体流程
Native Assets 的完整链路可以概括成 4 步:
dart test/dart run触发 build hook- build hook 编译出动态库
- build hook 用
CodeAsset把动态库注册给 Dart - Dart 运行时通过 asset id 加载动态库并查找符号
这里最重要的一点是: 不是“生成动态库”就完成了, 必须额外注册 CodeAsset, 否则 Dart 运行时并不知道这份库的存在.
编写 build hook
hook/build.dart 是整个方案的入口. 一个最小版本通常长这样:
import 'dart:io';
import 'package:code_assets/code_assets.dart';
import 'package:hooks/hooks.dart';
import 'package:native_toolchain_cmake/native_toolchain_cmake.dart';
Uri findBuiltLibrary(Uri outputDirectory, String fileName) {
final root = Directory.fromUri(outputDirectory);
final matches = root
.listSync(recursive: true)
.whereType<File>()
.where((file) => file.uri.pathSegments.isNotEmpty)
.where((file) => file.uri.pathSegments.last == fileName)
.toList();
if (matches.isEmpty) {
throw Exception('Cannot find built library: $fileName');
}
return matches.first.uri;
}
void main(List<String> args) async {
await build(args, (input, output) async {
final sourceDir = Directory('src').absolute.uri;
final builder = CMakeBuilder.create(
name: 'my_native_lib',
sourceDir: sourceDir,
defines: {
'CMAKE_BUILD_TYPE': 'Release',
},
);
await builder.run(input: input, output: output);
if (input.config.buildCodeAssets) {
final libraryName = input.config.code.targetOS.dylibFileName('my_native_lib');
final libraryUri = findBuiltLibrary(input.outputDirectory, libraryName);
output.assets.code.add(
CodeAsset(
package: input.packageName,
name: 'my_native_lib',
linkMode: DynamicLoadingBundled(),
file: libraryUri,
),
);
}
});
}
这个文件完成了两件事:
- 调用
native_toolchain_cmake编译 CMake 项目 - 用
CodeAsset(..., linkMode: DynamicLoadingBundled())告诉 Dart: 这份动态库要在运行时被打包并加载
下载第三方源码
如果你的 binding 依赖第三方源码, 可以在 hook 里先下载或 clone, 构建结束后再清理. 一个常见模式如下:
final sourceRoot = Directory('src').absolute;
final depDir = Directory('${sourceRoot.path}${Platform.pathSeparator}third_party_lib');
if (depDir.existsSync()) {
depDir.deleteSync(recursive: true);
}
final result = Process.runSync(
'git',
['clone', '--depth', '1', 'https://example.com/lib.git', depDir.path],
);
if (result.exitCode != 0) {
throw Exception('Clone failed: ${result.stderr}');
}
这就是 Native Assets 相比老式 ffi 工作流更方便的地方: 你不必把所有第三方 C/C++ 源码长期塞进仓库.
编写 CMakeLists.txt
native_toolchain_cmake 只负责调用 CMake, 真正的编译行为仍然由 src/CMakeLists.txt 决定.
一个最小动态库配置如下:
cmake_minimum_required(VERSION 3.14)
project(my_native_lib LANGUAGES C CXX)
add_library(my_native_lib SHARED
ffi.cpp
some_dep.c
)
target_include_directories(my_native_lib PRIVATE
${CMAKE_CURRENT_LIST_DIR}
)
这里有几个关键点:
- 必须使用
SHARED, 否则产物会变成静态库 - target 名和最终输出名不一定必须完全相同, 但建议保持简单一致
- 如果需要导出给 Dart 使用的符号, 最好统一包一层 C ABI
各平台的动态库文件名
不要手写平台分支去拼库名, 直接使用:
final fileName = input.config.code.targetOS.dylibFileName('my_native_lib');
它会自动得到:
- Windows:
my_native_lib.dll - Linux:
libmy_native_lib.so - Android:
libmy_native_lib.so - macOS:
libmy_native_lib.dylib - iOS:
libmy_native_lib.dylib
这点非常重要, 因为很多人第一次接触时会把 Windows 的命名和 Unix 的命名混在一起.
导出 C ABI
Dart FFI 最稳妥的方式仍然是绑定 C ABI. 如果底层是 C++, 建议自己包一层 extern "C".
例如:
// ffi.h
#ifdef _MSC_VER
#define DLLEXPORT __declspec(dllexport)
#else
#define DLLEXPORT __attribute__((visibility("default")))
#endif
extern "C" {
DLLEXPORT int add(int a, int b);
}
// ffi.cpp
#include "ffi.h"
extern "C" {
int add(int a, int b) {
return a + b;
}
}
如果不这样做, 很容易遇到 C++ 符号名被修饰之后, Dart 无法查找到导出函数的问题.
Dart 侧运行时加载
Native Assets 方案的关键不是 DynamicLibrary.open('某个路径'), 而是通过 asset id 触发加载.
一种通用做法是:
- 用一个很小的
@Native(assetId: ...)哨兵函数, 强制 Dart 运行时加载该 asset - 再通过
DynamicLibrary.process()查找剩余符号
示例:
import 'dart:ffi';
const _assetId = 'package:your_package/my_native_lib';
@Native<Int32 Function(Int32, Int32)>(
assetId: _assetId,
symbol: 'add',
)
external int _loadNativeLibrary(int a, int b);
final DynamicLibrary _lib = () {
_loadNativeLibrary(0, 0);
return DynamicLibrary.process();
}();
final add = _lib
.lookup<NativeFunction<Int32 Function(Int32, Int32)>>('add')
.asFunction<int Function(int, int)>();
这里要注意一件事:
assetId必须和 build hook 中注册CodeAsset时的package + name对应起来
也就是:
CodeAsset(
package: 'your_package',
name: 'my_native_lib',
...
)
对应的 asset id 就是:
package:your_package/my_native_lib
为什么不建议继续手写路径 fallback
很多人第一次接触 Native Assets 时, 还是会习惯这样写:
DynamicLibrary.open('my_native_lib.dll');
或者:
DynamicLibrary.open('build/windows/x64/Release/my_native_lib.dll');
这在开发阶段可能偶尔能跑, 但不是 Native Assets 的真正设计目标.
这样做的问题是:
- 强依赖本地 build 目录布局
- 打包后路径可能完全变化
- 不同平台路径规则不同
- Dart runtime 无法通过 asset id 管理这份库
因此推荐原则很简单:
- 构建阶段: 注册
CodeAsset - 运行阶段: 通过 asset id 使用它
- 尽量不要保留本地路径 fallback
测试
Native Assets 接入完成后, 最好先写一个最小 smoke test.
例如:
import 'package:test/test.dart';
void main() {
test('native add works', () {
expect(add(1, 2), 3);
});
}
然后运行:
dart test
如果构建链和注册链都正常, dart test 会自动:
- 触发 build hook
- 编译动态库
- 注册 native asset
- 运行测试
常见坑
1. 只编译库, 没注册 CodeAsset
表现:
- 动态库文件已经生成
- 运行时报
No asset with id ... found
原因:
- 漏掉了
output.assets.code.add(...)
2. CMake 产出的是静态库
表现:
- Windows 只看到
.lib - Linux/macOS 只看到
.a DynamicLibrary.open或@Native无法加载
原因:
- 使用了
STATIC而不是SHARED
3. assetId 不匹配
表现:
- 构建成功
- 但运行时报找不到 asset id
原因:
- build hook 里注册的是
package:foo/bar - Dart 侧写成了别的 id
4. 直接绑定 C++ 符号
表现:
- 库能加载
- 但
lookup找不到函数
原因:
- 符号名被 C++ name mangling 修改了
解决方式:
- 用
extern "C"导出 C ABI
5. 在 build hook 里写错输出对象
表现:
- build hook 执行了
- 库也编译了
- 但 Dart 运行时仍然看不到 asset
原因:
- 没把
CodeAsset写到当前 build 的output - 或者把注册逻辑放进了错误的闭包/错误的输出对象里
推荐的最小模板
如果你要从零开始做一个纯 Dart FFI package, 最推荐的模板是:
hook/build.dart中调用CMakeBuilder.create(...)- CMake 产出
SHARED动态库 - build hook 使用
CodeAsset(..., linkMode: DynamicLoadingBundled())注册产物 - Dart 侧用
@Native(assetId: ...)触发加载 - 剩余符号通过
DynamicLibrary.process()或直接@Native绑定
这样做的好处是:
- 纯 Dart 项目可用
- 不再强绑定 Flutter
- 不依赖手写平台路径
- 支持 hook 中下载第三方源码
- 更适合发布为 package 给别人使用