【Flutter】型安全に MethodChannel でネイティブとデータ通信を行う
概要
今回の記事では、型安全に MethodChannel で Flutter と iOS ネイティブ間のデータ通信を行う方法を紹介します。
作ったサンプルプロジェクトは、以下のような機能を持っています。
そして、サンプルとして作ったアプリの表示は以下のようになります。
背景
業務内プロジェクトで、Flutter と iOS ネイティブ間でのデータ通信を行う必要がありました。
その際に MethodChannel を使用したものの、相互通信でやり取りするデータの型が不明な状態でやり取りしなければいけません。
データの型が不明な状態でやり取りしなければいけないことにもやもやしていました。
もやもやしている中、以下の公式ドキュメントに構造化された型安全なメッセージを送ることができるパッケージ pigeon
が紹介されいることに気づきました。
Writing custom platform-specific code
Alternatively, you can use the Pigeon package for sending structured typesafe messages with code generation:
使用するライブラリ
Pigeon is a code generator tool to make communication between Flutter and the host platform type-safe, easier and faster.
開発環境
fvm flutter --version
Flutter 3.3.6 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 6928314d50 (2 weeks ago) • 2022-10-25 16:34:41 -0400
Engine • revision 3ad69d7be3
Tools • Dart 2.18.2 • DevTools 2.15.0
手順
1. パッケージをプロジェクトに追加する
Add Pigeon as a dev_dependency.
dev_dependencies:
pigeon: ^4.2.5
2. コード生成する際に使用されるスキーマを定義する
Make a ".dart" file outside of your "lib" directory for defining the communication interface.
Path: pigeons/story.dart
import 'package:pigeon/pigeon.dart';
class Story {
String? title;
String? author;
List<Comment?>? comments;
double? rates;
int? year;
bool? isFavorite;
}
class Comment {
String? user;
String? body;
}
()
abstract class HostStoryApi {
Story? respond();
}
@HostApi アノテーションについて
APIs should be defined as an abstract class with either HostApi() or FlutterApi() as metadata.
The former being for procedures that are defined on the host platform and the latter for procedures that are defined in Dart.
今回の実装では、ホスト(iOS ネイティブ)側で関数内を定義するために、@HostApi()
アノテーションを追加します。
3. スキーマを基に、コード生成する
以下のようなコマンドを通じて、生成できます。
それぞれのオプションに付与しているパス名は各自の環境で変更してください。
flutter pub run pigeon \
--input pigeons/story.dart \
--dart_out lib/pigeons_output/story.dart \
--objc_header_out ios/Runner/story.h \
--objc_source_out ios/Runner/story.m \
--experimental_swift_out ios/Runner/Story.swift
Android ネイティブ側のコード生成などは、こちらの公式例を参考に実行してください。
注意点
- objective-c 側のヘッダーファイルとソースファイルの生成は絶対に必要です。
- プロジェクト内で使用しているライブラリによっては、
pigeon
が依存するパッケージとのバージョンに差異が発生し、コード生成処理が失敗する可能性があります- 私の環境では、
freezed
が依存するAnalyzer
パッケージバージョンの差異により、freezed
のバージョンをダウングレードしました
- 私の環境では、
生成される dart ファイル
// Autogenerated from Pigeon (v4.2.5), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
import 'package:flutter/services.dart';
class Story {
Story({
this.title,
this.author,
this.comments,
this.rates,
this.year,
this.isFavorite,
});
String? title;
String? author;
List<Comment?>? comments;
double? rates;
int? year;
bool? isFavorite;
Object encode() {
final Map<Object?, Object?> pigeonMap = <Object?, Object?>{};
pigeonMap['title'] = title;
pigeonMap['author'] = author;
pigeonMap['comments'] = comments;
pigeonMap['rates'] = rates;
pigeonMap['year'] = year;
pigeonMap['isFavorite'] = isFavorite;
return pigeonMap;
}
static Story decode(Object message) {
final Map<Object?, Object?> pigeonMap = message as Map<Object?, Object?>;
return Story(
title: pigeonMap['title'] as String?,
author: pigeonMap['author'] as String?,
comments: (pigeonMap['comments'] as List<Object?>?)?.cast<Comment?>(),
rates: pigeonMap['rates'] as double?,
year: pigeonMap['year'] as int?,
isFavorite: pigeonMap['isFavorite'] as bool?,
);
}
}
class Comment {
Comment({
this.user,
this.body,
});
String? user;
String? body;
Object encode() {
final Map<Object?, Object?> pigeonMap = <Object?, Object?>{};
pigeonMap['user'] = user;
pigeonMap['body'] = body;
return pigeonMap;
}
static Comment decode(Object message) {
final Map<Object?, Object?> pigeonMap = message as Map<Object?, Object?>;
return Comment(
user: pigeonMap['user'] as String?,
body: pigeonMap['body'] as String?,
);
}
}
class _HostStoryApiCodec extends StandardMessageCodec{
const _HostStoryApiCodec();
void writeValue(WriteBuffer buffer, Object? value) {
if (value is Comment) {
buffer.putUint8(128);
writeValue(buffer, value.encode());
} else
if (value is Story) {
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else
{
super.writeValue(buffer, value);
}
}
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 128:
return Comment.decode(readValue(buffer)!);
case 129:
return Story.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
}
}
class HostStoryApi {
/// Constructor for [HostStoryApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
HostStoryApi({BinaryMessenger? binaryMessenger}) : _binaryMessenger = binaryMessenger;
final BinaryMessenger? _binaryMessenger;
static const MessageCodec<Object?> codec = _HostStoryApiCodec();
Future<Story?> respond() async {
final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.HostStoryApi.respond', codec, binaryMessenger: _binaryMessenger);
final Map<Object?, Object?>? replyMap =
await channel.send(null) as Map<Object?, Object?>?;
if (replyMap == null) {
throw PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel.',
);
} else if (replyMap['error'] != null) {
final Map<Object?, Object?> error = (replyMap['error'] as Map<Object?, Object?>?)!;
throw PlatformException(
code: (error['code'] as String?)!,
message: error['message'] as String?,
details: error['details'],
);
} else {
return (replyMap['result'] as Story?);
}
}
}
生成される Swift ファイル
// Autogenerated from Pigeon (v4.2.5), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation
#if os(iOS)
import Flutter
#elseif os(macOS)
import FlutterMacOS
#else
#error("Unsupported platform.")
#endif
/// Generated class from Pigeon.
///Generated class from Pigeon that represents data sent in messages.
struct Story {
var title: String? = nil
var author: String? = nil
var comments: [Comment?]? = nil
var rates: Double? = nil
var year: Int32? = nil
var isFavorite: Bool? = nil
static func fromMap(_ map: [String: Any?]) -> Story? {
let title = map["title"] as? String
let author = map["author"] as? String
let comments = map["comments"] as? [Comment?]
let rates = map["rates"] as? Double
let year = map["year"] as? Int32
let isFavorite = map["isFavorite"] as? Bool
return Story(
title: title,
author: author,
comments: comments,
rates: rates,
year: year,
isFavorite: isFavorite
)
}
func toMap() -> [String: Any?] {
return [
"title": title,
"author": author,
"comments": comments,
"rates": rates,
"year": year,
"isFavorite": isFavorite
]
}
}
///Generated class from Pigeon that represents data sent in messages.
struct Comment {
var user: String? = nil
var body: String? = nil
static func fromMap(_ map: [String: Any?]) -> Comment? {
let user = map["user"] as? String
let body = map["body"] as? String
return Comment(
user: user,
body: body
)
}
func toMap() -> [String: Any?] {
return [
"user": user,
"body": body
]
}
}
private class HostStoryApiCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
case 128:
return Comment.fromMap(self.readValue() as! [String: Any])
case 129:
return Story.fromMap(self.readValue() as! [String: Any])
default:
return super.readValue(ofType: type)
}
}
}
private class HostStoryApiCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) {
if let value = value as? Comment {
super.writeByte(128)
super.writeValue(value.toMap())
} else if let value = value as? Story {
super.writeByte(129)
super.writeValue(value.toMap())
} else {
super.writeValue(value)
}
}
}
private class HostStoryApiCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return HostStoryApiCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return HostStoryApiCodecWriter(data: data)
}
}
class HostStoryApiCodec: FlutterStandardMessageCodec {
static let shared = HostStoryApiCodec(readerWriter: HostStoryApiCodecReaderWriter())
}
///Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol HostStoryApi {
func respond() -> Story?
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class HostStoryApiSetup {
/// The codec used by HostStoryApi.
static var codec: FlutterStandardMessageCodec { HostStoryApiCodec.shared }
/// Sets up an instance of `HostStoryApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: HostStoryApi?) {
let respondChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.HostStoryApi.respond", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
respondChannel.setMessageHandler { _, reply in
let result = api.respond()
reply(wrapResult(result))
}
} else {
respondChannel.setMessageHandler(nil)
}
}
}
private func wrapResult(_ result: Any?) -> [String: Any?] {
return ["result": result]
}
private func wrapError(_ error: FlutterError) -> [String: Any?] {
return [
"error": [
"code": error.code,
"message": error.message,
"details": error.details
]
]
}
4. Flutter 側で、メソッドを呼ぶ実装をする
Future<void> onPressedRequestPigeon() async {
try {
final storyApi = HostStoryApi();
final story = await storyApi.respond();
final comments = _parseComments(story?.comments);
// 状態を更新する
} catch (error) {
debugPrint(error.toString());
}
}
/// 受け取るデータが List<Comment?>? comments のための対応
List<UserComment> _parseComments(List<Comment?>? comments) {
if (comments == null) {
return [];
}
List<UserComment> userComments = [];
for (var comment in comments) {
final userComment = UserComment(
user: comment?.user ?? 'Anonymous',
body: comment?.body ?? '',
);
userComments.add(userComment);
}
return userComments;
}
生成したコードのおかげで、データの補完が効きます。
5. 受信したリクエストに対応する protocol を基に、Flutter 側にデータ返却実装を組み込む
Implement the generated iOS protocol for handling the calls on iOS
生成された Swift ファイル内に、respond
メソッドを保持する HostStoryApi
プロトコル(他言語でのインターフェース)があります。
///Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol HostStoryApi {
func respond() -> Story?
}
このプロトコルを継承したクラスを作成し、respond
メソッド内にデータ返却実装を加えていきます。
Path: StorySender.swift
import Foundation
class StorySenderApi: NSObject, HostStoryApi {
func respond() -> Story? {
let comments = [
Comment(user: "John", body: "Excellent Story!!"),
Comment(user: "Alice", body: "What a beautiful story!"),
Comment(user: "Adam", body: "You are an amazing writer."),
]
let story = Story(title: "Never let me go", author: "K.K", comments: comments, rates: 9.7, year: 2005, isFavorite: true)
return story
}
}
6. AppDelegate.swift に設定する
set it up as the handler for the messages.
AppDelegate に 2 行のコード追加するのみです。
import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
setupStoryApi()
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
private func setupStoryApi() -> Void {
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
HostStoryApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: StorySenderApi())
}
}
7. ビルドする
iOS ネイティブからの受け取ったデータを UI 上に表示して確認することができました。
サンプルプロジェクト
https://github.com/Kotaro666-dev/flutter_practice/tree/main/pigen_practice
参考資料
- ネイティブから逃げるな。Pigeonを使ったタイプセーフなFlutter + ネイティブ開発
- 【Flutter】初めてのPigeon
- Pigeon Examples
- pigeon package documentation
※上記で挙げた日本語記事は現行の最新バージョンと異なるせいか、実装方法に異なる点があります
Discussion