研究開発部の堤と申します。先日Eightのモバイルアプリで「タッチ名刺交換」という機能をリリースしました。
この機能では BLE (Bluetooth Low Energy) を利用するのですが、コアライブラリも含めて開発メンバーの誰でもメンテできるよう、6月頃にEight iOSチームを対象にBLE勉強会を開催しました。
勉強会は以下の3本立てで行ったのですが、
- 第1回: 「BLE / Core Bluetooth基礎」編
- 第2回: 「RSSIを用いた距離推定」編
- 第3回: 「Core BluetoothにおけるL2CAP実装」編
第1回は 拙著 と GitHubで公開しているサンプル をベースに解説したので割愛し、第2回の内容は下記記事にまとめました。
本記事では第3回の内容について書きたいと思います。
L2CAPとは
L2CAP (Logical Link Control and Adaptation Protocol) はBluetoothプロトコルスタックの中でGATTよりも下層に位置するプロトコルです。
といってもiOSエンジニアがCore Bluetoothを通してL2CAPを扱う上でプロトコルの詳細を意識する機会はあまりないので本記事では割愛します。
iOSエンジニアが知っておくべきポイントは以下:
GATTは利用せず、2つのデバイス間でのデータストリームを取り扱うのがL2CAP
→ GATTにあった1パケットのデータ長は20バイトまで 1 といった制限を受けない
→ ストリーミングや大量のデータ転送などを行う場合にL2CAPは最適
L2CAPを用いた通信は、Core BluetoothではiOS 11より使用できるようになりました 2。
もちろん、Core Bluetooth / Apple独自規格ではなくBluetooth標準の規格なので、Androidでも利用可能です。
実装のポイント
第1回で基本的なBLEによる通信の実装方法については解説したので、そことの差分をメインにL2CAPの実装を整理しました。
処理シーケンスで見ると一見複雑ですが、従来のBLE通信との差分だけに着目すると実は非常にシンプルです。
チャンネルをオープンするまでは従来のBLEの通信とほぼ同様
CBCentralManager
とCBPeripheralManager
を使う- スキャン/アドバタイズし、接続し、サービスとキャラクタリスティックを探索する
違いは「PSMの受け渡し → L2CAPチャンネルオープン」
- L2CAPチャンネルをオープンするためには、PSM(Protocol and Service Multiplexer)と呼ばれる
CBL2CAPPSM
型の値をピア間で受け渡す必要がある - ペリフェラル側でPSMを生成し、セントラル側でそれを受け取ってチャンネルオープンする
ペリフェラル側の違い
ペリフェラル側は、CBUUIDL2CAPPSMCharacteristicString
をUUIDとするキャラクタリスティック(以下PSMキャラクタリスティックと呼ぶ)を持つサービスを提供する。
let characteristic = CBMutableCharacteristic(type: CBUUID(string:CBUUIDL2CAPPSMCharacteristicString), properties: [.read], value: nil, permissions: [.readable]) service.characteristics = [characteristic] peripheralManager.add(service)
publishL2CAPChannel
メソッドで、L2CAPチャンネルをパブリッシュする。
peripheralManager.publishL2CAPChannel(withEncryption: false)
パブリッシュ完了すると呼ばれる、CBPeripheralManagerDelegate
の didPublishL2CAPChannel
メソッドを実装しておく。
public func peripheralManager(_ peripheral: CBPeripheralManager, didPublishL2CAPChannel PSM: CBL2CAPPSM, error: Error?) { ... channelPSM = PSM }
ここで得られる CBL2CAPPSM
型(UInt16のtypealias)の値は、PSMキャラクタリスティックの値にセットしてセントラル側に共有する。
セントラル側の違い
セントラル側では、CBPeripheralDelegate
の didDiscoverCharacteristicsFor
メソッドの中でPSMキャラクタリスティックを発見したら、値をReadしてPSMを取り出す。
// (コード自体はPSM固有のものはないので割愛)
PSMの値(型はCBL2CAPPSM
)を用いて、 CBPeripheral
の openL2CAPChannel
メソッドでL2CAPチャンネルをオープンする。
peripheral.openL2CAPChannel(psm)
チャンネルオープン後
L2CAPチャンネルがオープンすると、CBPeripheralDelegate
(セントラル側)、CBPeripheralManagerDelegate
(ペリフェラル側)でそれぞれ以下のメソッドが呼ばれる。
func peripheral(_ peripheral: CBPeripheral, didOpen channel: CBL2CAPChannel?, error: func peripheralManager(_ peripheral: CBPeripheralManager, didOpen channel: CBL2CAPChannel?, error: Error?)
それぞれ CBL2CAPChannel
の inputStream
, outputStream
を用いてストリーム処理を開始する。
channel.inputStream.delegate = self channel.outputStream.delegate = self channel.inputStream.schedule(in: RunLoop.main, forMode: .default) channel.outputStream.schedule(in: RunLoop.main, forMode: .default) channel.inputStream.open() channel.outputStream.open()
これ以降はiOS 2.0の頃からあるStream
クラス(Objectvie-CではNSStream
)を用いたストリーム処理であり、L2CAPやCore Bluetoothには依存しない話なので、本記事では割愛。
Core BluetoothのL2CAP関連APIのポイント
上述の実装以外に、勉強会ではL2CAP関連APIのポイントとなる点について解説しました。
Core BluetoothのL2CAP関連APIは数も多くなく非常にシンプルですが、「何がないか」に着目すると理解が深まります。上記記事の【メモ】にそのあたりをまとめてあるのでご参照ください。
L2CAPにおける親子問題
タッチ名刺交換という機能においては多対多での接続に対応する必要があるので、ひとつの端末で CBCentralManager
も CBPeripheralManager
も両方持つことになります。
その場合に、自身がセントラルロールとしてのL2CAPチャンネルと、自身がペリフェラルロールとしてのL2CAPチャンネルの2つのL2CAPチャンネルがオープンされる可能性があります。
これをどちらか一方のチャンネルのみ使用して通信したい場合に、以下のような問題があります。
先にdidOpen
が呼ばれた方を優先する(ストリームを open()
する)ことにする。
つまり後に didOpen
したチャンネルのストリームは open()
しない。
期待する挙動
先にペリフェラルとの接続が確立(L2CAPチャンネルのPSMを取得)した方の端末をAとする
⇒ 端末Aで openL2CAPChannel
を先に呼ぶことになる
- 端末Aではセントラル側の
peripheral(_:didOpen:error:)
が先に呼ばれ、 - 端末Bではペリフェラル側の
peripheralManager(_:didOpen:error:)
が先に呼ばれる
実際に発生しうる挙動
2台の端末どちらでも
- ペリフェラル側の
peripheralManager(_:didOpen:error:)
が先に呼ばれ、 - セントラル側の(つまりCBPeripheralDelgateの)
peripheral(_:didOpen:error:)
が次に呼ばれる
→ 相手側端末でストリームを open()
しないため通信できない
ということが容易に発生する。
これに対してどう対応するかというのは複数方法があるわけですが、それがL2CAPを用いたタッチ名刺交換の通信フロー 3 の設計に関わってくるため、「なぜこのようなフローになっているか」を設計に関わっていないメンバーも理解できるよう、勉強会でこの問題について共有しました。
まとめ
Core Bluetoothを用いたL2CAP実装について、基礎的・一般的な内容について解説しました。
より実践的な「ドキュメント等には記載されていないような挙動・ハマりどころ」について、また別記事にて解説したいと思います。