はじめに
Bluetooth許可アラートとは
これです。
これを任意のタイミングで表示できるようにしたい。
背景
特定の画面のみでBluetoothを利用するアプリでは、「利用する画面に遷移した時」や「使用するボタンを押した時」等のタイミングでBluetooth許可アラートを出すのが何にBluetoothを利用するのかわかりやすくてユーザー体験的に良いと思います。
そういった場面に対応するためには、任意のタイミングでBluetooth許可アラートを出すことができるようにする必要があります。
基礎知識
CBCentralManager
Bluetooth周りを扱うためにはCBCentralManager
を使います。
公式ドキュメント
CLLocationManager
(位置情報周りを扱うためのクラス)にはrequestWhenInUseAuthorization()
という能動的に許可アラート表示を行うメソッドが存在しますが、見たところ、CBCentralManager
には能動的に許可アラート表示を行うメソッドが存在しません。
ではどのタイミングで許可アラートが表示されるのでしょうか?
許可アラート表示タイミングの調査
まず簡単なプログラムで許可アラートが表示されるタイミングを確認してみましょう。
テストアプリ
ボタンを押したときに端末のBluetoothの状態を表示する簡単なアプリです。
import UIKit
import CoreBluetooth
private extension CBManagerState {
var name: String {
switch self {
case .unknown: return "unknown"
case .resetting: return "resetting"
case .unsupported: return "unsupported"
case .unauthorized: return "unauthorized"
case .poweredOff: return "poweredOff"
case .poweredOn: return "poweredOn"
@unknown default: return "unknown"
}
}
}
final class ViewController: UIViewController {
// MARK: - Outlet
@IBOutlet private weak var statusLabel: UILabel!
// MARK: - Property
private var centralManager: CBCentralManager!
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
print("manager init func: \(#function)")
centralManager = CBCentralManager()
centralManager.delegate = self
}
// MARK: - Action
@IBAction private func buttonAction(_ sender: UIButton) {
print("maneger used func: \(#function)")
statusLabel.text = centralManager.state.name
}
}
// MARK: - CBCentralManagerDelegate
extension ViewController: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
print("manager setuped func: \(#function)")
}
}
実行
起動直後にアラートが表示されました。
その時点のログを見てみると以下のようになっていました。
manager init func: viewDidLoad()
つまり、Bluetooth許可アラートはCBCentralManagerが初期化されたタイミングで自動的に出る ということです。
補足
CBCentralManager
の初期化時のoption
にCBCentralManagerOptionShowPowerAlertKey
というものがありましたが、これは 初期化時にBluetoothがOFFになっている場合に警告アラートを表示するかどうか というものであり、許可アラートとは別物でした。
参考
A Boolean value that specifies whether the system warns the user if the app instantiates the central manager when Bluetooth service isn’t available.
https://developer.apple.com/documentation/corebluetooth/cbcentralmanageroptionshowpoweralertkey
警告アラート↓
許可アラートを任意のタイミングで出す
本題です。
この初期化されたタイミングで自動的に出る仕様を踏まえて、任意のタイミングで許可アラートを出せるようにCBCentralManager
をラップします。
実装
アプローチは簡単です。
初期化すると許可アラートが出るのだから、CBCentralManager
をOptional
にして持っておき、任意のタイミングで初期化をできるようにすれば任意のタイミングで許可アラートを出すことができます。
import CoreBluetooth
final class BluetoothManager: NSObject {
// MARK: - Property
private var centralManager: CBCentralManager?
var state: String {
guard let centralManager = centralManager else {
fatalError("need call setupAndRequestBluetoothAuthrizationIfNeeded in BluetoothManager")
}
return centralManager.state.name
}
// MARK: - Internal
/// セットアップし、必要ならBluetooth許可アラートを表示する
func setupAndRequestBluetoothAuthorizationIfNeeded() {
if centralManager == nil {
// 必要な場合はここでBluetooth許可アラートが表示される
centralManager = CBCentralManager()
centralManager?.delegate = self
}
}
}
// MARK: - CBCentralManagerDelegate
extension AnyTimingBluetoothManager: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
print("BluetoohManager setuped")
}
}
setup
=初期化 と requestBluetoothAuthorization
=許可アラートを出す がCBCentralManager
の仕様上切り離せないためこのようなメソッド仕様となっています。
補足
「Optional
ではなくlazy var
にすれば最初に利用した際に初期化されるし楽ではないか?」
と思うかもしれません。その通りです。
しかし、setupAndrequestBluetoothAuthorizationIfNeeded()
のようなメソッドが存在した方が「能動的に許可アラートを出すことができる」と分かりますし
setupAnd...
を呼ばずにBluetoothManager().state
のようにプロパティを触った際に、lazy var
だと自動で初期化が起こり自動で許可アラートが表示されてしまいますが、Optional
にすればfatalError
を出して「先にsetupAnd...
を読んでね」と能動的に許可アラートを表示することを利用側に強制することができます。
利用側
import UIKit
final class ViewController: UIViewController {
// MARK: - Outlet
@IBOutlet private weak var statusLabel: UILabel!
// MARK: - Property
private let bluetoothManager = BluetoothManager()
// MARK: - Action
@IBAction private func buttonAction(_ sender: UIButton) {
bluetoothManager.setupAndRequestBluetoothAuthorizationIfNeeded()
statusLabel.text = bluetoothManager.state
}
}
また、好みによるかと思いますが、...IfNeeded
ではなく、以下のような仕様にすることもできます。
var needSetupAndRequestAuthorization: Bool {
return centralManager == nil
}
func setupAndRequestAuthorization() {
centralManager = CBCentralManager()
centralManager?.delegate = self
}
if bluetoothManager.needSetupAndRequestAuthorization {
bluetoothManager.setupAndRequestAuthorization()
}
実行
これでOK!!!
...かと思いきや、新たな問題が発生しました。
上のgifを見ても分かる通り、アラートを閉じた後、結果がunknown
となってしまっています。
これは、
許可アラートの表示中にプログラムの処理が止まらないため、許可アラート表示中にstatusLabel.text = bluetoothManager.state
の処理が走ってしまっている からです。
(許可がない状態で.state
を取得するとunknown
になります)
これでは、Bluetoothを利用する処理を正しく実行するために2回ボタンを押さなくてはいけなくなり、ユーザー体験的に良くありません。
アラート表示後に処理をしたい
先程の問題を解決するためには、許可アラートの表示中はプログラムの処理を止める必要があります。
bluetoothManager.setupAndRequestBluetoothAuthorizationIfNeeded()
// 許可アラートが閉じるまで処理を中断してほしい
statusLabel.text = bluetoothManager.state
こういう場合はどうすればいいのでしょうか?
そうです。非同期処理です。
setupAndRequestBluetoothAuthorizationIfNeeded()
を 許可アラートが閉じるまで待機する非同期処理関数 にすれば良いです。
実装
非同期処理関数として実装するためには、待機終了のポイントを適切に定める必要があります。
ここで登場するのがデリゲートメソッドのcentralManagerDidUpdateState
です。
このメソッドは以下のように動作します。
- 許可状態が未決定の場合、初期化し、許可アラートの結果が決定した後に呼ばれる
- 許可アラートの結果が許可/拒否のどちらでも呼ばれる
- 許可状態が決定済みの場合、初期化後に呼ばれる
つまり、アラートが閉じた時 または アラートを出す必要がない場合は初期化が完了した時 に呼ばれます。
今回実装したい非同期処理の待機終了ポイントして適切なので、このメソッドを使います。
import CoreBluetooth
final class BluetoothManager: NSObject {
// MARK: - Property
private var centralManager: CBCentralManager?
private var continuation: CheckedContinuation<Void, Never>?
var state: String {
guard let centralManager = centralManager else {
fatalError("need call setupAndRequestBluetoothAuthrizationIfNeeded in BluetoothManager")
}
return centralManager.state.name
}
// MARK: - Internal
/// セットアップし、必要ならBluetooth許可アラートを表示する
/// 許可状態が決定済みの場合: 許可アラートが表示されず、即時resume
/// 許可状態が未決定の場合: 許可アラートが表示され、許可アラートが閉じた後にresume
func setupAndRequestBluetoothAuthorizationIfNeeded() async {
return await withCheckedContinuation { continuation in
if centralManager == nil {
// クラス内の変数にcontinuationを出し、後でresumeできるようにする
self.continuation = continuation
centralManager = CBCentralManager()
centralManager?.delegate = self
} else {
// 既にセットアップされている場合は即待機終了
continuation.resume()
}
}
}
}
// MARK: - CBCentralManagerDelegate
extension BluetoothManager: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
print("BluetoohManager setuped")
// 待機終了
continuation?.resume()
self.continuation = nil
}
}
利用側
import UIKit
final class FinalSampleViewController: UIViewController {
// MARK: - Outlet
@IBOutlet private weak var statusLabel: UILabel!
// MARK: - Property
private let bluetoothManager = BluetoothManager()
// MARK: - Action
@IBAction private func buttonAction(_ sender: UIButton) {
Task {
await bluetoothManager.setupAndRequestBluetoothAuthorizationIfNeeded()
statusLabel.text = bluetoothManager.state
}
}
}
実行
これで1回ボタンを押すだけで許可アラートが表示され、アラートが閉じた後にBluetoothを利用した処理を実行することができました!!
(おまけ)iOS13未満にも対応させる場合
アプリごとのBluetoothの許可はiOS13から導入された物なので、iOS12以下はCBCentralManager
を初期化しても許可アラートが出現することはありません。
しかし、初期化後、centralManagerDidUpdateState
が呼ばれるのを待たないと正常に動作しないので少し待機する必要があります。
iOS13未満対応バージョン(iOS12を想定)
非同期処理ライブラリとしてPromiseKitを使っています
import Foundation
import CoreBluetooth
import PromiseKit
final class BluetoothManager: NSObject {
// MARK: - Property
private var centralManager: CBCentralManager?
private var statusObserver: (guarantee: Guarantee<Void>, resolve: (Void) -> Void)?
var state: String {
guard let centralManager = centralManager else {
fatalError("need call setupAndRequestBluetoothAuthrizationIfNeeded in BluetoothManager")
}
return centralManager.state.name
}
// MARK: - Internal
/// セットアップし、Bluetoothの許可が必要な場合はアラートを表示する
func setupAndRequestBluetoothAuthorizationIfNeeded() -> Guarantee<Void> {
if centralManager == nil {
// 待機開始
let statusObserver = Guarantee<Void>.pending()
// あとでresolveできるようにresolverを変数に出す
self.statusObserver = statusObserver
let centralManager = CBCentralManager()
centralManager.delegate = self
self.centralManager = centralManager
return statusObserver.guarantee
} else {
// 既にセットアップされている場合は即待機終了
return Guarantee { $0(()) }
}
}
}
// MARK: - CBCentralManagerDelegate
extension BluetoothManager: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
if #available(iOS 13.1, *) {
// iOS13以上の場合は
// 許可状態が決定済みの場合: 許可アラートが表示されず、即時呼ばれる
// 許可状態が未決定の場合: 許可アラートが表示され、許可アラートが閉じた後に呼ばれる
statusObserver?.resolve(())
self.statusObserver = nil
} else {
// iOS12以下の場合は
// 許可アラートは表示されず、セットアップが完了したら呼ばれる
// これを待たないと.stateから正常な結果が取得できない
statusObserver?.resolve(())
self.statusObserver = nil
}
}
}
サンプルコード
実行して確かめる場合は実機をご利用ください。シミュレーターではBluetooth許可アラートが表示されません。
感想
CBCentralManager
に手動で許可アラートを出すメソッドが存在しないのは、iOS13という途中からアプリごとの許可が追加されたことにより、仕様を変更できなかったからなのでしょうか...?
任意のタイミングで許可アラートを出せるように実装している最中も「本当に手動で出す方法はないのかな...?」とずっと不安でした()
もしもっと良い方法があれば教えてください。