Working with Platform Channels for Seamless Native Integration in Flutter

Flutter, Google’s UI toolkit for building natively compiled applications for mobile, web, and desktop from a single codebase, offers impressive cross-platform capabilities. However, there are times when you need to access platform-specific features and APIs that are not natively available in Flutter. This is where Platform Channels come in. Platform Channels provide a powerful mechanism to communicate between your Flutter code and the native code of the underlying platform (e.g., Android or iOS).

What are Platform Channels in Flutter?

Platform Channels are a way to invoke native code from your Flutter application, and vice versa. They allow you to send asynchronous method calls between the Flutter Dart code and the platform-specific code written in languages such as Kotlin or Java for Android, and Swift or Objective-C for iOS.

Why Use Platform Channels?

  • Access Native APIs: Utilize platform-specific APIs and features unavailable in Flutter’s native widgets and services.
  • Integrate Existing Native Code: Leverage existing native libraries and components.
  • Performance Optimization: Offload performance-intensive tasks to native code for optimal execution on the target platform.

How to Implement Platform Channels in Flutter

Implementing Platform Channels involves the following steps:

Step 1: Define a Method Channel

In your Flutter code (Dart), create a MethodChannel with a unique name. This name will be used to identify the channel on both the Flutter and native sides.


import 'package:flutter/services.dart';

const platform = MethodChannel('com.example.app/native_channel');

Here, com.example.app/native_channel is the unique name for the channel.

Step 2: Invoke a Native Method

Use the invokeMethod method on the MethodChannel to call a method in the native code. Pass the method name and any required arguments.


Future getPlatformVersion() async {
  String version;
  try {
    final result = await platform.invokeMethod('getPlatformVersion');
    version = 'Platform version: $result';
  } on PlatformException catch (e) {
    version = "Failed to get platform version: '${e.message}'.";
  }
  return version;
}

In this example, getPlatformVersion is the method called on the native side.

Step 3: Implement the Native Code

On the native side (Android or iOS), set up a MethodChannel using the same name defined in your Flutter code. Implement a method handler to respond to the method calls from Flutter.

Android (Kotlin)

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity: FlutterActivity() {
    private val CHANNEL = "com.example.app/native_channel"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
            call, result ->
            if (call.method == "getPlatformVersion") {
                result.success("Android ${android.os.Build.VERSION.RELEASE}")
            } else {
                result.notImplemented()
            }
        }
    }
}
iOS (Swift)

import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    let CHANNEL = "com.example.app/native_channel"
    let methodChannel = FlutterMethodChannel(name: CHANNEL,
                                              binaryMessenger: controller.binaryMessenger)
    methodChannel.setMethodCallHandler({
      (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
      guard call.method == "getPlatformVersion" else {
        result(FlutterMethodNotImplemented)
        return
      }
      result("iOS (UIDevice.current.systemVersion)")
    })

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

Step 4: Display the Result in Flutter

Back in Flutter, update your UI to display the result obtained from the native code.


import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State {
  String _platformVersion = 'Unknown';

  @override
  void initState() {
    super.initState();
    getPlatformVersion().then((version) {
      setState(() {
        _platformVersion = version;
      });
    });
  }

  Future getPlatformVersion() async {
    const platform = MethodChannel('com.example.app/native_channel');
    String version;
    try {
      final result = await platform.invokeMethod('getPlatformVersion');
      version = 'Platform version: $result';
    } on PlatformException catch (e) {
      version = "Failed to get platform version: '${e.message}'.";
    }
    return version;
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Platform Channel Demo'),
        ),
        body: Center(
          child: Text(_platformVersion),
        ),
      ),
    );
  }
}

Example: Accessing Battery Level (Native API)

Another common use case is accessing the device’s battery level. The code below illustrates how to accomplish this via a Platform Channel.

Flutter (Dart)

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class BatteryLevelScreen extends StatefulWidget {
  @override
  _BatteryLevelScreenState createState() => _BatteryLevelScreenState();
}

class _BatteryLevelScreenState extends State {
  static const platform = MethodChannel('com.example.app/battery');

  String _batteryLevel = 'Unknown battery level.';

  Future _getBatteryLevel() async {
    String batteryLevel;
    try {
      final int result = await platform.invokeMethod('getBatteryLevel');
      batteryLevel = 'Battery level: $result%.';
    } on PlatformException catch (e) {
      batteryLevel = "Failed to get battery level: '${e.message}'.";
    }

    setState(() {
      _batteryLevel = batteryLevel;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Battery Level'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(_batteryLevel),
            ElevatedButton(
              onPressed: _getBatteryLevel,
              child: Text('Get Battery Level'),
            ),
          ],
        ),
      ),
    );
  }
}
Android (Kotlin)

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES

class MainActivity: FlutterActivity() {
    private val CHANNEL = "com.example.app/battery"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
            call, result ->
            if (call.method == "getBatteryLevel") {
                val batteryLevel = getBatteryLevel()

                if (batteryLevel != -1) {
                    result.success(batteryLevel)
                } else {
                    result.error("UNAVAILABLE", "Battery level not available.", null)
                }
            } else {
                result.notImplemented()
            }
        }
    }

    private fun getBatteryLevel(): Int {
        val batteryLevel: Int
        if (VERSION.SDK_INT >= 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 = intent!!.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) * 100 / intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
        }

        return batteryLevel
    }
}
iOS (Swift)

import Flutter
import UIKit

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    let batteryChannel = FlutterMethodChannel(name: "com.example.app/battery",
                                              binaryMessenger: controller.binaryMessenger)
    batteryChannel.setMethodCallHandler({
      (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
      guard call.method == "getBatteryLevel" else {
        result(FlutterMethodNotImplemented)
        return
      }
      receiveBatteryLevel(result: result)
    })

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  private func receiveBatteryLevel(result: @escaping FlutterResult) {
    let device = UIDevice.current
    device.isBatteryMonitoringEnabled = true
    if device.batteryState == .unknown {
      result(FlutterError(code: "UNAVAILABLE",
                          message: "Battery info unavailable",
                          details: nil))
    } else {
      result(Int(device.batteryLevel * 100))
    }
  }
}

Best Practices for Platform Channels

  • Keep Channels Narrow and Specific: Design channels for specific purposes rather than general-purpose communication.
  • Handle Errors Gracefully: Always handle potential errors like PlatformException to prevent crashes.
  • Asynchronous Operations: Remember that calls are asynchronous. Use async and await for proper handling.
  • Document Channels Thoroughly: Proper documentation helps maintainability and collaboration.
  • Test Thoroughly: Write unit and integration tests to ensure your channels work as expected across different platforms.

Conclusion

Platform Channels are an essential tool for Flutter developers who need to access platform-specific features and APIs. By understanding how to implement and use them effectively, you can greatly extend the capabilities of your Flutter applications and create truly seamless native integrations. When working with Platform Channels in Flutter, careful planning, error handling, and testing are crucial to ensure a robust and reliable implementation.