[翻訳] 初めてのCore Bluetooth

Tim Oliver
Tim Oliver
March 3rd 2021
Share post:

初めに

スマートフォンとタブレットは世の中を大きく変えてきました。2007年に発売されたiPhoneから様々なデバイスが直感的なタッチインターフェースとネットワーク技術を持つようになりました。

iOSの開発者として、私は自分自身がネットワークに精通しているとは思っていません。最近の全てのアプリには当然のようにデータのエンコードやデコードを処理するREST APIのようなものが存在します。

しかし私がこれまで触れてこなかったスマートフォンのネットワーク技術に、Bluetoothがあります。 ワイヤレスイヤホンを着けながらこの記事を書いている時、手首にApple Watchを着けている時、Nintendo Switchで『どうぶつの森』をワイヤレスProコントローラーでプレイしている時、Bluetoothは当然のものの様に感じてしまいます。 意識せずとも動作している魔法のブラックボックスです。

これまでのiOSエンジニアとしてのキャリアの中で、Appleが提供するBluetooth API使うプロジェクトに関わったことはありませんでしたし、サイドプロジェクトでも必要性を感じたことはありませんでした。 なので友人のMax(訳注: Ditto社の共同創業者)がCore Bluetoothの動作を示すためのアプリを作って、そのことについての記事を書いてくれないかと依頼してきた時、このチャンスに飛びつきました。

この記事は私と同じような経験をしてきた人に向けて書きました。Appleのプラットフォーム向けの開発をしたことがあったとしてもCore Bluetooth APIを学ぼうと思わなかった人もいると思います。 Core Bluetoothの parent/child アーキテクチャ、簡単なデータを送受信する方法、そして遭遇したいくつかの落とし穴について書いていきます。

Core Bluetooth とは

Core BluetoothはAppleのパブリックフレームワークであり、サードパーティ製アプリがiOSやiPadOS上のアプリにBluetooth機能を組み込むための唯一の公式な方法です。

元々、Core Bluetoothは「Bluetooth Low Energy (BLE)を抽象化したもの」でした(BLEはBluetoothクラシックとは違い低消費電力デバイスでも動作するような省電力な通信をする技術です)。 つまりこれまでのCore Bluetoothは、心拍計やIoTデバイスのような低消費電力で定期的に小さなデータをブロードキャストするような用途に使われていました。

一方でゲームコントローラやワイヤレスヘッドフォンなどのようなBluetoothクラシックを利用しているデバイス(より高出力で常にデータをストリーミングしている)はサードパーティ製のアプリからアクセスすることができませんでした。

しかしiOS13から、AppleはCore BluetoothでもBluetoothクラシックをカバーできる様に拡張しました。 パブリックAPIはまだ変更されていませんが、より様々なデバイスが利用できる様になったということです。 Core Bluetoothを学ぶにはとても良いタイミングだと思います。

Core Bluetoothの基本概念

Core Bluetoothを利用するには、関連する専門用語とその関係性に慣れておく必要があります。

セントラル(Central)とペリフェラル(Peripheral)

BLE は、非常に伝統的なサーバー/クライアント型のモデルで動作します。つまり一つのデバイスが情報を含むサーバーとして機能し、別のデバイスはこの情報をクエリしてローカルで処理/表示するクライアントとして機能します。

いくつかのデバイスが自分のデータをブロードキャストします。これがペリフェラルです。 他のデバイスはそれをスキャンして接続します。これがセントラルです。 接続された後は通常は、ペリフェラルがサーバー、セントラルがクライアントとして動作します。 これがCore Bluetoothの動作です。

一般的な例として、Bluetoothヘッドホンをスマートフォンにペアリングする場合、スマートフォンがセントラルとなり、ヘッドホンがペリフェラルとなります。

Core Bluetooth Centrals Peripherals and Characteristics

サービス(Service)

もちろんBLEデバイスの種類によって、どのような機能を持っているかが決まります。 例えば心拍計は心拍数を記録し、温度計は温度を計測します。

ほとんどのアプリはデバイスの特有の機能をサポートするように構築されると思います。 例えば健康をトラッキングするアプリは温度計のような健康に関係のないデバイスとの接続には興味がないでしょう。 ペリフェラル機器の特有の機能を、Bluetoothでは「サービス」と呼んで扱います。

ペリフェラルは、自身のサービスを定義したアドバタイズメントパケットをブロードキャストすることで、セントラルに見つけられるようにします。 セントラルはスキャン中に、探していたサービスをサポートするペリフェラルからのパケットを見つけると、接続を開始します。 最初はメインのサービスのみ送信されますが、一度接続を構築した後は、セントラルはその他のペリフェラルのサービスも照会することができます。

センターがペリフェラルとの互換性を確認するには、ペリフェラルがサポートするIDを知る必要があります。 特殊なアプリやペリフェラル機器の場合、お互いのUUIDを利用してサービスを定義しても良いでしょう。

しかし一般的には、ペリフェラルがBluetoothの世界的な基準に則るのが自然だと思います。 例えば血圧を記録するデバイスは、どの機器で計測されたデータであってもそのデータを処理することが出来るかもしれません。 このような、特定のユースケースを利用したいセントラルとペリフェラルの間で利用できる、標準化されたサービスIDのデータベースが存在します。

キャラクタリスティック(Characteristic)

一つのサービスには、複数の様々なデータが含まれる場合があります。 例えば、心拍計には心拍数の情報とセンサーの配置の情報が含まれます。

この様にサービスは、様々なデータを計測したり関連した機能を実行するなど、複数の特徴(キャラクタリスティック)を含むことがあります。 これらのデータはペリフェラルから送られることもあれば、ペリフェラルに送り返されることもあります。 セントラルは一つのキャラクタリスティックをクエリしたり、キャラクタリスティックが更新するたびに呼び出されるオブザーバーを登録したりすることができます。

Bluetoothデバイスが自身の機能をサービスやキャラクタリスティックとして提供することを、GATT (Generic Attribute Profile) と呼びます。

Core Bluetoothのコンセプトのまとめ

ここまででCore Bluetoothの基本的な部分が理解できていることを願っています。

親デバイスは「センター」と呼ばれ、「ペリフェラル」と呼ばれる子デバイスに接続します。 ペリフェラルは「サービス」として機能を管理し、サービスは「キャラクタリスティック」として機能を管理します。

実践

Core Bluetoothの基本的な概念を説明したところで、早速実践してみましょう。 2つのデバイスを接続する流れを見せるために、Core Bluetoothを中心としたサンプルアプリを作ってみました。 このアプリは簡易的なチャットアプリで、お互いを接続し、メッセージのデータを送受信します。

このアプリはセントラルとしてスキャンする方法と、ペリフェラルとしてアドバタイズする方法を紹介します。 接続されると、アプリはその後、1つのパイプラインを介して上流と下流の両方にメッセージを送信することができます。

まず初めに

まず何よりも最初に、NSBluetoothAlwaysUsageDescriptionキーをアプリのInfo.plistに追加して、Bluetoothを使用する理由を記述する必要があります。 このキーが存在しない場合、アプリは提出時にApp Storeに拒否されるだけでなく、アプリ自体が例外をスローしてCore Bluetooth APIを呼び出そうとします。

これはAppleのセキュリティ要件であり、すべてのアプリはBluetoothを有効にする前にユーザーから明示的な許可を得なければならないからです。 今回は、チャットサービスを有効にするためにBluetoothが必要であることを説明します。

他のデバイスとメッセージをやりとりするためにBluetoothにアクセスします。

これでCore Bluetoothを使い始めることができます。 Swiftでは、このフレームワークを利用する全てのソースファイルに以下のようなインポート文を記述する必要があります。

import CoreBluetooth

セントラルとしてスキャンする

Bluetooth 接続でセントラルの役割を果たす iOS デバイスは、CBCentralManager と呼ばれるオブジェクトで表現されます。

まず、新しいインスタンスを作成してみましょう。

let centralManager = CBCentralManager(delegate: self, queue: nil)

このように、オブジェクトはインスタンス化時にデリゲートとして指定されなければなりません。 このオブジェクトは CBCentralManagerDelegate に準拠していなければなりません。 セントラルマネージャをインスタンス化すると、直ちにBluetoothに必要なアクティビティが開始されます。

この時点では、まだスキャンを開始することはできません。 Bluetoothが「電源が入った(powered on)」状態になるまでかなりの時間がかかります。 そのため、最初のデリゲートコールバックを待つ必要があります。

func centralManagerDidUpdateState(_ central: CBCentralManager) {
   guard central.state == .poweredOn else { return }
   // ペリフェラルのスキャンを開始する
}

centralManagerDidUpdateState は、システム上のBluetoothの状態が変化するたびに呼ばれます。 Bluetoothがリセットされた時や、アクセスが許可されていない場合にも呼ばれます。

本番環境では全ての状態を適切に処理する必要がありますが、ここではBluetoothがオンになった状態(powered on)のみ検知するようにします。 その状態になればスキャンを開始することができます。

ペリフェラルのスキャンはとても簡単です。 scanForPeripherals を呼び出して、利用したいサービスを指定するだけです。

let service = CBUUID(string: "9f37e282-60b6-42b1-a02f-7341da5e2eba")
centralManager.scanForPeripherals(withServices: [service], options: nil)

上述したように、サービスは一意の識別子を持っているので、ペリフェラルやセンターはそれにマッチする可能性があります。 Core Bluetoothでは、これらの識別子は CBUUID オブジェクトを介して処理されます。ここではシンプルな文字列を使用します。

このチュートリアルでは、オンラインのUUIDジェネレーターから生成されたUUID文字列値を使用しています。 値はグローバルに一意である必要がありますが、ペリフェラル側とセントラル側の両方から認識可能です。

この時点では、同じサービスIDを持つペリフェラルをスキャンしています。 好きなタイミングで centralManager.isScanning を呼ぶことで、スキャンしているかどうかを確認することができます。

ペリフェラルとしてアドバタイズする

セントラルがスキャンしているので、スキャンしているサービスと同じものをアドバタイズする別のペリフェラルのデバイスが必要です。

セントラルが CBCentralManager を介して管理されるのと同様に、ペリフェラルは CBPeripheralManager のインスタンスによって管理されます。

let peripheralManager = CBPeripheralManager(delegate: self, queue: nil)

セントラルマネージャと全く同じように、ペリフェラルマネージャも、作成時にデリゲートを必要とします。(今回は CBPeripheralManagerDelegate) そしてデバイス上のBluetoothの状態が「powered on」になるのを待つ必要があります。

func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
  guard peripheral.state == .poweredOn else { return }
  // Start advertising this device as a peripheral
}

ペリフェラルのBluetoothの状態がオンになると、ペリフェラルは自身のアドバタイズを始めることができます。


let characteristicID = CBUUID(string: "890aa912-c414-440d-88a2-c7f66179589b")

// キャラクタリスティックを作成し、設定する
let characteristic = CBMutableCharacteristic(type: characteristicID,
                          properties: [.write, .notify],
                          value: nil,
                          permissions: .writeable)

// サービスを作成し、そこにキャラクタリスティックを追加する
let serviceID = CBUUID(string: "9f37e282-60b6-42b1-a02f-7341da5e2eba")
let service = CBMutableService(type: serviceID, primary: true)
service.characteristics = [characteristic]

// このサービスをペリフェラルマネージャに登録する
peripheralManager.add(service)

// サービスIDによってサービスを指定し、アドバタイズを開始する
peripheralManager.startAdvertising(
            [CBAdvertisementDataServiceUUIDsKey: [service],
             CBAdvertisementDataLocalNameKey: "Device Information"])

複雑に見えますが、一つずつ見ればそれほど複雑ではありません。

  1. キャラクタリスティックを作成し、セントラルが期待する標準化されたUUIDを設定します。そしてそのキャラクタリスティックをwriteable(書き込み可能)にして、セントラルがデータを送り返せるようにする必要があります。
  2. 標準化されたサービスUUIDを持つサービスオブジェクトを生成し、タイプをプライマリに設定して、このペリフェラルの「メイン」サービスとしてアドバタイズされるようにします。
  3. 同じサービスIDでペリフェラルをアドバタイズします。CBAdvertisementDataLocalNameKey は通常、ペリフェラルのデバイス名を保持しますが、センターが使う追加データ(温度計の現在の温度など)を保持するようにすることもできます。

セントラルからペリフェラルを識別する

ここまでで、片方のデバイスがスキャンし、同じサービスIDでもう片方のデバイスがアドバタイズしているので、お互いを見つけられるはずです。

セントラル側では、ペリフェラルが見つかると以下のデリゲートコールバックが呼ばれます。

func centralManager(_ centralManager: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {

  // `advertisementData` をチェックして、これが正しいデバイスかどうかを判断する

  // このデバイスへの接続を試みる
  centralManager.connect(peripheral, options: nil)

  // ペリフェラルを保持する
  self.peripheral = peripheral
}

didDiscoverPeripheral はペリフェラルに関する多くの情報を提供します。 advertisementData には CBAdvertisementDataServiceUUIDsKey に定義された全てのサービスUUIDに加えて、デバイスの名前やメーカー名などのペリフェラルに関する情報が含まれています。 (他にももっとあるかもしれません)

必要であれば advertisementData[CBAdvertisementDataServiceUUIDsKey] を使って、このペリフェラルがセントラルの要求するサービスをサポートするかをチェックすることもできます。 またRSSI(Received Signal Strength Indicator) は、ペリフェラルとの距離を判定するのに役立ちます。 動作に近い距離であることが要求されることがあり、この値はその監視に使用することができます。

もしこのペリフェラルが接続したいものであれば、 centralManager.connect() を呼び接続を開始することができます。

このペリフェラルオブジェクトにデリゲートの外でアクセスする方法が無いため、クラス内のプロパティに保持しておくと良いと思います。

ペリフェラルへの接続

ペリフェラルを検出して centralManager.connect() を呼び出すと、セントラルはそのペリフェラルに接続しようとします。 接続すると、以下のデリゲートメソッドが呼び出されます。

func centralManager(_ centralManager: CBCentralManager,
                        didConnect peripheral: CBPeripheral) {
  // 接続されたため、スキャンを停止する
  centralManager.stopScan()

  // ペリフェラルのデリゲートを設定する
  peripheral.delegate = self

  // コミュニケーションに利用するチャットのキャラクタリスティックをdiscoverする
  let service = CBUUID(string: "9f37e282-60b6-42b1-a02f-7341da5e2eba")
  peripheral.discoverServices([service])
}

この時点でペリフェラルを扱えるようになり、セントラルマネージャではなく、ペリフェラルオブジェクトを直接操作します。 このペリフェラルオブジェクトは CBPeripheral という型で、CBPeripheralManager とは全く別のものです。

そのためまず最初に行うことは、このペリフェラルから直接イベントを受信できるように、自分自身をこのペリフェラルのデリゲートとして割り当てることです(CBPeripheralDelegateに準拠)。 次にペリフェラルの discoverServices を呼び出すことで、そのペリフェラルがサポートしているサービスを discover し、必要なサービスのキャラクタリクティックにアクセスすることができます。

ペリフェラルのサービス内のキャラクタリスティックを確認する

自分自身をペリフェラルのデリゲートに設定し、そのサービスを確認するためのリクエストを行うと、 CBPeripheralDelegate の以下のメソッドが呼び出されます。

func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
  // もしエラーが起きた場合、接続を遮断して最初からやり直せるようにする
  if let error = error {
    print("サービスが見つかりません: \(error.localizedDescription)")
    cleanUp()
    return
  }

  // 必要なキャラクタリスティックを指定する
  let characteristic = CBUUID("890aa912-c414-440d-88a2-c7f66179589b")

  // サービスが複数ある場合があるため、ループして必要なキャラクタリスティックをdiscoverする
  peripheral.services?.forEach { service in
    peripheral.discoverCharacteristics([characteristic], for: service)
  }
}

この時点でエラーが発生した場合は、接続を処理して終了させる必要があります。 そうでなければ、peripheral オブジェクトには、サポートしているすべてのサービスが登録されることになります。

サービスが CBUUID オブジェクトを介して識別されるのと同じように、キャラクタリスティックも識別されます。 キャラクタリスティックを購読してそのデータを読み込む前に、サービスの中の一つとして discover する必要があります。

ペリフェラルは複数のサービスを持てるため、ループによって必要なキャラクタリスティックを見つける必要があります。 peripheral.services を検索し、特定のキャラクタリスティックIDを見つけ出します。

キャラクタリスティックを購読する

チャットアプリでは、ペリフェラルからのデータの受動的なストリームには興味がなく、データが来たらすぐに通知されるようにしたいです。 そのため、キャラクタリスティックが更新されればすぐに通知されるように設定します。

サービス内のキャラクタリスティックがdiscoverされると、以下のデリゲートコールバックが呼び出されます。

func peripheral(_ peripheral: CBPeripheral,
      didDiscoverCharacteristicsFor service: CBService, error: Error?) {
  // もしエラーが起きた場合、接続を遮断して最初からやり直せるようにする
  if let error = error {
    print("キャラクタリスティックが見つかりません: \(error.localizedDescription)")
    cleanUp()
    return
  }

  // 必要なキャラクタリスティックを指定する
  let characteristicUUID = CBUUID("890aa912-c414-440d-88a2-c7f66179589b")

  // キャラクタリスティックが複数ある場合があるためループする
  service.characteristics?.forEach { characteristic in
    guard characteristic.uuid == characteristicUUID else { return }

    // キャラクタリスティックを購読し、データが来たら通知されるようにする
    peripheral.setNotifyValue(true, for: characteristic)

    // データを送信するために、キャラクタリスティックの参照を保持する
    self.characteristic = characteristic
  }
}

ここでも、何かエラーが起きた場合は適切なエラー処理をおこないます。

service オブジェクトを受け取りましたが、サービス内に複数のキャラクタリスティックが含まれる可能性があるため、ループして必要なものを探しだします。

必要なキャラクタリスティックを見つけたら、peripheral.setNotifyValue()true にして呼び出し、 その中のデータに変更があったら通知を受け取るようにします。

ペリフェラルから、通知が設定されているかを確認する

次にペリフェラルが、セントラルへのキャラクタリスティックの通知の設定が正しく動作しているかを報告します。 成功したか失敗したかに関わらず、以下のデリゲートコールバックが呼び出されます。

func peripheral(_ peripheral: CBPeripheral,
                    didUpdateNotificationStateFor characteristic: CBCharacteristic,
                    error: Error?) {
  // 適切なエラー処理を行う
  // ここでのエラーでは接続自体を破棄する必要はない
  if let error = error {
    print("キャラクタリスティック更新通知エラー: \(error.localizedDescription)")
    return
  }

  // キャラクタリスティックが指定したものであることを確かめる
  guard characteristic.uuid == characteristicUUID else { return }

  // 通知の設定が成功しているかチェックする
  if characteristic.isNotifying {
    print("キャラクタリスティックの通知が開始されている")
  } else {
    print("キャラクタリスティックの通知が止まっています。接続をキャンセルします。")
    centralManager.cancelPeripheralConnection(peripheral)
  }

  // セントラルからペリフェラルに何か情報を送信する
}

必須ではありませんが、サブスクリプションが失敗した場合に再度購読を試みる(必要に応じてクリーンアップコードを実行する)のも良いと思います。 また、もしセントラルにペリフェラルに送信したいが保留中のデータがある場合は、このタイミングで送信するのが良いでしょう。

ペリフェラルにデータを送る

セントラルマネージャがペリフェラルと必要なキャラクタリスティックを見つけ出すことができたら、 このキャラクたりスティックを通じてセントラルはデータを送ることができます。

一つ注意しなければならないのは、このキャラクタリスティックはセントラルで書き込み可能になるように、ペリフェラル側で設定しておく必要があります。

let data = messageString.data(using: .utf8)!
peripheral.writeValue(data, for: characteristic, type: .withResponse)

type引数によって、データを受信したことをペリフェラルに返信させるかどうかを指定します。 これは特定の順序が必要なデータと、頻繁に繰り返されるデータを区別するのに便利で、ペリフェラルが受信できなくても値が失われることがありません。

セントラルにデータを送信する

反対にペリフェラルからセントラルにデータを送る場合も同様に記述します。

let data = messageString.data(using: .utf8)!
peripheralManager.updateValue(data,
        for: characteristic, onSubscribedCentrals: [central])

ペリフェラルからデータを受け取る

ここまででペリフェラルからデータを受信する準備ができました。 キャラクタリスティックに新しいデータが来た際に、以下のデリゲートを利用して通知を受け取ります。

func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
  // 適切なエラー処理を行う
  if let error = error {
    print("キャラクタリスティックの値の更新に失敗しました: \(error.localizedDescription)")
    return
  }

  // キャラクタリスティックから値を取り出す
  guard let data = characteristic.value else { return }

  // デコード/パース処理を行う
  let message = String(decoding: data, as: UTF8.self)
}

セントラルからデータを受け取る

最後に、ペリフェラルがセントラルからデータを受け取ると、 CBPeripheralManager の以下のメソッドが呼び出されます。

func peripheralManager(_ peripheral: CBPeripheralManager,
                      didReceiveWrite requests: [CBATTRequest]) {
  guard let request = requests.first, let data = request.value else { return }
  let message = String(decoding: data, as: UTF8.self)
}

特に注意しなければならないことは、キャラクタリスティックの作成時に書き込み可能(writeable)に設定されていなかった場合、 ペリフェラルへのデータ送信は静かに失敗し、このデリゲートは決して呼び出されないということです。

まとめ

ここまでを経て、Core Bluetoothでデータを送受信するには、かなりのステップ数が必要になることが分かります。

ペリフェラル側では、サービスとキャラクたりスティックを設定してアドバタイズし、セントラルが購読/購読中止をした場合に管理する必要があります。 セントラル側では、ペリフェラルをスキャンし、接続し、サービスを検索し、キャラクタリスティックを検索し、そして購読する必要があります。

とはいえ、用語と、それぞれのオブジェクトがどのように連鎖するかを理解すれば、比較的簡単に上手くいきます。

チャットアプリのデザインパターン

上記のCore Bluetooth APIの紹介ではセントラルとペリフェラルを接続するための基本的な手順を示していますが、 チャットアプリを作成する際、「誰がセントラルで誰がペリフェラルなのか」という大きな疑問にぶつかります。

Core Bluetooth Centrals Peripherals and Characteristics

低消費電力のセンサーがiPhoneに接続されているよくあるユースケースでは、セントラルとペリフェラルの役割は明確です。 しかし、2台のiPhoneが接続されている場合、誰がどちらの役割を果たすのかという問題は、突然、はるかに難しくなります。

最初の時点では理解していないかもしれないこととして、Core Bluetoothではデバイスは同時にセントラルにもペリフェラルにもなれるということがあります。 デバイスはスキャンしながらアドバタイズも同時に行うことができます。

devices discovery view

アプリが起動してデバイス検索画面が開かれた時、セントラルマネージャとペリフェラルマネージャの両方が作成され、スキャンとアドバタイズを同時に開始します。

範囲内にあるデバイスも同じことをしています。このことによって、検出した他のデバイスをこちらの画面に表示できます。 同様に、他のデバイスもこちらのデバイスを検出して表示します。

デバイス検索画面ではチャット可能な全ての端末を検出しますが、チャット画面に入ると選択したデバイスからのメッセージのみを受信したいと考えます。

このケースではユーザーがデバイスを選択すると、そのデバイスのUUIDが保存され、チャット画面に渡されます。 両方のデバイスで同じチャット画面に入ると、どちらのデバイスも通常のセントラルとしてセットされ、まだ接続は行われません。 デバイス検索画面でアドバタイズしているデバイスを検知しないよう、別のサービスIDをここでの接続に使用します。

どちらかがメッセージを送信するまで、両方の端末はスキャンを続けます。 メッセージが送信すると、その端末はペリフェラルとなり自身をアドバタイズし始めます。 それを検知した他方のデバイスは、セントラルとしてペリフェラルと接続します。 最初にメッセージを送った人がペリフェラルになり、もう一人がセントラルのままで、一つの接続を共有します。 通信の間、デバイスUUIDは接続相手を識別するために使われ、アドバタイズを開始した他のセッションの誰かが誤って入ってこないことを保証します。

このやり方は少し変だと感じるかもしれません。 より実用的には、二つの接続を作成し維持する方が合理的かもしれません。 しかしその場合、途切れる可能性のある接続が2つ存在することになり、安定性が低くなる可能性があります。

技術に関しての考察

ここまでCore Bluetooth APIとそのデザインパターンについて説明してきました。 どのように動かせば良いかを理解するのは難しくないと思います。

それはなぜかというと、Core Bluetoothを動かすための最低限の部分のみ見てきたためです。 これは本番アプリには絶対的に不十分であるということです

Ditto社では、メインのプロダクトでCore Bluetoothを使い、さらにAndroidでのBluetooth Low Energyもサポートしています。 このプロジェクトで私が経験した課題や制限と、Dittoのエンジニアが直面した課題をいくつか紹介します。

メッセージのデータ容量の制限

私が全く理解していなかったことの一つは、キャラクタリスティックを通じて送信できるデータ量はかなり限られており、その制限はデバイスによって違うということです。 元々は20バイトのみでしたが、最近のスマートフォンでは180バイトあります。 メッセージあたりのデータ量の少ないチャットアプリではそれほど気になりませんが、本番アプリでは問題になる可能性があるでしょう。

Core Bluetoothでは各メッセージの許容可能な長さを検出することができます。 それ以上の長さを送信したい場合は、データを分割して複数のメッセージとして送信することになりますが、 その実装は独自で行う必要があります。

速度の制限

GATTを通じた通信の最大速度は、1秒あたり数キロバイトしかありません。 チャットアプリでは問題ありませんが、大きなアプリではこれがボトルネックになる可能性があります。 ユースケースによっては、メッセージのデータ量を最適化する必要が出てくるかもしれません。

安全な送信方法での更なる遅延

.withResponse を指定してペリフェラルが受信したことを保証する場合、この往復の動作が更なる遅延を発生させます。 速度を重視するユースケースでは、この方法を使わずに送信し、独自のエラー修正ロジックを実装するべきです。

プラットフォームごとの制御レベルの違い

Core Bluetoothの独自のポリシーが、Androidなどの他のデバイス上のBLEの実装とは噛み合わない可能性があります。 例として、Core Bluetoothがペリフェラルのアドバタイズメントパケットに含められるデータ量や種類に制限をかけていることです。 そのため、Androidでも同様のアプリを開発しようとしている場合、同じように動作するように注意を払う必要があります。

バックグラウンド起動でのセキュリティ/プライバシーポリシー

通常のBluetoothは画面の操作にかかわらず動作しますが、AppleはBLEを採用しているアプリに対して、厳しいプライバシーポリシーを課しています。 ペリフェラルデバイスのアプリがバックグラウンドになると、アドバタイズは続きますが、"Local Name"プロパティは含まれなくなります。 さらに、バックグラウンドになったセンターは、範囲内のどのペリフェラルからも、継続したアドバタイズメントを受け取らなくなります。

この制限は、新型コロナウイルス接触確認アプリをCore Bluetoothを使って実装しようとしていた組織にとって、大きな争点になりました。

使いこなすにはとても複雑なAPI

慣れれば作業が簡単になるのは確かですが、Core Bluetoothはすぐに使いこなせるほど簡単なフレームワークではありません。 データを送受信し、必要なデータを取れるようになるには、とても長いプロセスを辿る必要があります。

さらに、このステップはコールバックを経由して順番に行われます。 自分のユースケースに必要なプロセスを考え出すのは非常に時間がかかり、高い認知負荷が必要になると思います。

かなりしっかりとしたエラー処理が必要

デリゲートコールバックの処理のどの時点でも、プロセス全体を失敗させることが簡単に起こりえます。 ワイヤレス技術であるBluetoothは干渉に弱く、接続が途切れやすいです。 そのため、処理のどの段階でも発生しうる問題に対処するため、確実なエラー処理が必要になります。

予期したコールバックが発生しなかった場合に備えて、ハートビート(定期的にノード間で送信されるメッセージ)や状態チェックを行う必要がある可能性もあります。

不安定さと変な挙動

Core Bluetoothはすでにかなり古いものですが、たまに発生する変な動作は確実に残っています。

  • 送信キューが容量いっぱいになった場合、クリアしたというコールバックがスキップされることがあります。このため、定期的にキューの状態をテストする必要があります。
  • 特定のデバイス(iPad Mini 4やiPhone 6のような)は、ロックされた後にロックを解除すると、スキャンを誤って停止してしまう可能性があります。

暗号化が不足

Bluetoothで送信するデータの中には個人情報(健康記録など)が含まれる場合があるため、暗号化は常に強く推奨されています。 BLEにも暗号化はありますが、信頼性は高くありません。 そのため、暗号化レイヤーとそれに伴う(エラー修正など)の実装を全て独自で行う必要があるかもしれません。

まとめ

最初にチャットアプリが動作し、タイプした文字が別の端末に表示された時、魔法のようだと感じました。 この記事を書くためにCore Bluetoothを学んだのはとても有意義な時間でした。 読んでいただいた方にとっても有益であれば嬉しいです。

しかしながら、最後に一つはっきりとさせておきたいことがあります。 独自のCore Bluetoothの実装をするのはとても大変です。 かなり多くのステップがあり、ユーザーの体験のどの部分においても上手くいかない可能性が大いにあります。

もしあなたがCore Bluetoothを調査しているエンジニアで、ローカル通信を実装したプロダクトを作ろうとしているのであれば、 Dittoの同期技術をチェックしてみることをお勧めします。 Dittoの技術スタックは上記の課題を全て解決しており、アプリに通信を実装することを簡単にしてくれます。

読んでいただきありがとうございました!

サンプルアプリのGithubリポジトリ