Platform Channels & Native Integration

Writing a Flutter Plugin: Package Structure & Dart API

16 min Lesson 8 of 11

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
Note: For a fully federated layout you also create a 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 their pubspec.yaml. It re-exports the platform-interface and calls through to the registered implementation.
  • Platform-interface package (my_battery_info_platform_interface) — defines the abstract MyBatteryInfoPlatform class that every platform implementation must extend. It depends only on Dart and the plugin_platform_interface package.
  • Platform implementation packages (my_battery_info_android, my_battery_info_ios, …) — each provides a concrete subclass of MyBatteryInfoPlatform and ships the native code for exactly one platform.
Tip: Keeping native code in separate implementation packages means a Windows developer who adds your plugin does not have to download Android SDKs. It also lets the community publish alternative implementations without forking the whole plugin.

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 final or 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.');
  }
}
Warning: Never let the app-facing package communicate with native code directly via a 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 > platforms pointing to the per-platform implementation packages.
  • Platform-interface: a plain Dart package; no flutter.plugin key needed.
  • Implementation packages: declare implements: my_battery_info and dartPluginClass/pluginClass for 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.