How to write a Flutter plugin

Welcome to the "How to write a Flutter plugin" codelab!

Just what is a plugin?

A plugin is a piece of software that adds capabilities to your app. For example, you might want your mobile app to interact with the camera on your device. Plugins are an important part of the Flutter ecosystem. You should first check pub.dev to see if the plugin you need already exists. The authors of the Flutter SDK, as well as members of the Flutter community, have written many plugins and published them to pub.dev to share them with the community.

In particular, you should check out the Flutter Favorite packages and plugins. The Flutter Favorite Favorites tag identifies plugins that you should first consider when building your apps.

Flutter makes it easy for users to interact with cross-platform Dart libraries, but sometimes it's ideal to interact with platform-specific code. For example, you might want to communicate with a database that doesn't have a Dart library written for it. Flutter provides a mechanism for authoring plugins that allows you to communicate with platform-specific code and also allows you to publish your plugins on pub.dev so that others can use them.

In this codelab, you'll learn how to author your own plugins for iOS and Android. You'll implement a simple music plugin that processes audio on the host platform, and then you'll make an example app that uses your plugin to make a music keyboard.

Here are screenshots of the final app:

f4275505c0be0bd7.png 746b8f48aa63e2ff.png

What you learn

  • How to write a Flutter plugin for iOS and Android.
  • How to create an API for your plugin.
  • How to write an app that uses your plugin.
  • How to publish your plugin so that others can use it.

What would you like to learn from this codelab?

I'm new to the topic, and I want a good overview. I know something about this topic, but I want a refresher. I'm looking for example code to use in my project. I'm looking for an explanation of something specific.

You need two pieces of software to complete this lab: the Flutter SDK and an editor. You can use your preferred editor, such as Android Studio or IntelliJ with the Flutter and Dart plugins installed, or Visual Studio Code with the Dart Code and Flutter extensions.

Some of the tooling for plugin development recently changed, so this codelab assumes v1.15.19 or later of the Flutter SDK. You can check your version with the following command:

$ flutter doctor

You may have to change to the beta channel of Flutter to have access to that version. You can do that with the following command:

$ flutter channel beta

You can run the codelab by using any of the following devices:

Flutter ships with templates for plugins that makes it easy to get started. When you generate the plugin template, you can specify which language you want to use. The default is Swift for iOS and Kotlin for Android. For this codelab, you use Objective-C and Java.

Run the following command in your working directory to create the plugin template:

$ flutter create --template=plugin --ios-language=objc --android-language=java PluginCodelab

This command generates the following directory structure:

PluginCodelab/
├── CHANGELOG.md
├── LICENSE
├── README.md
├── android/
│   ├── build.gradle
│   └── src/
│       └── main/
│           ├── AndroidManifest.xml
│           └── java/
│               └── com/
│                   └── example/
│                       └── PluginCodelab/
│                           └── PluginCodelabPlugin.java
├── example/
│   ├── README.md
│   ├── android/
│   ├── ios/
│   ├── lib/
│   │   └── main.dart
│   ├── pubspec.yaml
│   ├── test/
│   │   └── widget_test.dart
├── ios/
│   ├── PluginCodelab.podspec
│   ├── Assets/
│   └── Classes/
│       ├── PluginCodelabPlugin.h
│       └── PluginCodelabPlugin.m
├── lib/
│   └── PluginCodelab.dart
├── pubspec.yaml
└── test/
    └── PluginCodelab_test.dart

Here is a description of some important files:

  • pubspec.yaml**—**The YAML file that defines your plugin. It specifies the plugin's name, dependencies, version, supported operating systems, and so on. This is used on your plugin's pub.dev page.
  • CHANGELOG.md**—**Any time that you want to publish a new version of a plugin you must update this markdown file to indicate the changes in the new version.
  • README.md**—**This markdown file shows up on the front page of the plugin's pub.dev listing. It should describe, in detail, what the plugin is and how to use it.
  • lib/PluginCodelab.dart**—**The Dart code that implements the frontend to your plugin. Plugin clients have access to the public classes and functions in this directory.
  • android/src/main/java/com/example/PluginCodelab/PluginCodelabPlugin.java**—**The native Java code that implements the Android feature described in PluginCodelab.dart.
  • ios/Classes/PluginCodelabPlugin.m**—**The Objective-C code that implements the iOS feature described in PluginCodelab.dart. (There is a matching header file as well.)
  • example/**—**This directory contains a client of your plugin. While developing your plugin, you edit this file to see your plugin in action.
  • example/lib/main.dart**—**The Dart code that exercises your plugin. You build the example UI here.

Run the examples on your iOS or Android device with the following instructions:

$ cd PluginCodelab/example
$ flutter run

You should see something like this:

52b7d03a33b9cbfa.png

Check out the generated code for the plugin's frontend:

lib/PluginCodelab.dart

class PluginCodelab {
  static const MethodChannel _channel =
      const MethodChannel('PluginCodelab');

  static Future<String> get platformVersion async {
    final String version =
      await _channel.invokeMethod('getPlatformVersion');
    return version;
  }
}

cf1e10b838bf60ee.png Observations

  • PluginCodelab is the class that users of your plugin invoke.
  • This class creates a MethodChannel that allows the Dart code to communicate with the host platform.
  • The plugin's API has only one method, the property getter platformVersion. When someone calls this getter in Dart, the MethodChannel invokes the getPlatformVersion() method and asynchronously waits for a String to be returned.
  • It's up to the platform-specific code to interpret the meaning of the getPlatformVersion message, and you'll see that later.

example/lib/main.dart

Future<void> initPlatformState() async {
  String platformVersion;
  // Platform messages may fail, so we use a try/catch PlatformException.
  try {
    platformVersion = await PluginCodelab.platformVersion;
  } on PlatformException {
    platformVersion = 'Failed to get platform version.';
  }

  // If the widget was removed from the tree while the asynchronous platform
  // message was in flight, we want to discard the reply rather than calling
  // setState to update our non-existent appearance.
  if (!mounted) return;

  setState(() {
    _platformVersion = platformVersion;
  });
}

cf1e10b838bf60ee.png Observations

  • This is a client of the plugin.
  • This code calls the getter defined in lib/PluginCodelab.dart.
  • Notice that the call is wrapped in a try-block. If the platform-specific code for iOS returns a FlutterError, or an exception is thrown in Java, then it gets resurfaced on the Dart side.

ios/Classes/PluginCodelabPlugin.m

+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
  FlutterMethodChannel* channel = [FlutterMethodChannel
      methodChannelWithName:@"PluginCodelab"
            binaryMessenger:[registrar messenger]];
  PluginCodelabPlugin* instance = [[PluginCodelabPlugin alloc] init];
  [registrar addMethodCallDelegate:instance channel:channel];
}

cf1e10b838bf60ee.png Observations

  • This initialization code is called when a new engine is set up.
  • This code generates a channel for communicating with the plugin.
  • Notice that the channel name specified here must match the name defined in lib/PluginCodelab.dart.
  • Setting itself up as the methodCallDelegate means that the created instance receives messages that are associated with the provided binary messenger.

ios/Classes/PluginCodelabPlugin.m

- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
  if ([@"getPlatformVersion" isEqualToString:call.method]) {
    result([@"iOS " stringByAppendingString:[[UIDevice currentDevice] systemVersion]]);
  } else {
    result(FlutterMethodNotImplemented);
  }
}

cf1e10b838bf60ee.png Observations

android/src/main/java/com/example/PluginCodelab/PluginCodelabPlugin.java

@Override
public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) {
  channel = new MethodChannel(flutterPluginBinding.getFlutterEngine().getDartExecutor(), "PluginCodelab");
  channel.setMethodCallHandler(this);
}

cf1e10b838bf60ee.png Observations

  • This Java code implements getPlatformVersion() for Android.

ios/Classes/PluginCodelabPlugin.m

@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
  if (call.method.equals("getPlatformVersion")) {
    result.success("Android " + android.os.Build.VERSION.RELEASE);
  } else {
    result.notImplemented();
  }
}

cf1e10b838bf60ee.png Observations

  • This Java code handles messages sent from Dart. Notice that this code is similar in form to the iOS plugin, but has some subtle differences.

72ad6e60941a67c6.pngYou've found something special!

Now, you provide the platform-specific implementations for a synthesizer that makes sounds when pressing keys on the keyboard. You can think of this code as the library you will surface to Flutter. Often, when making a plugin, you already have a defined platform API that you'll work from, as in this case.

You now have two separate implementations of the same functionality, one for iOS and one for Android. You need to get these compiling as part of your app so that the plugin can call into it.

Add to iOS

Add the following files to your project:

By placing these files in the ios/Classes location, they will compile as part of the iOS build for your plugin. You can look at ios/PluginCodelab.podspec to see that, by default, it uses globs to define which sources to compile. All you need to do is put the files in the right location.

Add to Android

Add the following file to your project:

android/src/main/java/com/example/PluginCodelab/Synth.java

By placing this Java file in the android/src/main/java/com/example location, it will compile as part of the Android build for your plugin. You can look at the Gradle build system to see that you only need to place the file in the correct directory to get it to compile.

Synthesizer interface explanation

The synthesizer interface is similar on iOS and Android, and consists of four methods:

class Synthesizer {
  void start();
  void stop();
  int keyDown(int key);
  int keyUp(int key);
}

The keyUp() and keyDown() methods represent the events sent when a key on the musical keyboard is pressed down and released. The key argument represents which key is being pressed or released. It's an enumeration of all the keys on the musical keyboard. The MIDI standard defines an enumeration for those keys, where 60 is the value for Middle C and increments one for every black or white key ( semitone). Your plugin uses this definition.

The next step in making a plugin is thinking about what sort of information you want to send back and forth between Flutter and the host platform. If you're trying to represent a library that already has an API defined, you can make your life easy, and mimic that interface.

In this codelab, we provide the synthesizer code for each platform, so you can mimic its interface in the Dart code:

lib/PluginCodelab.dart

import 'dart:async';

import 'package:flutter/services.dart';

class PluginCodelab {
  static const MethodChannel _channel =
      const MethodChannel('PluginCodelab');

  static Future<String> get platformVersion async {
    final String version = await _channel.invokeMethod('getPlatformVersion');
    return version;
  }

  static Future<int> onKeyDown(int key) async {
    final int numNotesOn = await _channel.invokeMethod('onKeyDown', [key]);
    return numNotesOn;
  }

  static Future<int> onKeyUp(int key) async {
    final int numNotesOn = await _channel.invokeMethod('onKeyUp', [key]);
    return numNotesOn;
  }
}

Notice that the second parameter to invokeMethod() lists the parameters that are sent to the method call.

Now you have platform-specific libraries for making sound and Dart code that controls that code, but they aren't hooked up. If you call any of these Dart methods now, they result in "Not Implemented" exceptions because you haven't implemented the host side in the plugin. That's the next step.

Hooking things up on iOS

First, modify the plugin to create and start a synthesizer instance:

ios/Classes/PluginCodelabPlugin.m

@implementation PluginCodelabPlugin {
  int _numKeysDown;
  FLRSynthRef _synth;
}
- (instancetype)init {
  self = [super init];
  if (self) {
    _synth = FLRSynthCreate();
    FLRSynthStart(_synth);
  }
  return self;
}

- (void)dealloc {
  FLRSynthDestroy(_synth);
}

Next, start handling messages sent over the channel:

- (void)handleMethodCall:(FlutterMethodCall *)call
                  result:(FlutterResult)result {
  if ([@"getPlatformVersion" isEqualToString:call.method]) {
    result([@"iOS "
        stringByAppendingString:[[UIDevice currentDevice] systemVersion]]);
  } else if ([@"onKeyDown" isEqualToString:call.method]) {
    FLRSynthKeyDown(_synth, [call.arguments[0] intValue]);
    _numKeysDown += 1;
    result(@(_numKeysDown));
  } else if ([@"onKeyUp" isEqualToString:call.method]) {
    FLRSynthKeyUp(_synth, [call.arguments[0] intValue]);
    
    _numKeysDown -= 1;
    result(@(_numKeysDown));
  } else {
    result(FlutterMethodNotImplemented);
  }
}

Notice that the code now looks for the onKeyDown and onKeyUp messages as well. In order to get the key argument, pull it from call.arguments. The returned value is boxed as an NSNumber (described in the Platform channels documentation), so convert it with intValue.

See the completed file, PluginCodelabPlugin.m.

Hooking things up on Android

First, modify the plugin to create and start a synthesizer instance:

android/src/main/java/com/example/PluginCodelab/PluginCodelabPlugin.java

public class PluginCodelabPlugin implements FlutterPlugin, MethodCallHandler {
  private MethodChannel channel;
  private Synth synth;
  private static final String channelName = "PluginCodelab";

  private static void setup(PluginCodelabPlugin plugin, BinaryMessenger binaryMessenger) {
    plugin.channel = new MethodChannel(binaryMessenger, channelName);
    plugin.channel.setMethodCallHandler(plugin);
    plugin.synth = new Synth();
    plugin.synth.start();
  }

  @Override
  public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) {
    setup(this, flutterPluginBinding.getBinaryMessenger());
  }

Next, start handling messages sent over the channel:

@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
  if (call.method.equals("getPlatformVersion")) {
    result.success("Android " + android.os.Build.VERSION.RELEASE);
  } else if (call.method.equals("onKeyDown")) {
    try {
      ArrayList arguments = (ArrayList) call.arguments;
      int numKeysDown = synth.keyDown((Integer) arguments.get(0));
      result.success(numKeysDown);
    } catch (Exception ex) {
      result.error("1", ex.getMessage(), ex.getStackTrace());
    }
  } else if (call.method.equals("onKeyUp")) {
    try {
      ArrayList arguments = (ArrayList) call.arguments;
      int numKeysDown = synth.keyUp((Integer) arguments.get(0));
      result.success(numKeysDown);
    } catch (Exception ex) {
      result.error("1", ex.getMessage(), ex.getStackTrace());
    }
  } else {
    result.notImplemented();
  }
}

Similar to iOS, the code now looks for the onKeyDown and onKeyUp messages. Use arguments.get() to extract the key value here, as well. Make sure that, on Android, your plugin handles any exceptions that might arise.

See the completed file, PluginCodelabPlugin.java.

Now that the plugin implements all of the plumbing, you probably want to see it in action. For that, you implement a simple keyboard UI example app:

example/lib/main.dart

import 'package:flutter/material.dart';
import 'dart:async';

import 'package:flutter/services.dart';
import 'package:PluginCodelab/PluginCodelab.dart';

enum _KeyType { Black, White }

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeRight])
      .then((_) {
    runApp(new MyApp());
  });
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  String _platformVersion = 'Unknown';

  @override
  void initState() {
    super.initState();
    initPlatformState();
  }

  // Platform messages are asynchronous, so we initialize in an async method.
  Future<void> initPlatformState() async {
    String platformVersion;
    try {
      platformVersion = await PluginCodelab.platformVersion;
    } on PlatformException {
      platformVersion = 'Failed to get platform version.';
    }

    if (!mounted) return;

    setState(() {
      _platformVersion = platformVersion;
    });
  }

  void _onKeyDown(int key) {
    print("key down:$key");
    PluginCodelab.onKeyDown(key).then((value) => print(value));
  }

  void _onKeyUp(int key) {
    print("key up:$key");
    PluginCodelab.onKeyUp(key).then((value) => print(value));
  }

  Widget _makeKey({@required _KeyType keyType, @required int key}) {
    return AnimatedContainer(
      height: 200,
      width: 44,
      duration: Duration(seconds: 2),
      curve: Curves.easeIn,
      child: Material(
        color: keyType == _KeyType.White
            ? Colors.white
            : Color.fromARGB(255, 60, 60, 80),
        child: InkWell(
          onTap: () => _onKeyUp(key),
          onTapDown: (details) => _onKeyDown(key),
          onTapCancel: () => _onKeyUp(key),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        backgroundColor: Color.fromARGB(255, 250, 30, 0),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: <Widget>[
              Text('Running on: $_platformVersion\n'),
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: <Widget>[
                  _makeKey(keyType: _KeyType.White, key: 60),
                  _makeKey(keyType: _KeyType.Black, key: 61),
                  _makeKey(keyType: _KeyType.White, key: 62),
                  _makeKey(keyType: _KeyType.Black, key: 63),
                  _makeKey(keyType: _KeyType.White, key: 64),
                  _makeKey(keyType: _KeyType.White, key: 65),
                  _makeKey(keyType: _KeyType.Black, key: 66),
                  _makeKey(keyType: _KeyType.White, key: 67),
                  _makeKey(keyType: _KeyType.Black, key: 68),
                  _makeKey(keyType: _KeyType.White, key: 69),
                  _makeKey(keyType: _KeyType.Black, key: 70),
                  _makeKey(keyType: _KeyType.White, key: 71),
                ],
              )
            ],
          ),
        ),
      ),
    );
  }
}

Notice the following:

  • You must import 'package:PluginCodelab/PluginCodelab.dart' in order to use the plugin. The dependency of the example on the plugin is defined in example/pubspec.yaml, which makes this work.
  • In main(), the orientation is forced to be landscape so that the whole keyboard can fit on the screen.
  • The _onKeyDown() and _onKeyUp() methods are both clients of the plugin API designed in previous steps.
  • The code uses InkWell, which are just interactive rectangles, to draw the individual keys.

Run the app to see your functioning music keyboard:

cd example
flutter run

It should look like this:

f4275505c0be0bd7.png

Congratulations! You successfully created a Flutter plugin for iOS and Android, and you have a nifty musical keyboard to jam on. The completed project can be downloaded from https://github.com/flutter/codelabs/tree/master/PluginCodelab for comparison.

Next steps

  • Add end-to-end testing. The Flutter team provides a library to create end-to-end integration tests called e2e.
  • Publish to pub.dev. After you create a plugin, you may want to share it online so that others can use it. You can find the full documentation on publishing your plugin to pub.dev in Developing plugin packages.

Extend the synthesizer

For fun, if you want to play around with the synthesizer and improve it, here are some next steps you might consider:

  • Right now the synthesizer generates a sine wave. How about generating a saw wave?
  • Did you notice the popping sounds when you press and release a key? That's due to the oscillator abruptly turning on and off. Usually synthesizers remedy that with amplitude envelopes.
  • Right now you can only play one key at a time. That's called monophonic. Real pianos are polyphonic.