Writing a Flutter Plugin: Package Structure & Dart API
Writing a Flutter Plugin: Package Structure & Dart API
A Flutter plugin is a specialised Dart package that wraps platform-specific (native) functionality and exposes it through a clean Dart API. Unlike a pure-Dart package, a plugin ships native code for one or more target platforms (Android, iOS, macOS, Windows, Linux, Web) alongside the Dart layer. Understanding how to scaffold, structure, and design the Dart API of a plugin is the foundation of every native integration project.
Scaffolding a Federated Plugin
The modern, recommended approach is a federated plugin: the functionality is split across multiple Dart packages so that each platform can be implemented independently without touching every other platform's code. Use the Flutter CLI to generate the scaffolding:
Scaffold a Federated Plugin
# Generate the app-facing package (what app developers import)
flutter create --template=plugin \
--org com.example \
--platforms android,ios,macos \
my_battery_info
# The command produces the following top-level files and directories:
# my_battery_info/
# lib/my_battery_info.dart <-- public Dart API
# lib/src/my_battery_info_platform_interface.dart
# android/ <-- Android native code
# ios/ <-- iOS native code
# macos/ <-- macOS native code
# example/ <-- example Flutter app
# pubspec.yaml
# CHANGELOG.md README.md LICENSE
my_battery_info_platform_interface package (the abstract contract) and one implementation package per platform (e.g. my_battery_info_android). The --template=plugin_ffi variant generates a Dart FFI plugin skeleton instead of a channel-based one.Understanding the Directory Layout
After scaffolding, the three most important packages in a federated plugin are:
- App-facing package (
my_battery_info) — the package app developers add to theirpubspec.yaml. It re-exports the platform-interface and calls through to the registered implementation. - Platform-interface package (
my_battery_info_platform_interface) — defines the abstractMyBatteryInfoPlatformclass that every platform implementation must extend. It depends only on Dart and theplugin_platform_interfacepackage. - Platform implementation packages (
my_battery_info_android,my_battery_info_ios, …) — each provides a concrete subclass ofMyBatteryInfoPlatformand ships the native code for exactly one platform.
Designing the Public Dart API
The app-facing package's lib/my_battery_info.dart is the sole public surface that end users see. Keep it thin: delegate every call to the platform-interface singleton. A good Dart API follows these conventions:
- Prefer static or top-level async methods returning typed
Future<T>values. - Throw named, documented exceptions rather than returning nullable sentinels.
- Keep the class
finalor use a factory constructor so users cannot subclass it accidentally. - Document every public symbol with a triple-slash
///comment.
App-Facing Dart API — lib/my_battery_info.dart
import 'package:my_battery_info_platform_interface/my_battery_info_platform_interface.dart';
/// Provides access to the device battery level and charging state.
///
/// All methods are asynchronous; they delegate to the platform-specific
/// implementation registered via [MyBatteryInfoPlatform.instance].
class MyBatteryInfo {
// Prevent instantiation — all members are static.
const MyBatteryInfo._();
/// Returns the current battery level as a percentage (0–100).
///
/// Throws [BatteryUnavailableException] if the device does not report
/// battery information (e.g. desktops without a battery).
static Future<int> getBatteryLevel() {
return MyBatteryInfoPlatform.instance.getBatteryLevel();
}
/// Returns `true` if the device is currently charging.
static Future<bool> isCharging() {
return MyBatteryInfoPlatform.instance.isCharging();
}
/// Stream of battery-level updates (emits on every 1 % change).
static Stream<int> get batteryLevelStream {
return MyBatteryInfoPlatform.instance.batteryLevelStream;
}
}
/// Thrown when battery information is unavailable on the current device.
class BatteryUnavailableException implements Exception {
const BatteryUnavailableException([this.message]);
final String? message;
@override
String toString() =>
'BatteryUnavailableException${message != null ? ': $message' : ''}';
}
The Platform-Interface Contract
The platform-interface package defines the abstract class that acts as the contract every implementation must fulfil. It relies on plugin_platform_interface to enforce that only approved subclasses can be assigned to instance.
Platform-Interface Package — lib/my_battery_info_platform_interface.dart
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
/// The interface that all implementations of my_battery_info must extend.
///
/// Platform implementations should extend this class rather than implement it,
/// as [instance] does not enforce the entire Dart interface.
abstract class MyBatteryInfoPlatform extends PlatformInterface {
MyBatteryInfoPlatform() : super(token: _token);
static final Object _token = Object();
static MyBatteryInfoPlatform _instance = _MethodChannelMyBatteryInfo();
/// The default instance of [MyBatteryInfoPlatform] to use.
static MyBatteryInfoPlatform get instance => _instance;
/// Override to provide a custom implementation (used in tests or
/// platform packages registering themselves).
static set instance(MyBatteryInfoPlatform instance) {
PlatformInterface.verifyToken(instance, _token);
_instance = instance;
}
/// Returns the current battery level (0–100).
Future<int> getBatteryLevel() {
throw UnimplementedError('getBatteryLevel() has not been implemented.');
}
/// Returns whether the device is currently charging.
Future<bool> isCharging() {
throw UnimplementedError('isCharging() has not been implemented.');
}
/// Stream of battery-level updates.
Stream<int> get batteryLevelStream {
throw UnimplementedError('batteryLevelStream has not been implemented.');
}
}
MethodChannel. Always route through the platform-interface. Bypassing the interface breaks the federated architecture and makes it impossible for platform teams to swap implementations.pubspec.yaml Wiring
Each package in the federation has a pubspec.yaml that declares its role. The app-facing package uses a flutter.plugin.platforms key to tell Flutter which implementation packages to use on each platform:
- App-facing: lists
flutter > plugin > platformspointing to the per-platform implementation packages. - Platform-interface: a plain Dart package; no
flutter.pluginkey needed. - Implementation packages: declare
implements: my_battery_infoanddartPluginClass/pluginClassfor their platform.
Summary
Scaffolding a federated Flutter plugin produces three logical layers: the app-facing package with a clean Dart API, the platform-interface package with the abstract contract, and one or more platform implementation packages that ship native code. The Dart API layer should be thin, fully typed, and well-documented, delegating all work to PlatformInterface.instance. This structure maximises code reuse, allows independent platform contributors, and keeps your public API stable across native changes.