在 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 上下文。