Plugin Development
Flutter plugin development — federated plugins, platform channels, Pigeon, testing, publishing
Plugin Development
Flutter plugins bridge Dart code to native platform APIs. When no existing package covers your requirements, building a plugin is the standard path to accessing device hardware, OS services, and third-party native SDKs. This guide covers the full lifecycle: architecture decisions, platform channel mechanics, code generation with Pigeon, platform implementations, testing, publishing, and long-term maintenance.
When to Build a Plugin
Decision Tree
Before writing native code, exhaust the alternatives:
- Search pub.dev — most common integrations (camera, location, Bluetooth, biometrics) already have mature packages with active maintainers.
- Evaluate the existing package — check pub points, last publish date, open issues, and platform coverage. If it meets 80% of your needs, use it.
- Fork and extend — if the package is close but missing a feature or has a bug the maintainer has not addressed, fork it, fix it, and submit a PR upstream.
- Build from scratch — only when no viable package exists, the integration is proprietary, or the existing options have fundamental architectural problems.
Plugin vs Package
| Characteristic | Package (pure Dart) | Plugin (native code) |
|---|---|---|
| Contains native code | No | Yes (Kotlin, Swift, C++, etc.) |
| Needs platform-specific setup | No | Yes (permissions, manifest entries) |
| Example | http, provider, riverpod | camera, url_launcher, shared_preferences |
| Build complexity | Low | High (must compile and test per platform) |
| pubspec.yaml | flutter.plugin section absent | flutter.plugin section with platforms map |
If your logic can be expressed entirely in Dart — even if it wraps a REST API or does heavy computation — prefer a pure Dart package. Plugins carry the cost of maintaining native code on every supported platform.
Federated vs Non-Federated
| Approach | Structure | When to use |
|---|---|---|
| Non-federated | Single package contains all platform code | Small plugin, single maintainer, few platforms |
| Federated | Three or more packages split by role | Multi-platform, community-contributed platforms, large surface area |
Non-federated plugins become unwieldy once you support more than two platforms. The federated model is the recommended default for anything intended for public consumption.
Federated Plugin Architecture
Why Federate
A federated plugin separates concerns into independently versioned packages. This means:
- Platform implementations can be maintained by different teams or community members.
- Adding a new platform (Linux, Windows, Web) does not require touching existing packages.
- The app-facing API is stable even as platform internals change.
- CI/CD can run platform-specific tests in isolation.
Three-Package Structure
my_plugin/ # app-facing package
pubspec.yaml # depends on my_plugin_platform_interface
lib/my_plugin.dart # public API, delegates to platform interface
my_plugin_platform_interface/ # abstract contract
pubspec.yaml
lib/my_plugin_platform_interface.dart
lib/method_channel_my_plugin.dart # default MethodChannel implementation
my_plugin_android/ # Android implementation
pubspec.yaml # implements: my_plugin
android/ # Kotlin/Java code
lib/my_plugin_android.dart # registers itself as the platform impl
my_plugin_ios/ # iOS implementation
pubspec.yaml # implements: my_plugin
ios/ # Swift/ObjC code
lib/my_plugin_ios.dart # registers itself as the platform implPlatform Interface Package
The platform interface defines the contract that every platform implementation must fulfill. It uses plugin_platform_interface to enforce that implementations extend (not implement) the base class.
// my_plugin_platform_interface/lib/my_plugin_platform_interface.dart
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'method_channel_my_plugin.dart';
abstract class MyPluginPlatform extends PlatformInterface {
MyPluginPlatform() : super(token: _token);
static final Object _token = Object();
static MyPluginPlatform _instance = MethodChannelMyPlugin();
static MyPluginPlatform get instance => _instance;
static set instance(MyPluginPlatform instance) {
PlatformInterface.verify(instance, _token);
_instance = instance;
}
Future<String> getPlatformVersion() {
throw UnimplementedError('getPlatformVersion() has not been implemented.');
}
Future<bool> isFeatureSupported(String feature) {
throw UnimplementedError('isFeatureSupported() has not been implemented.');
}
Stream<Map<String, dynamic>> onEventReceived() {
throw UnimplementedError('onEventReceived() has not been implemented.');
}
}Default MethodChannel Implementation
// my_plugin_platform_interface/lib/method_channel_my_plugin.dart
import 'package:flutter/services.dart';
import 'my_plugin_platform_interface.dart';
class MethodChannelMyPlugin extends MyPluginPlatform {
static const _methodChannel = MethodChannel('com.example/my_plugin');
static const _eventChannel = EventChannel('com.example/my_plugin/events');
@override
Future<String> getPlatformVersion() async {
final version = await _methodChannel.invokeMethod<String>('getPlatformVersion');
return version ?? 'unknown';
}
@override
Future<bool> isFeatureSupported(String feature) async {
final supported = await _methodChannel.invokeMethod<bool>(
'isFeatureSupported',
{'feature': feature},
);
return supported ?? false;
}
@override
Stream<Map<String, dynamic>> onEventReceived() {
return _eventChannel.receiveBroadcastStream().map((event) {
return Map<String, dynamic>.from(event as Map);
});
}
}App-Facing Package
// my_plugin/lib/my_plugin.dart
import 'package:my_plugin_platform_interface/my_plugin_platform_interface.dart';
class MyPlugin {
Future<String> getPlatformVersion() {
return MyPluginPlatform.instance.getPlatformVersion();
}
Future<bool> isFeatureSupported(String feature) {
return MyPluginPlatform.instance.isFeatureSupported(feature);
}
Stream<Map<String, dynamic>> onEventReceived() {
return MyPluginPlatform.instance.onEventReceived();
}
}Platform Implementation Registration
Each platform implementation registers itself using the registerWith pattern:
// my_plugin_android/lib/my_plugin_android.dart
import 'package:flutter/services.dart';
import 'package:my_plugin_platform_interface/my_plugin_platform_interface.dart';
class MyPluginAndroid extends MyPluginPlatform {
static void registerWith() {
MyPluginPlatform.instance = MyPluginAndroid();
}
static const _methodChannel = MethodChannel('com.example/my_plugin');
@override
Future<String> getPlatformVersion() async {
final version = await _methodChannel.invokeMethod<String>('getPlatformVersion');
return version ?? 'unknown';
}
}The corresponding pubspec.yaml declares the implementation:
# my_plugin_android/pubspec.yaml
flutter:
plugin:
implements: my_plugin
platforms:
android:
package: com.example.my_plugin_android
pluginClass: MyPluginAndroidPlugin
dartPluginClass: MyPluginAndroidPlatform Channels Deep Dive
Platform channels are the communication layer between Dart and native code. There are three types, each suited to a different pattern.
MethodChannel
Request/response pattern — Dart calls a named method on the native side and awaits a result.
Supported Data Types
| Dart | Android (Java/Kotlin) | iOS (Swift) |
|---|---|---|
null | null | nil (NSNull) |
bool | Boolean | Bool (NSNumber) |
int | Int (32-bit) / Long (64-bit) | Int (NSNumber) |
double | Double | Double (NSNumber) |
String | String | String |
Uint8List | byte[] | FlutterStandardTypedData(bytes:) |
Int32List | int[] | FlutterStandardTypedData(int32:) |
Int64List | long[] | FlutterStandardTypedData(int64:) |
Float64List | double[] | FlutterStandardTypedData(float64:) |
List | ArrayList | Array (NSArray) |
Map | HashMap | Dictionary (NSDictionary) |
Non-primitive types (custom classes, enums) must be serialized to Map/List before sending over a MethodChannel. If you find yourself writing boilerplate serialization, switch to Pigeon.
MethodChannel with Error Handling
Dart side:
const channel = MethodChannel('com.example/device_info');
Future<Map<String, dynamic>> getDeviceInfo() async {
try {
final result = await channel.invokeMethod<Map>('getDeviceInfo');
if (result == null) {
throw PlatformException(code: 'NULL_RESULT', message: 'Native returned null');
}
return Map<String, dynamic>.from(result);
} on PlatformException catch (e) {
// e.code, e.message, e.details are set by the native side
throw DeviceInfoException('Failed to get device info: ${e.message}', code: e.code);
} on MissingPluginException {
// Plugin not registered — common in tests or on unsupported platforms
throw DeviceInfoException('Plugin not available on this platform');
}
}Kotlin side:
class DeviceInfoPlugin : FlutterPlugin, MethodCallHandler {
private lateinit var channel: MethodChannel
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(binding.binaryMessenger, "com.example/device_info")
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getDeviceInfo" -> {
try {
val info = mapOf(
"model" to Build.MODEL,
"manufacturer" to Build.MANUFACTURER,
"sdkVersion" to Build.VERSION.SDK_INT,
)
result.success(info)
} catch (e: SecurityException) {
result.error("PERMISSION_DENIED", e.message, null)
} catch (e: Exception) {
result.error("UNKNOWN_ERROR", e.message, e.stackTraceToString())
}
}
else -> result.notImplemented()
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}EventChannel
Stream pattern for continuous data. The native side pushes events into a sink; the Dart side receives them as a Stream.
// Dart side: streaming accelerometer data
const eventChannel = EventChannel('com.example/accelerometer');
Stream<AccelerometerReading> accelerometerEvents() {
return eventChannel.receiveBroadcastStream().map((event) {
final data = Map<String, double>.from(event as Map);
return AccelerometerReading(
x: data['x']!,
y: data['y']!,
z: data['z']!,
timestamp: DateTime.now(),
);
});
}
class AccelerometerReading {
final double x, y, z;
final DateTime timestamp;
const AccelerometerReading({
required this.x, required this.y, required this.z, required this.timestamp,
});
}// Android side: EventChannel with lifecycle management
class AccelerometerPlugin : FlutterPlugin, EventChannel.StreamHandler {
private lateinit var eventChannel: EventChannel
private var sensorManager: SensorManager? = null
private var sensorListener: SensorEventListener? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
eventChannel = EventChannel(binding.binaryMessenger, "com.example/accelerometer")
eventChannel.setStreamHandler(this)
sensorManager = binding.applicationContext
.getSystemService(Context.SENSOR_SERVICE) as SensorManager
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
val accelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
sensorListener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
events?.success(mapOf(
"x" to event.values[0].toDouble(),
"y" to event.values[1].toDouble(),
"z" to event.values[2].toDouble(),
))
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
}
sensorManager?.registerListener(
sensorListener, accelerometer, SensorManager.SENSOR_DELAY_UI
)
}
override fun onCancel(arguments: Any?) {
sensorManager?.unregisterListener(sensorListener)
sensorListener = null
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
eventChannel.setStreamHandler(null)
}
}Always unregister native listeners in onCancel. Failing to do so leaks resources and drains battery. The onCancel callback fires when the Dart StreamSubscription is cancelled or the stream has no more listeners.
BasicMessageChannel
For simple message passing without the method-name dispatch overhead. Useful for sending raw strings, binary data, or JSON blobs between Dart and native code.
const channel = BasicMessageChannel<String>(
'com.example/config',
StringCodec(),
);
// Send a message and receive a reply
final reply = await channel.send('getConfig');
// Listen for messages from native
channel.setMessageHandler((message) async {
print('Received from native: $message');
return 'ack';
});Use BasicMessageChannel when you have a single-purpose channel that always sends the same type. For structured RPC-style communication, MethodChannel or Pigeon are better choices.
Pigeon (Code Generation)
Why Pigeon
Hand-written platform channels rely on string-based method names, manual type serialization, and unchecked argument maps. Pigeon eliminates all three by generating type-safe Dart, Kotlin, Swift, and Objective-C code from a single interface definition.
| Aspect | Manual MethodChannel | Pigeon |
|---|---|---|
| Method name matching | Strings — typos compile but fail at runtime | Generated — compiler catches mismatches |
| Type safety | Cast Map results manually | Generated data classes on both sides |
| Maintenance | Duplicate logic in Dart + native | Single source of truth |
| Async support | Always async | Sync or async, configurable per method |
Interface Definition
// pigeons/device_api.dart
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(PigeonOptions(
dartOut: 'lib/src/generated/device_api.g.dart',
kotlinOut: 'android/src/main/kotlin/com/example/DeviceApi.g.kt',
kotlinOptions: KotlinOptions(package: 'com.example.my_plugin'),
swiftOut: 'ios/Classes/DeviceApi.g.swift',
))
/// Data class — non-primitive type sent across the channel.
class DeviceInfo {
String model;
String manufacturer;
int sdkVersion;
bool isEmulator;
DeviceInfo({
required this.model,
required this.manufacturer,
required this.sdkVersion,
required this.isEmulator,
});
}
class BatteryStatus {
int level;
bool isCharging;
BatteryStatus({required this.level, required this.isCharging});
}
/// HostApi — methods implemented in native code, called from Dart.
@HostApi()
abstract class DeviceHostApi {
DeviceInfo getDeviceInfo();
@async
BatteryStatus getBatteryStatus();
bool isFeatureAvailable(String featureName);
}
/// FlutterApi — methods implemented in Dart, called from native code.
@FlutterApi()
abstract class DeviceFlutterApi {
void onBatteryLevelChanged(BatteryStatus status);
}Code Generation
Run the generator after editing the interface definition:
dart run pigeon --input pigeons/device_api.dartThis produces generated files in the paths specified by @ConfigurePigeon. The generated code includes data class serialization, channel setup, and method dispatch on both sides.
Implementing the Generated API (Kotlin)
// android/src/main/kotlin/com/example/MyPlugin.kt
class MyPlugin : FlutterPlugin {
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
DeviceHostApi.setUp(binding.binaryMessenger, DeviceHostApiImpl(binding))
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
DeviceHostApi.setUp(binding.binaryMessenger, null)
}
}
class DeviceHostApiImpl(
private val binding: FlutterPlugin.FlutterPluginBinding
) : DeviceHostApi {
override fun getDeviceInfo(): DeviceInfo {
return DeviceInfo(
model = Build.MODEL,
manufacturer = Build.MANUFACTURER,
sdkVersion = Build.VERSION.SDK_INT.toLong(),
isEmulator = Build.FINGERPRINT.contains("generic"),
)
}
override fun getBatteryStatus(callback: (Result<BatteryStatus>) -> Unit) {
val batteryManager = binding.applicationContext
.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
val level = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
val isCharging = batteryManager.isCharging
callback(Result.success(BatteryStatus(
level = level.toLong(),
isCharging = isCharging,
)))
}
override fun isFeatureAvailable(featureName: String): Boolean {
return binding.applicationContext.packageManager
.hasSystemFeature(featureName)
}
}Implementing the Generated API (Swift)
// ios/Classes/MyPlugin.swift
import Flutter
public class MyPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
let api = DeviceHostApiImpl()
DeviceHostApiSetup.setUp(
binaryMessenger: registrar.messenger(),
api: api
)
}
}
class DeviceHostApiImpl: DeviceHostApi {
func getDeviceInfo() throws -> DeviceInfo {
var systemInfo = utsname()
uname(&systemInfo)
let model = withUnsafePointer(to: &systemInfo.machine) {
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
String(cString: $0)
}
}
return DeviceInfo(
model: model,
manufacturer: "Apple",
sdkVersion: Int64(ProcessInfo.processInfo.operatingSystemVersion.majorVersion),
isEmulator: TARGET_OS_SIMULATOR != 0
)
}
func getBatteryStatus(completion: @escaping (Result<BatteryStatus, any Error>) -> Void) {
UIDevice.current.isBatteryMonitoringEnabled = true
let level = Int64(UIDevice.current.batteryLevel * 100)
let isCharging = UIDevice.current.batteryState == .charging
|| UIDevice.current.batteryState == .full
completion(.success(BatteryStatus(level: level, isCharging: isCharging)))
}
func isFeatureAvailable(featureName: String) throws -> Bool {
// iOS does not have a direct equivalent to Android's hasSystemFeature
// Map feature names to availability checks
switch featureName {
case "camera":
return UIImagePickerController.isSourceTypeAvailable(.camera)
case "bluetooth":
return true // check via CoreBluetooth in production
default:
return false
}
}
}Calling from Dart
// The generated Dart code provides a typed client
import 'src/generated/device_api.g.dart';
class DeviceService {
final _api = DeviceHostApi();
Future<DeviceInfo> getDeviceInfo() async {
return _api.getDeviceInfo();
}
Future<BatteryStatus> getBatteryStatus() async {
return _api.getBatteryStatus();
}
}Pigeon is the recommended approach for new plugins. It eliminates an entire class of bugs (string mismatches, type casting errors) and makes the native API surface reviewable from a single file.
iOS Implementation
Swift Plugin Class Structure
import Flutter
import UIKit
import CoreLocation
public class LocationPlugin: NSObject, FlutterPlugin, CLLocationManagerDelegate {
private var channel: FlutterMethodChannel?
private var eventChannel: FlutterEventChannel?
private var eventSink: FlutterEventSink?
private let locationManager = CLLocationManager()
public static func register(with registrar: FlutterPluginRegistrar) {
let instance = LocationPlugin()
let channel = FlutterMethodChannel(
name: "com.example/location",
binaryMessenger: registrar.messenger()
)
channel.setMethodCallHandler(instance.handle)
instance.channel = channel
let eventChannel = FlutterEventChannel(
name: "com.example/location_updates",
binaryMessenger: registrar.messenger()
)
eventChannel.setStreamHandler(instance)
instance.eventChannel = eventChannel
instance.locationManager.delegate = instance
}
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "requestPermission":
locationManager.requestWhenInUseAuthorization()
result(nil)
case "getCurrentLocation":
locationManager.requestLocation()
// Store result callback; deliver in delegate method
pendingResult = result
default:
result(FlutterMethodNotImplemented)
}
}
private var pendingResult: FlutterResult?
// CLLocationManagerDelegate
public func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
guard let location = locations.last else { return }
let data: [String: Any] = [
"latitude": location.coordinate.latitude,
"longitude": location.coordinate.longitude,
"altitude": location.altitude,
"accuracy": location.horizontalAccuracy,
]
// Deliver to pending one-shot request
if let pending = pendingResult {
pending(data)
pendingResult = nil
}
// Deliver to event stream
eventSink?(data)
}
public func locationManager(
_ manager: CLLocationManager,
didFailWithError error: Error
) {
pendingResult?(FlutterError(
code: "LOCATION_ERROR",
message: error.localizedDescription,
details: nil
))
pendingResult = nil
}
}
// MARK: - FlutterStreamHandler
extension LocationPlugin: FlutterStreamHandler {
public func onListen(
withArguments arguments: Any?,
eventSink events: @escaping FlutterEventSink
) -> FlutterError? {
self.eventSink = events
locationManager.startUpdatingLocation()
return nil
}
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
locationManager.stopUpdatingLocation()
eventSink = nil
return nil
}
}Threading Considerations
Flutter's platform channels dispatch calls on the main thread by default. If your native code does heavy work, dispatch to a background queue:
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "processImage":
DispatchQueue.global(qos: .userInitiated).async {
let processed = self.heavyImageProcessing(call.arguments)
// Must return result on the main thread
DispatchQueue.main.async {
result(processed)
}
}
default:
result(FlutterMethodNotImplemented)
}
}Info.plist Permissions
Plugins that access protected resources must document the required Info.plist entries. The app consuming the plugin adds these:
<!-- Location -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs location access to show nearby results.</string>
<!-- Camera -->
<key>NSCameraUsageDescription</key>
<string>This app needs camera access to scan barcodes.</string>
<!-- Microphone -->
<key>NSMicrophoneUsageDescription</key>
<string>This app needs microphone access for voice recording.</string>Failing to include required permission strings causes the app to crash immediately when the protected API is called. Document every required plist entry in your plugin's README.
Android Implementation
Kotlin Plugin Class Structure
class MyPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
private lateinit var channel: MethodChannel
private var activity: Activity? = null
private var context: Context? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
channel = MethodChannel(binding.binaryMessenger, "com.example/my_plugin")
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"openSettings" -> {
val activity = this.activity
if (activity == null) {
result.error("NO_ACTIVITY", "Plugin requires a foreground Activity", null)
return
}
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", activity.packageName, null)
}
activity.startActivity(intent)
result.success(null)
}
"getAppVersion" -> {
val packageInfo = context?.packageManager
?.getPackageInfo(context!!.packageName, 0)
result.success(packageInfo?.versionName)
}
else -> result.notImplemented()
}
}
// ActivityAware — required for plugins that interact with the Activity lifecycle
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
}
override fun onDetachedFromActivity() {
activity = null
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}ActivityAware Interface
Plugins that need access to the current Activity (for launching intents, requesting permissions, or accessing window-level features) must implement ActivityAware. Without it, the plugin only has access to the application Context, which cannot start activities or show dialogs.
Background Execution
For plugins that must run when the app is in the background (geofencing, background fetch, music playback), attach a FlutterEngine to an Android Service:
class BackgroundService : Service() {
private lateinit var flutterEngine: FlutterEngine
override fun onCreate() {
super.onCreate()
flutterEngine = FlutterEngine(this).apply {
dartExecutor.executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault()
)
}
// Register plugin channels on this engine
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.example/bg")
.setMethodCallHandler { call, result ->
// handle background tasks
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
flutterEngine.destroy()
super.onDestroy()
}
}Gradle Dependency Management
Plugin-level build.gradle.kts should declare dependencies as implementation (not api) to avoid leaking transitive dependencies to the consuming app:
// android/build.gradle.kts
dependencies {
implementation("com.google.android.gms:play-services-location:21.0.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}AndroidManifest.xml Permissions
<!-- android/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Declared by the plugin; merged into the app's manifest at build time -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
</manifest>Testing Plugins
Unit Testing the Dart API
Mock the platform interface to test the app-facing package in isolation:
import 'package:flutter_test/flutter_test.dart';
import 'package:my_plugin/my_plugin.dart';
import 'package:my_plugin_platform_interface/my_plugin_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
class MockMyPluginPlatform
with MockPlatformInterfaceMixin
implements MyPluginPlatform {
@override
Future<String> getPlatformVersion() async => 'mock-42';
@override
Future<bool> isFeatureSupported(String feature) async => feature == 'camera';
@override
Stream<Map<String, dynamic>> onEventReceived() {
return Stream.fromIterable([
{'type': 'test', 'value': 1},
{'type': 'test', 'value': 2},
]);
}
}
void main() {
late MyPlugin plugin;
setUp(() {
MyPluginPlatform.instance = MockMyPluginPlatform();
plugin = MyPlugin();
});
test('getPlatformVersion returns mock value', () async {
expect(await plugin.getPlatformVersion(), 'mock-42');
});
test('isFeatureSupported returns true for camera', () async {
expect(await plugin.isFeatureSupported('camera'), isTrue);
expect(await plugin.isFeatureSupported('lidar'), isFalse);
});
test('onEventReceived emits expected events', () async {
final events = await plugin.onEventReceived().toList();
expect(events, hasLength(2));
expect(events.first['value'], 1);
});
}Unit Testing the Platform Implementation
Test the MethodChannel implementation by mocking the native side:
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_plugin_platform_interface/method_channel_my_plugin.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final platform = MethodChannelMyPlugin();
const channel = MethodChannel('com.example/my_plugin');
setUp(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
switch (call.method) {
case 'getPlatformVersion':
return 'Android 14';
case 'isFeatureSupported':
final feature = (call.arguments as Map)['feature'];
return feature == 'nfc';
default:
return null;
}
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
test('getPlatformVersion returns native value', () async {
expect(await platform.getPlatformVersion(), 'Android 14');
});
test('isFeatureSupported delegates correctly', () async {
expect(await platform.isFeatureSupported('nfc'), isTrue);
expect(await platform.isFeatureSupported('ir_blaster'), isFalse);
});
}Integration Testing
Integration tests run on real devices or emulators. Place them in example/integration_test/:
// example/integration_test/plugin_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_plugin/my_plugin.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('getPlatformVersion returns a non-empty string', (tester) async {
final plugin = MyPlugin();
final version = await plugin.getPlatformVersion();
expect(version, isNotEmpty);
// On a real device, this should return something like 'Android 14' or 'iOS 17.4'
expect(version, isNot('unknown'));
});
}Run with:
cd example
flutter test integration_test/plugin_test.dartWidget Test with Mock Channels
For widget tests that exercise UI code calling your plugin:
testWidgets('displays platform version', (tester) async {
// Set up mock before pumping the widget
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('com.example/my_plugin'),
(call) async => 'Test OS 1.0',
);
await tester.pumpWidget(const MyApp());
await tester.pumpAndSettle();
expect(find.text('Test OS 1.0'), findsOneWidget);
});Publishing to pub.dev
Package Layout
my_plugin/
lib/
my_plugin.dart # single public export
src/ # private implementation
example/ # runnable example app (required for full pub points)
lib/main.dart
test/ # unit tests
CHANGELOG.md # required
LICENSE # required (BSD-3-Clause recommended)
README.md # required
pubspec.yaml
analysis_options.yamlpubspec.yaml Metadata
name: my_plugin
description: >
A Flutter plugin that provides device information and battery status
across Android and iOS.
version: 1.0.0
homepage: https://github.com/example/my_plugin
repository: https://github.com/example/my_plugin
issue_tracker: https://github.com/example/my_plugin/issues
environment:
sdk: ">=3.0.0 <4.0.0"
flutter: ">=3.10.0"
dependencies:
flutter:
sdk: flutter
my_plugin_platform_interface: ^1.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
plugin:
platforms:
android:
package: com.example.my_plugin
pluginClass: MyPlugin
ios:
pluginClass: MyPluginFor a federated plugin, the platform implementation packages use implements:
# my_plugin_android/pubspec.yaml
flutter:
plugin:
implements: my_plugin
platforms:
android:
package: com.example.my_plugin_android
pluginClass: MyPluginAndroidScoring Criteria (Pub Points)
| Category | Points | Requirements |
|---|---|---|
| Follow Dart file conventions | 30 | Valid pubspec, CHANGELOG, README, LICENSE, analysis_options |
| Provide documentation | 20 | Dartdoc comments on all public APIs |
| Support multiple platforms | 20 | Declare and support 2+ platforms |
| Pass static analysis | 30 | Zero analyzer warnings, follow recommended lint rules |
| Support up-to-date dependencies | 20 | Compatible with latest stable Flutter/Dart SDK |
Publishing Checklist
- Run
dart analyzeanddart format --set-exit-if-changed .to confirm zero warnings. - Run
flutter testand verify all tests pass. - Verify
flutter pub publish --dry-runsucceeds. - Review generated API documentation with
dart doc. - Ensure CHANGELOG.md has an entry for every version.
- Tag the release in git:
git tag v1.0.0. - Publish:
flutter pub publish.
Publishing to pub.dev is permanent. You cannot delete a published version — only retract it (which hides it from search but does not remove it). Test thoroughly before publishing. Use --dry-run first.
Maintenance
Supporting Multiple Flutter Versions
Set SDK constraints that cover your minimum supported version and leave the upper bound open within a major version:
environment:
sdk: ">=3.0.0 <4.0.0"
flutter: ">=3.10.0"Test against the minimum version in CI to catch regressions. Use conditional imports or runtime checks when adopting new APIs while maintaining backward compatibility:
// Check Flutter version at runtime for API differences
import 'dart:io' show Platform;
String get platformVersion {
// API added in a specific version — guard accordingly
try {
return Platform.operatingSystemVersion;
} catch (_) {
return 'unknown';
}
}Handling Breaking Changes in Platform APIs
When Android or iOS deprecate APIs your plugin depends on:
- Add the new API path behind a version check.
- Keep the old path for older OS versions.
- Document the minimum OS version required for each feature.
- Use
@Deprecatedannotations in Dart to guide consumers through transitions.
// Android: handle API differences across SDK versions
fun getDisplayMetrics(): Map<String, Any> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val metrics = activity.windowManager.currentWindowMetrics
val bounds = metrics.bounds
mapOf("width" to bounds.width(), "height" to bounds.height())
} else {
@Suppress("DEPRECATION")
val display = activity.windowManager.defaultDisplay
val metrics = DisplayMetrics()
@Suppress("DEPRECATION")
display.getMetrics(metrics)
mapOf("width" to metrics.widthPixels, "height" to metrics.heightPixels)
}
}Deprecation Strategy
When removing a public API method from your plugin:
- Mark it
@Deprecated('Use newMethod() instead. Will be removed in v3.0.0'). - Keep it functional for at least one major version.
- Log a warning at runtime if the deprecated method is called.
- Remove it in the next major version with a CHANGELOG entry.
Community Contribution Guidelines
For open-source plugins, establish clear contribution boundaries:
- Require an issue before large PRs — avoids wasted effort on features you will not accept.
- Provide a CONTRIBUTING.md with setup instructions, code style, and testing requirements.
- Use CI to enforce formatting, analysis, and test pass on every PR.
- For federated plugins, accept new platform packages as separate PRs that implement the existing platform interface without modifying it.
Anti-Patterns
Non-Federated Plugins That Are Hard to Extend
Putting all platform code in a single package means every contributor must understand every platform. A PR adding Windows support touches the same package as iOS bug fixes, creating merge conflicts and review burden. Federate early if you expect community contributions or multi-platform growth.
Blocking the Main Thread in Platform Code
// WRONG: blocks the UI thread
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val data = networkClient.fetchSync(call.argument("url")) // blocks
result.success(data)
}
// RIGHT: offload to a coroutine
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
scope.launch(Dispatchers.IO) {
try {
val data = networkClient.fetch(call.argument("url"))
withContext(Dispatchers.Main) { result.success(data) }
} catch (e: Exception) {
withContext(Dispatchers.Main) { result.error("NETWORK", e.message, null) }
}
}
}On iOS, the same principle applies — dispatch to a background queue and return the result on the main thread.
Missing Error Propagation
// WRONG: swallows the exception, Dart side hangs or gets null
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
try {
result.success(riskyOperation())
} catch (e: Exception) {
// Dart never learns about the failure
Log.e("Plugin", "Error", e)
}
}
// RIGHT: propagate the error to Dart
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
try {
result.success(riskyOperation())
} catch (e: Exception) {
result.error("OPERATION_FAILED", e.message, e.stackTraceToString())
}
}String-Based Channel Names Without Constants
// WRONG: duplicate strings across files — typos break silently
// In file A:
const channel = MethodChannel('com.example/my_plugin');
// In file B:
const channel = MethodChannel('com.example/my_plugn'); // typo
// RIGHT: centralize channel names
class ChannelNames {
static const method = 'com.example/my_plugin';
static const event = 'com.example/my_plugin/events';
}Apply the same discipline on the native side — define channel name constants in a companion object or static field.
Not Cleaning Up EventChannel Listeners
// WRONG: listener is never cancelled, native resources leak
void initState() {
super.initState();
eventChannel.receiveBroadcastStream().listen((data) {
setState(() => _data = data);
});
}
// RIGHT: cancel on dispose
late StreamSubscription _subscription;
void initState() {
super.initState();
_subscription = eventChannel.receiveBroadcastStream().listen((data) {
setState(() => _data = data);
});
}
void dispose() {
_subscription.cancel(); // triggers onCancel on the native side
super.dispose();
}A well-maintained plugin treats its native code with the same rigor as the Dart layer: typed APIs (via Pigeon), comprehensive tests, explicit error paths, and clean lifecycle management. The friction of crossing the platform boundary is real — the patterns in this guide exist to keep that friction manageable as the plugin grows.