· 5 min read

Flutter Pigeon: Wrapping a Native-Only SDK the Safe Way

When an SDK ships only for iOS and Android, Flutter Pigeon gives you a typed bridge. Here is how to keep that bridge thin, testable and easy to replace.

The Flutter vs native debate usually sounds like a debate about UI: widgets, frame rates, how "native" a list feels. In my experience with Flutter, React Native and native iOS/Android, the real cost of Flutter shows up somewhere else. It shows up the day you need an SDK that ships only as a Swift and a Kotlin library, with no Flutter plugin. This post shows how I handle that day with Flutter Pigeon: a typed bridge that stays thin, testable and easy to replace.

Where Flutter's cross-platform promise stops

Flutter gives you one codebase for the UI and the business logic. It does not give you one codebase for everything the phone can do. Hardware SDKs, payment terminals, some analytics and device-management tools are built for native apps first. Many have no Flutter package, or a community package that lags behind the native release.

At that point you write native code anyway. You now have Dart, Swift and Kotlin in one feature, and the question is how they talk.

This is the honest trade-off. A fully native app would call the SDK directly. A Flutter app needs a bridge, and the bridge is new code you own and maintain. The goal is to make that bridge as small and boring as possible.

Method channels by hand: what goes wrong

Flutter's built-in tool is the MethodChannel. You send a method name and a map of arguments, and the other side answers with a value. It works, but everything is stringly typed:

final result = await _channel.invokeMethod('connect', {
  'deviceId': id,
  'timeoutMs': 5000,
});

Nothing checks that Kotlin reads deviceId and not device_id. Nothing checks that Swift returns a map and not a string. A typo compiles fine on all three sides and fails at runtime, often only on one platform. With every new method, you write the same encode and decode code three times.

Pigeon: one contract, generated code on every side

Pigeon is a code generator from the Flutter team. You describe the API once, in a Dart file, and it generates the Dart, Swift and Kotlin code for the channel. Each side gets real types, so a mismatch becomes a compile error instead of a crash.

Write the contract

Put the contract outside lib/, for example in pigeons/device_api.dart:

import 'package:pigeon/pigeon.dart';

@ConfigurePigeon(PigeonOptions(
  dartOut: 'lib/src/native/device_api.g.dart',
  kotlinOut: 'android/app/src/main/kotlin/com/example/app/DeviceApi.g.kt',
  swiftOut: 'ios/Runner/DeviceApi.g.swift',
))
class DeviceInfo {
  DeviceInfo({required this.id, required this.name});
  String id;
  String name;
}

@HostApi()
abstract class DeviceHostApi {
  @async
  List<DeviceInfo> scan(int timeoutMs);

  @async
  bool connect(String deviceId);
}

@FlutterApi()
abstract class DeviceEventsApi {
  void onDisconnected(String deviceId);
}

@HostApi is code that native implements and Dart calls. @FlutterApi is the other direction: native calls Dart, which is how SDK callbacks reach your app. @async marks methods that take time, like a scan.

Generate and implement

Add pigeon as a dev dependency and run:

dart run pigeon --input pigeons/device_api.dart

On Android, you implement the generated DeviceHostApi interface in Kotlin and register it with the engine's binary messenger. On iOS, you do the same with the generated Swift protocol. Inside those classes you call the vendor SDK and map its types to DeviceInfo. That mapping is the only place where SDK types exist.

Commit the generated files. Reviewers can then see exactly what changed in the bridge when the contract changes.

Keep the bridge thin: three rules

Pigeon fixes the typing problem. It does not fix the design problem. These are the rules I follow so the native part does not grow into a second app.

1. Native code translates, it does not decide

Swift and Kotlin call the SDK, convert the result and return it. No retries, no caching, no "if the user is premium" checks. All logic stays in Dart, where it is written once and tested once. If you find an if about business rules in Kotlin, it belongs in a Cubit.

2. The app depends on an interface, not on Pigeon

The generated DeviceHostApi class is an implementation detail. The app talks to its own abstract class:

abstract class DeviceService {
  Future<List<DeviceInfo>> scan({Duration timeout});
  Future<bool> connect(String deviceId);
  Stream<String> get disconnects;
}

class PigeonDeviceService implements DeviceService {
  PigeonDeviceService(this._api);
  final DeviceHostApi _api;
  // Calls _api and turns DeviceEventsApi callbacks into a stream.
}

This is the same pattern I used to swap ad providers in Flutter without touching the UI. Cubits and widgets get a DeviceService through dependency injection. They never import the generated file. If a real Flutter plugin appears next year, you write one new class and change one binding.

It also makes tests simple. A FakeDeviceService returns fixed devices, so you can test the whole flow without a phone and without the SDK.

3. Map errors at the edge

Native SDKs fail in their own ways: error codes, exceptions, empty results. Pigeon passes native errors to Dart as a PlatformException. Catch it in PigeonDeviceService and turn it into your own error type, like DeviceNotFound or PermissionDenied. The rest of the app should never see a raw platform code.

When native is still the better choice

A bridge is fine for one or two SDKs. It is a warning sign when most of a feature lives behind it. If your app is mostly a shell around native SDKs, with heavy background work and platform-specific UI, you may write more Swift and Kotlin than Dart. Then the Flutter layer adds a third language without saving much.

My rough test: count what is shared. If the shared Dart code (UI, state, rules) is clearly bigger than the native glue, Flutter pays off. If the glue keeps growing with every sprint, native, or a native core with a smaller cross-platform UI, deserves a serious look.

Takeaways