🐈

【Flutter】型安全に MethodChannel でネイティブとデータ通信を行う

2022/11/12に公開

概要

今回の記事では、型安全に MethodChannel で Flutter と iOS ネイティブ間のデータ通信を行う方法を紹介します。

作ったサンプルプロジェクトは、以下のような機能を持っています。

PlantUML シークエンス図

そして、サンプルとして作ったアプリの表示は以下のようになります。

背景

業務内プロジェクトで、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

参考資料

※上記で挙げた日本語記事は現行の最新バージョンと異なるせいか、実装方法に異なる点があります

GitHubで編集を提案

Discussion