77
55

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Flutter #1Advent Calendar 2020

Day 6

ネイティブから逃げるな。Pigeonを使ったタイプセーフなFlutter + ネイティブ開発

Last updated at Posted at 2020-12-05

はじめに

この記事はFlutterAdventカレンダー2020#1 の6日目の記事です。

こんにちは。@glassmonkeyです。
最近サボりがちですがたまにFlutterのことなどをつぶやいてたりします。
仕事ではサーバーサイドエンジニアをやっています。普段はPHP,Go,Pythonあたりを書くことが多いですが、趣味でFlutterアプリ開発をしています。

今回はFlutterの1.20リリースで発表されたPigeonを使ったネイティブ連携をやってみたので、そのご紹介になります。

ネイティブ開発はほぼ素人です。Swiftは業務で3ヶ月ほど、Kotlinは今回初めてまともに書きましたが
そこまで苦労せずに書けました。

これからネイティブのプラグインを作らないといけないと思ってる方々などの助けになれば幸いです。

要約

  • Pigeonは静的ジェネレータだよ
  • 型ファイルからネイティブとdartファイルを生成するので、各種補完が効くようになるよ
  • バージョン1未満だからこれからが楽しみだね

サンプルコードについて

この記事で紹介したプログラムに関してはglassmonkey/flutter_wifiにアップしているので、もしよければご覧ください。

今回用意したサンプルは、このような感じで
Wifiの接続状態(Wifi/モバイル通信/接続なし)をネイティブからコールバックで受け取って表示を切り替える簡単なものです。動画はAndroidのものですが、iOSも同様です。

  • ボタンを押すと現在の接続状態がわかる
    ボタンを押して検知モード(ボタンが緑色)の状態になると接続状態を表示します。
    • Wifi Connection(Wifi接続中のとき)
    • Mobile Connection(いわゆるキャリア通信のとき)
    • Lost Connection(機内モードや圏外のとき)

switch.gif

  • 接続状態がわかる状態だと現在の接続状態が変更すると自動で表示が切り替わる

例だとクイックメニューでWifiをOFFにするとキャリア通信に切り替えたので、
Wifi ConnectionからMobile Connectionに表示が切り替わっています。

9684a63db6b4aa2a019c59f56eb663e8.gif

各種バージョンについて

執筆にあたっての筆者の各バージョンはこのような感じです

$ flutter doctor
[✓] Flutter (Channel stable, 1.22.3, on Mac OS X 10.15.7 19H15, locale ja-JP)
 
[✓] Android toolchain - develop for Android devices (Android SDK version 29.0.2)
[✓] Xcode - develop for iOS and macOS (Xcode 12.2)
[✓] Android Studio (version 4.0)

また、サンプルアプリの実機確認では以下のバージョンの両OSで確認を取りました。

iOSに関してはシミュレータで接続状態のシミューレートの確認ができなかったので実機で動作させることをおすすめします。

Anrdroid: 9
iOS: 14.1 

Androidに関してはminSdkVersion: 21としたので、実機で確認する場合はOSのバージョンなどご注意ください。

Flutterとネイティブ連携について

私自身ほとんどネイティブ機能に関して書いた経験がないのですが公式
に詳しく記載されているので詳細はそちらをご覧いただけたらと思います。

基本的な構成は以下の図の通りです。
MethodCnannelと呼ばれる通信経路を用意して、ネイティブとFlutterをやりとりする形となります。
これは後術するPigeonを使った方法でも代わりません。FlutterとAndroidの実装例を示します。

PlatformChannels.png
platform-integration/platform-channelsより引用しました。

1. メソッドチャンネルを用意する

Flutter側のmethodチャンネルの呼び出しをします。

final platform = const MethodChannel('samples.flutter.dev/battery');

ネイティブ側の呼び出しを登録します。


class MainActivity: FlutterActivity() {
  private val CHANNEL = "samples.flutter.dev/battery"

  override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)
    MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
      call, result ->
      // Note: this method is invoked on the main thread.
      // TODO
    }
  }
}

慣例としてメソッドチャンネル名はパッケージ名を使って、他のアプリとの衝突を防ぐようにしておくようです。

2. メソッドチャンネルからメソッドを呼び出す


 final int result = await platform.invokeMethod('getBatteryLevel');

ネイティブ側の実装は以下の感じです


  private fun getBatteryLevel(): Int {
    val batteryLevel: Int
    if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
      val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
      batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
    } else {
      val intent = ContextWrapper(applicationContext).registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
      batteryLevel = intent!!.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) * 100 / intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
    }

    return batteryLevel
  }

この実装でも悪くはないのですが、メソッド呼び出しが

 result = await platform.invokeMethod('getBatteryLevel');

といった形式になるので、コンパイルセーフではないといった問題があげられます。補完も効かないですしね。

連携するメソッドが増えてくれば結構きつくなってきそうかなと思います。

Pigeonとは

そこで、従来の補完が効かないといった問題の対処に開発されたのが、Pigeonとよばれるツールです。

Flutter1.20で公式で公開された、
Flutterとホストプラットフォーム間の通信をタイプセーフで簡単にするコードジェネレータツールです。

生成もとのスキーマファイルとなるDartファイルを用意しておき、
iOSのためのObjective-CとAndroid用のJavaを生成します。

うげっと思われる方もいらっしゃるかもしれませんが、SwiftからObject-Cのコードは呼べますし、
KotlinからもJavaのコードは呼べるので実際はそんなに気にならなかったりはします。

データのやりとりはいわゆるスキーマファイルから各環境共通のApi定義をしてFlutterをクライアント、
ネイティブをサーバーとして、サバクラなやりとりをします。

まだまだバージョン1にもなっていないですが、issueがたくさんあるのでこれからに期待したいですね
https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+pigeon

スキーマファイルについて

以下のような感じで書きます。

ファイル生成は --inputでスキーマファイルを指定して実行できます。
スキーマファイルのパスは自由に決めちゃってください。lib配下でなくてもOKです。

$ flutter pub run pigeon --input lib/pigeon/scheme.dart

Scheme.dart

import 'package:pigeon/pigeon.dart';

// 引数の定義
class BatteryRequest {
  String unit;
}

// 戻り値の定義
class BatteryResponse {
  String responseMessage;
}

@HostApi()
abstract class BatteryApi {
  BatteryResponse call(BatteryRequest req);
}

// 戻り値の定義
class WifiResponse {
  bool availableWifi;
  bool availableMobile;
}

@HostApi()
abstract class WifiApi {
  WifiResponse call();
}

@FlutterApi()
abstract class WifiCallbackApi {
  void apply(WifiResponse response);
}

// 生成されるファイルの出力先などの設定
void configurePigeon(PigeonOptions opts) {
  opts.dartOut = 'lib/native/api.dart';
  opts.javaOut = 'android/app/src/main/java/nagano/shunsuke/plugins/Pigeon.java';
  opts.javaOptions.package = "nagano.shunsuke.plugins";
  opts.objcHeaderOut = 'ios/Runner/Pigeon.h';
  opts.objcSourceOut = 'ios/Runner/Pigeon.m';
  opts.objcOptions.prefix = 'Flutter';
  //nnbdしたいときはこれをONにする
  //opts.dartOptions.isNullSafe = true;
}

このスキーマファイルで重要な点は3点あります。

  • Apiを定義する
    データをやりとりするApiになります。
    ネイティブ側で機能を実装するう@HostApiとFlutter側で実装をする@FlutterApiが用意されています。

  • Apiでやりとりするためのデータ構造を決定する。
    ネイティブ Flutter間でデータをやりとりするための、
    いわゆるリクエストとレスポンス用のデータ構造を決めます。

  • configurePigeonで出力設定を決めます。
    生成コマンドラインオプションからも設定できたりはするので、ここで設定するかはわりと好みですね。
    ここで設定した内容はコマンドラインオプションよりも優先度はあったりします。

Apiについて

@HostApi@FlutterApiの使い分けや例を説明します。
ネイティブとの細かい連携に関しては後術します。

HostApiについて

前者の@HostApiはネイティブ側でApi実装を書く場合が該当します。
Flutterからネイティブ側の機能を呼び出したいときに便利です。

FlutterApiについて

後者の@FlutterApiはFlutter側でApi実装を書く場合が該当します。
ネイティブからFlutterを呼び出すときに利用するとよさげで、コールバック実装やネイティブ側のイベントハンドラから呼び出すときに便利です。

Apiでやりとりするためのデータ構造をきめる。

この記事を執筆時点だと以下の型がサポートされているので、それを組み合わせたデータ構造となります。それぞれJavaとObject-Cで対応するデータ構造に変換されます。

const List<String> _validTypes = <String>[
  'String',
  'bool',
  'int',
  'double',
  'Uint8List',
  'Int32List',
  'Int64List',
  'Float64List',
  'List',
  'Map',
];

configurePigeonで出力設定を決める

以下の設定は設定しておくと良いでしょう。パッケージ名などは適宜読み替えてください。

void configurePigeon(PigeonOptions opts) {
  opts.dartOut = 'lib/native/api.dart';
  opts.javaOut = 'android/app/src/main/java/nagano/shunsuke/plugins/Pigeon.java';
  opts.javaOptions.package = "nagano.shunsuke.plugins";
  opts.objcHeaderOut = 'ios/Runner/Pigeon.h';
  opts.objcSourceOut = 'ios/Runner/Pigeon.m';
  opts.objcOptions.prefix = 'Flutter';
  //nnbdしたいときはこれをONにする
  //opts.dartOptions.isNullSafe = true;
}
  • opts.dartOutは生成するdartファイル名パスを指定します。Flutter側で利用します。

  • opts.javaOutは生成するJavaファイル名パスを指定します。Android側で利用します。

  • opts.javaOptions.packageは生成するファイルのパッケージ名です。生成するJavaファイルとのパスとの兼ね合いに注意しましょう。

  • opts.objcHeaderOutはiOSで利用する。Object−C用のヘッダーファイル名パスです。生成したファイルをXcode側でAddすることを忘れないようにしましょう。Runner-Bridging-Header.hに読み込ませて利用します。

  • opts.objcSourceOutはiOSで利用する。Object−Cファイルの実装です。こちらもヘッダーファイルと同様にXcode側でAddすることを忘れないようにしましょう。

  • opts.objcOptions.prefixで付与された文字列はiOS側で呼び出す際のPrefixとなります。

  • opts.dartOptions.isNullSafeをするとFlutter側で生成されるコードがNNBD準拠になります。今回はサンプルコードなので無効にしました。

開発フロー

ではサンプルアプリのコードを交えて、実際にネイティブとFlutter側の開発フローの説明をします。

このサンプルアプリ特有うの接続状態を取得するために追加ライブラリを入れるフローも入れてたりも含めています。
適宜読み替えていただけたらと思います。

iOS, Android共通設定

1. Pigeonを使うための準備をする

準備といってもライブラリを入れるだけです。
(Android・iOSの両ビルドができる前提ではあります)

基本的に生成用に使用するだけで、プロダクションコードからは呼び出さないものなので、
pubspec.ymlのdev_dependenciesに追記します。

dev_dependencies:
  pigeon: ^0.1.15

2. 実行コマンドを叩いてファイル生成をする

今回は実行コマンドのラッパーシェルを用意したのでtools/pigeon.shを用意したのでそれを叩きます。

$ ./tools/pigeon.sh

内容は大したことないですが、android側の生成先ディレクトリが存在しない場合にエラーとなることがあったので、ディレクトリを追加を行っています。

tools/pigeon.sh
#!/bin/bash

cd "$(PWD)"

mkdir -p android/app/src/main/java/nagano/shunsuke/plugins/

flutter pub run pigeon --input lib/pigeon/scheme.dart

3. 生成されたファイルを確認する

scheme.dartに記載されている以下の内容に応じてファイルが生成されています。

AndroidとiOS側の生成ファイルに関してはそれぞれのOS導入の説明で詳しく触れます。

// 生成されるファイルの出力先などの設定
void configurePigeon(PigeonOptions opts) {
  opts.dartOut = 'lib/native/api.dart';
  opts.javaOut =
      'android/app/src/main/java/nagano/shunsuke/plugins/Pigeon.java';
  opts.javaOptions.package = "nagano.shunsuke.plugins";
  opts.objcHeaderOut = 'ios/Runner/Pigeon.h';
  opts.objcSourceOut = 'ios/Runner/Pigeon.m';
  opts.objcOptions.prefix = 'Flutter';
  //nnbdしたいときはこれをONにする
  //opts.dartOptions.isNullSafe = true;
}
  • Flutter側生成ファイル: lib/native/api.dart

  • Android側生成ファイル: android/app/src/main/java/nagano/shunsuke/plugins/Pigeon.java

  • ios側生成ファイル: ios/Runner/Pigeon.hおよびios/Runner/Pigeon.m

Flutter用の生成ファイルについての解説


// Autogenerated from Pigeon (v0.1.15), 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
// @dart = 2.8
import 'dart:async';
import 'package:flutter/services.dart';
import 'dart:typed_data' show Uint8List, Int32List, Int64List, Float64List;

class WifiResponse {
  bool availableDetect;
  bool availableWifi;
  bool availableMobile;
  // ignore: unused_element
  Map<dynamic, dynamic> _toMap() {
    final Map<dynamic, dynamic> pigeonMap = <dynamic, dynamic>{};
    pigeonMap['availableDetect'] = availableDetect;
    pigeonMap['availableWifi'] = availableWifi;
    pigeonMap['availableMobile'] = availableMobile;
    return pigeonMap;
  }
  // ignore: unused_element
  static WifiResponse _fromMap(Map<dynamic, dynamic> pigeonMap) {
    final WifiResponse result = WifiResponse();
    result.availableDetect = pigeonMap['availableDetect'];
    result.availableWifi = pigeonMap['availableWifi'];
    result.availableMobile = pigeonMap['availableMobile'];
    return result;
  }
}

class WifiRequest {
  bool isDetect;
  // ignore: unused_element
  Map<dynamic, dynamic> _toMap() {
    final Map<dynamic, dynamic> pigeonMap = <dynamic, dynamic>{};
    pigeonMap['isDetect'] = isDetect;
    return pigeonMap;
  }
  // ignore: unused_element
  static WifiRequest _fromMap(Map<dynamic, dynamic> pigeonMap) {
    final WifiRequest result = WifiRequest();
    result.isDetect = pigeonMap['isDetect'];
    return result;
  }
}

abstract class CallbackApi {
  void apply(WifiResponse arg);
  static void setup(CallbackApi api) {
    {
      const BasicMessageChannel<dynamic> channel =
          BasicMessageChannel<dynamic>('dev.flutter.pigeon.CallbackApi.apply', StandardMessageCodec());
      if (api == null) {
        channel.setMessageHandler(null);
      } else {

        channel.setMessageHandler((dynamic message) async {
          final Map<dynamic, dynamic> mapMessage = message as Map<dynamic, dynamic>;
          final WifiResponse input = WifiResponse._fromMap(mapMessage);
          api.apply(input);
        });
      }
    }
  }
}

class Api {
  Future<WifiResponse> call(WifiRequest arg) async {
    final Map<dynamic, dynamic> requestMap = arg._toMap();
    const BasicMessageChannel<dynamic> channel =
        BasicMessageChannel<dynamic>('dev.flutter.pigeon.Api.call', StandardMessageCodec());
    
    final Map<dynamic, dynamic> replyMap = await channel.send(requestMap);
    if (replyMap == null) {
      throw PlatformException(
        code: 'channel-error',
        message: 'Unable to establish connection on channel.',
        details: null);
    } else if (replyMap['error'] != null) {
      final Map<dynamic, dynamic> error = replyMap['error'];
      throw PlatformException(
          code: error['code'],
          message: error['message'],
          details: error['details']);
    } else {
      return WifiResponse._fromMap(replyMap['result']);
    }
    
  }
}

生成されたコードの中身をみてみると内部的にはBasicMessageChannelを使ってるだけだったりします。

const BasicMessageChannel<dynamic> channel =
          BasicMessageChannel<dynamic>('dev.flutter.pigeon.CallbackApi.apply', StandardMessageCodec());

内部では勝手にdev.flutter.pigeon以下の名前でやりとりをするようなので、実際で扱う場合はApi名にアプリケーション名をPrefixなりをつけたりして名前衝突を避けた方が良さそうな気はしますね。

従来のPlatformChannelでメソッド名の文字列の対応を気にしながら開発していたときに比べると格段に扱いやすくなったと思いました。

基本的にデータのやりとりを行うためのオブジェクト(今回の例だとWifiRequestWifiResponse)に関してはイミュータブルな感じではなかったりします。ですので、Flutter側とネイティブ間のやりとりをするためのDTOと割り切って使いアプリ全体としては依存度を下げて使うのが行儀良さそうに思います。

また、前述のFlutterApiHostApiの違いがここに現れてきます。

FlutterApiの場合だとabstractキーワードがついたままで追加実装をFlutter側で行わないといけないことがわかります。

abstract class CallbackApi {
  void apply(WifiResponse arg);
  static void setup(CallbackApi api) {
    {
      const BasicMessageChannel<dynamic> channel =
          BasicMessageChannel<dynamic>('dev.flutter.pigeon.CallbackApi.apply', StandardMessageCodec());
      if (api == null) {
        channel.setMessageHandler(null);
      } else {

        channel.setMessageHandler((dynamic message) async {
          final Map<dynamic, dynamic> mapMessage = message as Map<dynamic, dynamic>;
          final WifiResponse input = WifiResponse._fromMap(mapMessage);
          api.apply(input);
        });
      }
    }
  }
}

今回だとFlutter側で呼び出したcallback関数を呼び出すようにしたいので、
このような形で追加実装しました

class CallbackApiImpl extends CallbackApi {
  final Function(WifiResponse response) caller;

  CallbackApiImpl(this.caller);

  @override
  void apply(WifiResponse response) {
    print(response);
    this.caller(response);
  }
}

また、initStateなどの初期化タイミングでabstract classの静的なsetupメソッドを呼び実装を登録します。
その際にコールバック関数changeConnectionの登録も同時に済ましてしまいます。

今回はサンプルなので、Setestate経由の状態変更で楽に実装したかったので、Statefulウィジェット初期化時に呼び出す形式としています。しかし、実際はProviderを使うと思うので配る前に初期化しておくと良いでしょう。

 CallbackApi.setup(CallbackApiImpl(this.changeConnection));

コールバック関数として渡しているchangeConnection
wifiの状態オブジェクト(WifiResponse response)を受け取ってsetStateするだけのメソッドです。

  void changeConnection(WifiResponse response) {
    setState(() {
      if (!response.availableDetect) {
        _wifiText = "Stop Detection";
        return;
      }

      if (response.availableWifi) {
        _wifiText = "WIFI Connection";
      } else if (response.availableMobile) {
        _wifiText = "Mobile Connection";
      } else {
        _wifiText = "Lost Connection";
      }
    });
  }
}

一方でHostApiの場合だと、abstractキーワードが外れているのでFlutter側はこれを使えばいいだけということがわかります。

class Api {
  Future<WifiResponse> call(WifiRequest arg) async {
    final Map<dynamic, dynamic> requestMap = arg._toMap();
    const BasicMessageChannel<dynamic> channel =
        BasicMessageChannel<dynamic>('dev.flutter.pigeon.Api.call', StandardMessageCodec());
    
    final Map<dynamic, dynamic> replyMap = await channel.send(requestMap);
    if (replyMap == null) {
      throw PlatformException(
        code: 'channel-error',
        message: 'Unable to establish connection on channel.',
        details: null);
    } else if (replyMap['error'] != null) {
      final Map<dynamic, dynamic> error = replyMap['error'];
      throw PlatformException(
          code: error['code'],
          message: error['message'],
          details: error['details']);
    } else {
      return WifiResponse._fromMap(replyMap['result']);
    }
    
  }
}

今回利用している箇所ではボタン押下時に、同期的にWifiの状態をネイティブに問い合わせする動作に利用しています。

Future<void> fetchData() async {
    setState(() {
      _isSwitch = !_isSwitch;
    });
    final api = Api();
    final request = WifiRequest();
    request.isDetect = _isSwitch;
    this.changeConnection(await api.call(request));
  }

以上で基本的なFlutter側の設定は完了です。

Android側の導入設定

Androidは基本的にすんなり実装できた気がしたので、まずはAndroidから手を付けることをおすすめします。

Android側の作業を行う際はTools > Flutter > Open for Editing in Android Studioから別窓でプロジェクトとして開いておくとよいでしょう。各種補完が効くので自分みたいなkotlin素人でも楽にできたのでおすすめです。
スクリーンショット 2020-12-02 1.28.23.png

1. パッケージと生成パスを設定する

今回はpackageをnagano.shunsuke.pluginsとしたので、それに対応するディレクトリを作成しておきます。
今回は静的ファイル生成時のtools/pigeon.shでディレクトリ生成をさせていいます。
このときにscheme.dart上のconfigurePigeonopts.javaOutopts.javaOptions.package
の対応を忘れないようにしておきましょう。

void configurePigeon(PigeonOptions opts) {
  opts.dartOut = 'lib/native/api.dart';
  opts.javaOut =
      'android/app/src/main/java/nagano/shunsuke/plugins/Pigeon.java';
  opts.javaOptions.package = "nagano.shunsuke.plugins";
  opts.objcHeaderOut = 'ios/Runner/Pigeon.h';
  opts.objcSourceOut = 'ios/Runner/Pigeon.m';
  opts.objcOptions.prefix = 'Flutter';
  //nnbdしたいときはこれをONにする
  //opts.dartOptions.isNullSafe = true;
}

2.ビルド設定の変更

Pigeonそのものには関係ないですが、Wifiの情報取得の方法がsdkのバージョンで異なっていたので、最新の方法だけの対応としました。そのためminSdkを16から21に変更しています。

minSdkVersion 21

3.権限の追加

manifestにwifiのアクセス関係の権限を追加します。

    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />

4. Apiの実装をする

生成されたファイルにインタフェースなどの定義内容が記載されているのでそれに沿って実装します。
定義関係が用意されていることで、Android側の開発でもコード補完が効くようになるわけです。

生成されたファイルはJavaですが、実装そのものははkotlinからでも可能です。
今回はPigeon.Apiを実装します。

class WifiApi(private val connectivityManager: ConnectivityManager, private val binaryMessenger: BinaryMessenger) : Pigeon.Api {

    var isWifi = false;
    var isMobile = false;

    val callback = Pigeon.CallbackApi(binaryMessenger)

    val mainHandler = Handler(Looper.getMainLooper())

    private val networkCallback = object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network?) {
            super.onAvailable(network)
            // ネットワークが使用可能になったときの処理
            val capabilities = connectivityManager.getNetworkCapabilities(network)
            isWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ?: false
            isMobile = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
                    ?: false
            mainHandler.post {
                val response = getWifiInfo()
                callback.apply(response) {}
            }

        }

        override fun onLost(network: Network?) {
            super.onLost(network)
            val capabilities = connectivityManager.getNetworkCapabilities(network)
            isWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ?: false
            isMobile = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
                    ?: false
            // ネットワークの接続が切れたときの処理
            mainHandler.post {
                val response = getWifiInfo()
                callback.apply(response) {}
            }

        }

        override fun onUnavailable() {
            super.onUnavailable()
            isWifi = false
            isMobile = false
            mainHandler.post {
                val response = getWifiInfo()
                callback.apply(response) {}
            }

        }
    }

    private fun subscribe() {
        val request = NetworkRequest
                .Builder()
                .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
                .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
                .build()
        connectivityManager.requestNetwork(request, networkCallback)
    }

    fun unSubscribe() {
        connectivityManager.unregisterNetworkCallback(networkCallback)
    }

    fun getWifiInfo(isDetect: Boolean = true): Pigeon.WifiResponse {
        val response = Pigeon.WifiResponse()
        response.availableWifi = isWifi
        response.availableMobile = isMobile
        response.availableDetect = isDetect
        return response;
    }

    override fun call(request: Pigeon.WifiRequest): Pigeon.WifiResponse {
        return if (request.isDetect) {
            subscribe()
            getWifiInfo()
        } else {
            unSubscribe()
            getWifiInfo(isDetect = false)
        }

    }
}

基本的な内容としてはFlutter側から送られてくるrequestのフラグに応じて、Wifiの状態をサブスクライブするかどうかを切り替えているだけです。


 override fun call(request: Pigeon.WifiRequest): Pigeon.WifiResponse {
        return if (request.isDetect) {
            subscribe()
            getWifiInfo()
        } else {
            unSubscribe()
            getWifiInfo(isDetect = false)
        }

    }

また、サブスクライブ中に変化した状態を通知する必要があるのですが、BinaryMessenger
が必要なので忘れないようにしましょう。iOSでも同様の作業が必要です。
kotlinの場合はエラーになるので気づけますが、swiftからobjectCのコードを呼ぶ場合にコンパイル通ってしまうので注意です。

val callback = Pigeon.CallbackApi(binaryMessenger)

また、コールバックを叩くときはメインスレッドで呼び出すことも留意しておきましょう。
Flutterがシングルスレッドでkotlin側からすると、Mainスレッドで動いているためです。
詳しくは公式のAndroindのためのFlutter解説のAsync UIを見ると良いでしょう。

mainHandler.post {
   val response = getWifiInfo()
     callback.apply(response) {}
}

5. APIを登録する

FlutterActivityconfigureFlutterEngine中でPigeon.Api.setupで登録すれば完了です。
今回だと実装したWifiApiを登録しています。
このレイヤーでいい感じでDIするようにしておけば、testableなコードに出来そうですね。


 override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        Pigeon.Api.setup(flutterEngine.dartExecutor.binaryMessenger,  WifiApi(connectivityManager, flutterEngine.dartExecutor.binaryMessenger))
    }

この作業が完了したらandroid側のビルドが通るようになったはずです。

iOS側の導入設定

androidで作業が完了したら、対応する概念をiOS側にも実装する流れです。
android開発と同様にiOS開発ではXcodeを使った開発になるのかなと思われます。
スクリーンショット 2020-12-02 2.37.52.png

また、Xcodeからビルドは通るが、Flutterコマンド経由でビルドできなかったときは
プロジェクト設定に齟齬がある場合があるので以下のコマンドでプロジェクト設定を確認すると良いかなと思われます。

open ios/Runner.xcodeproject

詳しくはFlutterでSwiftPackageManager利用時のパッケージ依存関係エラーを解消する方法に記載しているのでそちらもご覧ください。

1 Swift Package Managerでライブラリを入れる

ネットワークの接続情報でAndroidは標準ライブラリが使いやすかったので、何もいれませんでしたが、
iOSはちょっと触りづらいとのことで【Swift, iOS】iOS11以降のNetwork Reachabilityを参考にReachability.swiftを使いました。

導入方法はPodsCarthageもありますがSwift Package Managerが一番扱いやすくておすすめです。

File > Swift Packages > Add Package Dependencyを選んで
スクリーンショット 2020-12-02 2.49.50.png

https://github.com/ashleymills/Reachability.swift
Enter Package repository URLに入力して完了です
スクリーンショット 2020-12-02 2.51.01.png

2 Xcodeで生成したファイルをリンク及びSwiftから呼び出し可能にする

iOSのビルドに取り込まいといけない生成ファイルが以下の2つです

  • 'ios/Runner/Pigeon.h'(ヘッダーファイル)
  • 'ios/Runner/Pigeon.m'(Object-Cファイル)

Runnerディレクトリを右クリックしてAdd Files to Runnerから生成したファイルをビルドに含めるようにします。

スクリーンショット 2020-12-02 3.01.51.png

また、実装そのものはSwiftで行うのでObject-Cで生成されたファイルを呼ぶためにRunner-Bridging-Header.hに以下の記述を追加します。

#import "Pigeon.h"

3. APIを実装する

基本的なコードの流れAndroidと同じはずです。
各名称にFlutterというPrefixをつける設定を付与しているので適宜読み変えていただけるとありがたいです。

import Foundation
import Reachability

class Api: FlutterApi {
   
   init(_ flutterBinaryMessenger: FlutterBinaryMessenger) {
       self.callbackApi = FlutterCallbackApi(binaryMessenger: flutterBinaryMessenger)
   }


   var isWifi = false
   var isMobile = false
   
   let callbackApi: FlutterCallbackApi

   let reachability = try! Reachability()

   func call(_ input: FlutterWifiRequest, error: AutoreleasingUnsafeMutablePointer<FlutterError?>) -> FlutterWifiResponse? {

       let isDetect = input.isDetect as! Bool

       if isDetect {
           subscribe()
       } else {
           self.reachability.stopNotifier()
       }
       return self.fetchStatus(isDetect)
   }



   func subscribe() {
       reachability.whenReachable = { reachability in
           switch reachability.connection {
           case .wifi:
               self.isWifi = true
               self.isMobile = false
           case .cellular:
               self.isWifi = false
               self.isMobile = true
           default:
               self.isWifi = false
               self.isMobile = false
           }
           DispatchQueue.main.async {
               self.callbackApi.apply(self.fetchStatus(), completion: { (error: Error?) -> Void in
               })
           }
       }
       try! self.reachability.startNotifier()
   }

   func fetchStatus(_ isDetect: Bool = true) -> FlutterWifiResponse {
       let response = FlutterWifiResponse()
       response.availableMobile = self.isMobile as NSNumber
       response.availableWifi = self.isWifi as NSNumber
       response.availableDetect = isDetect as NSNumber
       return response
   }
}

Androidと同じようにメインスレッドでで動作するようにしていますが、今回に限っていうと呼び出し元もメインスレッドなので不要だったりはします(今回はkotlinとの対称性の関係で明示的に書いておきました)


 DispatchQueue.main.async {
                self.callbackApi.apply(self.fetchStatus(), completion: { (error: Error?) -> Void in
                })
            }

またコールバック初期化時にBinaryMessengerを渡すことを忘れないでください。
渡さなくてもビルドが通るのが厄介です。
忘れていると、コールバックの動作が無効化されるので、ボタンが押されたときのみ画面更新するような挙動になります。


self.callbackApi = FlutterCallbackApi(binaryMessenger: flutterBinaryMessenger)

4. APIを登録する

FlutterAppDelegate内のfunc application内で以下の登録処理を行います。

  • FlutterViewControllerから送受信用のbinaryMessengerを利用する
  • FlutterApiSetupを実行する


  override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
        FlutterApiSetup(controller.binaryMessenger, Api(controller.binaryMessenger))
        ...  ... 
    }

この作業が完了したらiOS側のビルドが通るようになったはず

おわりに

Pigeonに関してサンプルアプリを通した実装方法の解説をしました。
いかがだったでしょうか。

私のような素人レベルでもPigeonのおかげでコンパイル前にエラーが得られるようになったので開発はかなりしやすかったです。

Pigeonを使うことで、ネイティブとFlutterで1つの定義ファイルを利用することになるので、
チーム開発で分担する場合でも認識揃えに良いと思いました。

また、ネイティブのロジックはユニットテストを書くなどで堅実にすすめつつ、
テストが書きづらいビュー周りはFlutterのホットリロードを活用しすといった組み合わせができる点は
チーム開発では大きな武器になるなと思いました。

 サンプルアプリでは同期的な呼び出しと非同期の呼び出しに対応できるようにHostApiFlutterApiの両方の実装を試してみましたが、FlutterApiな実装はサンプルコード含めてほとんどなかったので調査と実装には少し苦労しました。それでも生成されたコードがシンプルだったので読めば理解できました。

細かい内容はまだバージョン1未満なので変わる可能性はありますが、
流れとか思想的なところはキャッチアップしてて損はないと思いました。最悪生成されたコードを読めばなんとかなる
現時点でも使えるレベルだとは思ったので、積極的に自分の開発では導入しようと思いました。
 

77
55
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
77
55

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?