Developing a Platform Channel for Flutter Applications (Android)
A Platform Channel is a bridge between Dart code in Flutter and native Android code (Kotlin/Java). It's needed when pub.dev doesn't offer a ready-made plugin: hardware-vendor-specific SDK, low-level audio work via AudioRecord, or integration with corporate MDM systems. Three channel types exist: MethodChannel (RPC), EventChannel (event stream from native to Dart), BasicMessageChannel (bidirectional arbitrary messages).
MethodChannel: Invoking Native Methods from Dart
Dart side:
class NfcService {
static const _channel = MethodChannel('com.example.app/nfc');
Future<bool> isNfcAvailable() async {
try {
return await _channel.invokeMethod<bool>('isNfcAvailable') ?? false;
} on PlatformException catch (e) {
debugPrint('NFC error: ${e.code} — ${e.message}');
return false;
}
}
Future<String?> readNfcTag() async {
return _channel.invokeMethod<String>('readNfcTag');
}
}
Kotlin side (MainActivity.kt or separate Handler):
class MainActivity : FlutterActivity() {
private val CHANNEL = "com.example.app/nfc"
private lateinit var nfcAdapter: NfcAdapter
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"isNfcAvailable" -> result.success(nfcAdapter.isEnabled)
"readNfcTag" -> startNfcRead(result)
else -> result.notImplemented()
}
}
}
private fun startNfcRead(result: MethodChannel.Result) {
// NFC reading implementation
}
}
Channel name is a string. Convention: com.company_name.application/module. Mismatched names between Dart and Kotlin result in MissingPluginException at runtime with no build-time hints.
EventChannel: Event Stream to Dart
For data the native side generates continuously: sensor readings, Bluetooth GATT notifications, GPS changes.
Kotlin:
class SensorStreamHandler(private val context: Context) : EventChannel.StreamHandler {
private var sensorManager: SensorManager? = null
private var eventSink: EventChannel.EventSink? = null
private val sensorListener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
eventSink?.success(mapOf(
"x" to event.values[0].toDouble(),
"y" to event.values[1].toDouble(),
"z" to event.values[2].toDouble(),
"timestamp" to event.timestamp
))
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
}
override fun onListen(arguments: Any?, sink: EventChannel.EventSink) {
eventSink = sink
sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val accelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
if (accelerometer == null) {
sink.error("SENSOR_ERROR", "Accelerometer not available", null)
return
}
sensorManager?.registerListener(sensorListener, accelerometer, SensorManager.SENSOR_DELAY_UI)
}
override fun onCancel(arguments: Any?) {
sensorManager?.unregisterListener(sensorListener)
eventSink = null
sensorManager = null
}
}
Register in MainActivity:
EventChannel(flutterEngine.dartExecutor.binaryMessenger, "com.example.app/accelerometer")
.setStreamHandler(SensorStreamHandler(this))
Dart:
class AccelerometerService {
static const _channel = EventChannel('com.example.app/accelerometer');
Stream<Map<String, dynamic>> get accelerometerStream {
return _channel.receiveBroadcastStream().map(
(event) => Map<String, dynamic>.from(event as Map),
);
}
}
// Usage:
AccelerometerService().accelerometerStream.listen((data) {
setState(() {
x = data['x'] as double;
y = data['y'] as double;
});
});
Data Types: What Passes Through the Channel
Platform Channel automatically converts between Dart and Kotlin the following types:
| Dart | Kotlin |
|---|---|
null |
null |
bool |
Boolean |
int |
Int / Long |
double |
Double |
String |
String |
Uint8List |
ByteArray |
List |
List<Any?> |
Map |
HashMap<Any, Any?> |
Important: int in Dart converts to Int if it fits, otherwise Long. If the native side returns Int and Dart expects int, no problem. But if Kotlin returns Double and Dart expects int, you get a TypeError. Explicit type checking on the native side is mandatory.
Extracting into a Separate Plugin
To reuse Platform Channel across multiple projects, package it as a Flutter Plugin:
flutter create --template=plugin --platforms=android my_nfc_plugin
Entry point is a class implementing FlutterPlugin:
class MyNfcPlugin : FlutterPlugin, MethodCallHandler {
private lateinit var channel: MethodChannel
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(binding.binaryMessenger, "my_nfc_plugin")
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: Result) {
// handler
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}
onDetachedFromEngine cleans up the handler. Without it, on Flutter hot reload the old handler stays in memory, and the next channel call fires twice.
Multithreading: The Main Pitfall
Kotlin's MethodChannel.setMethodCallHandler is called on the main thread. Any blocking operation inside causes ANR. Pattern:
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"heavyOperation" -> {
CoroutineScope(Dispatchers.IO).launch {
val data = performHeavyOperation()
withContext(Dispatchers.Main) {
result.success(data)
}
}
}
}
}
result.success() must be called on main thread—it's a Flutter requirement. withContext(Dispatchers.Main) or Handler(Looper.getMainLooper()).post { } are mandatory for responses from background threads.
Calling result.success() twice crashes the Flutter engine: Methods can only be called once. If the operation is cancelled, call result.error() or call nothing (but then Dart side waits forever). Best practice: explicitly return an error on cancellation.
Testing
Unit testing Kotlin logic without Flutter:
@Test
fun `isNfcAvailable returns false when adapter disabled`() {
val mockAdapter = mockk<NfcAdapter> { every { isEnabled } returns false }
val handler = NfcMethodHandler(mockAdapter)
val result = mockk<MethodChannel.Result>(relaxed = true)
handler.handleIsNfcAvailable(result)
verify { result.success(false) }
}
Integration tests via Flutter's integration_test package run a real Flutter app with the native part on an emulator.
Developing a Platform Channel takes 1–3 days for a simple MethodChannel with 2–4 methods. EventChannel with lifecycle management and tests takes 3–5 days. Packaging as a reusable plugin adds 1–2 days. Cost is calculated individually.







