Skip to content

Add Flutter to an existing app

Not every Flutter project starts from scratch. Many teams have large, mature native apps and want to adopt Flutter incrementally, one screen or one feature at a time. Flutter supports this through a feature called add-to-app: the ability to embed a Flutter module inside an existing Android or iOS application as a library dependency.

Add-to-app is currently supported on Android, iOS, macOS, and web. This guide focuses on the two most common platforms: Android and iOS.

The two primary use cases are:

  • Hybrid navigation stacks: An app contains multiple screens, some rendered by Flutter and others by the native framework. The user navigates between them freely.
  • Partial-screen views: A single native screen hosts both native views and Flutter widgets side by side. The Flutter portion renders one component (a chart, a form, a product card) while the rest of the screen stays native.

Beyond UI, add-to-app also lets you run shared non-UI Dart logic (networking, business rules, cryptography) inside a native app, taking advantage of Dart’s portability and interoperability with other languages.

When you create a standalone Flutter app (shorebird create my_app), the project contains a complete android/ and ios/ host wrapper that Flutter owns entirely. For add-to-app, you create a Flutter module instead. Run this command from the directory that will contain both the Flutter module and the native host project. Put the module name before --template=module: shorebird create runs shorebird init in the directory named by its first argument.

shorebird create my_flutter_module --template=module

The resulting structure is a little different from a regular app:

my_flutter_module/
  lib/
    main.dart          # Dart entry point
  pubspec.yaml
  .android/            # Auto-generated Android host (for standalone testing)
  .ios/                # Auto-generated iOS host (for standalone testing)

The .android/ and .ios/ directories are generated wrappers that let you run the module in isolation with flutter run. They are prefixed with a dot to signal that they are managed by Flutter, so never edit them directly. These wrappers are also not compiled into your app binary — they exist purely as a local development convenience. When the module is embedded into your real native app, the native project provides the host instead.

The Dart code inside lib/ is identical to a regular Flutter app. The only structural requirement is a valid entry point:

import 'package:flutter/material.dart';

@pragma('vm:entry-point')
void main() => runApp(const MyFlutterApp());

The @pragma('vm:entry-point') annotation prevents tree-shaking from removing main in ahead-of-time (AOT) compiled builds.

Before any Flutter UI can appear on screen, the native app must start a Dart runtime. That runtime is encapsulated in the FlutterEngine class.

graph LR
    A["Native App<br>(Android / iOS)"] -->|"creates & starts"| B["FlutterEngine<br>(Dart VM + Flutter runtime)"]
    B -->|"renders into"| C["FlutterActivity /<br>FlutterViewController"]
    C -->|"displays in"| D["Native UI hierarchy"]

    style A fill:#e1f5fe,stroke:#03a9f4,stroke-width:2px,color:#000
    style B fill:#e8f5e9,stroke:#4caf50,stroke-width:2px,color:#000
    style C fill:#fff3e0,stroke:#ff9800,stroke-width:2px,color:#000
    style D fill:#fce4ec,stroke:#e91e63,stroke-width:2px,color:#000

A FlutterEngine:

  • Starts a Dart isolate and executes the entry-point function (main() by default).
  • Owns all platform channels between Dart and native code.
  • Holds a reference to the active FlutterRenderer that draws pixels.

Starting an engine takes time: the first engine loads the Flutter library and starts the Dart VM, and every engine creates an isolate and runs your entrypoint (see Flutter’s Load sequence, performance, and memory). If you wait until the user taps a button to start the engine, they will see a visible delay before the Flutter UI appears. The recommended pattern is to pre-warm the engine before it is needed.

class MyApplication : Application() {
    lateinit var flutterEngine: FlutterEngine

    override fun onCreate() {
        super.onCreate()

        flutterEngine = FlutterEngine(this)

        // Start executing Dart code immediately.
        flutterEngine.dartExecutor.executeDartEntrypoint(
            DartExecutor.DartEntrypoint.createDefault()
        )

        // Cache the engine so FlutterActivity/FlutterFragment can reuse it.
        FlutterEngineCache
            .getInstance()
            .put("my_engine_id", flutterEngine)
    }
}
import Flutter

@main
class AppDelegate: FlutterAppDelegate {
    lazy var flutterEngine = FlutterEngine(name: "my_engine")

    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // Start the engine. Dart's main() runs immediately.
        flutterEngine.run()
        GeneratedPluginRegistrant.register(with: self.flutterEngine)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}

Sometimes you need more than one Flutter screen running at the same time. Common examples are a picture-in-picture overlay sitting on top of a native screen, or an app that shows two independent Flutter panels side by side in a tablet layout. In these cases, use FlutterEngineGroup rather than creating additional FlutterEngine instances directly. FlutterEngineGroup spawns engines that share resources such as the GPU context, font metrics, and the isolate group snapshot, so each additional engine costs only about 180 KB (Multiple Flutter screens or views).

class MyApplication : Application() {
    val engines = FlutterEngineGroup(this)
}

// To spawn a new engine from an Activity:
val dartEntrypoint = DartExecutor.DartEntrypoint.createDefault()
val newEngine = (applicationContext as MyApplication)
    .engines
    .createAndRunEngine(this, dartEntrypoint)
// In AppDelegate
lazy var engineGroup = FlutterEngineGroup(name: "my_engine_group", project: nil)

// To spawn a new engine from a view controller:
let newEngine = (UIApplication.shared.delegate as! AppDelegate)
    .engineGroup
    .makeEngine(withEntrypoint: nil, libraryURI: nil)

Each engine produced by FlutterEngineGroup is still an independent Dart program with its own isolate, state, and plugin registrations. The shared resources are managed internally and do not affect isolation between engines.

Flutter offers two ways to add a module to an Android app: as a source dependency, or as a prebuilt Android Archive (AAR).

Both projects must sit on the same machine. Gradle builds the module from source along with the host app. New Android projects use the Kotlin DSL (settings.gradle.kts); applying include_flutter.groovy from Kotlin needs Flutter 3.27 or newer.

// settings.gradle.kts (host app)
include(":app")

val filePath = settingsDir.parentFile.toString() +
    "/my_flutter_module/.android/include_flutter.groovy"
apply(from = File(filePath))
// app/build.gradle.kts
dependencies {
    implementation(project(":flutter"))
}

Build the module into a local Maven repository (flutter build aar, or shorebird release aar with Shorebird), then add that repository to the host app and depend on the AAR like any other library. See Flutter’s Depend on the Android Archive (AAR) instructions, or the Code Push for Android add-to-app guide for the Shorebird setup.

Flutter provides three ways to show Flutter content in an Android app:

APIBest for
FlutterActivityFull-screen Flutter experiences
FlutterFragmentEmbedding Flutter inside an existing Activity
FlutterViewManual, fine-grained layout control

The simplest path to a full-screen Flutter screen:

// Using a cached, pre-warmed engine (recommended).
val intent = FlutterActivity
    .withCachedEngine("my_engine_id")
    .build(context)
startActivity(intent)

If you have not pre-warmed an engine, use FlutterActivity.createDefaultIntent(context) instead. Flutter will start a new engine when the Activity is created, which introduces a cold-start delay.

Embed Flutter inside any existing FragmentActivity:

val flutterFragment = FlutterFragment
    .withCachedEngine("my_engine_id")
    .build<FlutterFragment>()

supportFragmentManager
    .beginTransaction()
    .add(R.id.fragment_container, flutterFragment, "flutter_fragment")
    .commit()

For maximum flexibility, attach a FlutterView directly in your layout:

val flutterView = FlutterView(context)
myConstraintLayout.addView(flutterView)

// Attach the engine's renderer to this view.
flutterView.attachToFlutterEngine(flutterEngine)

Call flutterView.detachFromFlutterEngine() before attaching the engine to a different view. An engine can only render into one view at a time.

Since Flutter 3.44, Flutter has recommended adding a module to an iOS app as a Swift package with Swift Package Manager (SPM). Its legacy docs also cover CocoaPods and prebuilt XCFrameworks linked and embedded in Xcode.

Run the following command in the Flutter module directory:

flutter build swift-package --platform ios

It generates a FlutterNativeIntegration Swift package in build/ios/SwiftPackages/. To add it to your app, follow Flutter’s Integrate with Swift Package Manager instructions: add the package to your Xcode project with Add Files to “MyApp”…, add it to your app target, then add the build settings and Run Script steps.

Build the module into XCFrameworks (flutter build ios-framework, or shorebird release ios-framework with Shorebird), then link and embed them in your app target. See Flutter’s Link and Embed frameworks in Xcode instructions, or the Code Push for iOS add-to-app guide for the Shorebird setup.

import Flutter

class HomeViewController: UIViewController {
    @IBAction func showFlutter(_ sender: Any) {
        let appDelegate = UIApplication.shared.delegate as! AppDelegate
        let flutterEngine = appDelegate.flutterEngine

        let flutterViewController = FlutterViewController(
            engine: flutterEngine,
            nibName: nil,
            bundle: nil
        )

        present(flutterViewController, animated: true, completion: nil)
    }
}

Partial-screen: FlutterViewController as a child

Section titled “Partial-screen: FlutterViewController as a child”

To embed Flutter inside part of an existing UIViewController:

let flutterVC = FlutterViewController(
    engine: appDelegate.flutterEngine,
    nibName: nil,
    bundle: nil
)

// Add as a child view controller.
addChild(flutterVC)
flutterVC.view.frame = CGRect(x: 0, y: 200, width: view.bounds.width, height: 300)
view.addSubview(flutterVC.view)
flutterVC.didMove(toParent: self)

Platform channels are how Dart and native code send messages to each other. The most common type is MethodChannel, which uses an asynchronous request/response model. For a complete reference, see the platform channels documentation.

sequenceDiagram
    participant Dart
    participant MethodChannel
    participant Native

    Dart->>MethodChannel: invokeMethod('getBatteryLevel')
    MethodChannel->>Native: onMethodCall('getBatteryLevel')
    Native-->>MethodChannel: result(42)
    MethodChannel-->>Dart: Future resolves to 42

The examples below read the device’s battery level. This is a good illustration because battery information is only available through a native OS API, so there is no way to get it from Dart without crossing the platform boundary.

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

Future<int> getBatteryLevel() async {
  final int level = await channel.invokeMethod('getBatteryLevel');
  return level;
}
val channel = MethodChannel(
    flutterEngine.dartExecutor.binaryMessenger,
    "com.example.myapp/battery"
)

channel.setMethodCallHandler { call, result ->
    if (call.method == "getBatteryLevel") {
        val level = getBatteryLevel()
        if (level != -1) {
            result.success(level)
        } else {
            result.error("UNAVAILABLE", "Battery level unavailable", null)
        }
    } else {
        result.notImplemented()
    }
}
let channel = FlutterMethodChannel(
    name: "com.example.myapp/battery",
    binaryMessenger: flutterViewController.binaryMessenger
)

channel.setMethodCallHandler { call, result in
    guard call.method == "getBatteryLevel" else {
        result(FlutterMethodNotImplemented)
        return
    }
    result(UIDevice.current.batteryLevel * 100)
}

Channel names must be unique across the application. Use reverse-domain notation (com.yourcompany.appname/feature) to avoid collisions with plugins.

Flutter supports two flavors of add-to-app depending on the platform:

Multi-engineMulti-view
PlatformsAndroid, iOS, macOSWeb
Dart programsOne per engine (isolated)One shared program
State sharingNot possible between enginesFull object sharing
Memory costHigher (one isolate per engine)Lower
Use caseIndependent Flutter screens with no shared stateMultiple embedded Flutter widgets on the same web page

On Android and iOS, each FlutterEngine runs its own Dart isolate, and isolates share no memory. Flutter’s docs route communication between engines through the host app, with platform channels or Pigeon. If two embedded Flutter screens need to share application state, consider using a single engine and routing between screens inside Flutter rather than spinning up a second engine.

Start the FlutterEngine in Application.onCreate() (Android) or application(_:didFinishLaunchingWithOptions:) (iOS), not when the user triggers a navigation action. The first call to dartExecutor.executeDartEntrypoint is where the Dart VM initializes and the most latency is incurred.

Impeller is the only renderer on iOS and the default on Android API 29 and newer. It compiles its shaders when Flutter itself is built, not while your app runs, so there is no shader compilation jank and most apps get smooth first frames without any extra configuration.

The cost of each additional engine depends on how it is created:

Flutter’s documentation puts each additional engine spawned from a FlutterEngineGroup at about 180 KB, because the group shares the GPU context, font metrics, and isolate group snapshot across its engines. A standalone FlutterEngine() doesn’t share them, so each one costs more.

Avoid pre-warming engines that the user is unlikely to reach. Lazy initialization (creating the engine on first navigation) is acceptable for infrequently visited screens where a small delay is tolerable.

When Flutter renders inside a native view hierarchy, it composites its texture with the native rendering layer. On Android, this uses a SurfaceTexture or SurfaceView depending on the display mode. On iOS, it uses a CALayer. The compositing step adds a small GPU cost; for partial-screen views where Flutter covers only a fraction of the screen, this cost is negligible.

With Shorebird, the module’s Dart code is what a patch updates. The host app’s own code, the plugins’ native code, and the module’s assets ship only with a new release of the host app. In practice:

  • A fix to a Flutter screen, including its state management and networking code, can ship as a patch to any host app version you released with shorebird release aar or shorebird release ios-framework.
  • A change to the native side of a platform channel, a new plugin with native code, or a new image needs a new host app release.
  • A patch covers every entrypoint and engine in the module, but the host app still starts the entrypoints it shipped with. See Multiple Dart entrypoints and engines.

The Android and iOS add-to-app guides walk through releasing and patching a module.