Dart FFI 回调无法使用局部变量?使用 NativeCallable.isolateLocal

在 Flutter 项目中使用 Dart FFI 调用 Windows API 时,经常需要把 Dart 函数作为回调传给 Native。

例如使用 EnumDisplayMonitors 枚举显示器:

ini 复制代码
final pointer = ffi.Pointer.fromFunction<ddc.MonitorEnumProcNative>(
  _logicalMonitorsEnumProc,
  0,
);

一开始看起来没有问题,但当回调需要操作当前方法中的局部变量时,问题就出现了。

例如:

arduino 复制代码
void enumerateMonitors() {
  final monitors = <int>[];

  // 希望在回调中把显示器句柄加入 monitors
}

理想情况下,希望直接这样写:

csharp 复制代码
monitors.add(hMonitor);

但 Pointer.fromFunction 对回调函数有严格限制。

Pointer.fromFunction 的限制

使用:

ini 复制代码
ffi.Pointer.fromFunction<MonitorEnumProcNative>(
  _logicalMonitorsEnumProc,
  0,
);

传入的 _logicalMonitorsEnumProc 必须是:

  • 顶级函数
  • static 静态方法

不能直接使用普通实例方法,也不能使用需要捕获局部变量的闭包。

例如下面这种写法就无法直接作为 Pointer.fromFunction 的回调:

ini 复制代码
void enumerateMonitors() {
  final monitors = <int>[];

  final callback = (int hMonitor) {
    monitors.add(hMonitor);
  };
}

原因在于这个闭包捕获了:

复制代码
monitors

而 Pointer.fromFunction 要求的是静态或顶级函数。

于是就出现了一个实际问题:

markdown 复制代码
当前方法
    ↓
monitors
    ↓
需要传给 Native 回调
    ↓
Pointer.fromFunction
    ↓
回调不能捕获 monitors

传统解决方式:通过上下文传递数据

Windows API 的回调通常会提供一个上下文参数。

例如 EnumDisplayMonitors 的回调包含:

objectivec 复制代码
BOOL CALLBACK MonitorEnumProc(
    HMONITOR hMonitor,
    HDC hdcMonitor,
    LPRECT lprcMonitor,
    LPARAM dwData
);

其中:

复制代码
dwData

可以用来传递上下文。

于是可以设计一个 ID:

ini 复制代码
final id = _nextId++;

然后保存:

ini 复制代码
_instances[id] = this;

调用 Windows API 时把 id 作为 dwData 传进去。

回调仍然使用静态方法:

java 复制代码
static int _logicalMonitorsEnumProc(
  int hMonitor,
  int hdcMonitor,
  ffi.Pointer<ffi.Void> lprcMonitor,
  int dwData,
) {
  final instance = _instances[dwData];

  if (instance != null) {
    instance.onMonitorFound(hMonitor);
  }

  return 1;
}

这样就形成了:

javascript 复制代码
Native
  ↓
dwData
  ↓
ID
  ↓
静态 Map
  ↓
找到 Dart 对象
  ↓
调用实例方法

这种方式可以工作,但代码会增加一层状态管理。

如果只是为了让回调访问一个局部 List,就显得比较重。

Dart 3.1+:使用 NativeCallable.isolateLocal

如果 Native API 会在创建回调的 Dart isolate 中同步调用回调,可以使用:

复制代码
ffi.NativeCallable.isolateLocal

它允许回调使用闭包。

例如:

php 复制代码
void enumerateMonitors() {
  final monitors = <int>[];

  final callable =
      ffi.NativeCallable<ddc.MonitorEnumProcNative>.isolateLocal(
    (
      int hMonitor,
      int hdcMonitor,
      ffi.Pointer<ffi.Void> lprcMonitor,
      int dwData,
    ) {
      monitors.add(hMonitor);
      return 1;
    },
    exceptionalReturn: 0,
  );

  final callback = callable.nativeFunction;

  // 调用 Windows API
  // EnumDisplayMonitors(nullptr, nullptr, callback, 0);

  callable.close();
}

这里最关键的变化是:

ini 复制代码
final monitors = <int>[];

可以直接被回调使用:

csharp 复制代码
monitors.add(hMonitor);

不需要:

javascript 复制代码
static Map
ID
dwData
实例查找

闭包直接捕获了当前作用域。

为什么 NativeCallable.isolateLocal 可以做到?

可以把两种方式理解成:

scss 复制代码
Pointer.fromFunction

Native
  ↓
static / top-level Dart function
  ↓
无法捕获当前作用域

而:

复制代码
NativeCallable.isolateLocal

Native
  ↓
NativeCallable
  ↓
Dart closure
  ↓
可以捕获当前作用域

因此:

ini 复制代码
final monitors = <int>[];

final callable =
    ffi.NativeCallable<MonitorEnumProcNative>.isolateLocal(
  (hMonitor, hdcMonitor, rect, dwData) {
    monitors.add(hMonitor);
    return 1;
  },
  exceptionalReturn: 0,
);

monitors 和回调处在同一个 Dart 作用域中。

使用完必须 close

NativeCallable 创建之后,需要在不再使用时关闭:

go 复制代码
callable.close();

因此完整生命周期是:

go 复制代码
创建 NativeCallable
        ↓
获取 nativeFunction
        ↓
传给 Native API
        ↓
Native 同步执行回调
        ↓
完成
        ↓
callable.close()

不要创建后一直保留而不关闭,否则会留下 Native 侧资源。

isolateLocal 有线程限制

这里有一个非常重要的前提。

NativeCallable.isolateLocal 只能用于:

Native 在创建它的 Dart isolate 中同步调用回调。

例如:

复制代码
Dart
 ↓
调用 Windows API
 ↓
Windows API
 ↓
同步执行 callback
 ↓
返回 Dart

这种场景可以使用:

scss 复制代码
NativeCallable.isolateLocal(...)

但如果 Native API 创建了其他线程:

markdown 复制代码
Dart
 ↓
Windows API
 ↓
创建 Native 线程
        ↓
        callback

就不能简单使用 isolateLocal。

这种情况下需要考虑:

scss 复制代码
NativeCallable.listener(...)

listener 面向的是 Native 线程向 Dart isolate 发送回调的场景。

Pointer.fromFunction 还需要注意什么?

Pointer.fromFunction 并不是不能使用。

如果回调很简单,而且本身就是顶级函数:

arduino 复制代码
int monitorCallback(
  int hMonitor,
  int hdcMonitor,
  ffi.Pointer<ffi.Void> rect,
  int dwData,
) {
  return 1;
}

那么:

ini 复制代码
final callback =
    ffi.Pointer.fromFunction<MonitorEnumProcNative>(
  monitorCallback,
  0,
);

依然可以使用。

真正需要考虑的是:

markdown 复制代码
回调是否需要捕获当前作用域?
        ↓
是
        ↓
Native 是否同步在当前 isolate 调用?
        ↓
是 → NativeCallable.isolateLocal

如果 Native 回调可能来自其他线程,则需要根据 API 的调用方式选择其他方案,例如 NativeCallable.listener。

Pointer.fromFunction 适合简单的静态回调, NativeCallable.isolateLocal 更适合需要访问当前 Dart 作用域的同步 FFI 回调。

选择哪一种,关键不只是看 API 写法,还要看 Native 回调发生在哪个线程,以及回调是否需要捕获 Dart 上下文。

相关推荐
hai_android1 小时前
一个公式看懂动态跨表查找:P8 + VLOOKUP
前端·javascript·vue.js
鬼手点金2 小时前
opencode-全能开发者配置
java·linux·服务器·前端·javascript·学习·前向传播
溪语流沙2 小时前
【Web全栈进阶】Alembic数据库迁移:改表结构不再删库
前端·数据库·python·elasticsearch·postgresql
AI你一生一世2 小时前
当构建工具成为基础设施:从 Tailwind 被收购谈起,前端工具链的“归属”难题
前端·构建工具·tailwind css·前端工具链·开源软件收购·技术中立性·前端基础设施
_zxd2 小时前
TypeScript 类型树
前端·typescript
炸鸡叔2 小时前
我做了 BotBus:从手机续聊本地 Agent,查看文件和终端
前端·后端
骑着蜗牛撵大象3272 小时前
多 Agent 串行流水线:把一个任务拆成可重试、可续跑的 Pipeline 节点链
前端·langchain
2601_962885723 小时前
AlphaFeed 支持哪些 K 线周期?period参数怎么传?
前端·数据库·python