HomeDocumentationc_006_dart_ffi
c_006_dart_ffi
13

From Tutorial to Production: Hardening Your Dart FFI Integration

Dart FFI in Production: NativeFinalizer, isLeaf, Testing, and Real Linking

March 20, 2026

The Problem with try/finally

Throughout this series, the memory management pattern has looked like this:

dart
final buffer = calloc<Uint8>(size);
try {
  _processData(buffer, size);
  return Uint8List.fromList(buffer.asTypedList(size));
} finally {
  calloc.free(buffer); // runs whether or not an exception was thrown
}

This works. It's correct. For code you control — where you can see the calloc and the free in the same function — it's the right teaching pattern because it makes the ownership contract explicit.

But production FFI code tends to grow. Functions get refactored. Allocations happen in one method and are used in another. Ownership gets passed around. And try/finally has a subtle failure mode: it only protects the scope it's written in. The moment an allocation outlives its function — stored in a field, passed to another class, returned as part of a larger structure — the try/finally guarantee disappears.

dart
class ImagePipeline {
  Pointer<Uint8>? _inputBuffer;
  Pointer<Uint8>? _outputBuffer;

  void prepare(int size) {
    _inputBuffer = calloc<Uint8>(size);
    _outputBuffer = calloc<Uint8>(size);
    // No try/finally here — these fields need to outlive prepare()
  }

  void process() {
    _nativeProcess(_inputBuffer!, _outputBuffer!, ...);
  }

  void dispose() {
    calloc.free(_inputBuffer!); // What if dispose() is never called?
    calloc.free(_outputBuffer!);
  }
}

If dispose() is never called — because the developer forgot, because an exception was thrown before disposal, because the object was held in a list that was replaced without clearing — those allocations leak. Forever. The GC never sees them. They don't show up in DevTools heap snapshots because they're not Dart objects.

This is exactly the class of bug that gets reported months after shipping, on specific devices, under specific memory pressure conditions, as a mysterious crash or OOM termination.

NativeFinalizer: Binding C Cleanup to Dart Object Lifetime

NativeFinalizer (available since Dart 2.17) solves this by attaching a native cleanup function to a Dart object's lifecycle. When the Dart object becomes unreachable and the GC collects it, the finalizer calls the C function automatically.

dart
// The finalizer is created once, at the class level
// It points to the C function that does the cleanup
static final _finalizer = NativeFinalizer(calloc.nativeFree);

class NativeBuffer {
  final Pointer<Uint8> _ptr;
  final int size;
  bool _disposed = false;

  NativeBuffer(this.size) : _ptr = calloc<Uint8>(size) {
    // Attach the finalizer to this object
    // When this NativeBuffer becomes unreachable, _finalizer will call
    // calloc.nativeFree(_ptr) automatically
    _finalizer.attach(this, _ptr.cast(), detach: this);
  }

  Uint8List get data {
    if (_disposed) throw StateError('NativeBuffer already disposed');
    return _ptr.asTypedList(size);
  }

  // Explicit disposal is still the right practice —
  // but now forgetting it is no longer catastrophic
  void dispose() {
    if (_disposed) throw StateError('NativeBuffer already disposed');
    _disposed = true;
    _finalizer.detach(this);
    calloc.free(_ptr);
  }
}

The attach call does three things:

  • Registers this (the Dart object) as the token to watch
  • Registers _ptr.cast() (the native pointer) as the argument to pass to the cleanup function when triggered
  • Sets detach: this so that if you call dispose() explicitly, the finalizer is removed and won't run again

The important nuance: NativeFinalizer is a safety net, not a replacement for explicit dispose(). The GC doesn't run on a predictable schedule — a finalizer might not fire for seconds or minutes after the object becomes unreachable. If your native library has resource limits (a maximum number of open handles, for example), relying on the finalizer to release them in time is not safe. Call dispose() explicitly. The finalizer catches the cases where you can't.

For your own C libraries, you wrap your cleanup function the same way:

cpp
// In your C library
extern "C" void my_buffer_free(void* ptr) {
    free(ptr);
}
dart
// In Dart — look up the native function pointer (not a Dart callable)
// NativeFinalizer needs a Pointer<NativeFunction<...>>, so use lookup(), not lookupFunction()
final _myBufferFreePtr = DynamicLibrary.open('libyours.so')
    .lookup<NativeFunction<Void Function(Pointer<Void>)>>('my_buffer_free');

static final _finalizer = NativeFinalizer(_myBufferFreePtr);

The externalSize hint: telling the GC what it can't see

There's a subtlety that NativeFinalizer alone doesn't solve. Consider a class that wraps a 50MB native image buffer. From the Dart GC's perspective, the wrapper object is tiny — a few fields, maybe 48 bytes of Dart heap. The 50MB lives in native memory, completely invisible to the collector.

The GC decides when to run based on how much Dart heap has been allocated since the last collection. If your code creates and discards a hundred 48-byte wrapper objects, the GC sees ~5KB of allocation pressure. Not worth collecting yet. Meanwhile, the native heap has grown by 5GB because each of those wrappers was holding a 50MB buffer that the finalizer hasn't had a chance to release.

The externalSize parameter on NativeFinalizer.attach fixes this by telling the GC: "this Dart object is keeping this many bytes of native memory alive. Count them toward your allocation pressure."

dart
class NativeImageBuffer {                                                 
  static final _finalizer = NativeFinalizer(calloc.nativeFree);           
                                                                          
  final Pointer<Uint8> _ptr;                                              
  final int size;                                                         
  bool _disposed = false;                                                 
                                                                          
  NativeImageBuffer(this.size) : _ptr = calloc<Uint8>(size) {             
    // externalSize tells the GC that this 48-byte Dart object            
    // is actually responsible for 'size' bytes of native memory          
    _finalizer.attach(this, _ptr.cast(), detach: this, externalSize: size);                                                                         
  }                                                                       
                                                                          
  void dispose() {                                                        
    if (_disposed) throw StateError('Already disposed');                  
    _disposed = true;                                                     
    _finalizer.detach(this);                                              
    calloc.free(_ptr);                                                    
  }                                                                       
} 

With externalSize: size, the GC counts that 50MB toward its pressure threshold. Creating ten of these wrappers registers 500MB of pressure, which triggers collection aggressively — exactly what you want.

Without externalSize, the same ten wrappers register ~480 bytes. The GC sleeps. The finalizers don't fire. Native memory climbs. On a device with 3GB of RAM, the OS kills your app long before the Dart GC decides it's time to collect.

The rule: any time a small Dart object keeps a large native allocation a live, pass `externalSize` to `attach`. Image buffers, audio buffers, database handles backed by large caches, ML model weights loaded via FFI — these are the cases where a forgotten externalSize turns a correctly finalized object into a slow memory leak that only surfaces under real usage patterns, never in testing.

isLeaf: The Performance Switch Most Tutorials Miss

Every FFI call — even add(3, 4) — pays an overhead cost: the Dart VM suspends the calling thread's participation in GC safepoints, transitions out of managed execution context, runs the C function, and transitions back. This is the correct behavior for C functions that might allocate Dart objects, call back into Dart, or run long enough that the GC needs to be able to interrupt them.

For simple, short-running C functions that do none of those things, this overhead is unnecessary and measurable. A function that executes in 100 nanoseconds shouldn't pay 500 nanoseconds of VM transition overhead.

isLeaf: true removes that overhead:

dart
// Modern @Native annotation (Dart 2.19+)
// isLeaf: true tells the VM this function is short, pure C, no callbacks
@Native<Int32 Function(Int32, Int32)>(symbol: 'add', isLeaf: true)
external int add(int a, int b);

// Or with lookupFunction (older pattern)
final add = lib.lookupFunction<
  Int32 Function(Int32, Int32),
  int Function(int, int)
>('add', isLeaf: true);

The constraints for using isLeaf: true:

  • The function must not call back into Dart
  • The function must not allocate Dart objects
  • The function should be short-running (doesn't block; doesn't run long enough that the GC would need to preempt it)
  • The function must not call anything that might trigger a GC

For signal processing functions called on every audio frame, image filter functions called on every camera frame, or math functions called in tight loops — isLeaf: true is a genuine win. For a function that takes 50ms, it doesn't matter. For a function that takes 50 nanoseconds and is called 10,000 times per second, it does.

If you use isLeaf: true on a function that violates the constraints, you get undefined behavior — usually a crash, and usually not immediately or reproducibly. That's the trade-off. Use it confidently for the cases it's designed for; leave it off when in doubt.

Isolates and Large Data: The Copy You Didn't Know Was Happening

In the earlier posts in this series, we moved FFI processing off the main thread using Isolate.run():

dart
final result = await Isolate.run(() {
  return _processSync(pixels, width, height);
});

This keeps the UI thread free. What it also does, silently, is copy `pixels` into the new isolate's memory before the function runs. Isolates have separate heaps — they don't share memory. A Uint8List passed as a parameter gets duplicated. For a 48MB image buffer, you've just used 96MB at peak: the original in the UI isolate and the copy in the worker isolate.

On a mid-range device with memory pressure, this is the spike that triggers the OS to terminate your app.

TransferableTypedData solves this by transferring ownership of the underlying memory buffer rather than copying it:

dart
Future<Uint8List> processImageOffThread(Uint8List pixels, int width, int height) async {
  // Wrap the buffer — this "detaches" it from the current isolate
  final transferable = TransferableTypedData.fromList([pixels]);
  // After this line, pixels is no longer accessible in this isolate

  return Isolate.run(() {
    // Materialize the buffer in the worker isolate — zero copy
    final buffer = transferable.materialize().asUint8List();
    return _processSync(buffer, width, height);
  });
}

After TransferableTypedData.fromList([pixels]), the original pixels reference is detached — accessing it throws. The data moved, not copied. Peak memory stays at 48MB, not 96MB.

The limitation: TransferableTypedData is a one-way transfer. Once materialized in the worker isolate, you'd need to transfer it back the same way to return the result. For the image processing case:

dart
return Isolate.run(() {
  final inputBuffer = transferable.materialize().asUint8List();
  // Process in-place, then wrap the result for transfer back
  _applyFilter(inputBuffer, width, height);
  return TransferableTypedData.fromList([inputBuffer]);
});
// Back in the main isolate — materialize the result
final resultTransferable = await processResult;
final processedPixels = resultTransferable.materialize().asUint8List();

For smaller buffers — anything under a few megabytes — the copy overhead of normal Isolate.run() is negligible and the simpler code is preferable. TransferableTypedData is the right tool specifically for large buffers where peak memory allocation matters.

Symbol Visibility: The "Symbol Not Found" Mystery

You've written the C function. You've added extern "C". You've compiled. You call lookupFunction and get:

javascript
Invalid argument(s): No top-level function 'my_function' found.

On Android, this is often a symbol visibility issue. By default, the linker may strip or hide symbols that aren't explicitly marked for export, especially when compiler optimization is enabled.

The fix is a visibility attribute:

cpp
// Add this before extern "C" functions you need Dart to find
#ifdef _WIN32
  #define EXPORT __declspec(dllexport)
#else
  #define EXPORT __attribute__((visibility("default")))
#endif

extern "C" {
  EXPORT int32_t my_function(int32_t input) {
    return input * 2;
  }
}

Or in CMakeLists.txt, you can set the default visibility for the entire library:

cmake
add_library(mylib SHARED my_code.cpp)

# Make all symbols visible by default
# (Alternatively, use the EXPORT macro for fine-grained control)
target_compile_options(mylib PRIVATE -fvisibility=default)

The CMake approach is simpler for libraries you control entirely. The macro approach is better when you're integrating third-party C++ that may have mixed visibility requirements — you only export the extern "C" boundary functions, and everything else stays hidden.

iOS: The Two Linking Scenarios

The earlier posts in this series used DynamicLibrary.process() for iOS. That's correct in one specific scenario and wrong in another. Here's the exact distinction.

Scenario A: Native code compiled directly into your app binary. This is what happens when you add .cpp files directly to the Xcode target, or compile them via your ios/ Podfile as part of the app target itself. In this case, the symbols end up in the main executable's symbol table. DynamicLibrary.process() searches the main process's symbol table and finds them.

dart
// ✅ Works when symbols are in the main app binary
final lib = DynamicLibrary.process();
final myFunction = lib.lookupFunction<...>('my_function');

Scenario B: Native code in a Flutter plugin, packaged as a `.framework`. When you're distributing native code as a Flutter plugin — which is the case for any reusable package, and for most enterprise library integrations — the code is compiled into a .framework bundle. These symbols are not in the main process's global symbol table. DynamicLibrary.process() won't find them.

dart
// ❌ Fails with "Symbol not found" — symbols are in a framework, not the main binary
final lib = DynamicLibrary.process();

// ✅ Open the framework explicitly
final lib = DynamicLibrary.open('my_library.framework/my_library');

To find the correct path for your framework at runtime:

dart
import 'dart:ffi';
import 'dart:io';

DynamicLibrary _openLibrary() {
  if (Platform.isAndroid) {
    return DynamicLibrary.open('libmylibrary.so');
  } else if (Platform.isIOS) {
    // For code in the main app binary:
    // return DynamicLibrary.process();

    // For code in a framework (plugin):
    return DynamicLibrary.open('MyLibrary.framework/MyLibrary');
  }
  throw UnsupportedError('Platform not supported');
}

The practical rule: if you're the only one using this native code (it's in your own app, not a package), compile it directly into the app binary and use process(). If it's going into a plugin or a framework you'll share, use open() with the framework path.

Testing FFI Code

This is the part almost no FFI tutorial covers, and the first wall you hit when trying to add CI to a project with native integrations.

The challenge: FFI code requires compiled native libraries. Unit tests run on your dev machine or a CI runner. The .so files don't exist in the test environment by default.

Strategy 1: Test the Dart wrapper, mock the native layer.

Structure your code so the native function calls are injectable:

dart
// Instead of calling the native function directly...
typedef ProcessFn = int Function(Pointer<Uint8> buf, int len);

class ImageProcessor {
  final ProcessFn _process; // injected, not hardcoded

  ImageProcessor({ProcessFn? process})
      : _process = process ?? _loadNativeProcess();

  static ProcessFn _loadNativeProcess() {
    final lib = DynamicLibrary.open('libimageprocessor.so');
    return lib.lookupFunction<
      Int32 Function(Pointer<Uint8>, Int32),
      int Function(Pointer<Uint8>, int)
    >('process_image');
  }

  Future<Uint8List> apply(Uint8List pixels, int w, int h) async {
    // ... uses _process
  }
}

In tests, inject a Dart function that mimics the behavior:

dart
test('apply returns processed pixels', () async {
  // A pure Dart stand-in for the native function
  int fakeProcess(Pointer<Uint8> buf, int len) {
    // Invert all bytes — predictable for testing
    final list = buf.asTypedList(len);
    for (int i = 0; i < len; i++) list[i] = 255 - list[i];
    return 0; // success
  }

  final processor = ImageProcessor(process: fakeProcess);
  final input = Uint8List.fromList([0, 128, 255]);
  final result = await processor.apply(input, 1, 3);

  expect(result, equals([255, 127, 0]));
});

This tests your Dart wrapper logic — memory allocation, error handling, result copying, ownership — without requiring the compiled library. It's fast, runs anywhere, and catches the category of bugs that live in the Dart layer.

Strategy 2: Integration tests with a prebuilt library.

For testing the actual C implementation, build the native library as part of your CI setup and run Dart tests against it:

yaml
# .github/workflows/test.yml
- name: Build native library
  run: |
    cd native
    cmake -B build -DCMAKE_BUILD_TYPE=Release
    cmake --build build

- name: Run FFI integration tests
  run: dart test test/ffi_integration_test.dart
  env:
    NATIVE_LIB_PATH: native/build/libimageprocessor.so
dart
// test/ffi_integration_test.dart
import 'dart:io';

void main() {
  final libPath = Platform.environment['NATIVE_LIB_PATH']
      ?? 'native/build/libimageprocessor.so';

  group('Native image processing', () {
    late ImageProcessor processor;

    setUp(() {
      processor = ImageProcessor.fromLibrary(libPath);
    });

    test('greyscale produces correct luminance values', () async {
      // RGBA: pure red pixel
      final input = Uint8List.fromList([255, 0, 0, 255]);
      final result = await processor.applyGreyscale(input, 1, 1);

      // Luminance of red: 0.299 * 255 ≈ 76
      expect(result[0], closeTo(76, 2)); // R
      expect(result[1], closeTo(76, 2)); // G
      expect(result[2], closeTo(76, 2)); // B
      expect(result[3], equals(255));    // A unchanged
    });

    test('dispose releases memory without throwing', () {
      final buffer = NativeBuffer(1024);
      expect(() => buffer.dispose(), returnsNormally);
    });
  });
}

This tests the actual C implementation and the correctness of your Dart bindings together. It requires the native build step in CI but gives you true end-to-end confidence.

Strategy 3: Smoke test NativeFinalizer behavior.

You can't directly test when a finalizer fires — the GC doesn't run on a schedule. But you can test that your dispose() path works correctly and that the object is in a valid state before disposal:

dart
test('NativeBuffer tracks size correctly', () {
  final buf = NativeBuffer(512);
  expect(buf.size, equals(512));
  expect(buf.data.length, equals(512));
  buf.dispose(); // should not throw
});

test('NativeBuffer.dispose is idempotent via detach', () {
  final buf = NativeBuffer(512);
  buf.dispose();
  // Second dispose would double-free without the detach() call in NativeFinalizer
  // This test verifies the finalizer was detached
  expect(() => buf.dispose(), throwsStateError); // or whatever your guard throws
});

The Production Checklist

Before shipping FFI code to users:

Memory:

  • [ ] Every calloc allocation has a NativeFinalizer attached as a safety net
  • [ ] Every class that allocates native memory has an explicit dispose() method
  • [ ] dispose() detaches the finalizer before freeing, preventing double-free

Performance:

  • [ ] Hot-path functions (called per-frame, per-sample, in tight loops) have isLeaf: true
  • [ ] isLeaf: true is only on functions that don't call back into Dart or run long
  • [ ] Large buffer transfers between isolates use TransferableTypedData

Linking:

  • [ ] Android: extern "C" functions have __attribute__((visibility("default"))) or CMake sets -fvisibility=default
  • [ ] iOS: using DynamicLibrary.process() only for code in the main app binary; DynamicLibrary.open() for plugin frameworks
  • [ ] Tested on both debug and release builds (release build optimization can strip symbols)

Testing:

  • [ ] Dart wrapper logic covered by unit tests with injected mock native functions
  • [ ] Native implementation correctness covered by integration tests with a CI build step
  • [ ] dispose() path tested for correct behavior and no double-free

The six posts in this series followed a natural progression: why FFI exists, how to move data across the boundary, how to represent C types, how callbacks work, and how to integrate a real library. This post added the last layer — making it something you can trust in production, test in CI, and ship without quietly leaking memory on your users' devices.

The code from the tutorial posts works. This post makes it robust. That's the gap, and now you've crossed it.

If you arrived here after the enterprise FFI guide, the NativeFinalizer pattern addresses the memory safety gap that guide leaves open. The structs post covers alignment and padding in depth if struct ABI behavior is your concern.

Ready to build your app?

Turn your ideas into reality with our expert mobile app development services.