Sending Data Between Flutter and Native Code

Flutter, Google’s UI toolkit for building natively compiled applications for mobile, web, and desktop from a single codebase, provides a powerful mechanism for communicating with platform-specific (native) code. This communication is often necessary when you need to access platform-specific features or libraries not available directly through Flutter’s Dart code. This article delves into the intricacies of sending data between Flutter and native code, exploring various methods, use cases, and best practices.

Understanding Flutter’s Platform Channels

Flutter facilitates communication between Dart code and native code via Platform Channels. These channels act as a bridge, allowing you to invoke native code functions from Dart and vice versa. Communication over these channels is asynchronous, ensuring that the UI remains responsive.

Why Use Platform Channels?

  • Accessing Native APIs: To utilize platform-specific functionalities, such as accessing sensors, Bluetooth, or specific hardware features.
  • Integrating Existing Native Libraries: Leveraging pre-existing native codebases written in languages like Java/Kotlin for Android, and Swift/Objective-C for iOS.
  • Performance Optimization: Offloading computationally intensive tasks to native code for improved performance.

Setting Up Platform Channels

To establish communication between Flutter and native code, you need to define a channel in your Dart code and implement corresponding handlers in the native platform (Android or iOS).

Step 1: Define a Channel in Dart

In your Flutter Dart code, create a MethodChannel:


import 'package:flutter/services.dart';

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

Here, 'com.example.my_app/native_channel' is a unique identifier for the channel. It’s crucial to use a consistent identifier across both the Dart and native code.

Step 2: Implement Method Handlers in Native Code

Android (Kotlin)

In your Android project (MainActivity.kt), handle method calls from Flutter:


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.my_app/native_channel"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
            call, result ->
            when (call.method) {
                "getPlatformVersion" -> {
                    result.success("Android ${android.os.Build.VERSION.RELEASE}")
                }
                "addTwoNumbers" -> {
                   val a = call.argument("a")!!
                   val b = call.argument("b")!!
                   result.success(a + b)
                }
                else -> {
                    result.notImplemented()
                }
            }
        }
    }
}

In this Kotlin code:

  • A MethodChannel is initialized with the same identifier as in Dart.
  • setMethodCallHandler listens for method calls from Flutter.
  • The when statement handles different method names.
  • result.success() sends data back to Flutter.
  • result.notImplemented() is called when the method is not implemented.
iOS (Swift)

In your iOS project (AppDelegate.swift), handle method calls from Flutter:


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 channel = FlutterMethodChannel(name: "com.example.my_app/native_channel",
                                              binaryMessenger: controller.binaryMessenger)
    channel.setMethodCallHandler({
      (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
      switch call.method {
        case "getPlatformVersion":
          result("iOS " + UIDevice.current.systemVersion)
        case "addTwoNumbers":
            let a = (call.arguments as! [String: Any])["a"] as! Int
            let b = (call.arguments as! [String: Any])["b"] as! Int
            result(a + b)
        default:
          result(FlutterMethodNotImplemented)
      }
    })

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

In this Swift code:

  • A FlutterMethodChannel is initialized with the same identifier as in Dart.
  • setMethodCallHandler listens for method calls from Flutter.
  • The switch statement handles different method names.
  • result() sends data back to Flutter.
  • FlutterMethodNotImplemented is called when the method is not implemented.

Step 3: Invoking Native Code from Dart

Now, call native methods from your Dart code:


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';
  int _sumResult = 0;

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

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

    setState(() {
      _platformVersion = version;
    });
  }

    Future addTwoNumbers() async {
    int result;
    try {
      result = await platform.invokeMethod('addTwoNumbers', {'a': 5, 'b': 3});
    } on PlatformException catch (e) {
      result = 0;
      print('Failed to add numbers: ${e.message}');
    }

    setState(() {
      _sumResult = result;
    });
  }


  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Native Channel Example'),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('Running on: $_platformVersionn'),
              ElevatedButton(
                onPressed: getPlatformVersion,
                child: const Text('Get Platform Version'),
              ),
              SizedBox(height: 20),
              Text('Sum of 5 + 3 is: $_sumResultn'),
              ElevatedButton(
                onPressed: addTwoNumbers,
                child: const Text('Add Two Numbers'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Here:

  • platform.invokeMethod('getPlatformVersion') calls the getPlatformVersion method in native code.
  • platform.invokeMethod('addTwoNumbers', {'a': 5, 'b': 3}) calls the addTwoNumbers method in native code with arguments.
  • The result from the native code is received asynchronously and updates the UI.

Sending Data

Data can be sent in both directions (Dart to Native and Native to Dart) using the invokeMethod and result methods, respectively. Here’s how data is typically handled:

Dart to Native

When calling a native method from Dart, you can pass arguments as a Map:


final Map params = {
  'arg1': 'Hello',
  'arg2': 123,
  'arg3': true,
};

final String result = await platform.invokeMethod('myNativeMethod', params);

In the native code, access these arguments via the call.arguments (Android) or call.arguments (iOS).

Native to Dart

When returning data from native code, use result.success(data) (Android) or result(data) (iOS), where data can be any of the following types:

  • null
  • bool
  • int
  • double
  • String
  • List (containing elements of the same type)
  • Map (containing keys of type String and values of the same type)
  • Uint8List, Int32List, Int64List, Float64List (platform versions >= 26)

Error Handling

Handling errors properly is crucial when interacting with native code. In Dart, you can use a try-catch block to handle exceptions:


try {
  final String result = await platform.invokeMethod('myNativeMethod');
} on PlatformException catch (e) {
  print("Error: '${e.message}'.");
  print("Details: '${e.details}'.");
  print("Code: '${e.code}'.");
}

In the native code, use the result.error(errorCode, errorMessage, errorDetails) (Android) or result(FlutterError(code: errorCode, message: errorMessage, details: errorDetails)) (iOS) method to return an error.

Use Cases

  • Battery Level: Retrieving the battery level of a device.
  • Camera Access: Accessing the device’s camera for custom image processing.
  • Geocoding: Converting addresses to geographical coordinates.
  • Biometric Authentication: Implementing fingerprint or facial recognition.

Best Practices

  • Keep Channels Narrow: Design platform channels to handle specific tasks to avoid complexity.
  • Handle Asynchronous Operations: Always use asynchronous operations to keep the UI responsive.
  • Manage Data Types: Ensure the data types match between Dart and Native to avoid unexpected behavior.
  • Test Thoroughly: Test channel communication on both Android and iOS devices.

Conclusion

Sending data between Flutter and native code through platform channels is a powerful feature that unlocks access to platform-specific capabilities and libraries. By defining channels, implementing method handlers in native code, and invoking these methods from Dart, developers can create rich, cross-platform applications. Effective data handling, proper error handling, and adherence to best practices will ensure reliable and performant integration between Flutter and native components.