在使用 Dart FFI 调用 Windows API 时,经常需要手动申请 Native 内存。
例如调用 API 获取物理显示器信息,需要先分配一块内存保存返回值:
ini
final countPointer = calloc<ffi.Uint32>();
使用完成后,再手动释放:
arduino
try {
// 调用 Native API
} finally {
calloc.free(countPointer);
}
当一个方法需要申请多块 Native 内存时,代码很快会变成多个嵌套的 try-finally。
业务代码夹在内存申请和释放之间,代码变长,资源生命周期也不容易看清。
对于这种临时 Native 内存,可以使用 package:ffi 提供的 using 和 Arena。
使用 using + Arena 管理内存
Arena 可以理解成一个 Native 内存作用域。
在 using 中通过 arena 分配的内存,会在作用域结束时统一释放。
例如:
kotlin
import 'dart:ffi' as ffi;
import 'package:ffi/ffi.dart';
final result = using((Arena arena) {
final pointer = arena<ffi.Uint32>();
// 使用 pointer
return pointer.value;
});
不需要再为 pointer 编写:
arduino
try {
// ...
} finally {
calloc.free(pointer);
}
当 using 作用域结束后,Arena 会处理在这个作用域中分配的内存。
多块 Native 内存一起管理
实际使用 FFI 时,一个 API 调用可能需要多块内存。
例如获取物理显示器信息:
arduino
final countPointer = calloc<ffi.Uint32>();
try {
// 获取数量
final physicalArray = calloc<PhysicalMonitor>(count);
try {
// 获取物理显示器
// 使用 physicalArray
} finally {
calloc.free(physicalArray);
}
} finally {
calloc.free(countPointer);
}
这里的问题不是释放本身,而是资源管理代码开始占据业务代码的位置。
使用 Arena 后,可以把它改成:
ini
return using((Arena arena) {
final countPointer = arena<ffi.Uint32>();
final countResult = getNumberOfPhysicalMonitors(
hMonitor,
countPointer,
);
if (countResult == 0) {
throw StateError(
'获取物理监视器数量失败,Win32 错误 ${getLastError()}.',
);
}
final count = countPointer.value;
if (count == 0) {
throw StateError(
'监视器句柄 $hMonitor 没有关联的物理监视器.',
);
}
final physicalArray =
arena<PhysicalMonitor>(count);
final getResult = getPhysicalMonitorsFromHMonitor(
hMonitor,
count,
physicalArray,
);
if (getResult == 0) {
throw StateError(
'获取物理监视器信息失败,Win32 错误 ${getLastError()}.',
);
}
final handles = <int>[];
for (int index = 0; index < count; index++) {
final monitor = (physicalArray + index).ref;
handles.add(monitor.hPhysicalMonitor);
}
return handles;
});
这里申请了两块 Native 内存:
ini
final countPointer = arena<ffi.Uint32>();
final physicalArray =
arena<PhysicalMonitor>(count);
但不需要分别释放。
离开 using 作用域后,Arena 会统一处理这些内存。
using 的价值是什么?
原来的代码需要关注:
csharp
申请内存
↓
try
↓
调用 API
↓
finally
↓
释放内存
申请多块内存后,还需要考虑多个 try-finally 的嵌套关系。
使用 Arena 后,代码变成:
arduino
进入 using
↓
创建 Arena
↓
分配 Native 内存
↓
调用 API
↓
读取结果
↓
离开 using
↓
统一释放
资源生命周期从多个 try-finally 收缩成一个作用域。
这也是 Arena 更适合临时 FFI 内存的原因。
Arena 适合临时内存
并不是所有 Native 资源都应该交给 Arena。
例如:
scss
using((Arena arena) {
final pointer = arena<SomeStruct>();
// 调用 API
});
如果这个指针只在当前方法中使用,使用 Arena 很合适。
但如果需要把 Native 指针保存到一个 Dart 对象中,让它跨越当前方法继续存在,就不能简单地放进当前 Arena。
这时需要重新设计资源的生命周期。
NativeFinalizer 是另一种思路
如果需要把 Native 资源封装成 Dart 对象,可以考虑 NativeFinalizer。
例如:
markdown
Dart 对象
↓
持有 Native 指针 / 句柄
↓
对象生命周期结束
↓
NativeFinalizer
↓
调用 Native 释放函数
这种方式适合生命周期更长的 Native 资源。
因此,两种方案解决的问题不同:
| 方式 | 适合场景 |
|---|---|
using + Arena |
方法内部的临时 Native 内存 |
NativeFinalizer |
与 Dart 对象生命周期绑定的 Native 资源 |
对于一次 FFI 调用中临时申请的指针,没有必要为了它引入更复杂的对象生命周期管理。
还要注意 Native 句柄和 Native 内存不是一回事
使用 Arena 只能解决 Arena 管理的 Native 内存。
如果 Windows API 返回的是需要显式销毁的系统句柄,仍然需要调用对应的 Windows API。
例如某些 API 返回的资源需要:
markdown
Native 内存
↓
arena 自动释放
Windows 句柄
↓
调用对应 Destroy / Close API
不能因为使用了 Arena,就认为所有 Native 资源都会自动释放。
因此,在使用 FFI 时,需要区分:
- Dart 对象
- Native 堆内存
- Native 指针
- Windows 系统句柄
- API 特定的资源释放函数
它们的生命周期并不相同。