Platform Channels #

In the Flutter app development ecosystem, Dart code runs on its own isolated virtual machine (Dart Virtual Machine). Although Flutter is very reliable at rendering high-performance UIs cross-platform, there are scenarios where your app must interact directly with low-level operating system APIs (low-level OS APIs) or integrate third-party SDKs only available in native format. Some examples of these features include checking device encryption status in the Secure Enclave/Keystore, reading hardware sensor data, managing advanced Bluetooth connectivity, or integrating native payment systems.

To bridge the execution environment difference between the Dart VM and the host platform (Android/iOS), Flutter provides a mechanism called Platform Channels. This mechanism facilitates asynchronous message exchange across the platform boundary by leveraging a secure high-speed communication channel.

Platform Channels Architecture & Basic Communication #

The Platform Channels architecture relies on binary-based asynchronous message delivery. At the lowest layer, Flutter uses a component named BinaryMessenger to send raw byte buffers across the platform boundary. When you call a method through a Platform Channel on the Dart side, those arguments are encoded into binary by the codec, sent through the BinaryMessenger to the native side, decoded by the native codec, and finally executed by the platform handler.

This communication runs asynchronously and is non-blocking to ensure the main rendering thread (UI Thread) on the Dart side doesn’t freeze while waiting for responses from native operations that may take a long time.

Here’s a flow diagram showing the serialization, transmission, and deserialization process of data from Dart to Android/iOS native code through Platform Channels:

flowchart TD
    subgraph DartEnv["Dart Environment (UI Thread)"]
        DartCode["Dart Code (Service)"] -->|"invokeMethod(method, args)"| MethodChan["MethodChannel"]
        MethodChan -->|"encodeMethodCall()"| CodecDart["StandardMessageCodec (Dart)"]
        CodecDart -->|"Send byte buffer"| MessengerDart["BinaryMessenger (Dart)"]
    end

    subgraph NativeEnv["Native Environment (Main Thread)"]
        MessengerNative["BinaryMessenger (Native)"] -->|"Receive byte buffer"| CodecNative["StandardMessageCodec (Native)"]
        CodecNative -->|"decodeMethodCall()"| HandlerNative["MethodCallHandler"]
        HandlerNative -->|"Kotlin / Swift API"| NativeAPIs["Operating System APIs"]
    end

    MessengerDart -->|Channel Boundary| MessengerNative
    NativeAPIs -->|"Native Result"| HandlerNative
    HandlerNative -->|"encodeSuccessEnvelope()"| CodecNative
    CodecNative -->|"Send byte buffer back"| MessengerNative
    MessengerNative -->|Channel Boundary| MessengerDart
    MessengerDart -->|"Receive byte buffer back"| CodecDart
    CodecDart -->|"decodeEnvelope()"| MethodChan
    MethodChan -->|"Return Future"| DartCode

Three Types of Platform Channels #

Flutter provides three types of Platform Channels designed for different communication scenarios:

  1. MethodChannel: The most commonly used channel type. This channel is designed for one-time request-response communication (like a regular async function call). Dart sends a message containing the method name and arguments, then native processes it and returns one response back to Dart.
  2. EventChannel: This channel is specifically designed for continuous asynchronous data streaming from the native platform to Dart. Perfect for observing sensor data changes (accelerometer), listening to real-time network status changes (WiFi/Cellular), or receiving GPS location updates in the background.
  3. BasicMessageChannel: A flexible bidirectional communication channel for sending repeated messages with raw data types (like strings or bytes) using custom codecs. This type is less commonly used because its functionality is largely already covered by a combination of MethodChannel and EventChannel.

StandardMessageCodec and Data Serialization #

Before data is sent through the BinaryMessenger, Dart objects must be serialized into binary. By default, Flutter uses StandardMessageCodec to handle this encryption and decryption. This codec supports automatic conversion for the following basic data types:

DartAndroid (Kotlin)iOS (Swift)
nullnullnil
booljava.lang.BooleanNSNumber (Boolean)
intjava.lang.Integer / LongNSNumber (Integer)
doublejava.lang.DoubleNSNumber (Double)
Stringjava.lang.StringString
Uint8Listbyte[]FlutterStandardTypedData
Int32Listint[]FlutterStandardTypedData
Listjava.util.ArrayListArray
Mapjava.util.HashMapDictionary

If you want to send custom objects (e.g., your own data models), you can’t send them directly. You must serialize those objects into Map<String, dynamic> on the Dart side first, send them through the channel, then decode them on the native side into equivalent native object representations.


MethodChannel: One-Off Calls (Request-Response) #

Let’s dissect an in-depth implementation of using MethodChannel to fetch device battery level data and its charging status. We’ll implement very strict error handling on both the Dart side and the native side (Kotlin for Android and Swift for iOS).

1. Dart Side Implementation #

On the Dart side, we’ll create a service class wrapping the MethodChannel call. We must ensure the channel name used is unique (following the reverse domain convention) so it doesn’t collide with other plugins in your project.

// lib/core/platform/battery_platform_service.dart
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';

class BatteryPlatformService {
  // Use a unique channel name. Recommendation: domain/feature_name
  static const MethodChannel _channel = MethodChannel('com.unisbadri.flutter/battery');

  /// Gets the device's current battery percentage (0-100).
  /// Returns -1 if an error occurs or the status is unavailable.
  static Future<int> getBatteryLevel() async {
    try {
      // invokeMethod returns a Future that must be awaited asynchronously
      final int? result = await _channel.invokeMethod<int>('getBatteryLevel');
      return result ?? -1;
    } on PlatformException catch (e) {
      // Handle specific errors thrown from the native side
      debugPrint('Failed to get battery level: Code: ${e.code}, Message: ${e.message}, Details: ${e.details}');
      return -1;
    } on MissingPluginException catch (e) {
      // Handle errors if the channel name or method isn't registered on the native side
      debugPrint('Native implementation not found: ${e.message}');
      return -1;
    } catch (e) {
      debugPrint('Unexpected error: $e');
      return -1;
    }
  }

  /// Gets the current battery charging status information.
  static Future<String> getChargerStatus({required bool capitalizeFormat}) async {
    try {
      final String? status = await _channel.invokeMethod<String>(
        'getBatteryStatus',
        {
          'capitalizeFormat': capitalizeFormat, // Sending arguments as a Map
        },
      );
      return status ?? 'Unknown';
    } on PlatformException catch (e) {
      debugPrint('Failed to get charger status: ${e.message}');
      return 'Error: ${e.code}';
    }
  }
}

2. Android Side Implementation (Kotlin) #

On the Android side, we implement the handler inside MainActivity.kt. You need to note that calls from Dart by default enter the Android main UI thread. If you do heavy calculations inside the handler, you must move them to a background thread using Kotlin Coroutines to prevent application blocking (Application Not Responding / ANR).

// android/app/src/main/kotlin/com/unisbadri/flutter/MainActivity.kt
package com.unisbadri.flutter

import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

class MainActivity: FlutterActivity() {
    private val BATTERY_CHANNEL = "com.unisbadri.flutter/battery"

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        // Register the MethodChannel
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, BATTERY_CHANNEL)
            .setMethodCallHandler { call, result ->
                // Ensure safe execution handling
                when (call.method) {
                    "getBatteryLevel" -> {
                        // Run the battery fetch safely
                        val level = getBatteryPercentage()
                        if (level != -1) {
                            result.success(level)
                        } else {
                            result.error(
                                "UNAVAILABLE",
                                "The battery percentage cannot be read from the Android system.",
                                null
                            )
                        }
                    }
                    "getBatteryStatus" -> {
                        // Retrieve the Boolean argument sent from Dart
                        val capitalizeFormat = call.argument<Boolean>("capitalizeFormat") ?: false
                        
                        // Use a Coroutine if the process takes long or requires I/O access
                        CoroutineScope(Dispatchers.Main).launch {
                            val status = withContext(Dispatchers.IO) {
                                getChargingStatus(capitalizeFormat)
                            }
                            result.success(status)
                        }
                    }
                    else -> {
                        // Notify Dart that the requested method doesn't exist
                        result.notImplemented()
                    }
                }
            }
    }

    private fun getBatteryPercentage(): Int {
        val batteryLevel: Int
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
            batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
        } else {
            val intent = ContextWrapper(applicationContext).registerReceiver(
                null,
                IntentFilter(Intent.ACTION_BATTERY_CHANGED)
            )
            batteryLevel = if (intent != null) {
                val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
                val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
                if (level != -1 && scale != -1) {
                    (level * 100) / scale
                } else {
                    -1
                }
            } else {
                -1
            }
        }
        return batteryLevel
    }

    private fun getChargingStatus(capitalizeFormat: Boolean): String {
        val intent = ContextWrapper(applicationContext).registerReceiver(
            null,
            IntentFilter(Intent.ACTION_BATTERY_CHANGED)
        )
        val status = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
        val isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
                status == BatteryManager.BATTERY_STATUS_FULL

        val resultText = if (isCharging) "charging" else "not charging"
        return if (capitalizeFormat) {
            resultText.uppercase()
        } else {
            resultText
        }
    }
}

3. iOS Side Implementation (Swift) #

On the iOS side, we handle the platform channel in the AppDelegate.swift file. You must use safe memory handling ([weak self]) when registering call handlers to avoid triggering strong reference cycles.

// ios/Runner/AppDelegate.swift
import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    private let BATTERY_CHANNEL_NAME = "com.unisbadri.flutter/battery"

    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
        
        let batteryChannel = FlutterMethodChannel(
            name: BATTERY_CHANNEL_NAME,
            binaryMessenger: controller.binaryMessenger
        )
        
        // Register the handler callback safely with [weak self]
        batteryChannel.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in
            guard let self = self else { return }
            
            switch call.method {
            case "getBatteryLevel":
                self.getBatteryLevel(result: result)
            case "getBatteryStatus":
                // Parse arguments as? [String: Any]
                let arguments = call.arguments as? [String: Any]
                let capitalizeFormat = arguments?["capitalizeFormat"] as? Bool ?? false
                
                // Use DispatchQueue if doing heavy background calculations
                DispatchQueue.global(qos: .userInitiated).async {
                    let status = self.getChargingStatus(capitalizeFormat: capitalizeFormat)
                    
                    // Always return results to the iOS main thread
                    DispatchQueue.main.async {
                        result(status)
                    }
                }
            default:
                result(FlutterMethodNotImplemented)
            }
        }
        
        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

    private func getBatteryLevel(result: FlutterResult) {
        let device = UIDevice.current
        device.isBatteryMonitoringEnabled = true
        
        // The batteryLevel value ranges from 0.0 (empty) to 1.0 (full)
        let batteryLevel = device.batteryLevel
        
        if batteryLevel < 0 {
            // Send a specific error to Dart
            result(FlutterError(
                code: "UNAVAILABLE",
                message: "The iOS battery level is not detected (possibly running on the Simulator).",
                details: nil
            ))
        } else {
            result(Int(batteryLevel * 100))
        }
    }

    private func getChargingStatus(capitalizeFormat: Bool) -> String {
        let device = UIDevice.current
        device.isBatteryMonitoringEnabled = true
        
        let status = device.batteryState
        let isCharging = status == .charging || status == .full
        
        let resultText = isCharging ? "charging" : "not charging"
        return capitalizeFormat ? resultText.uppercased() : resultText
    }
}

EventChannel: Continuous Data Streams (Streaming) #

For continuous asynchronous data monitoring scenarios—like tracking device network (Internet) connectivity status updates—EventChannel is the most appropriate architectural choice. We’ll implement a dynamic network detector that notifies the Dart app in real-time when network status changes from connected to disconnected, or vice versa.

1. Dart Side Implementation #

On the Dart side, we’ll listen to the data stream (Stream) exposed by the EventChannel, do data type mapping, and ensure we manage that stream subscription lifecycle so memory leaks don’t occur.

// lib/core/platform/network_stream_service.dart
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';

enum NetworkStatus { connected, disconnected, unknown }

class NetworkStreamService {
  static const EventChannel _eventChannel = EventChannel('com.unisbadri.flutter/network_status');

  /// A stream emitting the latest NetworkStatus in real-time.
  static Stream<NetworkStatus> get networkStatusStream {
    return _eventChannel
        .receiveBroadcastStream()
        .map((dynamic event) {
          // Convert raw data from native into a status enum in Dart
          if (event is String) {
            switch (event) {
              case 'CONNECTED':
                return NetworkStatus.connected;
              case 'DISCONNECTED':
                return NetworkStatus.disconnected;
            }
          }
          return NetworkStatus.unknown;
        })
        .handleError((error) {
          debugPrint('Error on the Network EventChannel: $error');
          return NetworkStatus.unknown;
        });
  }
}

Here’s an example of using NetworkStreamService inside your widget using StreamBuilder:

// lib/presentation/widgets/network_status_widget.dart
import 'package:flutter/material.dart';
import '../../core/platform/network_stream_service.dart';

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

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<NetworkStatus>(
      stream: NetworkStreamService.networkStatusStream,
      initialData: NetworkStatus.unknown,
      builder: (context, snapshot) {
        final status = snapshot.data ?? NetworkStatus.unknown;
        
        Color statusColor = Colors.grey;
        String statusText = 'Checking Network...';

        if (status == NetworkStatus.connected) {
          statusColor = Colors.green;
          statusText = 'ONLINE';
        } else if (status == NetworkStatus.disconnected) {
          statusColor = Colors.red;
          statusText = 'OFFLINE';
        }

        return Container(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
          decoration: BoxDecoration(
            color: statusColor.withOpacity(0.15),
            borderRadius: BorderRadius.circular(20),
            border: Border.all(color: statusColor),
          ),
          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              Icon(
                status == NetworkStatus.connected ? Icons.wifi : Icons.wifi_off,
                color: statusColor,
              ),
              const SizedBox(width: 8),
              Text(
                statusText,
                style: TextStyle(color: statusColor, fontWeight: FontWeight.bold),
              ),
            ],
          ),
        );
      },
    );
  }
}

2. Android Side (Kotlin) — EventChannel #

On Android, we implement the EventChannel.StreamHandler interface. We register a BroadcastReceiver dynamically when Dart listens to the stream, and remove it immediately when Dart cancels the stream subscription to prevent Android context reference leaks.

// android/app/src/main/kotlin/com/unisbadri/flutter/MainActivity.kt (Continued)
package com.unisbadri.flutter

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.NetworkInfo
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel

class MainActivity: FlutterActivity() {
    private val NETWORK_CHANNEL = "com.unisbadri.flutter/network_status"
    private var networkReceiver: BroadcastReceiver? = null

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        EventChannel(flutterEngine.dartExecutor.binaryMessenger, NETWORK_CHANNEL)
            .setStreamHandler(object : EventChannel.StreamHandler {
                override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
                    if (events == null) return

                    // Send the initial status when first listened to
                    events.success(getCurrentConnectionStatus())

                    // Create a dynamic receiver to listen for network status changes
                    networkReceiver = object : BroadcastReceiver() {
                        override fun onReceive(context: Context?, intent: Intent?) {
                            events.success(getCurrentConnectionStatus())
                        }
                    }

                    registerReceiver(
                        networkReceiver,
                        IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
                    )
                }

                override fun onCancel(arguments: Any?) {
                    // Clean up the receiver to prevent Android context memory leaks
                    if (networkReceiver != null) {
                        unregisterReceiver(networkReceiver)
                        networkReceiver = null
                    }
                }
            })
    }

    private fun getCurrentConnectionStatus(): String {
        val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        val activeNetwork: NetworkInfo? = cm.activeNetworkInfo
        val isConnected = activeNetwork?.isConnectedOrConnecting == true
        return if (isConnected) "CONNECTED" else "DISCONNECTED"
    }
}

3. iOS Side (Swift) — EventChannel #

On iOS, we use NWPathMonitor from the Network framework to observe device connectivity changes. You must be careful to start and cancel this monitor at the right handler lifecycle.

// ios/Runner/AppDelegate.swift (Continued)
import UIKit
import Flutter
import Network

class AppDelegate: FlutterAppDelegate, FlutterStreamHandler {
    private let NETWORK_CHANNEL_NAME = "com.unisbadri.flutter/network_status"
    
    // Initialize the network monitor properties
    private var monitor: NWPathMonitor?
    private var eventSink: FlutterEventSink?
    
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
        
        let networkChannel = FlutterEventChannel(
            name: NETWORK_CHANNEL_NAME,
            binaryMessenger: controller.binaryMessenger
        )
        networkChannel.setStreamHandler(self)
        
        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

    // Called when Dart starts listening to the stream
    func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
        self.eventSink = events
        self.monitor = NWPathMonitor()
        
        // Register the status change monitoring callback
        self.monitor?.pathUpdateHandler = { [weak self] path in
            guard let self = self, let sink = self.eventSink else { return }
            
            let statusString = (path.status == .satisfied) ? "CONNECTED" : "DISCONNECTED"
            
            // Always send updates back to the Main Thread
            DispatchQueue.main.async {
                sink(statusString)
            }
        }
        
        // Run the monitor on a background queue
        let queue = DispatchQueue(label: "NetworkMonitorQueue")
        self.monitor?.start(queue: queue)
        
        return nil
    }

    // Called when Dart stops monitoring the stream
    func onCancel(withArguments arguments: Any?) -> FlutterError? {
        // Turn off the monitor cleanly to free system resources
        self.monitor?.cancel()
        self.monitor = nil
        self.eventSink = nil
        return nil
    }
}

BasicMessageChannel: Open Bidirectional Communication #

BasicMessageChannel is designed for scenarios where you need a persistent bidirectional async communication channel for sending serialized messages continuously. This channel leverages the MessageCodec object to control data format conversion.

Here’s a simple implementation of raw JSON string exchange between Dart and Native using BasicMessageChannel.

1. Dart Side #

// lib/core/platform/message_sync_service.dart
import 'package:flutter/services.dart';

class MessageSyncService {
  // Use StringCodec to send messages as direct text
  static const BasicMessageChannel<String> _messageChannel =
      BasicMessageChannel<String>('com.unisbadri.flutter/sync_message', StringCodec());

  static void initializeMessageReceiver() {
    // Handle incoming messages from native to Dart
    _messageChannel.setMessageHandler((String? message) async {
      print('Received a message from Native: $message');
      return 'Received by Dart'; // Optional reply to native
    });
  }

  static Future<void> sendMessageToNative(String payloadJson) async {
    // Send a message from Dart to native
    final String? reply = await _messageChannel.send(payloadJson);
    print('Reply from Native: $reply');
  }
}

2. Android Side (Kotlin) #

// Initialize the BasicMessageChannel in MainActivity.kt
val messageChannel = BasicMessageChannel(
    flutterEngine.dartExecutor.binaryMessenger,
    "com.unisbadri.flutter/sync_message",
    StringCodec.INSTANCE
)

// Set up the message receiver from Dart
messageChannel.setMessageHandler { message, reply ->
    println("Received from Dart: $message")
    
    // Send an instant reply to Dart
    reply.reply("Android received your message.")
}

// Example of sending a message to Dart asynchronously
messageChannel.send("Message from the Android background") { reply ->
    println("Dart confirmation: $reply")
}

3. iOS Side (Swift) #

// Initialize the BasicMessageChannel in AppDelegate.swift
let messageChannel = FlutterBasicMessageChannel(
    name: "com.unisbadri.flutter/sync_message",
    binaryMessenger: controller.binaryMessenger,
    codec: FlutterStringCodec.sharedInstance()
)

// Set up the message receiver from Dart
messageChannel.setMessageHandler { (message, reply) in
    print("Received from Dart: \(String(describing: message))")
    
    // Send a reply to Dart
    reply("iOS received your message.")
}

// Send a message to Dart asynchronously
messageChannel.sendMessage("Message from the iOS background") { (reply) in
    print("Dart confirmation: \(String(describing: reply))")
}

Pigeon: A Type-Safe Alternative (Type-Safe Code Generation) #

Although writing manual Platform Channels code is very flexible, this approach has major weaknesses in medium-to-large-scale projects:

  1. Raw String Matching: You’re prone to typos in method names or Map parameter names. These errors are only detected at runtime, not at compile time.
  2. Manual Casting: You must write boilerplate code to manually parse data from Maps or Lists on both the Dart and native sides, which risks triggering type casting errors.

To overcome these weaknesses, the Flutter team provides an alternative tool called Pigeon. Pigeon is a code generation tool that lets you define your platform channel API schema using a standard Dart interface file, then automatically generates strongly-typed handler classes for Dart, Kotlin (Android), and Swift (iOS).

Here are the detailed implementation steps using Pigeon:

Step 1: Add the Pigeon Dependency #

Add the Pigeon package to the dev_dependencies section of your pubspec.yaml file:

dev_dependencies:
  flutter_test:
    sdk: flutter
  pigeon: ^22.3.0

Step 2: Define the API Schema Interface #

Create a new schema definition file, e.g., in the pigeons/battery_api.dart folder:

// pigeons/battery_api.dart
import 'package:pigeon/pigeon.dart';

// Configure the code generator output file destinations
@ConfigurePigeon(PigeonOptions(
  dartOut: 'lib/src/generated/battery_api.g.dart',
  kotlinOut: 'android/app/src/main/kotlin/com/unisbadri/flutter/BatteryApi.g.kt',
  kotlinOptions: KotlinOptions(package: 'com.unisbadri.flutter'),
  swiftOut: 'ios/Runner/BatteryApi.g.swift',
))

// A structured type-safe data model
class SystemInformation {
  final String deviceName;
  final String osVersion;
  final int batteryLevel;

  SystemInformation({
    required this.deviceName,
    required this.osVersion,
    required this.batteryLevel,
  });
}

// The API interface to be implemented on the Native side and called by Dart
@HostApi()
abstract class BatteryPlatformApi {
  SystemInformation getSystemInformation();
}

Step 3: Run the Pigeon Generator Command #

Execute the Pigeon generator through your project terminal console:

dart run pigeon --input pigeons/battery_api.dart

The command above generates boilerplate files containing type-safe classes in the lib/src/generated/ folder location.

Step 4: Implement the Native Interface #

A. Android Side (Kotlin) #

The Pigeon compiler generates the BatteryPlatformApi interface. You just implement this interface and register it in MainActivity.kt.

// android/app/src/main/kotlin/com/unisbadri/flutter/MainActivity.kt
package com.unisbadri.flutter

import android.os.Build
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine

class MainActivity: FlutterActivity(), BatteryPlatformApi {

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        // Connect our class implementation to the Pigeon Host API
        BatteryPlatformApi.setUp(flutterEngine.dartExecutor.binaryMessenger, this)
    }

    override fun getSystemInformation(): SystemInformation {
        val batteryLevel = 90 // Simulated battery level calculation
        return SystemInformation(
            deviceName = Build.MODEL,
            osVersion = "Android " + Build.VERSION.RELEASE,
            batteryLevel = batteryLevel.toLong()
        )
    }
}

B. iOS Side (Swift) #

On iOS, we implement the generated Swift BatteryPlatformApi protocol.

// ios/Runner/AppDelegate.swift
import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate, BatteryPlatformApi {
    
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
        
        // Register the AppDelegate class implementation to the Pigeon setup
        BatteryPlatformApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: self)
        
        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
    
    func getSystemInformation() -> SystemInformation {
        return SystemInformation(
            deviceName: UIDevice.current.name,
            osVersion: "iOS " + UIDevice.current.systemVersion,
            batteryLevel: Int64(95) // Simulated battery level calculation
        )
    }
}

Step 5: Call the Pigeon API on the Dart Side #

Now on the Dart side, you just call the API instance without worrying about manual string parsing or error-prone Map conversions:

// lib/core/platform/pigeon_battery_service.dart
import '../../src/generated/battery_api.g.dart';

class PigeonBatteryService {
  static final BatteryPlatformApi _api = BatteryPlatformApi();

  static Future<void> printDeviceInformation() async {
    try {
      // Type-safe function calls directly return SystemInformation objects
      final SystemInformation info = await _api.getSystemInformation();
      
      print('Device Name   : ${info.deviceName}');
      print('OS Version    : ${info.osVersion}');
      print('Battery Level : ${info.batteryLevel}%');
    } catch (e) {
      print('Failed to get data from Pigeon: $e');
    }
  }
}

Performance & Error Handling Best Practices #

Here are the main rules and principles you must apply when designing Platform Channels in production projects:

1. Move Heavy Computations to Background Threads (Native Threading) #

By default, all Platform Channel handler calls on the Android (Kotlin/Java) and iOS (Swift/Objective-C) sides run on the main UI thread (Main Platform Thread). If your native handler does heavy computation operations, image manipulation, large database reads, or slow network communication, those will block the operating system’s main UI rendering, making the app appear frozen.

  • Android: Use Kotlin Coroutines with Dispatchers.IO dispatchers or Java thread executors to move processing off the main thread.
  • iOS: Use Grand Central Dispatch (GCD) to send heavy work to background queues (Dispatchers.global(qos: .background)), but make sure you always call the result() callback back on the main thread (Dispatchers.main.async).

2. Avoid Sending Large Binary Objects Repeatedly #

StandardMessageCodec has performance overhead when copying binary data across the platform channel memory boundary. If you need to process large image files, camera video streams, or raw binary databases, avoid sending them as giant byte arrays across the platform channel. As a solution, save those files to local storage, send the file path reference (file path string) across the platform channel, then let the native side read that file data locally from the storage medium.

3. Keep Resource Management Clean (Memory Leak Prevention) #

When using EventChannel, failing to unregister native listeners or receivers on the handler side when streams are no longer used causes dangerous memory leaks. Android activity contexts or iOS internal monitors can never be removed from heap memory because they’re still actively held by event sinks. Make sure you implement memory freeing, timer cancellation, and receiver unregistration inside the onCancel() method of your native stream handler interface.

4. Apply Consistent and Unique Channel Naming Schemes #

When developing custom plugins, avoid overly generic channel names like battery or sensor. Use a structured reverse DNS format:

$$\text{Name Format} = \text{company_domain} + \text{"/"} + \text{app_name} + \text{"/"} + \text{service_name}$$

Example: com.companyname.appname/sensor_acceleration.


Summary #

  • Platform Channels function as an asynchronous bridge between the Dart VM execution environment and the native Android/iOS operating systems through BinaryMessenger binary message transmission.
  • MethodChannel is used for one-call request-response interactions. Always use proper data type handling and catch PlatformException exceptions on the Dart side.
  • EventChannel is designed for continuous data streaming from native to Dart. It’s very important to turn off sensors, cancel monitors, and unregister receivers inside the native onCancel callback to prevent memory leaks.
  • Pigeon is Flutter’s official code generation tool that must be used in large-scale projects to avoid runtime bugs from string name typos or unsafe data type conversions.
  • Threading Rules: Always move resource-heavy native tasks (I/O, encryption, large data parsing) off the native main thread using Kotlin Coroutines or Swift GCD so they don’t block the main UI thread.

← Previous: Best Practice   Next: Background Tasks →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact