Platform Channels & Native Integration

Codecs & Data Serialization Across the Channel

16 min Lesson 7 of 11

Codecs & Data Serialization Across the Channel

Every message exchanged between Flutter and native code must be serialized into bytes before it crosses the platform boundary, and then deserialized back on the other side. The component responsible for this transformation is called a codec. Flutter ships four built-in codecs, each optimized for a different use case. Choosing the right one directly impacts both performance and type fidelity.

The Four Built-in Codecs

Flutter's services library provides these codecs out of the box:

  • StandardMessageCodec — the default codec, supports rich Dart types (int, double, bool, String, Uint8List, List, Map) with an efficient binary encoding
  • JSONMessageCodec — encodes messages as UTF-8 JSON strings; good for interoperability, but loses type precision (all numbers become doubles)
  • BinaryCodec — passes raw ByteData unchanged; zero encoding overhead, but you own all serialization logic
  • StringCodec — encodes a single Dart String as UTF-8; useful for simple text-only protocols
Note: The codec is specified when constructing the channel object — MethodChannel, BasicMessageChannel, or EventChannel. Only BasicMessageChannel lets you swap in a non-default codec; MethodChannel and EventChannel are fixed to StandardMessageCodec.

StandardMessageCodec in Depth

StandardMessageCodec is the workhorse of platform channels. It encodes each value with a one-byte type tag followed by the payload. The type mapping between Dart and Java/Kotlin/Swift/ObjC is well-defined:

  • null → null
  • bool → Boolean
  • int (32-bit) → Integer; (64-bit) → Long
  • double → Double
  • String → String (UTF-8)
  • Uint8List / Int32List / Int64List / Float64List → typed byte arrays
  • List → ArrayList (Java) / Array (Swift)
  • Map → HashMap (Java) / Dictionary (Swift)

BasicMessageChannel with StandardMessageCodec (default)

import 'package:flutter/services.dart';

// StandardMessageCodec is the default — you can omit it
const _channel = BasicMessageChannel<dynamic>(
  'com.example.app/settings',
  StandardMessageCodec(),
);

Future<void> sendPreferences() async {
  // All these Dart types survive the round-trip intact
  final reply = await _channel.send({
    'userId': 42,           // int  → Integer on Android
    'score': 3.14,          // double → Double
    'enabled': true,        // bool  → Boolean
    'tags': ['a', 'b'],     // List  → ArrayList
    'meta': <String, dynamic>{'key': 'value'},  // Map → HashMap
  });
  print('Native replied: $reply');
}

JSONMessageCodec

JSONMessageCodec serializes your message to a JSON string (via jsonEncode) before handing it to the native layer, and deserializes it on the way back. This makes it trivially interoperable with any platform that can parse JSON, but there is a significant trade-off: all numeric types collapse to double on deserialization because JSON has a single number type. Integers will arrive as doubles unless you cast them explicitly.

BasicMessageChannel with JSONMessageCodec

import 'package:flutter/services.dart';

const _jsonChannel = BasicMessageChannel<dynamic>(
  'com.example.app/json',
  JSONMessageCodec(),
);

Future<void> fetchConfig() async {
  // Send: Dart Map serialized to JSON string automatically
  final result = await _jsonChannel.send({'action': 'getConfig'});

  // result is decoded from JSON — numeric values are doubles
  if (result is Map) {
    // CAUTION: 'version' was an int on native, arrives as double here
    final version = (result['version'] as double).toInt();
    print('Config version: $version');
  }
}

BinaryCodec

BinaryCodec performs no encoding — it passes a ByteData object directly to the native side as raw bytes. This is the highest-performance option: there is zero overhead from type-tagging or JSON stringification. Use it when you are implementing your own binary protocol (e.g., Protocol Buffers, MessagePack, FlatBuffers) or streaming large binary payloads such as camera frames or audio buffers.

BinaryCodec for raw byte transfer

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

const _binaryChannel = BasicMessageChannel<ByteData?>(
  'com.example.app/binary',
  BinaryCodec(),
);

Future<void> sendRawFrame(Uint8List pixels) async {
  // Wrap Uint8List in a ByteData view — zero copy
  final byteData = pixels.buffer.asByteData();

  final response = await _binaryChannel.send(byteData);
  if (response != null) {
    final ack = response.getUint8(0);
    print('Native ACK: $ack');
  }
}

StringCodec

StringCodec encodes a single String as UTF-8 bytes. It is the simplest codec and a good choice for text-only channels — for example, sending log messages to the native side or receiving locale strings from the OS. It has negligible overhead but only handles String; passing any other type throws an assertion error in debug mode.

Tip: Use StringCodec when the channel carries simple commands or labels, and combine it with a lightweight parsing convention (e.g., pipe-separated values) rather than paying the full JSON encoding cost for a small payload.

Choosing the Right Codec

Use the following decision guide when designing a new channel:

  • Need rich types (int, List, Map, typed arrays) with accurate round-trips?StandardMessageCodec (default choice)
  • Integrating with a third-party native library that speaks JSON?JSONMessageCodec (watch out for int→double coercion)
  • Streaming raw bytes or using a custom binary protocol?BinaryCodec
  • Channel carries only a plain string?StringCodec
Warning: Never use JSONMessageCodec when your protocol relies on integer precision (e.g., 64-bit IDs, Unix timestamps in milliseconds). JSON does not distinguish integers from floating-point numbers, and you will silently lose precision for large integers beyond 2^53.

Custom Codecs

You can implement a custom codec by extending MessageCodec<T> and overriding encodeMessage and decodeMessage. This is the right path for Protocol Buffers or any schema-driven serialization format. The native counterpart must implement the corresponding codec on the Android (MessageCodec<T>) or iOS (FlutterMessageCodec) side.

Summary

Flutter's four built-in codecs cover the full spectrum from richly-typed binary encoding (StandardMessageCodec) to raw byte passthrough (BinaryCodec). StandardMessageCodec is the right default for most channels because it preserves Dart's native types across the boundary. Switch to JSONMessageCodec only when JSON interoperability is a hard requirement, and always account for the int-to-double coercion it introduces. Reach for BinaryCodec when performance is critical and you own the serialization format end-to-end.