Flutter Plugin Development with Native Code: Channels, Pigeon, and FFI
A practical, end-to-end guide to building robust Flutter plugins with native code, Pigeon, FFI, testing, and multi-platform best practices.
Image used for representation purposes only.
Why Write a Flutter Plugin?
Flutter’s widget and package ecosystem is vast, but some capabilities live only in platform SDKs (Android, iOS, macOS, Windows, Linux) or in existing native libraries. A plugin is the bridge between Dart and those native APIs. You’ll typically build a plugin when you need to:
- Access device features (sensors, camera, Bluetooth, NFC, biometrics)
- Integrate an existing native SDK (payments, analytics, media)
- Render or embed a native view (maps, web views, AR)
- Reuse performant native or C/C++ code via FFI
A well-designed plugin hides platform differences behind a single Dart API, keeping apps clean and testable.
Flutter–Native Communication: The Options
Flutter offers several interop mechanisms, each suited to different problems:
- MethodChannel: Simple request/response calls (invoke a method, get a result). Good default.
- EventChannel: Continuous or broadcast streams (sensors, connectivity changes).
- BasicMessageChannel: Raw message pipe for custom protocols.
- Platform Views: Host native UI components inside Flutter (e.g., a map view).
- FFI (dart:ffi): Call C/C++/Rust directly, bypassing the platform channel overhead.
- Pigeon: Code generation for type-safe platform channels (reduces boilerplate and runtime typos).
Choose the simplest tool that meets your needs. If you need OS APIs only available in Kotlin/Swift/Obj‑C/C++, prefer channels or Pigeon. If you have portable C/C++ and don’t need OS objects, prefer FFI for speed.
Scaffolding a Plugin Project
Use Flutter’s template to generate a plugin with example and tests:
flutter create --org com.example --template=plugin --platforms=android,ios,macos,windows,linux my_native_plugin
cd my_native_plugin
Key directories:
- lib/: Dart API your app imports
- android/, ios/, macos/, windows/, linux/: platform implementations
- example/: a runnable Flutter app to develop, test, and publish screenshots
- test/: unit tests for the Dart API
In pubspec.yaml, you’ll see a plugin: section that declares each platform’s registration class. Keep it accurate as you add platforms.
A Minimal MethodChannel Plugin
Dart API (lib/my_native_plugin.dart):
import 'package:flutter/services.dart';
class MyNativePlugin {
static const MethodChannel _channel = MethodChannel('com.example.my_native_plugin');
static Future<String?> platformVersion() async {
final version = await _channel.invokeMethod<String>('getPlatformVersion');
return version;
}
}
Android (android/src/main/kotlin/…/MyNativePlugin.kt):
package com.example.my_native_plugin
import android.os.Build
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class MyNativePlugin: FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var channel: MethodChannel
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(binding.binaryMessenger, "com.example.my_native_plugin")
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getPlatformVersion" -> result.success("Android ${Build.VERSION.RELEASE}")
else -> result.notImplemented()
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}
iOS (ios/Classes/MyNativePlugin.swift):
import Flutter
import UIKit
public class MyNativePlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "com.example.my_native_plugin", binaryMessenger: registrar.messenger())
let instance = MyNativePlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getPlatformVersion":
result("iOS \(UIDevice.current.systemVersion)")
default:
result(FlutterMethodNotImplemented)
}
}
}
Use it in example/lib/main.dart:
import 'package:flutter/material.dart';
import 'package:my_native_plugin/my_native_plugin.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
home: FutureBuilder(
future: MyNativePlugin.platformVersion(),
builder: (context, snap) => Scaffold(
appBar: AppBar(title: const Text('Plugin Demo')),
body: Center(child: Text('Version: ${snap.data ?? 'loading…'}')),
),
),
);
}
Streaming Data with EventChannel
For sensors or connectivity, prefer a stream:
Dart:
static const _events = EventChannel('com.example.my_native_plugin/events');
Stream<int> get stepCount => _events.receiveBroadcastStream().cast<int>();
Android Kotlin:
class StepStreamHandler: EventChannel.StreamHandler {
private var events: EventChannel.EventSink? = null
override fun onListen(args: Any?, sink: EventChannel.EventSink) { events = sink /* start sensor */ }
override fun onCancel(args: Any?) { events = null /* stop sensor */ }
}
Register the channel in onAttachedToEngine and keep the sensor lifecycle in sync with onDetachedFromEngine and app foreground/background.
Typed APIs with Pigeon
Pigeon reduces dynamic channel errors. Define a Dart spec, then generate platform code.
pigeons/messages.dart:
import 'package:pigeon/pigeon.dart';
class BatteryLevel { int? percent; }
@HostApi()
abstract class DeviceApi {
BatteryLevel getBatteryLevel();
}
Generate code:
flutter pub run pigeon \
--input pigeons/messages.dart \
--dart_out lib/src/pigeon.g.dart \
--swift_out ios/Classes/Pigeon.g.swift \
--kotlin_out android/src/main/kotlin/com/example/my_native_plugin/Pigeon.g.kt \
--kotlin_package "com.example.my_native_plugin"
Implement the generated interfaces in Kotlin/Swift, then call the generated Dart stubs. Pigeon enforces types at compile time and avoids stringly-typed method names.
High-Performance Paths with FFI
When you have C/C++ or Rust logic and minimal OS interaction, FFI is often the fastest interop.
C library (src/add.c):
#include <stdint.h>
int32_t add(int32_t a, int32_t b) { return a + b; }
Dart FFI:
import 'dart:ffi' as ffi;
import 'package:ffi/ffi.dart';
typedef c_add = ffi.Int32 Function(ffi.Int32, ffi.Int32);
typedef dart_add = int Function(int, int);
final dylib = ffi.DynamicLibrary.open(
Platform.isAndroid ? 'libnative.so' : Platform.isWindows ? 'native.dll' : 'libnative.dylib',
);
final add = dylib.lookupFunction<c_add, dart_add>('add');
Bundle the native library per platform (Gradle CMake for Android, CocoaPods/Xcode for iOS/macOS, CMake for Windows/Linux). Avoid blocking the UI isolate if you perform heavy work; dispatch to Isolate.run for CPU-bound tasks.
Platform Views (Embedding Native UI)
Platform views let you render native components inside the Flutter widget tree (e.g., a camera preview):
- Android: implement
PlatformViewand aPlatformViewFactory; register viaFlutterPlugin. - iOS: implement
FlutterPlatformViewand aFlutterPlatformViewFactory. - Expose a Dart
Widgetthat creates aUiKitView(iOS) orAndroidViewwith aviewTypestring.
Remember: platform views are heavier than pure Flutter widgets; cache and reuse when possible.
Permissions, Threads, and Lifecycles
- Android: declare permissions in
AndroidManifest.xml, request runtime permissions for dangerous scopes (camera, location, Bluetooth). Offload blocking I/O to background threads (e.g.,Dispatchers.IO). - iOS: add usage strings to
Info.plist(e.g.,NSCameraUsageDescription). Long-running tasks may need background modes. - Respect app lifecycles: pause sensors on background, release resources on
onDetachedFromEngine/deinit.
Testing Strategy
- Unit test Dart API with mocks:
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const channel = MethodChannel('com.example.my_native_plugin');
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
ServicesBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
channel, (call) async => call.method == 'getPlatformVersion' ? 'test-1.0' : null,
);
});
tearDown(() {
ServicesBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null);
});
test('returns version', () async {
final v = await MyNativePlugin.platformVersion();
expect(v, 'test-1.0');
});
}
-
Integration test in example/ using
integration_testto validate real native behavior. -
Platform unit tests: Kotlin (JUnit, Robolectric if needed), Swift (XCTest). Keep platform code thin; business logic should be testable in Dart or in native units.
Federated Plugins for Multi‑Platform Support
A federated plugin splits the API into:
- app-facing package (pure Dart interface)
- platform interface package (abstract class +
package:plugin_platform_interface) - 1+ platform implementation packages (android, ios, web, macos, windows, linux)
The app depends on the app-facing package; each platform auto-registers its implementation. This design enables web/desktop contributions later without breaking changes and allows alternate implementations.
Versioning and Publishing
- Follow semantic versioning. Bump major for API breaks, minor for features, patch for fixes.
- Document supported platforms and minimum OS/SDK versions.
- Add a CHANGELOG.md and meaningful README with setup steps (permissions, build settings).
- Run
flutter pub publish --dry-runbefore publishing; ensure example compiles on targeted platforms.
CI and Local Development Loops
- Set up GitHub Actions/CI to run
flutter analyze,dart test, and build the example app for at least Android and iOS. - On Android, use
adb logcatandflutter attachfor logs. On iOS/macOS, use Xcode console andprint()/NSLogin Swift. - Keep method names and channel names constant; changing them is a breaking change.
Performance Tips
- Batch calls across channels to reduce context switches.
- Prefer Pigeon for structured, binary codecs. Avoid sending massive payloads through channels; share via files or memory-mapped buffers when possible.
- For streaming data, use
EventChannelorPlatformViewinstead of repeatedly polling aMethodChannel. - Validate main-thread rules: UI work on the main thread; heavy I/O/CPU on background threads/isolates.
Common Pitfalls and Fixes
- Not registered: check the plugin registrar names in
pubspec.yamland the generatedGeneratedPluginRegistrant. - Missing permissions: crashes or silent failures—verify manifests and Info.plist strings.
- Null-safety mismatches: ensure
sdk: '>=3.0.0 <4.0.0'(or your chosen range) and migrate platform code as needed. - Codec mismatches: make sure the types you send are supported by the selected codec (StandardMessageCodec covers most primitives, lists, maps, and byte buffers).
A Practical Checklist
- Define a clear Dart API first (method names, models, error cases)
- Pick interop path: MethodChannel, EventChannel, Pigeon, FFI, or PlatformView
- Scaffold with
flutter create --template=plugin - Implement one platform end-to-end and write tests
- Add permissions, lifecycle handling, and error reporting
- Expand to more platforms (consider a federated structure)
- Add example app usage and README instructions
- Wire up CI, versioning, and publish
Conclusion
Flutter plugins unlock the full potential of each platform while preserving a single, idiomatic Dart API. Start with channels for straightforward calls, reach for Pigeon to add type safety, and use FFI when you need raw speed or direct access to native libraries. With careful attention to lifecycles, permissions, and testing, your plugin will feel native everywhere and be a pleasure to maintain.
Related Posts
Flutter barcode scanning with the camera plugin: a production-ready guide
Build a fast, on‑device Flutter barcode scanner using the camera plugin and ML Kit, with code, overlays, performance tips, and platform setup.
Mastering Flutter Platform Channels on iOS and Android: A Practical Guide
A practical, end-to-end guide to Flutter platform channels on iOS and Android with Kotlin, Swift, Dart code, testing, performance, and pitfalls.
Flutter Flavors Done Right: Production and Staging Configuration
A practical, end-to-end guide to configuring Flutter flavors for production and staging across Android, iOS, CI/CD, Firebase, and more.