Plugins in a multi-engine setup
How Flutter plugins register per engine, why iOS add-to-app never registers them for you, and which plugin categories misbehave when engines come and go.
There is a failure that hits almost every add-to-app team, usually a few weeks after
everything seems to already work: the Flutter module runs fine standalone, runs fine on
Android inside the host, and then every call to shared_preferences, url_launcher, or any
other plugin with native iOS code throws MissingPluginException in the iOS host app.
Nothing about the plugin's documentation warns you, because a regular Flutter app never sees
this - it has one engine, and the app template registers plugins on it at startup.
Add-to-app changes both halves of that sentence. There are many engines, and there is no template doing the registering. This page explains the mechanics, shows how Inlay handles them, and covers the harder question that registration alone doesn't solve: what happens to plugins when engines are created and destroyed per screen.
How plugin registration actually works
A plugin's native half attaches to one engine at a time. Registration means walking the
module's plugin list and attaching each plugin to a specific engine's binary messenger -
that's what the generated GeneratedPluginRegistrant does. It is generated into your
module, not into the framework, so no plugin (Inlay included) can call it for you; only
code that can see your module's dependency list can.
The two platforms treat this completely differently:
| Android | iOS | |
|---|---|---|
| Who registers plugins | The Flutter embedding, automatically - every FlutterEngine looks up GeneratedPluginRegistrant reflectively when it's created. | Nobody. The host must call GeneratedPluginRegistrant. on every engine itself. |
| What happens if you do nothing | Plugins work on every engine. | Every plugin call fails with MissingPluginException, on every engine. |
| Where the registrant lives | Generated into the module's Android project, found via reflection. | The FlutterPluginRegistrant module: a Swift package target inside FlutterNativeIntegration (SwiftPM), a pod (CocoaPods), or FlutterPluginRegistrant. (prebuilt frameworks). Either way, import FlutterPluginRegistrant. |
This asymmetry is easy to miss precisely because Android keeps working. Teams that develop on Android first often discover the iOS half from a crash report.
Registering plugins on Inlay's engines
Inlay creates engines internally - one per native container (each screen or dialog pushed from native, or explicitly pushed into a new container from Flutter), plus a hidden prewarmed one - so
the host can't reach them directly. setOnEngineCreated is the hook for that: the callback
runs exactly once for every engine Inlay creates, right after the engine starts.
// AppDelegate - before start(), so the prewarmed engine is covered too.
import FlutterPluginRegistrant
InlayNavigator.shared.setOnEngineCreated { engine in
GeneratedPluginRegistrant.register(with: engine)
}
InlayNavigator.shared.start()Set the callback before start(). The prewarmed engine runs your Dart entrypoint just
like a visible one, so any plugin touched during startup (before runApp) needs
registration there too. If a prewarmed engine already exists when the callback is set,
Inlay invokes the callback on it immediately.
Engine churn: the part registration doesn't solve
Registration gets a plugin attached; it doesn't change what the plugin assumes about its lifetime. Most plugins were written against the single-engine mental model: one engine, created at launch, alive until the process dies. How far your setup departs from that depends on the engine strategy you chose: a single cached engine never churns, so this whole section is moot for it. But any engine-per-container model - which is what Inlay uses, creating an engine when a container is pushed and destroying it on pop - means plugins attach and detach constantly, and each live container holds its own independent instance of every plugin.
How much that matters depends on the kind of plugin:
- Stateless call-through plugins (
package_info_plus,device_info_plus,url_launcher,share_plus) are safe. Every call is a self-contained round trip to a platform API; nothing survives between calls, so nothing is lost when the engine dies. - Plugins with per-screen state (
video_player, audio players, camera previews) work, with two caveats. Their state lives and dies with one container's engine, so nothing carries over to the next Flutter screen - and during a push or pop transition two engines are briefly alive at once, which matters for exclusive resources like the camera or an audio session. Keep screens that hold exclusive hardware in a single Flutter screen, or native. - Plugins wrapping process-global SDKs (
firebase_coreand friends) attach per engine but configure a native singleton. Initialize the SDK once on the native side at app startup instead of letting the first Flutter screen do it - per-engine initialization from Dart is at best redundant work on every push, and at worst a race between screens. - Background-execution and callback plugins (
firebase_messagingbackground handlers,workmanager, geolocation streams) are the genuinely incompatible category. They assume one long-lived engine to deliver callbacks into. A per-screen engine is gone by the time the callback fires, and some of these plugins spawn their own headless engine that knows nothing about your entrypoint wiring. Treat their Dart APIs as unusable in this model.
Working with single-engine assumptions instead of against them
The pattern that holds up in production is to use plugins for per-screen concerns and the
native side for app-level concerns. A Flutter screen calling share_plus or playing a video
is using exactly the lifetime a per-screen engine gives it. Push notifications, background
sync, location tracking, analytics, session state - anything that must outlive a screen -
belongs to the host app, which then exposes what Flutter needs over the typed boundary: state
through stores, actions through
native routes. That
split also survives the migration endgame
better than a pile of plugin workarounds, because app-level services already have a single
owner.
One habit worth copying from the example app: put one plugin-backed value on an early
Flutter screen (the example renders the host app's version via package_info_plus) and
assert it in a native UI test. It's a single assertion, but it catches a whole class of
regressions - a plugin added without registration, a broken registrant after a Flutter
upgrade - on the platform where nothing else would warn you.
Native-Flutter communication
Method channels, Pigeon, and direct FFI - how to move data and events across the native-Flutter boundary, and which one to reach for.
Embedding
Flutter views inside native screens and native views inside Flutter - how each works, what it costs, and what we suggest trying before you commit to either.