Creating Pigeon for Type-Safe Interop with Native Code in Flutter

Flutter’s strength lies in its ability to create beautiful, performant applications for multiple platforms from a single codebase. However, when you need to interact with platform-specific APIs, you often rely on platform channels. While effective, traditional platform channels can be verbose and error-prone. Pigeon is a tool developed by the Flutter team to simplify and ensure type-safe communication between Flutter and native platform code. This article will guide you through the process of creating and using Pigeon for seamless and type-safe interop with native code in your Flutter applications.

What is Pigeon?

Pigeon is a code generation tool designed to generate type-safe asynchronous message passing code for Flutter and its host platforms (Android and iOS). It removes the boilerplate involved in manually serializing and deserializing data and ensures that your Dart code and native platform code can communicate with each other safely and efficiently.

Why Use Pigeon?

  • Type Safety: Ensures that the data passed between Dart and native code is type-safe, reducing runtime errors.
  • Code Generation: Automates the creation of boilerplate code, saving time and reducing the risk of manual errors.
  • Asynchronous Communication: Simplifies asynchronous message passing, improving performance and responsiveness.
  • Clear API Definition: Provides a clear and concise interface definition using a Dart-like syntax.

How to Create Pigeon for Type-Safe Interop in Flutter

Follow these steps to create and use Pigeon in your Flutter project:

Step 1: Add Pigeon Dependency

First, add Pigeon as a dev dependency in your pubspec.yaml file:

dev_dependencies:
  pigeon: ^14.0.0 # Use the latest version

Then, run flutter pub get to install the dependency.

Step 2: Define the Pigeon Interface

Create a .dart file to define your Pigeon interface. This interface specifies the methods that can be called from Flutter to native code and vice versa. For example, let’s create pigeon.dart:

import 'package:pigeon/pigeon.dart';

@ConfigurePigeon(
  PigeonOptions(
    dartOut: 'lib/src/generated/messages.g.dart',
    kotlinOut:
        'android/src/main/kotlin/com/example/my_app/Messages.g.kt',
    swiftOut: 'ios/Runner/Messages.g.swift',
  ),
)
class ExampleRequest {
  String? message;
}

class ExampleResponse {
  String? result;
}

@HostApi()
abstract class ExampleApi {
  ExampleResponse sendMessage(ExampleRequest request);
}

Explanation:

  • @ConfigurePigeon: Configures the Pigeon tool with output paths for the generated Dart, Kotlin, and Swift code.
  • ExampleRequest and ExampleResponse: Data classes used for passing data between Flutter and native code.
  • @HostApi(): Defines an interface for methods that can be called from Flutter to native code.
  • ExampleApi: An abstract class that defines the sendMessage method.

Step 3: Run Pigeon to Generate Code

Execute the Pigeon code generation tool using the Flutter CLI. Add a script in your `pubspec.yaml`:

scripts:
  pigeon: flutter pub run pigeon --input pigeons/messages.dart

Run the Pigeon tool:

flutter pub run pigeon

This command generates the necessary Dart, Kotlin, and Swift files based on your Pigeon interface definition. The generated files are placed in the paths specified in PigeonOptions.

Step 4: Implement the Native Platform Code

Android (Kotlin)

Implement the ExampleApi interface in your Kotlin code:


package com.example.my_app

import io.flutter.plugin.common.BinaryMessenger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

class ExampleApiImpl(private val messenger: BinaryMessenger) : ExampleApi {
    override fun sendMessage(request: ExampleRequest): ExampleResponse {
        val result = "Hello from Kotlin: ${request.message}"
        return ExampleResponse().apply {
            this.result = result
        }
    }
}

fun setupExampleApi(messenger: BinaryMessenger): ExampleApi {
    val api = ExampleApiImpl(messenger)
    ExampleApi.setup(messenger, api)
    return api
}
iOS (Swift)

Implement the ExampleApi protocol in your Swift code:


import Flutter

class ExampleApiImpl: NSObject, ExampleApi {
    func sendMessage(request: ExampleRequest, error: AutoreleasingUnsafeMutablePointer) -> ExampleResponse? {
        let result = "Hello from Swift: (request.message ?? "")"
        let response = ExampleResponse()
        response.result = result
        return response
    }
}

func setupExampleApi(binaryMessenger: FlutterBinaryMessenger) {
    let api = ExampleApiImpl()
    ExampleApiSetup.setUp(binaryMessenger: binaryMessenger, api: api)
}

Step 5: Register the Native Implementation

Android (Kotlin)

Register the Kotlin implementation in your MainActivity.kt:


package com.example.my_app

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine

class MainActivity: FlutterActivity() {
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        setupExampleApi(flutterEngine.dartExecutor.binaryMessenger)
    }
}
iOS (Swift)

Register the Swift implementation in your AppDelegate.swift:


import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    setupExampleApi(binaryMessenger: controller.binaryMessenger)
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

Step 6: Use the API in Flutter

Call the native method from your Flutter code using the generated API:


import 'package:flutter/material.dart';
import 'src/generated/messages.g.dart';

void main() {
  runApp(MyApp());
}

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

class _MyAppState extends State {
  String _result = 'No result yet.';

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

  Future _callNativeMethod() async {
    final api = ExampleApi();
    final request = ExampleRequest()..message = 'Hello from Flutter!';
    try {
      final response = await api.sendMessage(request);
      setState(() {
        _result = response.result ?? 'No result';
      });
    } catch (e) {
      setState(() {
        _result = 'Error: $e';
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Pigeon Example'),
        ),
        body: Center(
          child: Text(_result),
        ),
      ),
    );
  }
}

Conclusion

Pigeon significantly simplifies the process of interacting with native code in Flutter by providing type safety and automating boilerplate code generation. By following the steps outlined in this article, you can create a robust, efficient, and type-safe bridge between your Flutter and native platform code. This approach not only reduces the risk of runtime errors but also makes your codebase more maintainable and scalable. Pigeon is an excellent tool for any Flutter developer needing to leverage platform-specific functionalities while maintaining a high standard of code quality.