【Swift】バッテリー消費を抑えるCore Bluetoothの実装
はじめに
技術の発展とともに身近になってきたBluetoothですが、
ソニックムーブ内でもBluetooth絡みの案件を見ることも増えてきました。
今回は「Core Bluetooth」を使用したiOSアプリ開発においてバッテリーを消費するポイントと解決方法をまとめていきたいと思います。
今回のポイントと解決策は以下の3つです。
- スキャン時のタイムアウト
- 再接続を使用
- スキャンの絞り込み
スキャン時のタイムアウト
ベストプラクティス:スキャンにはタイマーを設置する
セントラル側を実装する際にアドバタイズしているペリフェラル側を検出するためのスキャン処理はバッテリー消費の激しい処理の1つです。
Core BluetoothではscanForPeripheralsメソッドとしてスキャン処理が定義されていますが、このメソッドは実行すると自動的に停止することはありません。
無事見つかった際はペリフェラルを取得する処理の中で処理をストップすれば良いですが、見つからなかった際は明示的にストップ処理を実行する必要があります。
今回は解決策として7秒間のタイマーを設置し、時間経過後には自動でストップ処理を実行するようにしておきます。
private var timer: Timer = Timer()
@Published var time: Int = 0
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [weak self] _ in
self?.time += 1
})
}
public func startScan() {
// スキャンの開始とともにタイマーをスタート
startTimer()
if centralManager.state == .poweredOn {
centralManager.scanForPeripherals(withServices: nil, options: nil)
}
}
public func stopScan() {
// タイマーのストップと初期化
timer.invalidate()
time = 0
// スキャンの停止
centralManager.stopScan()
}
Swift UI側の実装は以下の通りになります。
Button {
blueCentralManager.startScan()
} label: {
Text("スタートスキャン")
}.onAppear {
cancellable = blueCentralManager.$time.sink { time in
if time > 5 {
blueCentralManager.stopScan()
}
}
}
再接続を使用する
ベストプラクティス:一度でも接続済みであればスキャンではなく、再接続処理を実行する
セントラルがペリフェラル側と一度でも接続していれば、再接続を行うことが可能なります。これによりバッテリー消費の激しいスキャン処理を行わなくて済みます。
そもそもセントラルは接続したペリフェラルの情報を保持しており、retrievePeripheralsメソッドでその履歴(UUIDリスト)を取得できます。
ペリフェラルとの接続時にUUIDをローカルに保存しておき、履歴とローカルを照合しマッチするものがあれば、切断が切れた状態からconnectメソッドで再接続することが可能です。
以下手順
- ペリフェラル接続時にUUIDをローカルに保持
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
peripheral.delegate = self
// 接続したペリフェラルのUUIDをローカルに保存
let uuidStr = peripheral.identifier.uuidString
userDefaults.set(uuidStr, forKey: userDefaultsKey)
let services = [serviceUUID]
peripheral.discoverServices(services)
}
- 切断後再度ペアリングしたい時はローカルからUUIDを取得しretrievePeripheralsで接続履歴を取得
- 希望のペリフェラルとconnec(再接続)する
if let uuidStr = userDefaults.string(forKey: userDefaultsKey) {
if let uuid = UUID(uuidString: uuidStr) {
let peripherals = centralManager.retrievePeripherals(withIdentifiers: [uuid])
if peripherals.count != 0 {
centralManager.connect(peripherals.first!)
return
}
}
}
注意点(ハマったポイント)
この実装をしていた際に再接続という認識をアプリが停止してもできると考え、試してみましたが、一度停止しているとうまく再接続できませんでした。
どうやらアプリはずっと起動中である前提で接続→切断→再接続としたい場合のみの操作のようです。
3. スキャンの最適化
ベストプラクティス:明示的にUUIDを指定する
アドバタイズしているペリフェラルをスキャンするscanForPeripheralsメソッドの定義を見てみると以下のようになっています。
func scanForPeripherals(
withServices serviceUUIDs: [CBUUID]?,
options: [String : Any]? = nil
)
1つ目の引数には配列形式のCBUUIDオブジェクトまたはnilが渡せるようになっています。それぞれ渡した際の挙動の違いを見てみます。
- [CBUUID] → 指定したサービスをアドバタイズしているもののみ検出
- nil → 全てのアドバタイスしているものを検出
ペリフェラルはセントラルとのペアリングを試みる際に自身の情報をアドバタイズ(送信)することで自身の存在をアピールします。その際には自身が保持しているサービスUUIDを含ませることができます。
セントラル側では接続したいペリフェラルのサービスUUIDが分かっていればスキャン時に指定することで検出を絞り込むことが可能になっています。
全てのペリフェラルを検出するより絞り込んだ方がバッテリーの消費をグッと抑えることができます。
// サービスのUUID
let uuids = [CBUUID(string:"サービスのUUID")]
centralManager.scanForPeripherals(withServices: uuids, options: nil)
注意点
この方法はペリフェラル側が明示的にサービスUUIDをアドバタイズしてくれないと実装できません。例えばiOSアプリでペリフェラル側も実装する際は以下のように実装します。
let serviceUUIDs = [serviceUUID]
let advertisementData:[String:Any] = [
CBAdvertisementDataLocalNameKey: "ペリフェラル名",
CBAdvertisementDataServiceUUIDsKey: serviceUUIDs
]
self.peripheralManager.startAdvertising(advertisementData)
サービス/キャラクタリスティックも同様
ペリフェラルの検出時だけでなく、サービス/キャラクタリスティックの検出時にもこの方法は有効です。また公式リファレンスにもnilを指定した場合は「much slower than providing(提供するよりはるかに遅い)」と明記されています。
♦︎サービス
- [CBUUID] → 指定したサービスのみ検出
- nil →保持しているサービス全てを検出
let uuids: [CBUUID] = [CBUUID(string: "サービスのUUID")]
peripheral.discoverServices(uuids)
♦︎キャラクタリスティック
- [CBUUID] → 指定したキャラクタリスティックのみ検出
- nil → 保持しているキャラクタリスティック全てを検出
let uuids: [CBUUID] = [CBUUID(string: "キャラクタリスティックのUUID")]
peripheral.discoverCharacteristics(uuids, for: service)
Discussion