Dart FFI 内存管理:用 using + Arena 替代嵌套 try-finally

0 阅读3分钟

在使用 Dart FFI 调用 Windows API 时,经常需要手动申请 Native 内存。

例如调用 API 获取物理显示器信息,需要先分配一块内存保存返回值:

final countPointer = calloc<ffi.Uint32>();

使用完成后,再手动释放:

try {
  // 调用 Native API
} finally {
  calloc.free(countPointer);
}

当一个方法需要申请多块 Native 内存时,代码很快会变成多个嵌套的 try-finally。

业务代码夹在内存申请和释放之间,代码变长,资源生命周期也不容易看清。

对于这种临时 Native 内存,可以使用 package:ffi 提供的 using 和 Arena。

使用 using + Arena 管理内存

Arena 可以理解成一个 Native 内存作用域。

在 using 中通过 arena 分配的内存,会在作用域结束时统一释放。

例如:

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 编写:

try {
  // ...
} finally {
  calloc.free(pointer);
}

当 using 作用域结束后,Arena 会处理在这个作用域中分配的内存。

多块 Native 内存一起管理

实际使用 FFI 时,一个 API 调用可能需要多块内存。

例如获取物理显示器信息:

final countPointer = calloc<ffi.Uint32>();

try {
  // 获取数量

  final physicalArray = calloc<PhysicalMonitor>(count);

  try {
    // 获取物理显示器

    // 使用 physicalArray
  } finally {
    calloc.free(physicalArray);
  }
} finally {
  calloc.free(countPointer);
}

这里的问题不是释放本身,而是资源管理代码开始占据业务代码的位置。

使用 Arena 后,可以把它改成:

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 内存:

final countPointer = arena<ffi.Uint32>();

final physicalArray =
    arena<PhysicalMonitor>(count);

但不需要分别释放。

离开 using 作用域后,Arena 会统一处理这些内存。

using 的价值是什么?

原来的代码需要关注:

申请内存
    ↓
try
    ↓
调用 API
    ↓
finally
    ↓
释放内存

申请多块内存后,还需要考虑多个 try-finally 的嵌套关系。

使用 Arena 后,代码变成:

进入 using
    ↓
创建 Arena
    ↓
分配 Native 内存
    ↓
调用 API
    ↓
读取结果
    ↓
离开 using
    ↓
统一释放

资源生命周期从多个 try-finally 收缩成一个作用域。

这也是 Arena 更适合临时 FFI 内存的原因。

Arena 适合临时内存

并不是所有 Native 资源都应该交给 Arena。

例如:

using((Arena arena) {
  final pointer = arena<SomeStruct>();

  // 调用 API
});

如果这个指针只在当前方法中使用,使用 Arena 很合适。

但如果需要把 Native 指针保存到一个 Dart 对象中,让它跨越当前方法继续存在,就不能简单地放进当前 Arena。

这时需要重新设计资源的生命周期。

NativeFinalizer 是另一种思路

如果需要把 Native 资源封装成 Dart 对象,可以考虑 NativeFinalizer。

例如:

Dart 对象
    ↓
持有 Native 指针 / 句柄
    ↓
对象生命周期结束
    ↓
NativeFinalizer
    ↓
调用 Native 释放函数

这种方式适合生命周期更长的 Native 资源。

因此,两种方案解决的问题不同:

方式适合场景
using + Arena方法内部的临时 Native 内存
NativeFinalizer与 Dart 对象生命周期绑定的 Native 资源

对于一次 FFI 调用中临时申请的指针,没有必要为了它引入更复杂的对象生命周期管理。

还要注意 Native 句柄和 Native 内存不是一回事

使用 Arena 只能解决 Arena 管理的 Native 内存。

如果 Windows API 返回的是需要显式销毁的系统句柄,仍然需要调用对应的 Windows API。

例如某些 API 返回的资源需要:

Native 内存
    ↓
arena 自动释放

Windows 句柄
    ↓
调用对应 Destroy / Close API

不能因为使用了 Arena,就认为所有 Native 资源都会自动释放。

因此,在使用 FFI 时,需要区分:

  • Dart 对象
  • Native 堆内存
  • Native 指针
  • Windows 系统句柄
  • API 特定的资源释放函数

它们的生命周期并不相同。