Steven's Knowledge

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:

  1. Search pub.dev — most common integrations (camera, location, Bluetooth, biometrics) already have mature packages with active maintainers.
  2. Evaluate the existing package — check pub points, last publish date, open issues, and platform coverage. If it meets 80% of your needs, use it.
  3. 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.
  4. Build from scratch — only when no viable package exists, the integration is proprietary, or the existing options have fundamental architectural problems.

Plugin vs Package

CharacteristicPackage (pure Dart)Plugin (native code)
Contains native codeNoYes (Kotlin, Swift, C++, etc.)
Needs platform-specific setupNoYes (permissions, manifest entries)
Examplehttp, provider, riverpodcamera, url_launcher, shared_preferences
Build complexityLowHigh (must compile and test per platform)
pubspec.yamlflutter.plugin section absentflutter.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

ApproachStructureWhen to use
Non-federatedSingle package contains all platform codeSmall plugin, single maintainer, few platforms
FederatedThree or more packages split by roleMulti-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 impl

Platform 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: MyPluginAndroid

Platform 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

DartAndroid (Java/Kotlin)iOS (Swift)
nullnullnil (NSNull)
boolBooleanBool (NSNumber)
intInt (32-bit) / Long (64-bit)Int (NSNumber)
doubleDoubleDouble (NSNumber)
StringStringString
Uint8Listbyte[]FlutterStandardTypedData(bytes:)
Int32Listint[]FlutterStandardTypedData(int32:)
Int64Listlong[]FlutterStandardTypedData(int64:)
Float64Listdouble[]FlutterStandardTypedData(float64:)
ListArrayListArray (NSArray)
MapHashMapDictionary (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.

AspectManual MethodChannelPigeon
Method name matchingStrings — typos compile but fail at runtimeGenerated — compiler catches mismatches
Type safetyCast Map results manuallyGenerated data classes on both sides
MaintenanceDuplicate logic in Dart + nativeSingle source of truth
Async supportAlways asyncSync 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.dart

This 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.dart

Widget 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.yaml

pubspec.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: MyPlugin

For 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: MyPluginAndroid

Scoring Criteria (Pub Points)

CategoryPointsRequirements
Follow Dart file conventions30Valid pubspec, CHANGELOG, README, LICENSE, analysis_options
Provide documentation20Dartdoc comments on all public APIs
Support multiple platforms20Declare and support 2+ platforms
Pass static analysis30Zero analyzer warnings, follow recommended lint rules
Support up-to-date dependencies20Compatible with latest stable Flutter/Dart SDK

Publishing Checklist

  1. Run dart analyze and dart format --set-exit-if-changed . to confirm zero warnings.
  2. Run flutter test and verify all tests pass.
  3. Verify flutter pub publish --dry-run succeeds.
  4. Review generated API documentation with dart doc.
  5. Ensure CHANGELOG.md has an entry for every version.
  6. Tag the release in git: git tag v1.0.0.
  7. 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:

  1. Add the new API path behind a version check.
  2. Keep the old path for older OS versions.
  3. Document the minimum OS version required for each feature.
  4. Use @Deprecated annotations 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:

  1. Mark it @Deprecated('Use newMethod() instead. Will be removed in v3.0.0').
  2. Keep it functional for at least one major version.
  3. Log a warning at runtime if the deprecated method is called.
  4. 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.

On this page