ข้ามไปยังเนื้อหา

Platform Channels

Flutter วาด pixel ของตัวเอง แต่บางอย่างอยู่แค่ใน native OS เท่านั้น — battery level, sensor เฉพาะตัว, platform SDK, secure enclave เพื่อเข้าถึงมัน Flutter ใช้ platform channel: message pipe แบบ typed และ asynchronous ระหว่างโค้ด Dart ของคุณกับ native (Kotlin/Java บน Android, Swift/Objective-C บน iOS)

โมเดลนี้คือ async message passing แบบเดียวกับที่คุณรู้จักจาก Dart อยู่แล้ว: คุณส่ง message, ฝั่ง native จัดการมัน แล้ว Future ก็ complete ด้วยคำตอบ ไม่มีอะไร block UI thread เลย

flowchart LR
  dart["Dart
invokeMethod('getBattery')"] -->|message| platform["platform channel"]
  platform --> native["native code
reads battery level"]
  native -->|reply| platform
  platform -->|Future completes| dart
MethodChannel round-trip ไปกลับ

MethodChannel ตั้งชื่อ pipe แล้วให้คุณ invokeMethod บนมันได้ การเรียกเป็น async และ return Future ที่มีผลลัพธ์จาก native argument กับ result เป็น type มาตรฐาน (number, string, list, map) ที่ถูก serialize ข้าม boundary ด้วย MethodCodec — codec เดียวกับที่ mock handler ข้างล่าง round-trip ผ่าน ดังนั้น test พิสูจน์การ (de)serialize จริง ไม่ใช่แค่ stub

lib/battery_channel.dart
import 'package:flutter/services.dart';
const batteryChannel = MethodChannel('samples.app/battery');
Future<int> getBatteryLevel() async {
final level = await batteryChannel.invokeMethod<int>('getBatteryLevel');
return level ?? -1;
}

flutter_test มี mock messenger จริงสำหรับเรื่องนี้โดยเฉพาะ: TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, handler) ดักจับ channel แล้วให้ test ของคุณเล่นบทฝั่ง native

test/battery_channel_test.dart
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/battery_channel.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
tearDown(() {
// Always unregister — a handler left behind leaks into the next test.
messenger.setMockMethodCallHandler(batteryChannel, null);
});
test('a mocked native handler answers invokeMethod through the real codec round trip', () async {
messenger.setMockMethodCallHandler(batteryChannel, (call) async {
expect(call.method, 'getBatteryLevel');
return 76;
});
expect(await getBatteryLevel(), 76);
});
test('a native-side error surfaces as a real PlatformException', () async {
messenger.setMockMethodCallHandler(batteryChannel, (call) async {
throw PlatformException(code: 'UNAVAILABLE', message: 'Battery info unavailable');
});
expect(
() => batteryChannel.invokeMethod<int>('getBatteryLevel'),
throwsA(isA<PlatformException>().having((e) => e.code, 'code', 'UNAVAILABLE')),
);
});
test('with NO handler registered at all, invokeMethod throws the real MissingPluginException text', () async {
// No setMockMethodCallHandler call in this test — nothing is listening.
expect(
() => batteryChannel.invokeMethod<int>('getBatteryLevel'),
throwsA(isA<MissingPluginException>().having(
(e) => e.toString(),
'message',
'MissingPluginException(No implementation found for method getBatteryLevel on channel samples.app/battery)',
)),
);
});
}
✓ a mocked native handler answers invokeMethod through the real codec round trip
✓ a native-side error surfaces as a real PlatformException
✓ with NO handler registered at all, invokeMethod throws the real MissingPluginException text
00:00 +3: All tests passed!

ข้อความจริงของ test ที่สาม — MissingPluginException(No implementation found for method getBatteryLevel on channel samples.app/battery) — คือ toString() จริงของ MissingPluginException (packages/flutter/lib/src/services/message_codec.dart: 'MissingPluginException($message)') และมันคือสิ่งที่คุณได้จริง ๆ ในแอปจริงที่เรียก channel ที่ไม่มี platform implementation เลย (ไม่มี plugin ไม่มี native code) เคยลงทะเบียนไว้ — ไม่ใช่สมมติฐาน เป็นข้อความตัวเป๊ะที่ bug report “หา plugin ไม่เจอ” ทุกอันมี

ในขณะที่ MethodChannel คือหนึ่งการเรียกหนึ่งคำตอบ EventChannel ส่ง stream ของ event จาก native — sensor update, การเปลี่ยนแปลง connectivity, location มัน map เข้ากับ Dart Stream ดังนั้นคุณ consume มันเหมือน stream ปกติทุกประการ

lib/accelerometer_channel.dart
import 'package:flutter/services.dart';
const accelerometerChannel = EventChannel('samples.app/accelerometer');
Stream<double> accelerometerReadings() {
return accelerometerChannel.receiveBroadcastStream().cast<double>();
}

flutter_test มี mock ที่เทียบเท่าสำหรับ stream: setMockStreamHandler(channel, MockStreamHandler.inline(onListen: ...)) — onListen ได้รับ MockStreamHandlerEventSink ที่มี success/error/endOfStream เลียนแบบ EventSink ของ native จริง

test/accelerometer_channel_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/accelerometer_channel.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
test('EventChannel stream delivers mocked native events, in order, then closes', () async {
messenger.setMockStreamHandler(
accelerometerChannel,
MockStreamHandler.inline(
onListen: (Object? arguments, MockStreamHandlerEventSink sink) {
sink.success(1.0);
sink.success(2.0);
sink.endOfStream();
},
),
);
final received = await accelerometerReadings().toList();
expect(received, [1.0, 2.0]);
});
}
✓ EventChannel stream delivers mocked native events, in order, then closes
00:00 +1: All tests passed!

invokeMethod('getBatteryLevel') ที่เขียนเองเป็น stringly-typed ทั้งสองฝั่ง — การพิมพ์ชื่อ method ผิดหรือ argument shape ไม่ตรงกันคือความล้มเหลวตอน runtime ไม่ใช่ compile error ทั้งฝั่ง Dart และ ฝั่ง native Pigeon (ปัจจุบัน: 29.0.4 บน pub.dev) generate typed Dart API และ native interface ที่ตรงกัน (Kotlin, Swift และอื่น ๆ) จากไฟล์ Dart ไฟล์เดียว ดังนั้นการเปลี่ยนชื่อหรือ type จะ compile ไม่ผ่านทุกที่ที่ใช้ แทนที่จะพังแบบเงียบ ๆ ที่ channel boundary

// pigeons/battery.dart — the Pigeon *definition* file (not part of the shipped app)
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(PigeonOptions(
dartOut: 'lib/battery.g.dart',
kotlinOut: 'android/app/src/main/kotlin/com/example/app/Battery.g.kt',
kotlinOptions: KotlinOptions(package: 'com.example.app'),
swiftOut: 'ios/Runner/Battery.g.swift',
))
@HostApi()
abstract class BatteryHostApi {
int getBatteryLevel();
}

รัน generator จริง (dart run pigeon --input pigeons/battery.dart, pigeon 29.0.4 ไม่มี output ขึ้น console ตอนสำเร็จ — ไฟล์สามไฟล์ข้างล่างคือผลลัพธ์จริง ไม่ได้แก้อะไรเลย):

// generated by Pigeon — lib/battery.g.dart (real output, trimmed to the parts you call)
// Autogenerated from Pigeon (v29.0.4), do not edit directly.
class BatteryHostApi {
BatteryHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix =
messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
final String pigeonVar_messageChannelSuffix;
Future<int> getBatteryLevel() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.pigeon_demo.BatteryHostApi.getBatteryLevel$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final pigeonVar_replyList = await pigeonVar_channel.send(null) as List<Object?>?;
final pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as int;
}
}
// generated by Pigeon — android/.../Battery.g.kt (real output, trimmed)
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface BatteryHostApi {
fun getBatteryLevel(): Long
companion object {
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: BatteryHostApi?, messageChannelSuffix: String = "") {
val channel = BasicMessageChannel<Any?>(
binaryMessenger,
"dev.flutter.pigeon.pigeon_demo.BatteryHostApi.getBatteryLevel",
codec,
)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getBatteryLevel())
} catch (exception: Throwable) {
BatteryPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}

คุณ implement BatteryHostApi เป็น Kotlin class ธรรมดา class MainBatteryApi : BatteryHostApi { override fun getBatteryLevel(): Long = ... } แล้วเรียก BatteryHostApi.setUp(binaryMessenger, MainBatteryApi()) ครั้งเดียว — channel name string, codec และ message envelope ไม่เคยถูกเขียนหรืออ่านด้วยมือทั้งสองฝั่งเลย Swift stub (ios/Runner/Battery.g.swift) ถูก generate ด้วยวิธีเดียวกันจาก pigeons/battery.dart เดียวกัน

pubspec.yaml
dev_dependencies:
pigeon: ^29.0.4

โค้ด plugin/host ของ Android แต่ก่อนต้องการ Kotlin Gradle plugin แยกต่างหากที่ config ด้วยมือ release notes ของ Flutter เอง 3.44 ระบุเรื่องนี้ตรง ๆ — “Add Support For Built-in Kotlin” (landed, revert ชั่วคราว แล้ว re-land ใน cycle 3.44 เดียวกัน ตาม entry ของ changelog ใน release notes เอง) — หมายความว่าโปรเจกต์ Flutter ใหม่เอี่ยมสามารถใช้ Kotlin ในโค้ด host ฝั่ง Android (แบบที่ Battery.g.kt ที่ generate ข้างบนทำ) โดยไม่ต้องเพิ่มและ pin version ของ Kotlin Gradle plugin เองก่อน

คุณแทบไม่ต้องเขียนโค้ด channel หรือ Pigeon ด้วยมือสำหรับความต้องการทั่วไปเลย plugin คือ package ที่ห่อ platform channel (มากขึ้นเรื่อย ๆ ที่ Pigeon-generate) บวก native implementation ไว้หลัง Dart API ที่สะอาด — battery_plus, geolocator, camera เอื้อมไปหา plugin ที่ดูแลดีก่อน เขียน raw channel (หรือ Pigeon definition) เองเฉพาะสิ่งที่เฉพาะเจาะจงกับแอปคุณจริง ๆ

plugin ของ Flutter ทางการส่วนใหญ่วันนี้เป็นแบบ federated: แยกออกเป็น app-facing package, platform-interface package และ implementation package หนึ่งตัวต่อ platform — ยกจาก docs การพัฒนา package ของ Flutter เอง ตรง ๆ: “Federated plugins are a way of splitting the API of a plugin into a platform interface, independent platform implementations of that interface, and an app-facing interface that uses the registered implementation of the running platform.” คุณ depend แค่บน app-facing package; platform implementation ที่ถูกต้องถูก resolve ให้คุณ pubspec.yaml ของ platform implementation package ประกาศว่ามันครอบคลุม platform ไหนใต้ flutter: plugin: platforms: — รูปแบบจริงเดียวกับที่ docs โชว์:

# A federated platform-implementation package's pubspec.yaml
flutter:
plugin:
implements: hello
platforms:
windows:
pluginClass: HelloPlugin
# The app-facing package's pubspec.yaml — endorses a default implementation per platform
flutter:
plugin:
platforms:
android:
package: com.example.hello
pluginClass: HelloPlugin
ios:
pluginClass: HelloPlugin
windows:
default_package: hello_windows

Platform channel มีไว้เรียกเข้า API เฉพาะของ Android/iOS ผ่าน glue code ของ Kotlin/Swift สำหรับการเรียก native C library ตรง ๆ (ไม่มี layer ของ Kotlin/Swift เลย — image codec, crypto library, C/C++ engine ที่มีอยู่แล้ว) Dart มีกลไกแยกต่างหากที่ระดับต่ำกว่า: dart:ffi ยกจาก docs FFI/C-interop ทางการของ Dart ตรง ๆ: “Dart mobile, command-line, and server apps running on the Dart Native platform can use the dart:ffi library to call native C APIs, and to read, write, allocate, and deallocate native memory.” มันเป็นเครื่องมือคนละตัวสำหรับงานคนละแบบ — platform channel คุยกับ platform-specific SDK ผ่าน native glue code ที่คุณเขียน; dart:ffi คุยตรงกับ C ABI ไม่ต้องมี channel หรือ native glue เลย (แต่ยังต้อง bundle ตัว C library เองสำหรับ target platform อยู่ดี)

การเรียก platform channel ปกติรันบน root isolate การเรียกมันจาก background isolate (ใน compute() หรือ Isolate.run ครอบคลุมในโมดูล async-and-performance) ต้องการขั้นตอนเพิ่มอีกหนึ่งขั้น: BackgroundIsolateBinaryMessenger.ensureInitialized(token) โดย token คือ RootIsolateToken ที่ได้มาจาก root isolate ก่อน spawn background isolate (ServicesBinding.rootIsolateToken ยกจาก doc comment ของ class เองใน SDK 3.47.5 ที่ติดตั้งอยู่ packages/flutter/lib/src/services/_background_isolate_binary_messenger_io.dart) แล้วส่งข้ามไป ถ้าไม่เรียก ensureInitialized ก่อน BackgroundIsolateBinaryMessenger.instance จะ throw — ยกจาก source เดียวกันตรง ๆ: “The BackgroundIsolateBinaryMessenger.instance value is invalid until BackgroundIsolateBinaryMessenger.ensureInitialized is executed.”

// On the root isolate, before spawning:
final rootToken = ServicesBinding.rootIsolateToken!;
// Inside the background isolate's entry point:
BackgroundIsolateBinaryMessenger.ensureInitialized(rootToken);
// Platform channel calls now work on THIS isolate too.

ไม่ใช่ native แต่เป็นส่วนหนึ่งของโครงสร้างแอป: รูป, font และ JSON ที่คุณ bundle ต้องประกาศใน pubspec.yaml ถึงจะ ship ไปพร้อมแอป

flutter:
assets:
- assets/images/logo.png
- assets/data/
fonts:
- family: Inter
fonts:
- asset: assets/fonts/Inter-Regular.ttf
// Then load them by their declared path:
Image.asset('assets/images/logo.png');

platform channel คือ invokeMethod/EventChannel ฝั่ง Dart และ handler ที่ลงทะเบียนโดย key ด้วย channel-name string เดียวกันเป๊ะฝั่ง native — ไม่มีอะไรเชื่อมทั้งสองฝั่งตอน compile time เลย นั่นคือเหตุผลที่ MissingPluginException ที่พิสูจน์ไว้ข้างบนคือ bug ของ platform channel ที่พบบ่อยที่สุดตัวเดียว: channel name (หรือ platform) แค่ไม่มีใครฟังอยู่เลย Pigeon ไม่ได้เปลี่ยน wire format นั้นเลย — มันคือ named-channel, same-codec message pipe เดียวกันข้างใต้ — มันแค่ generate glue code ของการ match string, cast type ทั้งสองฝั่งจาก definition ที่ใช้ร่วมกันไฟล์เดียว ดังนั้นความไม่ตรงกันกลายเป็น compile error ใน generated code แทนที่จะเป็น string mismatch ตอน runtime dart:ffi เป็นคนละ tree ไปเลย — ไม่มี channel ไม่มี native glue เรียกตรงเข้า C ABI

ตัวเลือกข้อดีต้นทุน
MethodChannel/EventChannel เขียนเองไม่มี dependency เพิ่ม ควบคุมได้เต็มที่stringly-typed ทั้งสองฝั่ง; ไม่ตรงกันคือ MissingPluginException/crash ตอน runtime ไม่ใช่ compile error
channel ที่ Pigeon generatecompile-time-safe ทั้งฝั่ง Dart และ native, source of truth เดียวมี codegen step (dart run pigeon) อยู่ในลูป ยังเป็น channel เดียวกันข้างใต้อยู่ดี
plugin ที่ดูแลดี (battery_plus, geolocator, …)ไม่ต้องเขียนหรือดูแล channel code เองเลยพึ่งการดูแลของคนอื่น; ควบคุม native behavior ที่แน่นอนได้น้อยกว่า
dart:ffiเข้าถึง C ABI ตรง ๆ ไม่มี platform glue code เลยระดับต่ำกว่า (จัดการ memory เอง); ใช้ได้แค่กับ C library จริง ไม่ใช่ platform SDK
BackgroundIsolateBinaryMessengerเรียก platform channel นอก main isolate ได้มีขั้นตอน ensureInitialized เพิ่มอีกขั้นที่ต้องส่ง token จาก root isolate ก่อน
  • “MissingPluginException แปลว่า plugin package หายไปจาก pubspec.yaml” มันแปลว่าไม่มีอะไรตอบ channel name นั้นเป๊ะ ๆ — พิสูจน์แล้วข้างบน exception เดียวกันเกิดจาก channel ที่เขียนเองโดยไม่มี plugin เกี่ยวข้องเลย เมื่อไหร่ก็ตามที่แค่ไม่มี handler ลงทะเบียนไว้ (native-side code ยังไม่ได้เขียน, platform ผิด หรือ channel name พิมพ์ผิด)
  • “Pigeon แทนที่ platform channel ด้วยอะไรบางอย่าง” มัน generate โค้ด MethodChannel/BasicMessageChannel เดียวกับที่คุณจะเขียนเองด้วยมือ — พิสูจน์แล้วข้างบน Dart ที่ generate ยังสร้าง BasicMessageChannel ด้วย channel-name string และ codec ข้างใต้
  • “คุณเรียก platform channel จาก isolate ไหนก็ได้โดยไม่ต้อง setup พิเศษ” มีแค่ root isolate เท่านั้นที่ได้มาฟรี — background isolate ต้องการ BackgroundIsolateBinaryMessenger.ensureInitialized(token) ก่อน ไม่งั้น .instance throw StateError ตาม source ที่ยกมาข้างบน
  • “dart:ffi กับ platform channel แก้ปัญหาเดียวกัน แค่ความเร็วต่างกัน” มันแก้ปัญหาคนละแบบ: platform channel เข้าถึง SDK เฉพาะของ platform (Android/iOS API) ผ่าน native glue code; dart:ffi เข้าถึง C ABI ตรง ๆ ไม่มี platform glue และไม่มี channel เกี่ยวข้องเลย
  • “Federated plugin คือ Flutter คิดค้น plugin architecture ใหม่โดยไม่มีเหตุผล” มันแก้ปัญหาจริง: API เดียวที่ app-facing กับ platform implementation ที่สลับได้สะอาด ๆ (รวมถึง platform ที่ community มีส่วนร่วม) — พิสูจน์ด้วยการแยกเป็นสองไฟล์จริง (implements: hello ฝั่ง platform, default_package: ฝั่ง app-facing) ที่ยกมาจาก docs ข้างบน

pattern ที่ AI-generated เจอบ่อยมาก คือเรียก platform channel โดยไม่เคยห่อด้วย try/catch เลย — มันทำงานได้ใน demo (เครื่องที่มี plugin จริงติดตั้งอยู่) แล้ว throw exception ที่ไม่ถูกจัดการครั้งแรกที่มันรันที่ไหนสักแห่งที่ native side ไม่ได้ต่อสายไว้ (platform คนละตัว, CI simulator ใหม่เอี่ยม, plugin ที่ลงทะเบียนไม่สำเร็จอย่างเงียบ ๆ)

// AI-generated: "get the battery level and show it"
Future<void> showBatteryLevel() async {
final level = await batteryChannel.invokeMethod<int>('getBatteryLevel');
print('Battery: $level%');
}

โค้ดนี้ compile ผ่านสะอาดและทำงานได้ดีในกรณีทั่วไป พิสูจน์แล้วข้างบน โหมดความล้มเหลวจริงเมื่อไม่มีอะไรตอบ channel คือ MissingPluginException ไม่ใช่ผลลัพธ์เป็น null — print('Battery: $level%') ไม่เคยรันเลยด้วยซ้ำ await throw ก่อนถึงบรรทัดนั้น ทางแก้ห่อโหมดความล้มเหลวที่เป็นไปได้จริงทั้งสองแบบ:

Future<void> showBatteryLevel() async {
try {
final level = await batteryChannel.invokeMethod<int>('getBatteryLevel');
print('Battery: $level%');
} on MissingPluginException {
print('No native battery implementation registered on this platform.');
} on PlatformException catch (e) {
print('Native side reported an error: ${e.message}');
}
}

🐛 หา bug ในโค้ดนี้

// AI-generated: "read accelerometer data off the main thread to avoid jank"
Future<void> startAccelerometerInBackground() async {
await Isolate.run(() {
final events = EventChannel('samples.app/accelerometer');
events.receiveBroadcastStream().listen((e) => print(e));
});
}
เฉลย

การเรียก platform channel ทำงานได้แค่บน isolate ที่มี BinaryMessenger ลงทะเบียนไว้เท่านั้น — root isolate ได้มาโดยอัตโนมัติ แต่ background isolate ของ Isolate.run ไม่ได้ เว้นแต่จะเรียก BackgroundIsolateBinaryMessenger.ensureInitialized(token) ก่อน ด้วย RootIsolateToken ที่ได้จาก root isolate ก่อน spawn snippet นี้ไม่เคยเรียกมันเลย และยังไม่เคย capture token ด้วย (ซึ่งอ่านได้แค่บน root isolate เท่านั้น ไม่ใช่จากข้างใน closure ที่ spawn) — ดังนั้นการเรียก EventChannel ข้างใน Isolate.run จะ throw BackgroundIsolateBinaryMessenger.instance is invalid until ensureInitialized is executed ทางแก้คือ capture final token = ServicesBinding.rootIsolateToken!; บน root isolate ส่ง token เข้าไปใน isolate closure แล้วเรียก BackgroundIsolateBinaryMessenger.ensureInitialized(token) เป็นบรรทัดแรกสุดข้างใน เหมือนที่แสดงไว้ในบทนี้เป๊ะ

platform channel คืออะไร?
ตาม widget test ในบทนี้ exception และ message ที่แน่นอนที่ invokeMethod throw เมื่อไม่มี handler ลงทะเบียนบน channel เลยคืออะไร?
Pigeon generate อะไร ตาม output จริงของ dart run pigeon ในบทนี้?
การเรียก platform channel จาก background isolate (เช่น ข้างใน Isolate.run) ต้องการอะไร ตาม source ที่ยกมาในบทนี้?
dart:ffi มีไว้ทำอะไร ตาม docs ทางการที่ยกมาในบทนี้ และต่างจาก platform channel อย่างไร?
ตาม docs ที่ยกมาในบทนี้ federated plugin แยกโครงสร้างอะไรออกจากกัน?