Understanding Native Interoperability with Kotlin and Swift in Flutter

Flutter, Google’s UI toolkit for building natively compiled applications for mobile, web, and desktop from a single codebase, is highly versatile. However, there are situations where leveraging platform-specific code written in native languages like Kotlin (for Android) and Swift (for iOS) becomes essential. This approach is known as native interoperability. Understanding how to integrate Kotlin and Swift code into your Flutter applications can significantly extend their capabilities and provide access to platform-specific features or optimized libraries.

What is Native Interoperability?

Native interoperability refers to the ability of different programming languages and platforms to work together seamlessly. In the context of Flutter, it involves calling native code (Kotlin for Android, Swift/Objective-C for iOS) from Dart code and vice versa. This capability allows Flutter applications to access native APIs, use optimized libraries, and perform tasks that are difficult or impossible to achieve with Dart alone.

Why Use Native Interoperability?

  • Accessing Native APIs: Utilize device-specific features and APIs that are not available through Flutter plugins.
  • Performance Optimization: Leverage native libraries and code for performance-critical tasks.
  • Existing Native Codebases: Integrate Flutter into existing Android (Kotlin) or iOS (Swift/Objective-C) applications.
  • Platform-Specific Functionality: Implement features that require native code, such as advanced media processing or hardware-level interactions.

How to Implement Native Interoperability in Flutter

To implement native interoperability, Flutter provides the Platform Channels API. This mechanism allows communication between the Dart code in your Flutter app and the native code on the Android or iOS platform.

Step 1: Setting up Platform Channels

Platform Channels are used to send asynchronous method calls between Dart and native code. The communication occurs over a named channel using a codec, typically MethodCodec, for encoding and decoding messages.

Create a Method Channel in Dart


import 'package:flutter/services.dart';

const platform = MethodChannel('my_app/native_channel');

Here, 'my_app/native_channel' is a unique channel name used to identify the communication channel between Dart and native code.

Step 2: Implementing Native Code (Kotlin for Android)

On the Android side, implement the channel using 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 = "my_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 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
    }
}

In this Kotlin code:

  • A MethodChannel is created with the same channel name as defined in Dart.
  • The setMethodCallHandler listens for method calls from Dart.
  • If the method is getPlatformVersion, it returns the Android version.
  • If the method is getBatteryLevel, it retrieves the battery level using Android APIs.

Step 3: Implementing Native Code (Swift for iOS)

On the iOS side, implement the channel using 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 batteryChannel = FlutterMethodChannel(name: "my_app/native_channel",
                                              binaryMessenger: controller.binaryMessenger)
    batteryChannel.setMethodCallHandler({
      (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
      guard call.method == "getPlatformVersion" else {
        result(FlutterMethodNotImplemented)
        return
      }
      if call.method == "getBatteryLevel" {
                let batteryLevel = self.getBatteryLevel()
                if(batteryLevel != -1){
                    result(batteryLevel)
                } else {
                    result(FlutterError(code: "UNAVAILABLE",
                                        message: "Battery level not available.",
                                        details: nil))
                }
            } else if (call.method == "getPlatformVersion") {
                result("iOS " + UIDevice.current.systemVersion)
            }
             else {
              result(FlutterMethodNotImplemented)
              return
            }
    })

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
  
  private func getBatteryLevel() -> Int {
        let device = UIDevice.current
        device.isBatteryMonitoringEnabled = true
        if device.batteryState == UIDevice.BatteryState.unknown {
            return -1
        }
        return Int(device.batteryLevel * 100)
    }
}

In this Swift code:

  • A FlutterMethodChannel is created with the same channel name as defined in Dart.
  • The setMethodCallHandler listens for method calls from Dart.
  • If the method is getPlatformVersion, it returns the iOS version.
  • If the method is getBatteryLevel, it retrieves the battery level using iOS APIs.

Step 4: Calling Native Code from Dart

Now, call the native methods from your Flutter (Dart) code.


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

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

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

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

  @override
  void initState() {
    super.initState();
    getPlatformVersion();
  }

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Native Interop in Flutter'),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(_platformVersion),
               ElevatedButton(
                onPressed: getBatteryLevel,
                child: Text('Get Battery Level'),
              ),
              Text(_batteryLevel),
            ],
          ),
        ),
      ),
    );
  }
}

In this Dart code:

  • The platform.invokeMethod is used to call native methods.
  • The getPlatformVersion and getBatteryLevel methods call corresponding native methods in Kotlin/Swift.
  • The results are displayed in the Flutter UI.

Best Practices for Native Interoperability

  • Minimize Native Code: Use native code only when necessary to maintain code portability and simplicity.
  • Asynchronous Communication: Always use asynchronous calls to prevent blocking the UI thread.
  • Error Handling: Implement robust error handling on both Dart and native sides to handle exceptions gracefully.
  • Channel Naming: Use unique and descriptive channel names to avoid conflicts with other plugins.
  • Data Serialization: Ensure proper serialization and deserialization of data passed between Dart and native code.

Conclusion

Native interoperability in Flutter provides a powerful way to leverage platform-specific features and optimized code written in Kotlin (Android) and Swift (iOS). By using Platform Channels, you can create Flutter applications that seamlessly integrate with native functionality, unlocking new possibilities and enhancing user experiences. Understanding the nuances of native interop can significantly expand the capabilities of your Flutter projects and enable you to deliver high-performance, feature-rich applications.