Flutter Forward: Crafting Type-Safe Native Interfaces with Pigeon

This post is for Day 17 of Mercari Advent Calendar 2024, brought to you by @howie.zuo from the Mercari Hallo mobile team.

Introduction

Hello! I’m @howie.zuo, an engineer on the Mercari Hallo mobile team. In this article, I will guide you through the process of generating type-safe native bridges using Pigeon.

Flutter is an incredibly powerful framework. With a vast ecosystem of community-supported plugins, you usually only need to write a minimal amount of native code to create a mobile application. However, finding the right plugin that meets your product’s needs can sometimes be challenging. Even worse, the perfect plugin may have already been deprecated. Therefore, it’s essential to think carefully before adopting a plugin, especially if maintainability and security are critical for your project.

While working on a feature to interact with the calendar app in Mercari Hallo, I discovered that the only suitable plugin I found wasn’t being actively maintained and had poor code quality, as evident from its GitHub repository. As a result, I decided to build the functionality myself.

Note: The code examples in this article are simplified for demonstration purposes. You may need to adjust them for your own codebase. The implementation specifics regarding calendar interactions are not included here, as we’ll focus primarily on Pigeon.

What is Pigeon?

Borrowed the description from here since it describes clearly enough.

Pigeon is a code generator tool used to make communication between Flutter and the host platform type-safe, easier, and faster.

Pigeon removes the necessity to manage strings across multiple platforms and languages. It also improves efficiency over common method channel patterns. Most importantly though, it removes the need to write custom platform channel code, since pigeon generates it for you.

Installation

  1. Start by installing the latest version of Pigeon (22.7.0 as of this writing) in your project’s pubspec.yaml:

    dev_dependencies:
        pigeon: ^22.7.0
  2. Optionally, run dart pub get if your environment doesn’t automatically refresh dependencies.

Configuration

  1. Create a folder named pigeon at the root of your project, and then create a file named message.dart inside the pigeon directory.

    ROOT_PATH_OF_YOUR_PROJECT/pigeon/message.dart

    You can choose a different file structure or naming convention if it suits you better.

  2. Import the Pigeon package at the top of your message.dart file:

    import 'package:pigeon/pigeon.dart'; 
  3. Define the input data structures:

    class Request {
      Request({
        required this.payload,
        required this.timestamp,
      });
      Payload payload;
      int timestamp;
    }
    
    class Payload {
      Payload({
        this.data,
        this.priority = Priority.normal,
      });
      String? data;
      Priority priority;
    }
    
    enum Priority {
      high,
      normal,
    }

    You can find a list of supported data types here. Pigeon also supports custom classes, nested data types, and enums. In Swift, Kotlin, and Dart, you can use sealed classes for a more organized data structure.

  4. Configuration settings

    Place the following code at the top of your message.dart file. This tells Pigeon how you want it to generate the code:

    @ConfigurePigeon(
      PigeonOptions(
        dartOptions: DartOptions(),
        dartOut: 'lib/pigeon/message.g.dart',
        kotlinOptions: KotlinOptions(
          package: 'com.example.pigeon',
        ),
        kotlinOut:
            'android/app/src/main/kotlin/com/example/pigeon/Message.g.kt',
        swiftOptions: SwiftOptions(),
        swiftOut: 'ios/Runner/Pigion/Message.g.swift',
      ),
    )

    Pigeon options also support other languages like C, Java, and Objective-C.

  5. Define the output data structures and method interface.

    Add the following code at the end of your message.dart file:

    class Response {
      Response({
        this.result,
      });
      String? result;
    }
    
    @HostApi()
    abstract class MessageApi {
      bool isAvailable();
    
      @async
      Response send(Request req);
    }

    The @HostApi() annotation is used for procedures defined on the host platform that can be called by Flutter. Conversely, @FlutterApi() is for procedures defined in Dart that you want to call from the host platform.

    The @async annotation indicates that the method is asynchronous.

Code generation

Once the interface is defined, generate the code by running:

flutter pub run pigeon --input pigeon/message.dart

This command will generate code for each platform:

  • lib/pigeon/message.g.dart
  • android/app/src/main/kotlin/com/example/pigeon/Message.g.kt
  • ios/Runner/Pigeon/Message.g.swift

Android Implementation

  1. Create a class named MessageHandler that implements the MessageApi interface:

    class MessageHandler : MessageApi {
        fun setUp(message: BinaryMessenger) {
            MessageApi.setUp(message, this)
        }
    
        override fun isAvailable(): Boolean {
            // your logics go here
    
            return true
        }
    
        override fun send(res: Request, callback: (Result<Response>) -> Unit) {
            // get the input
            val data = res.payload.data
    
            // your logics go here
    
            // return result asynchronously use callback
            callback(Result.success(Response()))
        }
    }
    

    isAvailable and send are the methods we defined earlier. Feel free to implement your own logic inside these methods to handle requests from the Flutter side.

  2. You may have noticed the setUp method; we’ll use this to attach the MessageHandler to the Flutter engine. Override configureFlutterEngine in MainActivity (if it isn’t already present):

    class MainActivity: FlutterActivity() {
        override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
            super.configureFlutterEngine(flutterEngine)
    
            // setup the event handler
            MessageHandler().setUp(flutterEngine.dartExecutor.binaryMessenger)
        }
    }

    That’s the Android part done. Now let’s move on to the iOS implementation.

iOS Implementation

  1. Similarly to Android, create a class named MessageHandler that implements the MessageApi protocol:

    class MessageHandler : MessageApi {
        func setUp(binaryMessenger: FlutterBinaryMessenger) {
            MessagesApiSetup.setUp(binaryMessenger: binaryMessenger, api: self)
        }
    
        func isAvailable() throws -> Bool {
            // your logics go here
    
            return true
        }
    
        func send(res: Request, completion: @escaping (Result<Response, any Error>) -> Void) {
            // get the input
            let data = res.payload.data
    
            // your logics go here
    
            // return result asynchronously use callback
            completion(.success(Response()))
        }
    }

    The class structure is quite similar to the one we created for Android.

  2. Just like in Android, we need to attach MessageHandler to the Flutter engine here as well. Open AppDelegate.swift and insert the following lines inside application(_:didFinishLaunchingWithOptions:):

    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    MessageHandler().setUp(binaryMessenger: controller.binaryMessenger)

Flutter

Finally, let’s see how to call the host platform methods from Flutter.

  1. For isAvailable

    final messageApi = MessageApi();
    final isAvailable = messageApi.isAvailable();
  2. For send, which is an asynchronous function:

    final messageApi = MessageApi();
    final res = await messageApi.send(Request(
      payload: Payload(
        data: 'Hello, Pigeon!',
        priority: Priority.normal,
      ),
      timestamp: DateTime.now().millisecondsSinceEpoch,
    ));

    The code above is straightforward and should be easy to understand.

A few more things

  • Pigeon also supports macOS, Windows, and Linux.
  • There are more features not covered in this article that you can explore, such as EventChannelApi.
  • As a Flutter engineer, you don’t need to be an expert in platform-specific languages, but having some experience in Android or iOS development will undoubtedly be helpful in the development of native API-dependent functionality.

Reference

Some of the resources you may also find useful
https://docs.flutter.dev/platform-integration/platform-channels
https://pub.dev/packages/pigeon
https://github.com/flutter/packages/blob/main/packages/pigeon/example/README.md

Tomorrow’s article will be by @naka. Look forward to it!

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加