Skip to content

Plugins and Native Code

Most Flutter apps call native code somewhere: the camera, payments, push notifications, secure storage. That code comes from plugins or from your own android/ and ios/ folders, and it is built differently from your Dart code. This page explains how the two sides connect, and why the line between them is also the line between what a Shorebird patch can and can’t change.

Flutter’s docs describe three kinds of package:

  • Dart packages contain only Dart code, such as path, http or flutter_bloc. Everything in them is compiled into your app’s Dart code.
  • Plugin packages combine a Dart API with platform-specific implementations: Kotlin or Java for Android, Swift or Objective-C for iOS and macOS, C++ for Windows and Linux. url_launcher and camera are plugins.
  • FFI packages call C-compatible native code directly through dart:ffi. A build hook (hook/build.dart) compiles the native code and bundles it with your app.

A plugin’s pubspec.yaml declares the platforms it implements, and that is the quickest way to tell whether a package ships native code:

flutter:
  plugin:
    platforms:
      android:
        package: com.example.hello
        pluginClass: HelloPlugin
      ios:
        pluginClass: HelloPlugin

An Android, iOS, macOS, Windows or Linux entry with a pluginClass, or with ffiPlugin: true, ships native code for that platform (web entries are Dart). An FFI package has no platforms: entry; its hook/build.dart file is the sign that it builds native code. Large plugins are often federated: the package you depend on (url_launcher) pulls in one package per platform (url_launcher_android, url_launcher_ios), so check those too.

A platform channel sends messages between your Dart code and the host app. The Dart side calls a method by name, and a handler registered in Kotlin or Swift answers:

const channel = MethodChannel('com.example.app/battery');

Future<int> batteryLevel() async {
  final level = await channel.invokeMethod<int>('getBatteryLevel');
  return level ?? -1;
}

Messages are serialized with StandardMessageCodec, which carries JSON-like values: null, booleans, numbers, strings, typed byte lists, lists and maps. Calls are asynchronous, and the native handler runs on the platform’s main thread unless it uses a task queue. Each plugin registers its handlers when the engine starts, through the generated plugin registrant.

Channels aren’t type safe: both sides have to agree on method names and argument types. Flutter recommends Pigeon, which generates matching Dart and native code from one Dart definition, for new code.

When the Dart side calls a method that has no handler on the native side, or the handler answers “not implemented”, invokeMethod throws a MissingPluginException:

MissingPluginException(No implementation found for method getBatteryLevel on channel com.example.app/battery)

In development, that usually means the app needs a full restart after adding a plugin, because hot reload and hot restart don’t rebuild native code. In a release, it means the Dart code expects native code that isn’t in the installed app, which is exactly the situation a patch can create (see below).

dart:ffi calls C functions directly, without serializing messages, and synchronously. That makes it the faster option for heavy work such as image processing, cryptography or a database engine. The C code is compiled for each target platform and shipped inside the app as a native library. Generators such as ffigen and jnigen write the Dart bindings for C, Objective-C, Swift, Java and Kotlin APIs.

A Shorebird patch replaces your app’s compiled Dart code and nothing else (see How Flutter builds your app). For a plugin, that splits along the channel:

ChangePatch?Why
Your Dart code that calls a pluginYesIt’s Dart code
A Dart-only package added, removed or upgradedYesIt compiles into your Dart code
The Dart half of a plugin, with its native half unchangedYesOnly the Dart code changed
A plugin with native code added to the appNoIts native code isn’t in the installed app
A plugin upgraded in a way that changes its native codeNoThe installed app still has the old native code
Your own Kotlin, Java, Swift or Objective-C codeNoNative code ships only with a store release
C code built for FFINoIt’s a native library inside the installed app

If a patch needs native code the installed app doesn’t have, the patch still installs, but the calls fail on users’ devices: a platform channel call throws MissingPluginException, FFI code can’t find its native library, and a plugin whose Dart and native halves no longer match can crash the app. Ship native changes in a new release with shorebird release, then patch on top of it.

shorebird patch helps you catch these changes. It compares the new build with the release and warns about native changes it can detect. On Android it compares the app’s .dex files (Java and Kotlin code), so a change to a C or C++ library alone doesn’t produce a warning. On iOS and macOS it compares Podfile.lock once the app’s executable differs, so an app whose plugins all come through Swift Package Manager, with no Podfile.lock, gets no native-changes warning. The details, and what to do about a warning you didn’t expect, are in Your app contains native changes warning when creating a patch.

Every patch you ship runs against the native code of the release it patches. Some habits make that easy:

  • Commit pubspec.lock, so a plugin upgrade, including a transitive one, shows up in review rather than in a native-changes warning.
  • Add a plugin in a release, even if you only start using it later. The Dart code that calls it can then arrive in a patch.
  • Treat your own channels as an API. When a patch’s Dart code calls a method that only newer native code implements, catch MissingPluginException and fall back, so the same Dart change can also ship as a patch to older releases that don’t have the method yet.

The FAQ’s Can new packages or plugins be added in a patch? has more on checking a package for native code.