Add-to-app embeds Flutter screens into an existing Android or iOS app instead of rewriting it. A module is a Flutter project built as a library for that host: it has no real app shell of its own, and its .android and .ios folders are generated wrappers you never edit.
Why teams use it: migrate a large native app screen by screen, or share one feature (checkout, onboarding) between two native apps
**module: in pubspec.yaml**: holds androidPackage and iosBundleIdentifier; that block is what makes the project a module
Generated hosts: flutter pub get recreates .android and .ios; they exist only to run the module on its own and stay out of git, so native code you need goes into the host app or a local plugin
The Dart side is ordinary: widgets, packages and plugins work as usual; what changes is that the host starts the engine and owns navigation
FlutterSenior
Add-to-App & Native Integration
FlutterEngine, multiple engines, embedding Flutter in an existing iOS or Android app, Pigeon, plugin vs FFI, size and startup
Questions closed
0 / 16
- 0Review
- 1Asked often
Questions
The FlutterEngine runs your Dart code: the isolate, the channels, the plugins and the renderer. FlutterActivity, FlutterFragment and FlutterViewController are only native containers that give an engine a surface to draw on and pass it input and lifecycle events.
Separate lifetimes: an engine can start before any Flutter screen exists (pre-warming) and outlive it; Dart state, timers and sockets survive while no Flutter screen is visible
One view per engine: on mobile an engine draws into one container at a time, so two Flutter screens alive at once need two engines
The default is expensive: a container created without an engine (FlutterActivity.withNewEngine(), FlutterViewController(project:nibName:bundle:)) starts its own and destroys it on close, so every open pays the full start-up
Today the documented route is Swift Package Manager: flutter build swift-package --platform ios generates a local package that you link in Xcode. CocoaPods (podhelper.rb) and flutter build ios-framework xcframeworks still work but are legacy.
Swift Package Manager
Build settings: set FLUTTER_SWIFT_PACKAGE_OUTPUT to the generated folder (build/ios/SwiftPackages/) so Xcode finds the package
Rebuild Dart from Xcode: optionally set FLUTTER_APPLICATION_PATH and ENABLE_USER_SCRIPT_SANDBOXING=NO, so every Xcode build recompiles Dart changes
Build hooks: add the scheme pre-action flutter_integration.sh prebuild and a Run Script phase assemble
New dependency in the module: run flutter build swift-package again
Legacy: CocoaPods and frameworks
CocoaPods: podhelper.rb builds the module from source and needs the Flutter SDK on every iOS machine and CI agent; CocoaPods is in maintenance mode and its trunk becomes read-only on 2 Dec 2026
**flutter build ios-framework**: writes Debug, Profile and Release xcframeworks to embed per Xcode configuration; the host needs no Flutter SDK, but Dart changes mean a rebuild
Either include the module's Gradle project in the host build (include_flutter.groovy, then implementation(project(":flutter"))), or publish AARs with flutter build aar into a local Maven repository and depend on them like any other library.
Source dependency: Gradle compiles the Dart code in every host build, so changes show up at once; every Android developer and CI agent needs the Flutter SDK, and the host's AGP and Kotlin versions must suit the Flutter Gradle plugin
AAR: the host needs no Flutter SDK and builds faster; Dart changes need flutter build aar again and a new version (--build-number)
Three variants: flutter_debug, flutter_profile and flutter_release, plus one AAR per plugin in the same repository; the host needs a profile build type to consume the profile variant
Two repositories: the local one the build writes, and https://storage.googleapis.com/download.flutter.io, where the engine artifacts live
ABIs: Flutter ships native code only for arm64-v8a, armeabi-v7a and x86_64; a host that also builds x86 needs abiFilters, or the Flutter screen crashes there with no libflutter.so
FlutterActivity for a full-screen Flutter flow, because it needs the least code; FlutterFragment when Flutter is part of a native screen, such as one tab; a bare FlutterView only when neither fits, because then you wire the lifecycle yourself.
**FlutterActivity**: declare it in AndroidManifest.xml with a theme and configChanges, start it with withCachedEngine, withNewEngine or withNewEngineInGroup; backgroundMode makes it transparent
**FlutterFragment**: the host Activity must forward onPostResume, onNewIntent, onUserLeaveHint, onTrimMemory and permission results, or plugins and the keyboard misbehave; shouldAutomaticallyHandleOnBackPressed(true) lets the fragment take Back itself
**RenderMode**: surface (the default, a SurfaceView) is fastest but sits in its own layer; texture blends with animated or overlapping native views at some GPU cost
Config changes: reuse the fragment the FragmentManager restored by tag instead of adding a second one
**FlutterView**: you call attachToFlutterEngine, report resume and pause to the engine's lifecycleChannel and attach plugins to the Activity yourself; easy to get subtly wrong
On Android a new FlutterEngine registers every plugin automatically, via GeneratedPluginRegistrant; on iOS nothing is automatic, so you call GeneratedPluginRegistrant.register(with:) on each engine you run. Plugins break where they assume the host is a Flutter app.
Per engine: each engine gets its own plugin instances; a plugin that keeps native state in statics or singletons sees two engines and mixes them up
No Activity yet: on a pre-warmed Android engine, ActivityAware plugins (pickers, permissions, sign-in) have no Activity until a FlutterActivity or FlutterFragment attaches; call them only from a visible Flutter screen
App and scene events: iOS plugins get app events only if the app delegate subclasses FlutterAppDelegate (or adopts FlutterAppLifeCycleProvider), and scene events (URLs, universal links) only if the scene delegate subclasses FlutterSceneDelegate (or adopts FlutterSceneLifeCycleProvider and forwards to a FlutterPluginSceneLifeCycleDelegate)
Dependency clashes: a plugin's native SDK (Firebase, maps, OkHttp) resolves together with the host's copy; Gradle silently picks one version and CocoaPods may refuse, so align versions or let the host own the SDK and expose it to Dart
Opting out: FlutterEngine(context, null, false) turns automatic registration off on Android when you must control it
Before the engine runs, set its initial route or pick a Dart entrypoint and pass it arguments. Once the engine is running those are already consumed, so later visits push a route or call a typed API on the engine's messenger.
Initial route: navigationChannel.setInitialRoute before executeDartEntrypoint on Android, run(withEntrypoint:initialRoute:) on iOS; Dart sees it as PlatformDispatcher.defaultRouteName, and MaterialApp picks it up on its own
Deep initial routes stack up: MaterialApp splits /card/42 into /, /card and /card/42 and pushes each one onGenerateRoute resolves; supply onGenerateInitialRoutes for a single screen
Entrypoints: one top-level function per feature, marked @pragma('vm:entry-point'), or the AOT compiler tree-shakes it away; arguments arrive as a List<String>
Later visits: navigationChannel.pushRoute or FlutterViewController.pushRoute reaches the app's Navigator or Router; for anything carrying data, a Pigeon @FlutterApi method such as openOrder(id) is clearer
Pass ids, not objects: let Dart load the rest from the host or its own repository, and never put tokens or personal data in a route
In add-to-app, AppLifecycleState describes the Flutter view, not the app. When the user leaves the Flutter screen for a native one, the engine reports paused, and detached once the view is gone, although the app never left the foreground.
What the engine reports: Android sends paused from the FlutterActivity's onStop and detached when it detaches from the engine; iOS sends paused when the FlutterViewController disappears
The opposite bug: while a native screen is on top, a real trip to the background produces no new event in Dart, because it is already paused
Dart keeps running: a cached engine without a view still runs timers, streams and sockets, only frames stop; pause polling yourself or destroy the engine
The fix: app-wide policy (locking, session timeouts, analytics sessions) belongs to the host, or the host forwards the real state over a channel from ProcessLifecycleOwner or UIApplication.didEnterBackgroundNotification
**AppLifecycleListener**: has the same semantics, so switching to it changes nothing
showProfile creates and boots a brand-new FlutterEngine on every tap, so each visit pays the full engine start before the first frame and begins with an empty Dart heap. Create one engine up front, run it once and reuse it for every visit.
Why it is slow: run creates an isolate, loads the compiled Dart code, runs main and then the plugins register; the view controller shows nothing until the first frame arrives
Why state is lost: each engine is its own isolate; the previous one went away with its view controller, taking the scroll position, loaded data and open connections with it
Why it was written this way: the initial route is read only when an engine starts, so a per-visit route seemed to need a new engine; on a running engine, send the screen and its data over a channel or pushRoute instead
Memory: an engine made with init(name:) keeps running headless until destroyContext(); if anything retains one, such as a channel handler that captures it, each tap adds another isolate and plugin set
One engine, one view: reuse works because only one profile screen is visible at a time; two Flutter screens at once need engines from a FlutterEngineGroup
Run the host app in debug from Xcode or Android Studio, then run flutter attach in the module directory: it finds the running engine's Dart VM service and gives you hot reload, hot restart, DevTools and Dart breakpoints.
Order does not matter: flutter attach waits for an engine; with a pre-warmed engine it connects at launch, otherwise when the first Flutter screen opens
Choosing the target: -d picks the device, --app-id the app when several advertise a VM service, and --debug-url connects to a known address when discovery fails
iOS discovery: the tool finds the VM service over mDNS, so debug builds need NSLocalNetworkUsageDescription and NSBonjourServices with _dartVmService._tcp, kept in a debug-only plist (for example Info-Debug.plist) so they never reach a Release build, and the user must allow local network access
Hot restart restarts Dart only: the host's screens, native state and the engine stay; native code changes and new plugins still need a full native rebuild
Build modes: release builds have no VM service, so attach to --profile builds for performance work; a debug Flutter build on a physical iPhone must be launched from Xcode or the Flutter tool
Both debuggers at once: Xcode or Android Studio stops in native code while flutter attach, or the IDE's Flutter Attach, handles Dart breakpoints
Engines spawned from one FlutterEngineGroup share the loaded AOT code, fonts and GPU context, so each extra engine costs far less memory and starts faster than an independent one. They share no Dart state: each runs its own isolate with its own heap, statics and plugin instances.
Why one engine is not enough
On mobile an engine renders into one view at a time, so screens visible together, or a native → Flutter → native → Flutter stack, each need their own engine
An independent engine loads everything again; the Flutter docs cite about 180 kB of static memory per extra engine from a group, plus whatever its Dart heap grows to
Sharing state between engines
Native as the source of truth: the host owns the session, cart or settings, exposes them over Pigeon on every engine's messenger and pushes changes to each
Not Dart singletons: a static or a BLoC/Riverpod store in one engine is invisible to the others; two copies of the same repository drift apart
Between engines: the only supported path is the host (Pigeon or channels), because each instance keeps its own navigation stack, UI and state
Costs and pitfalls
Each engine still runs its entrypoint and registers plugins, so pre-warm only the ones likely to open
Plugins that keep native statics or assume a single engine misbehave
Destroy engines you no longer need: destroy() on Android, destroyContext() and releasing it on iOS
The host owns the stack between screens and Flutter owns the stack inside its own view. Each Flutter screen lives in a native container on the host's stack, and Back first pops Flutter's inner routes, then closes the container.
Two Flutter containers, two engines: one engine backs one visible container, so reusing a single cached engine for both leaves the first screen without content when the user comes back; use a FlutterEngineGroup, or design flows so only one Flutter container is on the stack
Android Back: FlutterActivity hands Back to Flutter first; when the Navigator has nothing to pop, SystemNavigator.pop() finishes the Activity; with predictive back, PopScope(canPop: ...) decides whether the system animation may run
iOS edge swipe: a FlutterViewController pushed on a UINavigationController competes with Flutter's own back swipe; disable interactivePopGestureRecognizer while Flutter has inner routes, toggled from a NavigatorObserver over a channel
Closing from Dart: on iOS SystemNavigator.pop() pops the view controller from its navigation controller or dismisses it; an explicit close(result) on your host API is clearer and testable
Opening native from Flutter: Dart asks the host (openNative('kyc')) and the host pushes its screen; Dart never assumes it knows the host's stack
Deep links: the host parses the link and decides which container and which route to open
Treat it as a versioned contract: define it once in a Pigeon file, generate Kotlin, Swift and Dart from it, keep it small and coarse-grained, and make the host the owner of shared state such as the session.
The contract
Pigeon over raw channels: a renamed method breaks the build on both sides instead of throwing MissingPluginException in production; the generated host code lives in the host app or a local plugin, never in the generated .android or .ios
Coarse calls: getSession(), openNative(screen), close(result) rather than dozens of getters; every call is an async hop with serialisation
Released together: host and module ship in one binary, so there is no runtime version skew, but the module artifact must match the host's generated code; CI publishes them as one version
Direction and timing
@HostApi for Dart → native, @FlutterApi for native → Dart; call the latter only after Dart signals it is ready, or let Dart pull state on start
Several engines: call setUp on every engine's messenger; messageChannelSuffix gives each instance its own channel names when one messenger needs several
Ownership
The host owns auth: Dart asks for a fresh token per request instead of copying it into its own storage, so logout and refresh happen in one place
Errors are part of the contract: map native failures to error codes that Dart handles; do not encode "not logged in" as null
Call the C++ core through dart:ffi: it is the same code on both platforms and needs no serialisation. Call the platform SDKs through Pigeon by default, and consider jnigen or ffigen bindings only for wide, synchronous APIs whose threading you control.
The C++ core over FFI
dart:ffi sees only a C ABI: expose the core through an extern "C" header with plain functions and opaque handles, and point ffigen at that header
ffigen generates the bindings from the headers; the library is already in the host, so open it with DynamicLibrary.open('libcore.so') on Android and DynamicLibrary.process() on iOS, where it is linked statically
Release builds may strip symbols nothing in the binary references; set Strip Style to Non-Global Symbols and keep the functions exported, or the lookup fails only in release
Host and Dart share one copy of the library, so its global state is shared and must already be thread-safe
Platform SDKs
Pigeon: async, explicit threading, works with any SDK; the default
**jnigen: Dart bindings that call Java or Kotlin through JNI directly; good for many small calls, but you manage JNI references and threads, and the tooling is still young
ffigen for Objective-C**: direct calls into Objective-C APIs; a Swift SDK needs an @objc surface first
Threading since Flutter 3.29
Dart's root isolate now runs on the platform main thread on Android and iOS, so a slow synchronous FFI call from UI code freezes the native UI too; move long work to Isolate.run
The upside: main-thread-only APIs can be called synchronously from the root isolate
Size: the engine plus your AOT-compiled Dart, assets and plugins, several megabytes per CPU architecture. Time: nothing until an engine starts, then often a few hundred milliseconds to load it and run main, paid when the screen opens or earlier through pre-warming.
Size
Measure the delta: compare host builds with and without the module: per ABI on Android, where an App Bundle splits ABIs for you, and in App Store Connect's size report on iOS
Cut the Dart part: --split-debug-info moves symbols out of the binary; icon tree shaking is on by default; drop unused packages and heavy assets
Plugins bring native SDKs: the host often already ships the same maps or analytics SDK; exposing the host's copy over Pigeon avoids a second one
Start-up
**Do not pre-warm in Application.onCreate by reflex: it slows the host's own cold start, the metric the native team is judged by
Split the work**: FlutterLoader.startInitialization early loads libflutter.so and resources off the main thread; create and run the engine later, when the main thread is idle or the user heads towards the Flutter screen
iOS: if Flutter is not on the first screen, run the engine after launch work, not inside didFinishLaunching
Measure both ends: the host's start-up (adb shell am start -W, Macrobenchmark, Xcode Organizer) and tap-to-first-Flutter-frame (FlutterUiDisplayListener, setFlutterViewDidRenderCallback)
Start with a self-contained flow that has few links into the rest of the app, put the build pipeline, a thin host API and an engine strategy in place first, keep the host as owner of auth, navigation and app-wide state, and grow Flutter's share until inverting the hosting is cheaper.
First steps
Pick the first feature: a leaf flow (settings, onboarding, a new feature) rather than the home screen; it proves the build, release and analytics path at low risk
Plumbing once: module integration in both hosts' CI as versioned AARs and a SwiftPM package, a Pigeon contract, and one engine strategy: a single cached engine first, a FlutterEngineGroup when Flutter appears in several places
Keeping it consistent
One source of truth: session, feature flags, remote config and user settings live in the host and are read through the API; Dart caches nothing it cannot invalidate
No visible seam: the Flutter theme is built from the native design tokens, and analytics uses the same event and screen names
Clear ownership: app-wide lifecycle, push, deep links and permission prompts stay with the host
The tipping point
When most screens are Flutter, invert: Flutter becomes the app, and the remaining native screens open as their own Activities or view controllers through channels, or as platform views where they must sit inside a Flutter screen
Track crash-free rate, start-up time, size and memory at every step, so the decision rests on numbers