自分のアプリから周辺機器をつなごうとして、起動直後に「"アプリ名" が Bluetooth の使用を求めています」という全許可ダイアログが出る——これを嫌って、接続機能をホーム画面の奥に隠していた時期がありました。私自身、個人開発でユーティリティ系のアプリを長く運用していますが、最初の数秒で出る広い許可要求は、それだけで体験の温度を下げます。
iOS 18 の AccessorySetupKit は、この前提を変えます。アプリが Bluetooth 全体へのアクセスを求める代わりに、システムが描くシートの中で、対象の機器だけをユーザーに選んでもらう 仕組みです。選ばれた機器に対してだけ接続が許され、広い許可ダイアログは出ません。Rork Max が生成するネイティブ Swift を前提に、宣言からピッカー表示、接続までを追います(この機能はネイティブ実装が前提で、素の React Native では扱えません)。
なぜ「全許可」を求めずに済むのか
従来の CoreBluetooth は、スキャンを始める時点でアプリに Bluetooth の利用許可を求めました。ユーザーから見れば「このアプリは周りの機器を見られるのか」という広い許可です。AccessorySetupKit は発想が逆で、許可の単位を「この1台」まで絞り込みます 。
ユーザーはシステム製のシートの中で、近くにある対象機器を選びます。アプリはその結果として「選ばれた機器の識別子」を受け取り、以降はその機器に限って接続できます。広いスキャン許可を一度も求めずに済むので、最初の体験を壊しません。これは個人開発のように信頼を一から積む立場ほど効いてきます。App Store の審査でも、用途に対して過剰な権限を求めない設計は説明しやすく、リジェクトの芽を1つ減らせます。
必要な宣言(Info.plist)
最初に Info.plist で「どんな機器を扱うか」を宣言します。ここが抜けるとピッカーは無言で空になります。
< key >NSAccessorySetupKitSupports</ key >
< array >
< string >Bluetooth</ string >
</ array >
< key >NSAccessorySetupBluetoothServices</ key >
< array >
<!-- 自分の機器が広告するサービス UUID をすべて列挙する -->
< string >0000FE2C-0000-1000-8000-00805F9B34FB</ string >
</ array >
Rork Max で生成したプロジェクトでも、ネイティブ側の Info.plist にこの2つのキーを足します。NSAccessorySetupBluetoothServices には、対象機器がアドバタイズするサービス UUID を漏れなく書きます。ここに無い UUID の機器はシートに現れません。
セッションを起動してピッカーを出す
宣言ができたら、ASAccessorySession を1つ持ち、イベント購読を張ってからピッカーを表示します。ASDiscoveryDescriptor で「何を出すか」を、ASPickerDisplayItem で「どう見せるか」を決めます。
import AccessorySetupKit
import CoreBluetooth
@MainActor
final class AccessoryManager : ObservableObject {
private let session = ASAccessorySession ()
@Published var current: ASAccessory ?
func activate () {
session. activate ( on : .main) { [ weak self ] event in
self ? . handle (event)
}
}
func showPicker () {
let descriptor = ASDiscoveryDescriptor ()
descriptor.bluetoothServiceUUID = CBUUID ( string : "FE2C" )
// 名前の一部でさらに絞ると、近くの無関係な機器を出さずに済む
descriptor.bluetoothNameSubstring = "MyDevice"
let item = ASPickerDisplayItem (
name : "My Device" ,
productImage : UIImage ( named : "device-thumb" ) ! ,
descriptor : descriptor
)
session. showPicker ( for : [item]) { error in
if let error { print ( "picker failed: \( error ) " ) }
}
}
}
bluetoothNameSubstring を添えると、同じサービスを広告する無関係な機器を弾けます。近所に似た機器が多いと、ここを省いたシートは選びにくくなるので、可能なら名前の一部でも絞ることをお勧めします。
イベントを受けて接続する
activate(on:) に渡したクロージャへ、ピッカーの結果や機器の状態がイベントとして届きます。種類ごとに処理を分けます。
追加されたとき
ユーザーが機器を選んで承認すると .accessoryAdded が届きます。ここで受け取った ASAccessory の識別子を保存し、必要なら続けて CoreBluetooth 接続へ進みます。
改名・削除のとき
ユーザーは設定の中で機器の名前を変えたり、ペアを解除したりできます。.accessoryChanged と .accessoryRemoved を受けて、アプリ側の保存状態を必ず追従させます。ここを放置すると、消したはずの機器がアプリ内に残り続ける不整合が起きます。
失敗・キャンセルのとき
.activated はセッション準備完了の合図です。一方、ユーザーがシートを閉じた場合はエラーではなく単なるキャンセルとして扱い、再表示の導線だけ残します。キャンセルを失敗として赤いアラートで返すのは、体験を損なうので避けます。
private func handle ( _ event: ASAccessoryEvent) {
switch event.eventType {
case .accessoryAdded :
current = event.accessory
// ここから CoreBluetooth で接続してよい
case .accessoryChanged :
current = event.accessory
case .accessoryRemoved :
current = nil
default:
break
}
}
CoreBluetooth へどう橋渡しするか
AccessorySetupKit はあくまで「どの機器を使ってよいか」を確定させる役目です。実際のデータ通信は、これまで通り CoreBluetooth で行います。違いは、承認済みの機器に対しては、CoreBluetooth が広い許可ダイアログを出さずに接続できる 点です。
ASAccessory には Bluetooth の識別子が含まれるので、CBCentralManager の retrievePeripherals(withIdentifiers:) でその機器を取り戻し、connect します。スキャンからやり直す必要はありません。承認済みの1台だけを名指しで取り戻せるので、接続までの手数も減ります。
つまずきやすいところ
最初の罠は、Info.plist のサービス UUID と、コードの bluetoothServiceUUID の不一致です。片方だけ直すとシートが無言で空になり、原因が分かりにくくなります。本番運用に出す前に、実機で一度シートが機器を拾うことを必ず確認します。
次に、シミュレータでは Bluetooth が動かないため、AccessorySetupKit の検証は実機が前提になります。手元に対象機器が無い段階では、UUID と名前の宣言だけ先に固め、ピッカー表示までを実機で通すのが現実的です。
最後に、削除イベントの取りこぼしです。ユーザーがシステム設定からペアを解除したのにアプリが追従していないと、「つながらないのに接続済みに見える」という最もたちの悪い不整合になります。.accessoryRemoved での後始末は、接続処理と同じ熱量で書くことをお勧めします。
まずは自分の機器のサービス UUID を1つ Info.plist に入れ、showPicker がそれを拾うところまでを実機で確認してみてください。そこさえ通れば、あとは接続と後始末を足していくだけです。