Flutter 调用 Go:从 c-shared + ffigen 到 @Native + Native Assets 踩坑记录
最近在 Flutter 项目里接入一个 Go Core,最终采用 Go c-shared + Dart FFI:Go 导出 C ABI,ffigen 根据头文件生成 Dart Binding,Flutter 直接调用。第一版先用传统 DynamicLibrary.open() 跑通,之后改成 @Native,最后再接 package_ffi + Build Hooks + Native Assets,让 Flutter 构建时自动编译、打包 Go 动态库,业务层彻底不再关心 .so。
一、Go 用 c-shared 导出 C ABI
Flutter 不能直接调用 Go 函数,所以先把 Go API 转成稳定的 C ABI:
go
package main
/*
#include <stdlib.h>
*/
import "C"
import "unsafe"
//export DemoVersion
func DemoVersion() *C.char {
return C.CString("1.0.0")
}
//export DemoFree
func DemoFree(ptr unsafe.Pointer) {
C.free(ptr)
}
func main() {}
编译:
bash
go build -buildmode=c-shared -o libdemo.so .
会得到:
text
libdemo.so
libdemo.h
Flutter 不需要理解 Go,只需要通过 .h 知道有哪些 C API,再通过 .so 调用实际实现。
这里第一个坑是内存所有权。C.CString() 分配的是 C 内存,因此必须由 Native 侧提供 DemoFree()。Dart 可以把它转换成 String,但不能转换完就不释放,也不要拿 Dart 的 malloc.free() 去释放 Go 返回的 C.CString()。
二、先用传统 ffigen + DynamicLibrary 跑通
安装:
bash
flutter pub add ffi
flutter pub add --dev ffigen
配置 ffigen.yaml:
yaml
name: DemoBindings
output: lib/demo_bindings.dart
headers:
entry-points:
- native/libdemo.h
生成:
bash
dart run ffigen --config ffigen.yaml
传统 Binding 需要自己加载动态库:
dart
class Demo {
late final DynamicLibrary _library;
late final DemoBindings _bindings;
Demo() {
_library = _loadLibrary();
_bindings = DemoBindings(_library);
}
DynamicLibrary _loadLibrary() {
if (Platform.isAndroid || Platform.isLinux) {
return DynamicLibrary.open('libdemo.so');
}
if (Platform.isWindows) {
return DynamicLibrary.open('demo.dll');
}
if (Platform.isMacOS || Platform.isIOS) {
return DynamicLibrary.process();
}
throw UnsupportedError(
'Unsupported platform: ${Platform.operatingSystem}',
);
}
}
调用 Go 返回的字符串:
dart
String get version {
final ptr = _bindings.DemoVersion();
if (ptr == nullptr) {
return '';
}
try {
return ptr.cast<Utf8>().toDartString();
} finally {
_bindings.DemoFree(ptr.cast<Void>());
}
}
这套方案没有问题,而且很适合第一步验证 FFI。但跨平台以后会开始出现大量动态库加载逻辑:Android/Linux 是 .so,Windows 是 .dll,macOS/iOS 又有自己的加载方式。业务层还要持有 DynamicLibrary 和 Binding 实例,所以继续往下简化。
三、改成 @Native
Dart FFI 可以通过 @Native 直接声明 Native Symbol:
dart
@Native<Pointer<Char> Function()>(
symbol: 'DemoVersion',
)
external Pointer<Char> DemoVersion();
@Native<Void Function(Pointer<Void>)>(
symbol: 'DemoFree',
)
external void DemoFree(Pointer<Void> ptr);
Flutter 封装马上变成:
dart
class Demo {
const Demo();
String get version {
final ptr = DemoVersion();
if (ptr == nullptr) {
return '';
}
try {
return ptr.cast<Utf8>().toDartString();
} finally {
DemoFree(ptr.cast());
}
}
}
之前这些代码都可以删除:
dart
DynamicLibrary.open(...)
DynamicLibrary.process()
DemoBindings(_library)
但 @Native 只解决了「Dart 怎么绑定 C Symbol」,还有一个问题没有解决:libdemo.so 怎么编译、放在哪里、怎么自动打包进 APK?
这就是 Native Assets 要解决的问题。
四、继续接 package_ffi + Build Hooks + Native Assets
最终希望达到:
text
flutter run
↓
hook/build.dart
↓
go build -buildmode=c-shared
↓
libdemo.so
↓
Code Asset
↓
Flutter 自动打包
↓
@Native 自动绑定
这样业务层完全不需要 DynamicLibrary.open()。
项目结构:
text
clash/
├── bridge/
│ ├── bridge.go
│ ├── go.mod
│ └── go.sum
├── hook/
│ └── build.dart
├── lib/
│ ├── demo.dart
│ └── demo_bindings.dart
└── pubspec.yaml
也可以直接用:
bash
flutter create --template=package_ffi demo_bridge
创建标准 FFI Package。
五、Build Hook 第一个坑:没有 input.target
一些旧示例会写:
dart
input.target.os
input.target.architecture
实际使用当前 API 时,BuildInput 没有 target,应该从 Code Asset 配置读取:
dart
final codeConfig = input.config.code;
final os = codeConfig.targetOS;
final architecture = codeConfig.targetArchitecture;
但这里马上还有第二个坑。
六、不能直接访问 input.config.code
直接写:
dart
final os = input.config.code.targetOS;
运行过程中可能报:
text
Bad state: HookConfig.code should only be accessed when building code assets.
Check HookConfig.buildCodeAssets before accessing HookConfig.code.
原因是 Build Hook 会在不同构建阶段执行,并不是每次都在构建 Code Assets。正确写法:
dart
void main(List<String> args) async {
await build(args, (BuildInput input, BuildOutputBuilder output) async {
if (!input.config.buildCodeAssets) {
return;
}
final codeConfig = input.config.code;
final os = codeConfig.targetOS;
final architecture = codeConfig.targetArchitecture;
// ...
});
}
顺序不能反。
七、Android CGO 不能只写 GOOS=android
Go Bridge 里只要出现:
go
import "C"
就意味着启用了 CGO。Android 交叉编译不能只:
text
GOOS=android
GOARCH=arm64
还需要 Android NDK 的 clang:
text
CGO_ENABLED=1
GOOS=android
GOARCH=arm64
CC=aarch64-linux-android24-clang
macOS 上 NDK 通常在:
text
~/Library/Android/sdk/ndk/<version>/
arm64 clang 类似:
text
toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android24-clang
Build Hook 中:
dart
final result = await Process.run(
'go',
[
'build',
'-buildmode=c-shared',
'-trimpath',
'-o',
outputFile.toFilePath(),
'.',
],
workingDirectory: goRoot.toFilePath(),
environment: {
...Platform.environment,
'CGO_ENABLED': '1',
'GOOS': 'android',
'GOARCH': 'arm64',
'CC': clang,
},
);
这里建议第一版只跑 Android arm64,等整条链路打通以后再增加 armeabi-v7a、x86_64 和桌面平台。
八、Go build 路径踩坑
如果:
dart
workingDirectory: goRoot.toFilePath()
其中 goRoot 已经是:
text
/project/bridge/
那么构建目标应该写:
dart
'.'
不要再写:
dart
'./bridge'
否则 Go 实际寻找:
text
/project/bridge/bridge
然后:
text
stat /project/bridge/bridge: directory not found
本质就是已经 cd bridge 了,又执行了一次 go build ./bridge。
九、Code Asset 的 name 不是磁盘路径
Go 成功生成 .so 后,需要注册:
dart
output.assets.code.add(
CodeAsset(
package: input.packageName,
name: 'demo_bindings.dart',
file: outputFile,
linkMode: DynamicLoadingBundled(),
),
);
这里最容易误写成:
dart
name: 'lib/demo_bindings.dart'
假设文件实际是:
text
lib/demo_bindings.dart
Dart 导入却是:
dart
package:clash/demo_bindings.dart
因此 Native Asset ID 也必须对应:
text
package:clash/demo_bindings.dart
而不是:
text
package:clash/lib/demo_bindings.dart
写错后会看到一个非常有用的错误:
text
Couldn't resolve native function 'DemoVersion'
No asset with id
'package:clash/demo_bindings.dart' found.
Available native assets:
package:clash/lib/demo_bindings.dart
看到这个错误基本不用查 .so、NDK 或 Go,直接检查 CodeAsset.name。
十、最终最小 Build Hook
Android arm64 第一版可以先写成:
dart
import 'dart:io';
import 'package:code_assets/code_assets.dart';
import 'package:hooks/hooks.dart';
void main(List<String> args) async {
await build(args, (BuildInput input, BuildOutputBuilder output) async {
if (!input.config.buildCodeAssets) {
return;
}
final codeConfig = input.config.code;
if (codeConfig.targetOS != OS.android ||
codeConfig.targetArchitecture != Architecture.arm64) {
throw UnsupportedError(
'Unsupported target: '
'${codeConfig.targetOS} / '
'${codeConfig.targetArchitecture}',
);
}
final goRoot = input.packageRoot.resolve('bridge/');
final outputFile =
input.outputDirectory.resolve('libdemo.so');
final ndkHome = Platform.environment['ANDROID_NDK_HOME'];
if (ndkHome == null || ndkHome.isEmpty) {
throw StateError('ANDROID_NDK_HOME is not set.');
}
final clang = [
ndkHome,
'toolchains',
'llvm',
'prebuilt',
'darwin-x86_64',
'bin',
'aarch64-linux-android24-clang',
].join(Platform.pathSeparator);
final result = await Process.run(
'go',
[
'build',
'-buildmode=c-shared',
'-trimpath',
'-o',
outputFile.toFilePath(),
'.',
],
workingDirectory: goRoot.toFilePath(),
environment: {
...Platform.environment,
'CGO_ENABLED': '1',
'GOOS': 'android',
'GOARCH': 'arm64',
'CC': clang,
},
);
if (result.exitCode != 0) {
throw StateError(
'Failed to build Go library:\n'
'${result.stdout}\n'
'${result.stderr}',
);
}
output.assets.code.add(
CodeAsset(
package: input.packageName,
name: 'demo_bindings.dart',
file: outputFile,
linkMode: DynamicLoadingBundled(),
),
);
});
}
然后正常:
bash
flutter run
Flutter 会自动触发 Build Hook,不需要手工执行 go build,也不需要把 .so 手工复制进 android/app/src/main/jniLibs/。
十一、最后 Flutter 只剩业务 API
Binding:
dart
@Native<Pointer<Char> Function()>(
symbol: 'DemoVersion',
)
external Pointer<Char> DemoVersion();
@Native<Void Function(Pointer<Void>)>(
symbol: 'DemoFree',
)
external void DemoFree(Pointer<Void> ptr);
封装:
dart
class Demo {
const Demo();
String get version {
final ptr = DemoVersion();
if (ptr == nullptr) {
return '';
}
try {
return ptr.cast<Utf8>().toDartString();
} finally {
DemoFree(ptr.cast());
}
}
}
调用:
dart
final demo = Demo();
print(demo.version);
最终业务代码不再关心 .so 在哪里,也不需要 DynamicLibrary.open()。