Step by Step Guide to Embed a Flutter Screen Inside a Native Android App (Add-to-App)

#flutter#android#add-to-app#native-integration#method-channel#flutter-engine#kotlin#gradle#flutter-activity#platform-channels

Answer

What is Flutter Add-to-App?

Flutter Add-to-App lets you embed Flutter UI as a full-screen module inside an existing native Android (or iOS) application. Instead of rewriting your entire app in Flutter, you can gradually adopt Flutter for specific screens while keeping the rest of your app native.

Real-world scenario: In a native Android app, when the user presses the user icon, load a Flutter Profile Screen.


Architecture Overview

ComponentRole
Flutter ModuleA standalone Flutter project embedded as a Gradle subproject
FlutterEngineThe runtime that executes Dart code
FlutterActivity / FlutterFragmentAndroid components that host the Flutter UI
MethodChannelBridge for bidirectional communication between native and Flutter
FlutterEngineCacheStores pre-warmed engines for instant Flutter screen launch

Step 1: Create the Flutter Module

Create a Flutter module alongside your existing Android project:

bash
cd /path/to/your/projects
flutter create -t module --org com.example my_flutter_module

This creates:

text
my_flutter_module/
├── .android/           # Auto-generated, do NOT edit or commit
├── lib/
│   └── main.dart       # Your Flutter entry point
├── pubspec.yaml
└── ...

Folder structure (sibling projects):

text
projects/
├── MyNativeAndroidApp/
│   ├── app/
│   ├── settings.gradle
│   └── build.gradle
└── my_flutter_module/
    ├── lib/main.dart
    └── pubspec.yaml

Step 2: Add Flutter Module Dependency in Android

2a. Update
text
settings.gradle

Add the Flutter module as a Gradle subproject:

groovy
// settings.gradle
dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.PREFER_SETTINGS
    String storageUrl = System.env.FLUTTER_STORAGE_BASE_URL ?: "https://storage.googleapis.com"
    repositories {
        google()
        mavenCentral()
        maven {
            url = uri("$storageUrl/download.flutter.io")
        }
    }
}

include(":app")

// Include the Flutter module
setBinding(new Binding([gradle: this]))
def flutterModulePath = settingsDir.parentFile.toString() + "/my_flutter_module/.android/include_flutter.groovy"
apply from: flutterModulePath

2b. Update
text
app/build.gradle

Add the Flutter dependency and required configuration:

groovy
// app/build.gradle
android {
    // ...
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    defaultConfig {
        // ...
        ndk {
            abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
        }
    }
}

dependencies {
    // ... your existing dependencies
    implementation project(':flutter')
}

Important: Remove any

text
repositories {}
blocks from
text
build.gradle
files since they are now centralized in
text
settings.gradle
.


Step 3: Initialize FlutterEngine in Application Class

Pre-warm the

text
FlutterEngine
at app startup for instant Flutter screen launch (no loading delay):

kotlin
// MyApplication.kt
package com.example.mynativeapp

import android.app.Application
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.plugin.common.MethodChannel

class MyApplication : Application() {

    companion object {
        const val FLUTTER_ENGINE_ID = "profile_engine"
        const val METHOD_CHANNEL = "com.example.mynativeapp/profile"
    }

    lateinit var flutterEngine: FlutterEngine

    override fun onCreate() {
        super.onCreate()

        // 1. Create a FlutterEngine instance
        flutterEngine = FlutterEngine(this)

        // 2. (Optional) Set initial route BEFORE executing Dart code
        flutterEngine.navigationChannel.setInitialRoute("/profile")

        // 3. Start executing Dart code (pre-warm)
        flutterEngine.dartExecutor.executeDartEntrypoint(
            DartExecutor.DartEntrypoint.createDefault()
        )

        // 4. Cache the engine for later use
        FlutterEngineCache
            .getInstance()
            .put(FLUTTER_ENGINE_ID, flutterEngine)
    }
}

Register in

text
AndroidManifest.xml
:

xml
<application
    android:name=".MyApplication"
    android:label="My App"
    ...
>
    <!-- Add FlutterActivity registration -->
    <activity
        android:name="io.flutter.embedding.android.FlutterActivity"
        android:theme="@style/Theme.AppCompat.Light.NoActionBar"
        android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
        android:hardwareAccelerated="true"
        android:windowSoftInputMode="adjustResize"
    />
</application>

Step 4: Launch Flutter Screen from Native Android

When the user presses the user icon, launch the Flutter profile screen:

kotlin
// MainActivity.kt
package com.example.mynativeapp

import android.os.Bundle
import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.plugin.common.MethodChannel

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val userIconButton = findViewById<ImageButton>(R.id.btn_user_icon)

        // Setup MethodChannel to pass user data to Flutter
        setupMethodChannel()

        // Launch Flutter profile screen on user icon press
        userIconButton.setOnClickListener {
            startActivity(
                FlutterActivity
                    .withCachedEngine(MyApplication.FLUTTER_ENGINE_ID)
                    .build(this)
            )
        }
    }

    private fun setupMethodChannel() {
        val flutterEngine = FlutterEngineCache
            .getInstance()
            .get(MyApplication.FLUTTER_ENGINE_ID) ?: return

        MethodChannel(
            flutterEngine.dartExecutor.binaryMessenger,
            MyApplication.METHOD_CHANNEL
        ).setMethodCallHandler { call, result ->
            when (call.method) {
                "getUserData" -> {
                    // Return user data from native to Flutter
                    val userData = mapOf(
                        "name" to "Venkat Raman",
                        "email" to "venkat@example.com",
                        "avatarUrl" to "https://example.com/avatar.png",
                        "isPremium" to true
                    )
                    result.success(userData)
                }
                "logout" -> {
                    // Handle logout requested from Flutter
                    performLogout()
                    result.success(true)
                }
                else -> result.notImplemented()
            }
        }
    }

    private fun performLogout() {
        // Native logout logic
    }
}

Step 5: Build the Flutter Profile Screen

dart
// my_flutter_module/lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() => runApp(const MyFlutterApp());

class MyFlutterApp extends StatelessWidget {
  const MyFlutterApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Profile',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const ProfileScreen(),
    );
  }
}

class ProfileScreen extends StatefulWidget {
  const ProfileScreen({super.key});

  
  State<ProfileScreen> createState() => _ProfileScreenState();
}

class _ProfileScreenState extends State<ProfileScreen> {
  // Must match the channel name on the native side
  static const platform = MethodChannel('com.example.mynativeapp/profile');

  String _name = 'Loading...';
  String _email = '';
  String _avatarUrl = '';
  bool _isPremium = false;

  
  void initState() {
    super.initState();
    _loadUserData();
  }

  Future<void> _loadUserData() async {
    try {
      final result = await platform.invokeMethod('getUserData');
      final data = Map<String, dynamic>.from(result);
      setState(() {
        _name = data['name'] ?? 'Unknown';
        _email = data['email'] ?? '';
        _avatarUrl = data['avatarUrl'] ?? '';
        _isPremium = data['isPremium'] ?? false;
      });
    } on PlatformException catch (e) {
      setState(() {
        _name = 'Error: ${e.message}';
      });
    }
  }

  Future<void> _handleLogout() async {
    try {
      await platform.invokeMethod('logout');
      // Navigate back to native
      SystemNavigator.pop();
    } on PlatformException catch (e) {
      debugPrint('Logout failed: ${e.message}');
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('My Profile'),
        leading: IconButton(
          icon: const Icon(Icons.arrow_back),
          onPressed: () => SystemNavigator.pop(),
        ),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            CircleAvatar(
              radius: 50,
              backgroundImage: _avatarUrl.isNotEmpty
                  ? NetworkImage(_avatarUrl)
                  : null,
              child: _avatarUrl.isEmpty
                  ? const Icon(Icons.person, size: 50)
                  : null,
            ),
            const SizedBox(height: 16),
            Text(
              _name,
              style: const TextStyle(
                fontSize: 24,
                fontWeight: FontWeight.bold,
              ),
            ),
            const SizedBox(height: 8),
            Text(
              _email,
              style: const TextStyle(
                fontSize: 16,
                color: Colors.grey,
              ),
            ),
            const SizedBox(height: 8),
            if (_isPremium)
              Container(
                padding: const EdgeInsets.symmetric(
                  horizontal: 12,
                  vertical: 4,
                ),
                decoration: BoxDecoration(
                  color: Colors.amber,
                  borderRadius: BorderRadius.circular(12),
                ),
                child: const Text(
                  'Premium Member',
                  style: TextStyle(fontWeight: FontWeight.bold),
                ),
              ),
            const SizedBox(height: 32),
            ElevatedButton.icon(
              onPressed: _handleLogout,
              icon: const Icon(Icons.logout),
              label: const Text('Logout'),
              style: ElevatedButton.styleFrom(
                backgroundColor: Colors.red,
                foregroundColor: Colors.white,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Step 6: MethodChannel Data Type Mapping

Dart TypeKotlin Type
text
null
text
null
text
bool
text
Boolean
text
int
(<=32 bits)
text
Int
text
int
(>32 bits)
text
Long
text
double
text
Double
text
String
text
String
text
Uint8List
text
ByteArray
text
List
text
List
text
Map
text
HashMap

withCachedEngine vs withNewEngine

Feature
text
withCachedEngine()
text
withNewEngine()
Launch SpeedInstant (pre-warmed)Slower (initializes engine)
Setup RequiredApplication class setupNone
MemoryEngine lives in memory from app startEngine created on demand
Initial RouteSet before
text
executeDartEntrypoint()
Set via
text
.initialRoute("/route")
Recommended ForProduction appsQuick prototyping
kotlin
// withNewEngine - simpler but slower
startActivity(
    FlutterActivity
        .withNewEngine()
        .initialRoute("/profile")
        .build(this)
)

// withCachedEngine - faster launch (recommended)
startActivity(
    FlutterActivity
        .withCachedEngine("profile_engine")
        .build(this)
)

FlutterEngine Load Sequence

StepWhat HappensWhen
1Find Flutter resources (
text
.so
libraries)
text
FlutterEngine
construction
2Load Flutter shared library into memory
text
FlutterEngine
construction
3Start Dart VM (once per app lifecycle)
text
FlutterEngine
construction
4Create Dart Isolate and run
text
main()
text
executeDartEntrypoint()
5Attach rendering surface (UI appears)
text
FlutterActivity
/
text
FlutterFragment
launch

With pre-warming, Steps 1-4 happen at app startup. Only Step 5 happens when the user taps the icon, making launch nearly instant.


Common Issues and Solutions

Issue 1: Black/White Screen on Flutter Launch

Cause:

text
FlutterEngine
not pre-warmed; all 5 load steps happen at once.

Solution: Pre-warm the engine in

text
Application.onCreate()
as shown in Step 3.

Issue 2:
text
FlutterEngine
Memory Cost

Facts:

  • First engine: ~19 MB on Android (includes Dart VM + isolate)
  • Additional engines: ~180 KB each (share the same Dart VM)
  • The Dart VM never shuts down once started

Solution: Use

text
FlutterEngineGroup
for multiple Flutter screens to share resources efficiently.

Issue 3: Initial Route Not Working with Cached Engine

Cause: Setting route after

text
executeDartEntrypoint()
has no effect.

Solution: Set the route before executing the entrypoint:

kotlin
flutterEngine.navigationChannel.setInitialRoute("/profile")
flutterEngine.dartExecutor.executeDartEntrypoint(
    DartExecutor.DartEntrypoint.createDefault()
)

Issue 4: Build Fails with Missing Architecture

Solution: Add ABI filters in

text
build.gradle
:

groovy
ndk {
    abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
}

Issue 5: MethodChannel Returns Null

Cause: Channel name mismatch between Dart and Kotlin.

Solution: Use a constant string for the channel name on both sides:

kotlin
// Kotlin
private val CHANNEL = "com.example.mynativeapp/profile"
dart
// Dart
static const platform = MethodChannel('com.example.mynativeapp/profile');

Using FlutterFragment (Alternative to FlutterActivity)

If you need Flutter as part of a screen instead of a full-screen activity:

kotlin
class HostActivity : FragmentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_host)

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

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

Summary Checklist

  • Create Flutter module with
    text
    flutter create -t module
  • Add Flutter module to
    text
    settings.gradle
    and
    text
    build.gradle
  • Pre-warm
    text
    FlutterEngine
    in Application class
  • Register
    text
    FlutterActivity
    in
    text
    AndroidManifest.xml
  • Launch Flutter screen with
    text
    withCachedEngine()
  • Use
    text
    MethodChannel
    for native-Flutter communication
  • Set initial route before
    text
    executeDartEntrypoint()
  • Add ABI filters for supported architectures