Step by Step Guide to Embed a Flutter Screen Inside a Native Android App (Add-to-App)
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
| Component | Role |
|---|---|
| Flutter Module | A standalone Flutter project embedded as a Gradle subproject |
| FlutterEngine | The runtime that executes Dart code |
| FlutterActivity / FlutterFragment | Android components that host the Flutter UI |
| MethodChannel | Bridge for bidirectional communication between native and Flutter |
| FlutterEngineCache | Stores pre-warmed engines for instant Flutter screen launch |
Step 1: Create the Flutter Module
Create a Flutter module alongside your existing Android project:
bashcd /path/to/your/projects flutter create -t module --org com.example my_flutter_module
This creates:
textmy_flutter_module/ ├── .android/ # Auto-generated, do NOT edit or commit ├── lib/ │ └── main.dart # Your Flutter entry point ├── pubspec.yaml └── ...
Folder structure (sibling projects):
textprojects/ ├── MyNativeAndroidApp/ │ ├── app/ │ ├── settings.gradle │ └── build.gradle └── my_flutter_module/ ├── lib/main.dart └── pubspec.yaml
Step 2: Add Flutter Module Dependency in Android
2a. Update textsettings.gradle
settings.gradleAdd 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 textapp/build.gradle
app/build.gradleAdd 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
blocks fromtextrepositories {}files since they are now centralized intextbuild.gradle.textsettings.gradle
Step 3: Initialize FlutterEngine in Application Class
Pre-warm the
FlutterEnginekotlin// 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 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 Type | Kotlin Type |
|---|---|
text | text |
text | text |
text | text |
text | text |
text | text |
text | text |
text | text |
text | text |
text | text |
withCachedEngine vs withNewEngine
| Feature | text | text |
|---|---|---|
| Launch Speed | Instant (pre-warmed) | Slower (initializes engine) |
| Setup Required | Application class setup | None |
| Memory | Engine lives in memory from app start | Engine created on demand |
| Initial Route | Set before text | Set via text |
| Recommended For | Production apps | Quick 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
| Step | What Happens | When |
|---|---|---|
| 1 | Find Flutter resources ( text | text |
| 2 | Load Flutter shared library into memory | text |
| 3 | Start Dart VM (once per app lifecycle) | text |
| 4 | Create Dart Isolate and run text | text |
| 5 | Attach rendering surface (UI appears) | text text |
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:
FlutterEngineSolution: Pre-warm the engine in
Application.onCreate()Issue 2: textFlutterEngine
Memory Cost
FlutterEngineFacts:
- 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
FlutterEngineGroupIssue 3: Initial Route Not Working with Cached Engine
Cause: Setting route after
executeDartEntrypoint()Solution: Set the route before executing the entrypoint:
kotlinflutterEngine.navigationChannel.setInitialRoute("/profile") flutterEngine.dartExecutor.executeDartEntrypoint( DartExecutor.DartEntrypoint.createDefault() )
Issue 4: Build Fails with Missing Architecture
Solution: Add ABI filters in
build.gradlegroovyndk { 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:
kotlinclass 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 andtext
settings.gradletextbuild.gradle - Pre-warm in Application classtext
FlutterEngine - Register intext
FlutterActivitytextAndroidManifest.xml - Launch Flutter screen with text
withCachedEngine() - Use for native-Flutter communicationtext
MethodChannel - Set initial route before text
executeDartEntrypoint() - Add ABI filters for supported architectures