FLUTTER • NATIVE INTEGRATION
How to Connect Flutter to Native Code Using Platform Channels
A step-by-step tutorial with Dart, Kotlin, and Swift examples, plus error handling and production best practices.
Reading time: 12–15 minutes
Flutter allows developers to build Android and iOS applications from a shared codebase. Sometimes an application needs a platform API or native SDK that is not available through an existing Flutter package. Platform channels provide a way for Dart code to communicate with native Android and iOS code.
In this article
1. Why Use Platform Channels?
Platform channels are useful when you need to integrate an existing native SDK, access a platform API that lacks a suitable Flutter package, reuse legacy native code, or implement a platform-specific feature.
For example, a payment provider may offer Android and iOS SDKs but no Flutter plugin. A platform channel can connect your Dart code to those native SDKs.
2. How Do Platform Channels Work?
A platform channel is a named communication path between Flutter and the host platform. Dart sends a method name and optional arguments. Native code handles the request and returns a result or an error.
Dart
Method and arguments
Kotlin or Swift
Figure 1. Request and response flow between Flutter and native code.
The three common channel types are:
- MethodChannel: request-and-response calls.
- EventChannel: continuous streams of native events.
- BasicMessageChannel: message exchange using a selected codec.
This tutorial uses MethodChannel.
3. Step One — Create the Dart Service
Use a dedicated class to keep native communication separate from your widgets. The channel name must match exactly in Dart, Kotlin, and Swift.
File: lib/native_bridge.dart
import 'package:flutter/services.dart';
class NativeBridge {
static const MethodChannel _channel =
MethodChannel('com.example.native_bridge/platform');
static Future<String> getPlatformMessage() async {
try {
final String? result =
await _channel.invokeMethod<String>('getPlatformMessage');
return result ?? 'Native code returned no message.';
} on PlatformException catch (e) {
throw Exception('Native call failed: ${e.message ?? e.code}');
} on MissingPluginException {
throw Exception('No native handler is registered.');
}
}
}
Create a Flutter Screen
The widget calls the service, shows a progress indicator while waiting, and displays either the result or an error.
File: lib/main.dart
import 'package:flutter/material.dart';
import 'native_bridge.dart';
void main() => runApp(const NativeBridgeApp());
class NativeBridgeApp extends StatelessWidget {
const NativeBridgeApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Native Bridge Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF5B4BDB),
),
useMaterial3: true,
),
home: const NativeBridgePage(),
);
}
}
class NativeBridgePage extends StatefulWidget {
const NativeBridgePage({super.key});
@override
State<NativeBridgePage> createState() => _NativeBridgePageState();
}
class _NativeBridgePageState extends State<NativeBridgePage> {
String _message = 'Tap the button to call native code.';
bool _loading = false;
Future<void> _callNative() async {
setState(() {
_loading = true;
_message = '';
});
try {
final result = await NativeBridge.getPlatformMessage();
if (!mounted) return;
setState(() => _message = result);
} catch (error) {
if (!mounted) return;
setState(() => _message = 'Error: $error');
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Native Bridge')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.swap_horiz, size: 48),
const SizedBox(height: 16),
Text('Flutter ↔ Native',
style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 12),
const Text(
'Call a native method and display its response.',
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
FilledButton(
onPressed: _loading ? null : _callNative,
child: Text(_loading ? 'Calling...' : 'Call native code'),
),
const SizedBox(height: 18),
if (_loading)
const CircularProgressIndicator()
else
SelectableText(_message, textAlign: TextAlign.center),
],
),
),
),
),
),
);
}
}
mounted? A native call is asynchronous. The user could leave the screen before it finishes. Checking mounted prevents updating a widget that is no longer in the widget tree.4. Step Two — Implement Android with Kotlin
In a typical Flutter Android project, open the main activity under android/app/src/main/kotlin/your/package/MainActivity.kt. Register the method handler in configureFlutterEngine.
File: MainActivity.kt
package com.example.native_bridge
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private val channelName =
"com.example.native_bridge/platform"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
channelName
).setMethodCallHandler { call, result ->
when (call.method) {
"getPlatformMessage" -> {
result.success("Hello from Android (Kotlin)!")
}
else -> result.notImplemented()
}
}
}
}
Key points:
binaryMessengertransports messages between Flutter and Android.setMethodCallHandlerregisters the native handler.call.methodidentifies which method Dart requested.result.success(...)returns a value to Flutter.result.notImplemented()reports an unsupported method.
If your project already has a MainActivity, merge the handler into the existing class rather than replacing unrelated setup. Make sure the Kotlin package declaration matches your project.
5. Step Three — Implement iOS with Swift
Open ios/Runner.xcworkspace in Xcode. In the standard Flutter template, you can register the channel in AppDelegate.swift.
File: AppDelegate.swift
import UIKit
import Flutter
@main
@objc class AppDelegate: FlutterAppDelegate {
private let channelName =
"com.example.native_bridge/platform"
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
if let controller = window?.rootViewController
as? FlutterViewController {
let channel = FlutterMethodChannel(
name: channelName,
binaryMessenger: controller.binaryMessenger
)
channel.setMethodCallHandler { call, result in
switch call.method {
case "getPlatformMessage":
result("Hello from iOS (Swift)!")
default:
result(FlutterMethodNotImplemented)
}
}
}
return super.application(
application,
didFinishLaunchingWithOptions: launchOptions
)
}
}
Some Flutter projects use customized engine or scene setup. If the root view controller is not a FlutterViewController, register the channel through the Flutter controller or engine used by your app. Avoid registering duplicate handlers for the same method.
6. Error Handling and Best Practices
For methods that accept arguments, Dart can pass a supported map:
Dart — pass an argument
final result = await _channel.invokeMethod<String>(
'greet',
<String, dynamic>{'name': 'Alex'},
);
Kotlin — validate input
"greet" -> {
val name = call.argument<String>("name")
if (name.isNullOrBlank()) {
result.error(
"INVALID_ARGUMENT",
"A non-empty name is required.",
null
)
} else {
result.success("Hello, $name!")
}
}
Swift — validate input
case "greet":
guard
let args = call.arguments as? [String: Any],
let name = args["name"] as? String,
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
result(FlutterError(
code: "INVALID_ARGUMENT",
message: "A non-empty name is required.",
details: nil
))
return
}
result("Hello, \(name)!")
Understand Native Responses
| Native response | Purpose | Dart result |
|---|---|---|
success(value) | Operation succeeded | Future returns the value |
error(code, message, details) | Operation failed | Throws PlatformException |
notImplemented() | Method is unsupported | No normal result is returned |
Production Checklist
- Use exactly the same channel and method names on every platform.
- Validate arguments in native code, not only in Dart.
- Keep platform calls in a service or repository instead of scattering them through widgets.
- Do not block the main thread with long-running native work.
- Handle permissions, lifecycle events, and platform-specific failures.
- Do not log secrets or sensitive user data.
- After changing Kotlin or Swift, perform a full rebuild if hot reload does not apply the native changes.
- Consider Pigeon for larger APIs that benefit from generated, typed communication.
7. MethodChannel vs Plugin vs Pigeon
- MethodChannel: best for a small number of request-and-response calls.
- Flutter plugin: useful for reusable native integrations shared across multiple apps.
- Pigeon: useful for larger APIs where generated, type-safe interfaces are helpful.
- Existing Flutter package: often the simplest choice for common functionality already supported by a reliable package.
Conclusion
Platform channels let Flutter's Dart code communicate with native Android and iOS implementations. Create a channel with a shared name, invoke a method from Dart, register matching handlers in Kotlin and Swift, and return either a value or a structured error.
Technical note: Native project structure can vary by Flutter version and app configuration. Adapt the registration code to your app's existing Android and iOS lifecycle setup.
Comments
Post a Comment