flutter_blueで両OS対応のSDKを書いてみる

#  はじめに
Flutterは今年のGoogle I/Oでも結構な推しを感じて，さらには先日1.0になったので，いよいよこれからのネイティブ開発でも一目置かれるような存在になるんじゃないかなぁって思います．

今回は**FlutterでBLEなことをやる機会があった**のでそこでの話を書きます．BLEと聞くといろいろと悪夢を思い浮かべる人もいると思いますが，両OSに対応するSDKを書くにはどうすればいいのか？どんなところにAndroidとiOS両方に対応するためのハマりポイントがあるかを紹介できたらと思います．

# FlutterでBLEをやるには？
現時点では，[flutter_blue](https://github.com/pauldemarco/flutter_blue)というライブラリを使うのがよいです．他にもBLEがさわれそうなライブラリはいくつかありますが，どれも

- dart1の時点で更新が止まっている．
- サポートしてるFlutterのバージョンが低く実質的に開発が終わっている

ような不本意な制約を受けてしまうので，flutter_blueを使うのがベストっぽい感じがします．

# 簡単な使い方
ざっくりとした使い方はsampleやGitHub公式を見るほうが正しいです．~~こういう更新が激しいライブラリはこんな信憑性のない記事の一例を見るよりも公式を参照したほうがいいです．~~
## scanする
```dart
final FlutterBlue _flutterBlue = FlutterBlue.instance;
void scanDevices() {
    _flutterblue.scan(timeout: Duration(seconds: timeout),)
        .listen((scanResult) {
            print("${scanResult.uuid.toString()}");
    }, onDone: stopScan);
}
```
結構簡単っぽい．

## notifyきたものを垂れ流す．
```dart
BluetoothDevice _device;
_device.setNotifyValue(characteristic, true); // characteristicはserviceから見つけてくるなどする必要があります
_device.onValueChanged(characteristic).listen((value) => print("${value.toString()}"));
```
シンプルに書けてよい．

# SDKを実装してみる
今回実装するのは[にぎるくん](https://gugen.jp/entry2018/2018-098)のSDKです．独自で作成したデバイスなのでいろんな仕様がオレオレです．
実際に実装したSDKは[ここ](https://github.com/cyder/nigiru_kun_sp/tree/master/lib/datasources/bluetooth)です．これ以降はこのコードを参考に話を進めます．

全部については掛けないので，`scan`，`write`，`notify`の3つについてどのように実装したかを示します．

## 構成
```
.
├── central_manager.dart  
├── nigirukun_peripheral.dart
├── nigirukun_processor.dart
└── nigirukun_profile.dart
```
それぞれ，

- central全体を管理する
- peripheral(端末)での処理の詳細(read/write/notifyのハンドル)を行う．
- binary形式でくる値を実際に使う形式の値に変換する．
- 固有のserviceやcharacteristicを格納しておく．

といった役割を持っています．

## scanする
ほとんど上の例と同じです．flutter_blueの`scan`のメソッドを使うだけで，Android/iOSで動作します．すごい．ただしそのまま使うと，にぎるくん以外のデバイスが出てきたり(世の中にはBLE機器は結構溢れている)，同じデバイスが重複してカウントされたりします．そこで以下のようにしました．

```dart
Observable<NigirukunPeripheral> get scannedDevice =>
    _scanSubject.stream
        .where((scanResult) => scanResult.advertisementData.connectable)
        .where((scanResult) => scanResult.advertisementData.serviceUuids
            .where((item) => (item == NigirukunServicesProfile.NIGIRUKUN_SERVICE || item.toLowerCase() == NigirukunServicesProfile.NIGIRUKUN_SERVICE)).length > 0)
        .map((scanResult) => NigirukunPeripheral.scanResult(scanResult)
        .distinct(([a, b]) => a.uuid == b.uuid);
```
全貌を解決していきます．

#### まずrxにする
RectiveXはstreamっぽいデータとの相性が非常にいいので，rxにラップしました．別にdartのstreamでもよかったは良かったんですが，Rxが好きなので．

#### connect可能かどうかでフィルタする
```dart
.where((scanResult) => scanResult.advertisementData.connectable)
```
connectできないものをscanの候補と出してもあんまり意味がないからね．

#### にぎるくんのserviceだけをフィルタする
```dart
.where((scanResult) => scanResult.advertisementData.serviceUuids
            .where((item) => (item == NigirukunServicesProfile.NIGIRUKUN_SERVICE || item.toLowerCase() == NigirukunServicesProfile.NIGIRUKUN_SERVICE)).length > 0)
```

この一文にAndroid/iOSに対応する苦しさが見えます．Androidでは，**serviceは小文字で格納されているため**，`or`式の前方で一致してくれますが，**iOSではserviceを大文字で格納**しています．そこで不本意ながら条件を複数個設定することで一方のOSでのみ`scan`できない問題を回避しています．

ココらへんの差異は一応`guid.dart`内で定義されるクラスで解決しようとはしているんですが，まだライブラリとして未熟なためか，細部までは行き届いていないようです．もしくは僕の使い方が悪いんでしょう．．．．

#### 独自のPeripheral管理
```dart
.map((scanResult) => NigirukunPeripheral.scanResult(scanResult)
```
ここでいろいろ独自に必要な情報を吸い出しつつ別の型にします．

#### 重複除去
```dart
.distinct(([a, b]) => a.uuid == b.uuid);
```
uuidを各デバイスごとに固有のものを振っていたのでここでフィルタすることで重複を除去します．ここにも両OSでの差が出ました．Androidでscanしたときに表示されるuuidと，iOSで表示されるuuidでは**別のもの**が表示されるのです．これは多分，dongleの差でしかないと思うので本質的に問題になることは無いですが，あっちの端末とこっちの端末でscanしたときの結果が違うと少々戸惑います．

## writeする
writeもすごく簡単で，基本的には

```dart
Future<void> _writeValue(BluetoothCharacteristic characteristic, List<int> value) async {
    await _rawPeripheral?.writeCharacteristic(characteristic, value);
}
```

上のようなサンプルが**公式に**書いてあります．
故にこんな感じのprivate methodをはやしておいて，後はUseCaseごとにwrapしたpublic methodを作ればいいと誰しも思うはずです．ところがこれでは自分の開発環境ではAndroidでは動いたのですが，**iOSではうまく動作しません**でした．

st###########ow風に結論だけ書くなら，以下のようにすれば多分動作**は**します．

```dart
Future<void> _writeValue(BluetoothCharacteristic characteristic, List<int> value) async {
    await _rawPeripheral?.writeCharacteristic(characteristic, value, type: CharacteristicWriteType.withResponse);
}
```

ここから下はなぜiOSではこのように記述する必要があるのかを書きます．結論だけ書くと，Androidの制約がゆるすぎるだけですが．．．興味がない人はすっ飛ばしてもらって構いません．

これはflutter_blueの`writeCharacteristic`のメソッドの中身を見るとわかるのですが，[ここを抜粋](https://github.com/pauldemarco/flutter_blue/blob/master/lib/src/bluetooth_device.dart#L96-L109)すると

```dart
/// Writes the value of a characteristic.
/// [CharacteristicWriteType.withoutResponse]: the write is not
/// guaranteed and will return immediately with success.
/// [CharacteristicWriteType.withResponse]: the method will return after the
/// write operation has either passed or failed.
Future<Null> writeCharacteristic(BluetoothCharacteristic characteristic, List<int> value,
    {CharacteristicWriteType type =
        CharacteristicWriteType.withoutResponse}) async {
  var request = protos.WriteCharacteristicRequest.create()
    ..remoteId = id.toString()
    ..characteristicUuid = characteristic.uuid.toString()
    ..serviceUuid = characteristic.serviceUuid.toString()
    ..writeType = protos.WriteCharacteristicRequest_WriteType.valueOf(type.index)
    ..value = value;
```

どうやらデフォルトで`CharacteristicWriteType`が`withoutResponse`になっているようです．ここで自分の接続したいGATTのパラメータをみてみましょう．

**withResponse**でした．．．

iOSではどうやらここの整合性をきちんとみているようです．CoreBluetoothの知見が無いとこれハマるわ．．．．これはつまりiOSのほうが正しく挙動していたということになります．じゃあAndroidではそこはゆるゆるなのか？flutter_blueのコードを追ってみましょう．

```dart
 var result = await FlutterBlue.instance._channel
        .invokeMethod('writeCharacteristic', request.writeToBuffer());
```
上の変数`var request`で作成したwrite用の設定で実際に書き込む処理を行っています．`invokeMethod`は，各実装(iOS/Android)それぞれの環境で実行されます．それぞれのコードを深掘りします．Android側のコード全体はこんな[感じ](https://github.com/pauldemarco/flutter_blue/blob/master/android/src/main/java/com/pauldemarco/flutterblue/FlutterBluePlugin.java#L332-L372)で，iOS側はこんな[感じ](https://github.com/pauldemarco/flutter_blue/blob/10a82beddae2da38196a1fe5d9fecbc8a2ee0e50/ios/Classes/FlutterBluePlugin.m#L195-L211)．

この内実際にAndroid/iosのAPI呼び出しを行って書き込んでいる部分を抜き取ると，

[[Android]]

```java
if(!gattServer.writeCharacteristic(characteristic)){
    result.error("write_characteristic_error", "writeCharacteristic failed", null);
    return;
}
```

[[iOS]]

```objc
// Write to characteristic
[peripheral writeValue:[request value] forCharacteristic:characteristic type:type];
result(nil);
```
これらのメソッドの挙動をドキュメントで確認してみましょう．[Android](https://developer.android.com/reference/android/bluetooth/BluetoothGatt.html#writeCharacteristic(android.bluetooth.BluetoothGattCharacteristic))，[iOS](https://developer.apple.com/documentation/corebluetooth/cbperipheral/1518747-writevalue?language=objc)

どうやら，これの挙動がosで異なるのが今回の原因のようです．
まとめると，Android側では**GATTとプロパティが一致しなくても書き込みを行ってしまう**(それがエラーとして出てくることはないがcallbackが呼ばれない)，しかしiOS側では**GATTのプロパティが一致しなければ書き込みそのものを行わない**．ようです．

これで謎が解けました．結論は正しくGATTパラメータを確認しましょう．．．．

## notifyする

最後にnotifyです．これは特にハマる部分もなく上の例に上げたようにやるといいです．rxにしてデータを流したいなら，`onValueChanged`の中でsubjectをpublishするような実装を行うといいと思います．

```dart
_rawPeripheral?.onValueChanged(characteristic)?.listen((value){
    _forceStream.add(NigirukunDataProcessor().toForce(value));
});
```

この際，`value`に入っている値はバイナリ値なので適切に変換する必要があります．この例では`toForce`というものをかませることで目的の値にしています．

# まとめ
今回，`scan`，`write`，`notify`の3つの事例をもとにflutterでBLEのSDKを実装することについて書きました．やはり細かい部分のデバッグにはAndroid/iOS両方の深い知識が求められるケースが多く，また，ライブラリの実装を読み解くのはほぼ必須だと思われます．それでも，流石にAndroid/iOS両方を実装するよりはかかる時間は短いと思うので，深く注意しながら実装を進めるといいと思いました．


# 参考

- [flutter_blue | Flutter Package](https://pub.dartlang.org/packages/flutter_blue)
- [Core Bluetooth | Apple Developer Documentation](https://developer.apple.com/documentation/corebluetooth)
- [Bluetooth low energy overview  |  Android Developers](https://developer.android.com/guide/topics/connectivity/bluetooth-le)