はじめに
Flutterは今年のGoogle I/Oでも結構な推しを感じて,さらには先日1.0になったので,いよいよこれからのネイティブ開発でも一目置かれるような存在になるんじゃないかなぁって思います.
今回はFlutterでBLEなことをやる機会があったのでそこでの話を書きます.BLEと聞くといろいろと悪夢を思い浮かべる人もいると思いますが,両OSに対応するSDKを書くにはどうすればいいのか?どんなところにAndroidとiOS両方に対応するためのハマりポイントがあるかを紹介できたらと思います.
FlutterでBLEをやるには?
現時点では,flutter_blueというライブラリを使うのがよいです.他にもBLEがさわれそうなライブラリはいくつかありますが,どれも
- dart1の時点で更新が止まっている.
- サポートしてるFlutterのバージョンが低く実質的に開発が終わっている
ような不本意な制約を受けてしまうので,flutter_blueを使うのがベストっぽい感じがします.
簡単な使い方
ざっくりとした使い方はsampleやGitHub公式を見るほうが正しいです.こういう更新が激しいライブラリはこんな信憑性のない記事の一例を見るよりも公式を参照したほうがいいです.
scanする
final FlutterBlue _flutterBlue = FlutterBlue.instance;
void scanDevices() {
_flutterblue.scan(timeout: Duration(seconds: timeout),)
.listen((scanResult) {
print("${scanResult.uuid.toString()}");
}, onDone: stopScan);
}
結構簡単っぽい.
notifyきたものを垂れ流す.
BluetoothDevice _device;
_device.setNotifyValue(characteristic, true); // characteristicはserviceから見つけてくるなどする必要があります
_device.onValueChanged(characteristic).listen((value) => print("${value.toString()}"));
シンプルに書けてよい.
SDKを実装してみる
今回実装するのはにぎるくんのSDKです.独自で作成したデバイスなのでいろんな仕様がオレオレです.
実際に実装したSDKはここです.これ以降はこのコードを参考に話を進めます.
全部については掛けないので,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機器は結構溢れている),同じデバイスが重複してカウントされたりします.そこで以下のようにしました.
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可能かどうかでフィルタする
.where((scanResult) => scanResult.advertisementData.connectable)
connectできないものをscanの候補と出してもあんまり意味がないからね.
にぎるくんのserviceだけをフィルタする
.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管理
.map((scanResult) => NigirukunPeripheral.scanResult(scanResult)
ここでいろいろ独自に必要な情報を吸い出しつつ別の型にします.
重複除去
.distinct(([a, b]) => a.uuid == b.uuid);
uuidを各デバイスごとに固有のものを振っていたのでここでフィルタすることで重複を除去します.ここにも両OSでの差が出ました.Androidでscanしたときに表示されるuuidと,iOSで表示されるuuidでは別のものが表示されるのです.これは多分,dongleの差でしかないと思うので本質的に問題になることは無いですが,あっちの端末とこっちの端末でscanしたときの結果が違うと少々戸惑います.
writeする
writeもすごく簡単で,基本的には
Future<void> _writeValue(BluetoothCharacteristic characteristic, List<int> value) async {
await _rawPeripheral?.writeCharacteristic(characteristic, value);
}
上のようなサンプルが公式に書いてあります.
故にこんな感じのprivate methodをはやしておいて,後はUseCaseごとにwrapしたpublic methodを作ればいいと誰しも思うはずです.ところがこれでは自分の開発環境ではAndroidでは動いたのですが,iOSではうまく動作しませんでした.
st###########ow風に結論だけ書くなら,以下のようにすれば多分動作はします.
Future<void> _writeValue(BluetoothCharacteristic characteristic, List<int> value) async {
await _rawPeripheral?.writeCharacteristic(characteristic, value, type: CharacteristicWriteType.withResponse);
}
ここから下はなぜiOSではこのように記述する必要があるのかを書きます.結論だけ書くと,Androidの制約がゆるすぎるだけですが...興味がない人はすっ飛ばしてもらって構いません.
これはflutter_blueのwriteCharacteristic
のメソッドの中身を見るとわかるのですが,ここを抜粋すると
/// 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のコードを追ってみましょう.
var result = await FlutterBlue.instance._channel
.invokeMethod('writeCharacteristic', request.writeToBuffer());
上の変数var request
で作成したwrite用の設定で実際に書き込む処理を行っています.invokeMethod
は,各実装(iOS/Android)それぞれの環境で実行されます.それぞれのコードを深掘りします.Android側のコード全体はこんな感じで,iOS側はこんな感じ.
この内実際にAndroid/iosのAPI呼び出しを行って書き込んでいる部分を抜き取ると,
[[Android]]
if(!gattServer.writeCharacteristic(characteristic)){
result.error("write_characteristic_error", "writeCharacteristic failed", null);
return;
}
[[iOS]]
// Write to characteristic
[peripheral writeValue:[request value] forCharacteristic:characteristic type:type];
result(nil);
これらのメソッドの挙動をドキュメントで確認してみましょう.Android,iOS
どうやら,これの挙動がosで異なるのが今回の原因のようです.
まとめると,Android側ではGATTとプロパティが一致しなくても書き込みを行ってしまう(それがエラーとして出てくることはないがcallbackが呼ばれない),しかしiOS側ではGATTのプロパティが一致しなければ書き込みそのものを行わない.ようです.
これで謎が解けました.結論は正しくGATTパラメータを確認しましょう....
notifyする
最後にnotifyです.これは特にハマる部分もなく上の例に上げたようにやるといいです.rxにしてデータを流したいなら,onValueChanged
の中でsubjectをpublishするような実装を行うといいと思います.
_rawPeripheral?.onValueChanged(characteristic)?.listen((value){
_forceStream.add(NigirukunDataProcessor().toForce(value));
});
この際,value
に入っている値はバイナリ値なので適切に変換する必要があります.この例ではtoForce
というものをかませることで目的の値にしています.
まとめ
今回,scan
,write
,notify
の3つの事例をもとにflutterでBLEのSDKを実装することについて書きました.やはり細かい部分のデバッグにはAndroid/iOS両方の深い知識が求められるケースが多く,また,ライブラリの実装を読み解くのはほぼ必須だと思われます.それでも,流石にAndroid/iOS両方を実装するよりはかかる時間は短いと思うので,深く注意しながら実装を進めるといいと思いました.