😺

【Flutter】Android向けSDKとの接続をMethodChannelで実現した話

2021/12/18に公開3

はじめに

Flutterアドベントカレンダー10日への参加です。
また、
airClosetアドベントカレンダー18日への参加です。

Flutterライフの中で、MethodChannelを使うことはそんなにないのではないでしょうか。

もちろん、audioPlayerのようなAndroidプラットフォームの機能(ドキュメントではplatform-specific functionalityと表現あり)を扱うライブラリの中ではガンガン使われています。
一方で、ライブラリがnative層とDart層のやり取りは吸収していて処理自体は隠蔽されているため、開発時に直接MethodChannelをガッツリ使うってことはそんなにない方が多いのも事実ではないでしょうか。

先日のPR Timesのプレスリリース、エアークローゼットが自社開発した倉庫管理システム(WMS)の運用を開始で紹介された業務システム開発の上でこのMethodChannelを使う機会があったので紹介しようと思います。

RFIDを使った業務の紹介

プレスリリースの中で「RFIDを使った在庫管理」の話が出ています。
具体的には服1点1点にRadio Frequency Identification(RFID)というセンサーに反応するチップが埋められており、それをRFIDリーダーで読み取ることでデータと実物を紐付けることができるという仕組みです。

以下のRFIDリーダーを業務では扱っています。
https://rfid.tss21.co.jp/product/ts100/
Android向けのSDKもダウンロードすることができ、そのSDKを介して、AndroidモバイルアプリとRFIDリーダー間のデータのやり取りを実現しています。

開発したAndroidアプリは倉庫の作業員の方が使うアプリでFlutter製です。

要件

RFIDリーダーを業務上扱う上で、接続するAndroidアプリが満たすべき要件は以下です。

  • 画面上で「RFIDリーダーを探す」ボタンを押すと、認識可能範囲に存在する接続可能なRFIDリーダーの一覧(ID, Macアドレス等)が画面上に表示される。
  • 画面上で、接続したいRFIDリーダーを選択するとそのRFIDリーダーに接続される。
  • 接続された状態で、RFIDリーダーでRFIDチップを読み取ると、RFIDに書き込まれている値(文字列)を画面上で表示する。
  • RFIDリーダーの上にRFIDチップを置いた状態で「書き込み」ボタンを押すと値をチップ上に書き込むことができる。

Flutterでの実現方法

SDK(aarファイル)の配置

Android ライブラリの作成
の通りに、aarファイル(中身はJavaのclass)をおき、それをimportできるように設定します。

今回のSDKが提供するclassは3個あります。
※SDKはJava製ですが、native層の実装はkotlinで書いたので、統一してkotlinで書きます。
※各classには紹介する以外のメソッドも用意されていますが、紹介しない部分は省略します。

  1. ScannerCallback
    このclassはandroidアプリと接続可能なRFIDリーダーを探す役割を果たします。
    接続可能なRFIDリーダーを探し、発見するとdidDiscoveredDeviceというメソッドが発見したデバイス情報(BaseDevice)を受け取り動きます。
class ScannerCallback {
    fun didDiscoveredDevice(baseDevice: BaseDevice) {}
}
  1. CommunicationCallback
    このclassはandroidアプリとRFIDリーダーの接続状態の変化を検知する役割を果たします。
    接続中のRFIDリーダーとの接続状態が変化(例えば接続が切れる)すると、発見するとdidUpdateConnectionというメソッドが動きます。
class CommunicationCallback {
    fun didUpdateConnection(connectionState: ConnectionState?, communicationType: CommunicationType?) {}
}
  1. UHFCallback
    このclassは接続状態でRFIDチップを読み取ると、RFIDチップの情報を検知する役割を果たします。
    RFIDリーダーがRFIDの情報を読み取るとdidDiscoverTagInfoというメソッドが動き、TagInformationFormatという形でRFIDの情報を取得します。
class UHFCallback {
    fun didDiscoverTagInfo(tagInformationFormat: TagInformationFormat?) {}
}

native側とDart層側を接続する

MethodChannelを用いて、kotlinの処理とDartの処理を接続します。

native側でDart層側からの通知を受け取るhandlerを設置

ドキュメント
に従って、MainActivityにchannelの定義とhandlerを設置します。

class MainActivity: FlutterActivity() {
    private lateinit var channel: MethodChannel
    lateinit var scannerManager: ScannerManageFragment

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        GeneratedPluginRegistrant.registerWith(flutterEngine)

        // MethodChannelを定義
        channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, ChannelName)
	// handlerを設置
        channel.setMethodCallHandler { methodCall: MethodCall, result: MethodChannel.Result ->
	    // 接続可能RFIDリーダーの検索用channel(画面からRFIDリーダーを探すボタンを押した時に動く)
            if (methodCall.method == SubscribeMethodChannel.GetDevices.toString()) {
                val context = getApplicationContext()
                this.scannerManager = ScannerManageFragment(context, result, channel)
                this.scannerManager.findDevices()
            }

	    // アプリとRFIDリーダーの接続用channel(画面からRFIDリーダーに接続ボタンを押された時に動かす)
            if (methodCall.method == SubscribeMethodChannel.ConnectDevice.toString()) {
                val macAddress = methodCall.arguments<String>()
                scannerManager.connectDevice(macAddress)
            }

	    // RFIDリーダーへ書き込み用channel
            if (methodCall.method == SubscribeMethodChannel.WriteRfid.toString()) {
                val rfid = methodCall.arguments<String>()
                // 0埋めした32桁のIntを指定 example: "00000000123456781234567812345678"
                val epcByte: ByteArray = this._hexStringToByteArray(rfid)
                this.scannerManager.device?.writeEpc(defaultPassword, epcByte);
            }

	    // RFIDリーダーとの接続解除用channel
            if (methodCall.method == SubscribeMethodChannel.Disconnect.toString()) {
                this.scannerManager.disconnectDevice()
            }
        }
    }
}

handlerでDart層側からの通知を受け取った時のSDK操作を実装する

先ほど紹介したSDKで定義された3個のclassのWrapper ClassとしてScannerManageFragment, MCommunicationCallback, MUHFCallbackというclassを用意します。

これらのclassはSDKで定義された処理を実行する役割を持ちます。

class ScannerManageFragment: Fragment, ScannerCallback {
    var uhfScanner: UHFScanner
    var channelResult: MethodChannel.Result
    var channel: MethodChannel
    var device: UHFDevice?
    var scanningMacAddress: String?

    constructor(context: Context, channelResult: MethodChannel.Result, channel: MethodChannel) {
        this.uhfScanner = UHFScanner(UhfClassVersion.TS100, context, this, CommunicationType.BLE)
        this.channelResult = channelResult
        this.channel = channel
        this.device = null
        this.scanningMacAddress = null
    }

    fun disconnectDevice() {
        this.device?.disconnect()
    }

    fun findDevices() {
        this.uhfScanner.startScan()
    }

    fun connectDevice(macAddress: String) {
        this.scanningMacAddress = macAddress;
        this.uhfScanner.startScan()
    }

    val clearDevice = fun() {
        this.device = null
    }
    // 接続可能なdeviceが検知された時、発火する
    override fun didDiscoveredDevice(baseDevice: BaseDevice) {
        val deviceInfo = DeviceInfo(
                baseDevice.getDeviceName(),
                baseDevice.getDeviceMacAddr()
        )

	val data = Gson().toJson(deviceInfo)
	baseDevice.setCommunicationCallback(MCommunicationCallback(this.channel, this.clearDevice))
	baseDevice.connect()
	val device = baseDevice as UHFDevice
	this.device = device
	this.device?.setUHFCallback(MUHFCallback(this.channel))
	this.channel.invokeMethod(PublishMethodChannel.DiscoverdDevice.toString(), data)
    }

    override fun didScanStop() {
        this.scanningMacAddress = null
    }
}
class MCommunicationCallback: CommunicationCallback {
    var channel: MethodChannel
    var clearDevice: () -> Unit
    constructor(channel: MethodChannel, clearDevice: () -> Unit) {
        this.channel = channel
        this.clearDevice = clearDevice
    }
    // 接続が確立された時、発火する
    override fun didUpdateConnection(connectionState: ConnectionState?, communicationType: CommunicationType?) {
        if (connectionState.toString() === "DISCONNECTED") {
            this.clearDevice();
        }
        Handler(Looper.getMainLooper()).post {
            this.channel.invokeMethod(PublishMethodChannel.DidUpdateConnection.toString(), connectionState.toString())
        }
    }
}
class MUHFCallback: UHFCallback {
    var channel: MethodChannel

    constructor(channel: MethodChannel) {
        this.channel = channel
    }

    // RFIDチップの情報をRFIDリーダーが取得した時、発火する
    override fun didDiscoverTagInfo(tagInformationFormat: TagInformationFormat?) {
        val message = "PC EPC: ${tagInformationFormat?.pcEPCHex} getepc ${tagInformationFormat?.epcHex}"
        Log.d("didDiscoverTagInfo", message)
        Handler(Looper.getMainLooper()).post {
            this.channel.invokeMethod(PublishMethodChannel.DiscoverTagInfo.toString(), tagInformationFormat?.epcHex.toString())
        }
    }
}

それぞれのclassでoverrideしているメソッドがSDK側で用意されているメソッドで、
以下の仕様です。

  1. 接続可能なdeviceが検知された時didDiscoveredDeviceが発火する。

    このメソッドの中で、検知されたdeviceインスタンスにsetCommunicationCallback, setUHFCallbackによってhandlerを設置し、接続とRFIDチップの読み取りができるようになります。このdeviceインスタンスをScannerManageFragment(のインスタンス)の内部で保持しておきます。

    また、検知された情報をinvokeMethodを用いて、Dart層側へ通知します。
    このinvokeMethodについては後述します。

  2. 接続が確立された時didUpdateConnectionが発火する。
    このメソッドの中で、変更後のdeviceの接続状態をDart層側へ通知します。

  3. RFIDチップの情報をRFIDリーダーが取得した時didDiscoverTagInfoが発火する。
    このメソッドの中で、読み取ったRFIDの情報をDart層側へ通知します。

Dartの実装

DartでもMethodChannelを定義して、native層からの通知(invokeMethod)をsubscribeできるようにします。また、そのMethodChannelを通して、Dart層からkotlinで定義した処理を呼び出すことができるようにします。

MethodChannelを定義する

ドキュメントに従って、DartでMethodChannelを実装します。

const MethodChannel rfIdChannel = MethodChannel(DeviceChannnel.channelName);

class RfidMethodChannelHandler {
  final Store<RootState> store;
  static const MethodChannel _channel =
      MethodChannel(DeviceChannnel.channelName);

  RfidMethodChannelHandler({
    this.store,
  }) {
    rfIdChannel.setMethodCallHandler(_platformCallHandler);
  }

  // native側からのeventをsubscribeする
  Future<void> _platformCallHandler(MethodCall call) async {
    switch (call.method) {
      // 接続可能なRFIDリーダーを検知した時にnative層から通知を受けるためのsubscriber
      case SubscribeMethodChannels.discoverdDevice:
        final seed = json.decode(call.arguments);
        final device = Device(
            deviceName: seed['name'],
            deviceMacAddress: seed['deviceMacAddress']);
        store.dispatch(AddDevice(device: device));
        break;

      // RFIDを検知した時にnative層から通知を受けるためのsubscriber
      case SubscribeMethodChannels.discoverTagInfo:
        final filledRFID = fillInRFID(call.arguments);

        if (filledRFID == store.state.deviceInfo.rfid) {
          break;
        }

        store.dispatch(SetRfid(rfid: filledRFID));
        store.dispatch(SetRfidErrorMessage(message: ''));
        store.dispatch(SetReturnArrivalRfidError(errorMessage: ''));
        break;
	
      // 接続確立時にnative層から通知を受けるためのsubscriber
      case SubscribeMethodChannels.didUpdateConnection:
        if (call.arguments == 'CONNECTING') {
          final devices = [...store.state.deviceInfo.devices];
          final deviceInfo = DeviceInfo(devices: devices);
          deviceInfo.connectionSuccess();
          store.dispatch(SetDevices(devices: deviceInfo.devices));
        }
        if (call.arguments == 'DISCONNECTED') {
          final devices = [...store.state.deviceInfo.devices];
          final deviceInfo = DeviceInfo(devices: devices);
          deviceInfo.disConnect();
          store.dispatch(SetDevices(devices: deviceInfo.devices));
        }
        break;
      default:
        break;
    }
    return;
  }

  // Dart層からnative層の処理をよぶためのstaticメソッド
  static Future<T> invokeMethod<T>(String method, [dynamic arguments]) async {
    return _channel.invokeMethod<T>(method, arguments);
  }
}

_platformCallHandlerの中では、native層からの通知をキャッチして定義している別のactionを呼ぶということをしています。
もちろん任意の処理を実行することができます。

MethodChannelを呼ぶWrapperを定義する

const MethodChannel _channel = rfIdChannel;

// 接続可能なRFIDリーダーを探す
void scanDevices({
   Store<RootState> store,
}) {
  // dart側にnativeからのevent handler設置
  RfidMethodChannelHandler(store: store);
  // native側にdevice scanを依頼
  RfidMethodChannelHandler.invokeMethod(PublishMethodChannels.getDevices);
}

// RFIDリーダーに接続する
void connectDevice({
   ConnectDevice action,
   Store<RootState> store,
}) {
  final deviceInfo = store.state.deviceInfo;
  deviceInfo.tryConnect(action.device.deviceMacAddress);
  store.dispatch(SetDevices(devices: deviceInfo.devices));

  RfidMethodChannelHandler(store: store);
  _channel.invokeMethod(
      PublishMethodChannels.connectDevice, action.device.deviceMacAddress);
}

// RFIDに値を書き込む
void writeRfid(payload) {
  RfidMethodChannelHandler.invokeMethod(
    PublishMethodChannels.writeRfid,
    payload.rfid,
  );
}

// RFIDリーダーとの接続を解除する
void disconnectDevice() {
  RfidMethodChannelHandler.invokeMethod(PublishMethodChannels.disconnect);
}

それぞれ要件にあったことはこれでFlatter上からこれらの関数を呼ぶことで実現できるようになりました。

処理の流れの整理

ユーザーが「接続可能なRFIDリーダーを検索」ボタンを押す→接続可能なRFIDリーダーの一覧が画面上に表示される、というuseCaseについて行われる処理の順番を改めて見ます。
接続、RFIDの読み取り時はこれとほぼ同じなので省略します。

図の1から7の順番で処理が行われていきます。
やっていることはシンプルで、Dart層、native層でそれぞれMethodChannelのsubscriberを準備しておき、それぞれからinvokeMethodによって通知するという仕組みでデータを送り合っています。

ちなみに、Dart層からinvokeMethodを呼び、そのレスポンスとして受け取ることもできます。
今回はSDKの仕様が、接続可能デバイスの検知依頼をするメソッドと接続可能デバイスの検知時に発火するメソッドを持つClassが別で存在していたのであえて、それぞれの層からinvokeMethodによるevent publishを行う設計にしています。

React Nativeとの比較

実はReact Nativeでも全く同じことができます。
ドキュメントはこちら

紹介

軽く紹介だけしておきます。
ちなみに、ReactNativeはプロジェクト作成時に自動的にJavaでnative層のclassが定義されます。
kotlinに変換するのが結構面倒だったので、そのままJavaで書くことにします。kotlinじゃなくてごめんなさい。

native層の実装

public class RfidModule extends ReactContextBaseJavaModule {
    RfidModule(ReactApplicationContext context) {
        super(context);
    }
    // ReactNative側でimportするときのmodule名(class名と揃える必要がある)
    @Override
    public String getName() {
        return "RfidModule";
    }

    // native側で実装するRFIDリーダーを探す処理
    @ReactMethod
    public void findDevice() {
      // SDKのClassの処理を呼ぶ
    }
    
    // SDKのhandlerでdeviceを検知した時に動かす想定のメソッド
    public void didDiscoveredDevice(Device device) {
        WritableMap res = Arguments.createMap();
        res.putString("device", device);
        sendEvent(getReactApplicationContext(), "discoveredDevice", res);
    }
    
    // ReactNative側に通知する処理(MethodChannelのinvokeMethodに相当)
    private void sendEvent(ReactContext reactContext,
                           String eventName,
                           @Nullable WritableMap params) {
        reactContext
                .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
                .emit(eventName, params);
    }
    @ReactMethod
    public void addListener(String eventName) {}

    @ReactMethod
    public void removeListeners(Integer count) {}
}

定義しているRfidModuleというmoduleがそのままReactNativeで使えるmoduleとして渡されます。
その際、ReactNativeからアクセスするメソッドには@ReactMethodアノテーションをつける必要があります。
findDeviceの場合、ReactNativeから処理を呼ぶ必要がある一方で、RFIDリーダーが検知できた際にそれをnative層でキャッチして動かすdidDiscoveredDeviceには@ReactMethodアノテーションは不要です。

reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(eventName, params)

がFlutterのMethodChannelでいうところのMethodChannel.invokeMethod()にあたります。
ちなみに、NativeModuleを扱い、ReactNativeの方でhandlerを設置する時に内部的に呼ばれる
addListner, removeListenersを定義しておく必要があります。
定義しないと以下のようなWarningがでます。

`new NativeEventEmitter()` was called with a non-null argument without the required `addListener` method.

JS層の実装

Flutterの時と同様にListnerを張っておいてnative層からのeventをsubscribeします。
また、native層の処理を呼び出すこともできます。

import {
  NativeModules,
  NativeEventEmitter,
  EmitterSubscription,
} from 'react-native';

const {RfidModule} = NativeModules;

const HogeComponent: React.FC = () => {
  const onPress = useCallback(() => {
    RfidModule.findDevice();
  }, []);

  React.useEffect(() => {
    const eventEmitter = new NativeEventEmitter(NativeModules.RfidModule);
    setEventListner(eventEmitter.addListener('discoveredDevice', (event) = {
      console.log(event);
    }));
  }, []);

比較

FlutterとReactNativeでどちらの方がAndroid用SDKとの接続がやりやすいかという点だとほぼ手間は変わらないと思います。結局やっていることはnative層と、Dart/JS層にそれぞれeventHandlerを設置して、通知し合うということをやっていてこの辺りの仕組みも学習コストも対して変わらないので一概にどちらが良さそうみたいなことは言えなさそうだと感じました。

ちなみに、Kotlinでさっとかける環境が作りやすいのはFlutterだなーと思っています。

まとめ

MethodChannelを使った事例を紹介しました。
ほぼ、Androidのnative層を実装したことがなかった自分でもかなり簡単に実現することができたので良い仕組みだなと思いました。
ReactNativeでも同じことができるので、技術選定の際の参考にしてみてください。

Discussion

Shin.WShin.W

コメント失礼致します。
医療系プロダクト開発のPdMをしているものです。
医療機器をRFIDで読み取る機能を実装しようと考えております。

ユーザーはiOSもいればandroidもいるので、flutterを使って、一つのアプリで両方に対応できるUHF帯のRFID読み取り機能を作りたいと考えております。

質問としてはshukubotaさまが今回作られたものの延長でiPhoneでも動くようにすることはできるものでしょうか?
ご相談させていただけましたら幸いです。

よろしくお願い致します。

shukubotashukubota

コメントありがとうございます!

>shukubotaさまが今回作られたものの延長でiPhoneでも動くようにすることはできるものでしょうか?

iOS用のSDKが用意されているRFIDリーダーであればできると思います!(逆にSDKが用意されていなければ厳しいと思います)

記事で紹介したRFIDリーダーの場合、iOS用のObjective-Cで書かれたSDKがあるので、SDKとのやり取りをする処理はnative層のObjective-Cで書き、あとはandroidと同じようにFlutterのmethod channelの仕組みに乗ってdart側とデータのやり取りをすればできます。https://docs.flutter.dev/development/platform-integration/platform-channels?tab=type-mappings-obj-c-tab

この場合、

  • iOSのnative層でSDKと接続するハンドラー等をObjective-c、swiftで実装
  • Androidのnative層でSDKと接続するハンドラー等をJava, kotlinで実装
  • dart側でiOS, android用のmethod channelを別々に用意する。platformがiOS, androidかを判定して、よぶchannelを条件分岐させる

という手順が少なくとも必要なように思えます。
すみませんがiOSに関しては実際に書いたことはないので、documentや他記事を読んだ範囲での範囲になりますができそうです。
(ちなみにReactNativeでは method channelに相当するnative層とのやり取りはiOSでもできることは確認しました。)

Shin.WShin.W

大変ご丁寧に返信頂戴しましてありがとうございます。調査させていただきます!また質問等させていただくかもしれませんがよろしくお願い致します。