Platform Channels & Native Integration

MethodChannel: Calling Native Code from Dart

15 min Lesson 2 of 11

MethodChannel: Calling Native Code from Dart

A MethodChannel is the primary mechanism Flutter provides for invoking platform-specific (native) code from Dart. It establishes a named, bidirectional communication pipe between the Dart layer and the native Android (Kotlin/Java) or iOS (Swift/Objective-C) layer. On the Dart side you call a named method; the native side handles that call and returns a result. The entire exchange is asynchronous and uses a codec to serialize arguments and return values.

Note: MethodChannel uses the StandardMethodCodec by default, which supports a rich set of primitive types: null, bool, int, double, String, Uint8List, Int32List, Int64List, Float64List, List, and Map. Complex objects must be serialized to one of these types before crossing the channel boundary.

Creating a MethodChannel

On the Dart side, you instantiate a MethodChannel with a unique channel name. This name is a string that must match exactly on both the Dart and native sides — a mismatch silently means no handler is found and a PlatformException is thrown with the code MissingPluginException.

Declaring and Using a MethodChannel

import 'package:flutter/services.dart';

class BatteryService {
  // The channel name must match the native registration exactly
  static const MethodChannel _channel = MethodChannel('com.example.myapp/battery');

  /// Returns the current battery level as an integer (0–100).
  Future<int> getBatteryLevel() async {
    try {
      final int level = await _channel.invokeMethod<int>('getBatteryLevel') ?? -1;
      return level;
    } on PlatformException catch (e) {
      // Native code threw an error — surface it to the caller
      throw Exception('Failed to get battery level: ${e.message}');
    }
  }
}

Key points about the snippet above:

  • MethodChannel is imported from package:flutter/services.dart
  • The channel is declared static const — create it once, reuse it everywhere
  • invokeMethod<T>(methodName, [arguments]) is generic: you supply the expected return type so Dart can cast the decoded value for you
  • The call returns a Future<T?> — always await it inside an async function
  • Wrap every call in a try/catch for PlatformException

Passing Arguments to Native Methods

You can pass an optional second argument to invokeMethod. It can be any value supported by the codec — most commonly a Map<String, dynamic> when you need to send multiple named parameters.

Sending Arguments Through the Channel

class FilePickerService {
  static const MethodChannel _channel = MethodChannel('com.example.myapp/files');

  /// Opens the native file picker filtered to [allowedExtensions].
  Future<String?> pickFile({required List<String> allowedExtensions}) async {
    try {
      final String? path = await _channel.invokeMethod<String>(
        'pickFile',
        <String, dynamic>{
          'extensions': allowedExtensions,  // List<String> is codec-safe
          'multiple': false,
        },
      );
      return path; // null if the user cancelled
    } on PlatformException catch (e) {
      debugPrint('pickFile error [${e.code}]: ${e.message}');
      return null;
    }
  }
}

Handling PlatformException

When native code cannot fulfil a request it throws a platform exception that arrives on the Dart side as a PlatformException. This exception carries three fields:

  • code — a short machine-readable error code (e.g., "UNAVAILABLE", "PERMISSION_DENIED")
  • message — a human-readable description
  • details — optional extra data (any codec-supported value)

In addition to PlatformException, invokeMethod can throw a MissingPluginException when no native handler has been registered for the channel name, and a FormatException when the returned value cannot be cast to the requested type T. Catch each appropriately in production code.

Fine-Grained Exception Handling

Future<String> readSecureValue(String key) async {
  const channel = MethodChannel('com.example.myapp/keychain');
  try {
    final String? value = await channel.invokeMethod<String>('read', {'key': key});
    if (value == null) throw StateError('Key not found: $key');
    return value;
  } on PlatformException catch (e) {
    switch (e.code) {
      case 'PERMISSION_DENIED':
        throw Exception('Biometric authentication required.');
      case 'NOT_FOUND':
        throw Exception('No value stored for key "$key".');
      default:
        throw Exception('Native error [${e.code}]: ${e.message}');
    }
  } on MissingPluginException {
    throw UnsupportedError('Keychain channel is not registered on this platform.');
  }
}

Best Practices for MethodChannel Usage

  • Use reverse-DNS naming — e.g., com.yourcompany.appname/featurename to avoid name collisions with plugins
  • Encapsulate in a service class — never scatter raw invokeMethod calls across the widget tree; centralise them in a dedicated Dart service or repository class
  • Always handle errors — native operations can fail due to permissions, OS version constraints, or hardware absence
  • Keep channels focused — one channel per feature domain (battery, camera, keychain) rather than a single "catch-all" channel
  • Test on real devices — many native APIs (NFC, biometrics) are unavailable on simulators
Tip: If you want to call Dart code from the native side (the reverse direction), use channel.setMethodCallHandler() on the Dart side to register a handler that the native code can invoke at any time. This is covered in the next lesson on bidirectional communication.

Summary

A MethodChannel bridges the Dart world and the native platform through a named channel. You call invokeMethod<T>(name, args) and await the resulting Future. Arguments and return values are serialised by the StandardMethodCodec. Always catch PlatformException — and optionally MissingPluginException — to handle native errors gracefully. Encapsulating channel calls in a dedicated service class keeps your Flutter code clean, testable, and decoupled from platform specifics.