Flutter 调用 Go:从 c-shared + ffigen 到 @Native + Native Assets 踩坑记录

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()。

相关推荐
LEE1 小时前
前端转型全栈 05:SQL 与迁移,AI 写的 SQL 怎么安全上线
前端·后端·ai编程
用户15741568165341 小时前
macOS 打包体积异常的排查实录
前端
数据掘金1 小时前
小程序埋点方案上线前要检查哪些点?我用一张检查清单过了二十多项
前端
Hooray1 小时前
后台管理框架存活率大调查(2026版)
前端
律宏阔1 小时前
Dart FFI 回调无法使用局部变量?使用 NativeCallable.isolateLocal
前端·flutter
hai_android1 小时前
一个公式看懂动态跨表查找:P8 + VLOOKUP
前端·javascript·vue.js
行者全栈架构师1 小时前
【鸿蒙心迹】从 TypeScript 迁移到 ArkTS——10 个编译报错逐个拆解(HarmonyOS 7.x)
前端·人工智能·开源
杨利杰YJlio1 小时前
Tibo的28天计划DAY1GPT-6提速50%意味着什么?
前端·javascript·后端
LiuYanG2 小时前
接口报错全返回 500?Spring Boot 全局异常处理与统一响应体实战(面试常问)
前端