「Rork で作ったアプリから心拍計の値を取りたい」「自作の M5Stack 基板にコマンドを送りたい」— BLE(Bluetooth Low Energy)が絡む相談を受けるたびに、最初の壁になるのは権限まわりとスキャン処理だと感じます。Web の解説記事の多くは Native iOS / Android が前提で、Expo・React Native・Rork の組み合わせで何をどこまで触れるのかが意外と整理されていません。
ライブラリは react-native-ble-plx 一択でいいか
React Native + Expo の世界で BLE を扱うとき、現実的な選択肢は次の二つです。
react-native-ble-plx: メンテナンスが活発で、Expo の prebuild と相性が良いです。iOS / Android の双方を一つの API で扱える設計になっていますreact-native-ble-manager: 古くから使われていますが、Expo SDK のバージョンアップに追随しないことがあります
特別な理由がない限り react-native-ble-plx を選んでおくと、Expo SDK のメジャー更新時に詰む確率が下がります。Rork のビルド構成(Expo prebuild ベース)にもそのまま乗りますので、追加の native コード書き換えはほぼ発生しません。
権限の落とし穴 — Android 12 以降は別物
iOS と Android で必要な権限が大きく異なる点が、最大の落とし穴です。
- iOS:
NSBluetoothAlwaysUsageDescriptionを Info.plist に必ず記述します。記述がないとアプリ起動直後にクラッシュします - Android 12(API 31)以降:
BLUETOOTH_SCANとBLUETOOTH_CONNECTがランタイム権限化されました。組み合わせによってはACCESS_FINE_LOCATIONも必要です - Android 11 以前: BLE スキャンに
ACCESS_FINE_LOCATIONが必須です
つまり Android では、SDK バージョンで挙動が変わる前提で実装します。Platform.Version で分岐するか、ライブラリ側のヘルパーで権限要求を行うのが安全です。
import { PermissionsAndroid, Platform } from "react-native";
// Android 12 以降と 11 以前で必要な権限が変わる
async function requestBlePermissions(): Promise<boolean> {
if (Platform.OS !== "android") return true;
const apiLevel = Platform.Version as number;
const perms =
apiLevel >= 31
? [
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
]
: [PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION];
const results = await PermissionsAndroid.requestMultiple(perms);
return Object.values(results).every(
(r) => r === PermissionsAndroid.RESULTS.GRANTED,
);
}
// 期待動作: 権限が全て許可されたとき true、ひとつでも拒否されたとき false を返す「Android 12 で ACCESS_FINE_LOCATION を要求すると Google Play 審査で弾かれるのでは」と心配されるかもしれませんが、BLE 用途であれば location 権限の利用は許容されています。ストア説明文に用途を明記すれば問題なく審査を通せます。
デバイスをスキャンして接続する流れ
スキャン・接続・解放までの基本フローを一つの hook にまとめると、UI 側の実装がきれいになります。
import { useEffect, useRef, useState } from "react";
import { BleManager, Device } from "react-native-ble-plx";
export function useBleScanner(targetName: string) {
const managerRef = useRef<BleManager | null>(null);
const [device, setDevice] = useState<Device | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const manager = new BleManager();
managerRef.current = manager;
const start = async () => {
const ok = await requestBlePermissions();
if (!ok) {
setError("Bluetooth/Location 権限が許可されていません");
return;
}
manager.startDeviceScan(null, null, (err, scanned) => {
if (err) {
setError(err.message);
return;
}
if (scanned?.name === targetName) {
manager.stopDeviceScan();
scanned
.connect()
.then((d) => d.discoverAllServicesAndCharacteristics())
.then(setDevice)
.catch((e) => setError(String(e)));
}
});
};
start();
return () => {
manager.stopDeviceScan();
manager.destroy();
};
}, [targetName]);
return { device, error };
}
// 期待動作: targetName と一致する BLE デバイスを発見したら接続し、device を state にセットするここで一番大事なのは discoverAllServicesAndCharacteristics() を必ず呼ぶことです。これを忘れると Service / Characteristic UUID の検索ができず、後段の readCharacteristicForDevice で Service not found エラーが出ます。私はこれで2時間溶かしました。
バイト列と Base64 — データの読み書き
react-native-ble-plx は Characteristic の値を Base64 でやり取りします。生のバイト列ではないので、自前のエンコード/デコードが必要です。
import { Buffer } from "buffer";
// 受信: Base64 → バイト配列
const value = await device.readCharacteristicForService(
serviceUUID,
characteristicUUID,
);
const bytes = Buffer.from(value.value ?? "", "base64");
console.log("Heart rate:", bytes[1]); // 例: HRP プロファイルの 2 バイト目
// 送信: バイト配列 → Base64
const payload = Buffer.from([0x01, 0x02, 0x03]).toString("base64");
await device.writeCharacteristicWithResponseForService(
serviceUUID,
characteristicUUID,
payload,
);
// 期待動作: 受信側 console に Heart rate の数値が出力され、送信は writeCharacteristicWithResponseForService の Promise が resolve する組込側の仕様書がリトルエンディアンかビッグエンディアンか、何バイト目に何が入るかは、必ず先に確認しておきましょう。私の経験では、ここの読み違いがバグの 7 割を占めます。
切断と再接続の設計
BLE は Wi-Fi よりずっと不安定です。電車や地下に入った瞬間に切れる前提で組まないと、ユーザー体験が壊れます。
device.onDisconnected()で切断を検知し、UI に状態を反映します- アプリをバックグラウンドに送っても接続を維持したい場合は、iOS では Background Modes の
bluetooth-centralを有効にします - 自動再接続を実装するなら、指数バックオフで最大 30 秒程度に上限を設けて諦める設計が無難です
エラーハンドリングの基本的な考え方は Rork アプリのエラーハンドリングとクラッシュ対策入門 で書いた原則と同じで、「BLE は失敗するもの」として扱うのが結局いちばん安定します。
実機での検証 — シミュレータでは確認できない
BLE は Xcode のシミュレータでも Android の Emulator でも動きません。実機での確認が必須です。
- スキャンに 10 秒以上かかる場合は、Service UUID をスキャンフィルタに渡して絞り込むとバッテリー消費も抑えられます
- 端末側のフォーム入力と組み合わせる場合は、Rork アプリのフォームバリデーション完全ガイド で扱った zod スキーマと組み合わせると、ペアリング前に入力ミスを弾けます
- 画面の縦横切替で接続が切れる挙動が出る場合は Rork で作ったアプリの画面回転・オリエンテーション制御が効かないときの対処法 と合わせて、Configuration 変更時の再接続ロジックを見直すと安定します
ハードウェア絡みのデバッグは「アプリ側 / ファーム側 / Bluetooth スタック」の三層で原因を切り分けると、特定の速度が一段上がります。
全体を振り返って — まずは「読み」だけ動かす
BLE 実装は「権限 → スキャン → 接続 → 読み書き → 切断」と段階が多いので、初手でいきなり全部つなげようとすると挫折しがちです。今日できる最小の一歩としては、心拍計や万歩計のような既存の BLE デバイスを一台用意して、device.readCharacteristicForService で値を1回だけ読み取るところまで書き切ってみてください。それが動けば、書き込みも通知(notify)も同じ枠組みで足し算するだけです。