Flutter × Bluetooth Low Energy (BLE) 端末間リアルタイム通信を実装してみた
はじめに
Flutter で BLE(Bluetooth Low Energy)を使って、スマホ同士でリアルタイムにデータをやりとりするデモアプリを作ってみました。
bluetooth_low_energy パッケージを使い、以下の機能を iOS / Android の実機2台で動作確認しています。
- BLE スキャン(Central): 受信側として周囲の BLE デバイスを発見して一覧表示
- GATT ブラウザ(Central): デバイスに接続して、提供されている機能の一覧(サービス / キャラクタリスティック)を閲覧・データ取得
- アドバタイズ(Peripheral): 自分のスマホを BLE デバイスとして発信側にする
- カウンターリモコン(Central + Peripheral): 2台の端末間で書き込み(Write)/ 通知(Notify: プッシュ送信)による双方向リアルタイム通信
最終的に作ったカウンターリモコンのデモです。片方の端末でボタンを押すと、もう片方のカウンターがリアルタイムに変わります。

ソースコードは以下のリポジトリで公開しています。
この記事で伝えたいこと・解決したい課題
Flutter で BLE を使いたいが、こんな壁がある:
- BLE の概念(Central / Peripheral、GATT)が抽象的でイメージしづらい
- iOS / Android で権限や API サポートが異なり、片方で動いてもう片方で動かない
- 動くデモまでたどり着けない
本記事では、BLE の概念を REST API に例えて整理し、実際に動くデモまで段階的に実装することでこれらの課題に取り組みました。特にプラットフォーム差異は最大のハマりポイントだったので、対処法を詳しくまとめています。
BLE の基本概念
Bluetooth Classic と BLE
Bluetooth には Bluetooth Classic と Bluetooth Low Energy(BLE) という2つの通信方式があります。同じ Bluetooth 規格内ですが、用途が異なります。
| Bluetooth Classic | BLE | |
|---|---|---|
| 用途 | 音声ストリーミング、ファイル転送 | センサー値の取得、コマンド送受信 |
| データ量 | 大容量・連続データ向き | 小さなデータの間欠的なやりとり向き |
| 消費電力 | 高い | 低い |
| 身近な例 | イヤホンで音楽を聴く、テザリング | スマートウォッチの通知、体重計の計測値取得 |
ワイヤレスイヤホンを例にすると、音楽の再生は Classic(A2DP)、バッテリー残量の通知は BLE(Battery Service)と、1台のデバイス内で両方式が使い分けられています。
本記事で扱うのは BLE です。
Central と Peripheral
BLE 対応デバイスには Central と Peripheral の2つの役割があります。
| 役割 | 動作 | 例 |
|---|---|---|
| Central | スキャンしてデバイスを発見・接続 | スマホアプリ |
| Peripheral | 自分の存在をアドバタイズ(発信) | ワイヤレスイヤホン、スマートウォッチ |
Peripheral が「自分はこういうデバイスです」と発信し続け、Central がそれを受信して接続する、という流れです。ワイヤレスイヤホンがスマホに見つかるのも、イヤホン(Peripheral)がアドバタイズし、スマホ(Central)がスキャンしているからです。
BLE のデータ構造(Service / Characteristic)
Central が Peripheral に接続すると、Peripheral が提供する機能の一覧(GATT: Generic Attribute Profile)を取得できます。この構造は REST API に例えると理解しやすいです。
例えば、バッテリー残量を提供する BLE デバイスの場合:
Battery Service … REST API でいう /battery リソース
└── Battery Level … GET /battery/level エンドポイント(Read 可能)
整理すると以下のような対応関係になります。
| BLE | REST API に例えると |
|---|---|
| Service | リソースのグループ(/users) |
| Characteristic | 個々のエンドポイント(GET /users/:id) |
| Property(Read / Write / Notify) | HTTP メソッド(GET / POST) |
Characteristic の Property は「そのエンドポイントに対して何ができるか」を宣言します。
| Property | 意味 | API に例えると |
|---|---|---|
| Read | 値の読み取り | GET |
| Write | 書き込み(応答あり) | POST |
| Notify | Peripheral → Central への通知 | WebSocket のプッシュ |
典型的なデータのやりとりは以下の流れです。
- Central から Write で Peripheral にデータを送る
- Peripheral から Notify で Central にデータを返す
実装
BLE は iOS シミュレーターでは動作しない可能性が高いため、実機での確認を推奨します。今回は iOS / Android の実機2台を使用しました。
パッケージ選定
Flutter で BLE を扱う主要パッケージは以下の通りです。
| パッケージ | 用途 | ライセンス | 備考 |
|---|---|---|---|
| flutter_blue_plus | Central 用 | 要確認 | 商用利用はライセンス注意 |
| flutter_ble_peripheral | Peripheral 用 | MIT | Android で動作しないケースあり |
| bluetooth_low_energy | Central / Peripheral 両用 | MIT | 1パッケージで両方対応 |
| ble_peripheral | Peripheral 用 | MIT | Peripheral 特化 |
今回は bluetooth_low_energy(v6.2.1)を選びました。
- MIT ライセンスで商用利用に制約がない(flutter_blue_plus はライセンス要確認)
- Central / Peripheral 両用で1パッケージ
Central の実装(スキャン → 接続 → Read)
スキャン
まず Central(受信側)として、周囲の BLE デバイスをスキャンしてみます。CentralManager でスキャンを開始し、discovered ストリームで発見したデバイスを受け取ります。
final centralManager = CentralManager();
// 権限リクエスト
await centralManager.authorize();
// スキャン結果の購読
centralManager.discovered.listen((e) {
final name = e.advertisement.name;
final rssi = e.rssi;
final id = e.peripheral.uuid.toString();
});
// スキャン開始 / 停止
await centralManager.startDiscovery();
await centralManager.stopDiscovery();
実際にスキャンすると、周囲のワイヤレスイヤホンやスマートウォッチなどが一覧に表示されました。
接続 → GATT 探索 → Read
次に、発見したデバイスに接続して中身を覗いてみます。discoverGATT() でサービス一覧を取得し、Characteristic の値を読み取れます。
// 接続
await centralManager.connect(peripheral);
// GATT 探索 → サービス / キャラクタリスティック一覧を取得
final services = await centralManager.discoverGATT(peripheral);
for (final service in services) {
print('Service: ${service.uuid}');
for (final char in service.characteristics) {
print(' Characteristic: ${char.uuid} ${char.properties}');
}
}
// Characteristic の値を読み取り
final value = await centralManager.readCharacteristic(
peripheral,
characteristic,
);
手元のワイヤレスイヤホンに接続してみたところ、10個以上のサービスが見えました。REST API に例えると、10個以上のエンドポイントグループを持つ API サーバーに接続したようなイメージです。
Peripheral の実装(アドバタイズ → 応答)
次に、自分のスマホを BLE デバイスとして発信側にしてみます。Central のときはデバイスを「見つける側」でしたが、今度は「見つけてもらう側」です。
サービス登録 → アドバタイズ開始
REST API に例えると「エンドポイントを定義して、サーバーを起動する」ステップです。提供する Service と Characteristic を定義し、アドバタイズを開始します。
final peripheralManager = PeripheralManager();
// 提供するサービスとキャラクタリスティックを定義
final service = GATTService(
uuid: UUID.fromString('12345678-1234-5678-9abc-123456789abc'),
isPrimary: true,
includedServices: [],
characteristics: [
GATTCharacteristic.mutable(
uuid: UUID.fromString('12345678-1234-5678-9abc-123456789abd'),
properties: [
GATTCharacteristicProperty.read,
GATTCharacteristicProperty.write,
GATTCharacteristicProperty.notify,
],
permissions: [
GATTCharacteristicPermission.read,
GATTCharacteristicPermission.write,
],
descriptors: [],
),
],
);
await peripheralManager.addService(service);
await peripheralManager.startAdvertising(
Advertisement(
name: 'MyDevice',
serviceUUIDs: [UUID.fromString('12345678-1234-5678-9abc-123456789abc')],
),
);
iOS でアドバタイズを開始し、もう1台の Android からスキャンしたところ、デバイスを発見して接続・Read まで成功しました。
リクエストへの応答
Central からの Read / Write リクエストをストリームで受信し、応答します。REST API でいうリクエストハンドラの実装に相当します。
// Read リクエスト → 値を返す
peripheralManager.characteristicReadRequested.listen((e) async {
await peripheralManager.respondReadRequestWithValue(
e.request,
value: Uint8List.fromList(utf8.encode('Hello')),
);
});
// Write リクエスト → 受け取って応答する
peripheralManager.characteristicWriteRequested.listen((e) async {
final receivedData = e.request.value; // Central から書き込まれたデータ
await peripheralManager.respondWriteRequest(e.request);
});
// Notify → Central にデータをプッシュ送信
await peripheralManager.notifyCharacteristic(
central,
characteristic,
value: Uint8List.fromList(utf8.encode('42')),
);
Central + Peripheral を組み合わせる
ここまでの Central と Peripheral を組み合わせて、「カウンターリモコン」を作りました。
2台のスマホを使います。
- 端末 A(Peripheral / 表示側): カウンターを表示。アドバタイズで接続を待機
- 端末 B(Central / リモコン側): スキャンして端末 A を発見・接続。
+1/-1/Resetの3ボタンで操作
ボタンを押すと Write でコマンド送信 → 端末 A がカウンター更新 → Notify で最新値を端末 B に返却 → 端末 B のカウンター表示もリアルタイムに更新される、という双方向通信です。
通信フロー
端末 B (Central / リモコン) 端末 A (Peripheral / 表示)
| |
| ---- Write: "increment" ------> |
| | カウンター +1
| <---- Notify: "1" ----------- |
| |
| ---- Write: "increment" ------> |
| | カウンター +1
| <---- Notify: "2" ----------- |
| |
| ---- Write: "reset" ----------> |
| | カウンター 0
| <---- Notify: "0" ----------- |
コマンド・カウンター値ともに UTF-8 文字列でやりとりしています。iOS / Android どちらの組み合わせでも動作確認できました。
ハマりポイント
BLE 実装共通
Android の BLE パーミッション
Android では4つのパーミッションが必要です。
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
| 権限 | 用途 | 備考 |
|---|---|---|
BLUETOOTH_SCAN |
Central: スキャン | |
BLUETOOTH_CONNECT |
Central: 接続・GATT 操作 | |
BLUETOOTH_ADVERTISE |
Peripheral: アドバタイズ | 忘れがち。これがないと Peripheral モードが unauthorized のまま |
ACCESS_FINE_LOCATION |
スキャン時に必要 | パッケージの authorize() では扱わない。permission_handler で別途リクエスト |
さらに、端末の位置情報サービス自体が ON でないとスキャン結果が返らないという罠があります。スキャンは動いているのにデバイスが一切見つからない場合、まず位置情報の ON/OFF を確認してみてください。
iOS の設定
Info.plist に以下を追加するだけです(ランタイム権限リクエストは不要)。
<key>NSBluetoothAlwaysUsageDescription</key>
<string>BLEデバイスのスキャンと接続に使用します</string>
bluetooth_low_energy 固有
iOS / Android で使える API に差がある
一部の API で iOS / Android の挙動が異なります。今回遭遇した例と対処法を整理しました。
| 機能 | iOS | Android | 対処法 |
|---|---|---|---|
権限リクエスト(authorize()) |
非対応。システムが自動管理 | 必須 | Platform.isIOS で分岐してスキップ |
| Peripheral の接続状態監視 | 非対応(OS の制限) | 利用可能 | Write リクエスト受信時に Central を検知 |
| スキャン結果の Service UUID | 返らない場合がある | 返る | デバイス名での識別を併用 |
上記以外にも思わぬ差異がある可能性があります。iOS / Android の両方で入念に動作確認を行うことをお勧めします。
Android で Peripheral の操作が完了しない
Peripheral としてサービスを登録(addService)やアドバタイズ開始(startAdvertising)を呼ぶと、Android では処理が返ってこないケースがありました(Issue #89)。ネイティブ側のログでは成功しているのに、Dart 側の Future が解決しません。
今回はタイムアウトを設定してネイティブの成功を信頼し続行する暫定対応としましたが、本格的に Peripheral を使う場合は ble_peripheral パッケージの利用も検討した方がよさそうです。
try {
await peripheralManager.addService(service).timeout(const Duration(seconds: 3));
} on TimeoutException {
// ネイティブ側は成功しているので続行
}
プロダクトでの活用を考える
BLE が向いているケース
- 近距離の小さなデータ交換: プロフィール交換、コマンド送受信、センサー値の取得など。今回のカウンターリモコンのようなリアルタイム双方向通信も可能
- サーバー不要のオフライン通信: インターネット接続なしで端末間の直接通信ができる
- IoT デバイスとの連携: BLE 対応のセンサーやマイコンとの通信
注意すべき制約
- 送れるデータ量が小さい: MTU(Maximum Transmission Unit)の制約があり、1回に数十〜数百バイト程度。画像やファイルの転送は Bluetooth Classic の領域であり、BLE には向かない
- 市販デバイスとの連携は SDK 次第: 標準 GATT プロファイルに欲しいデータがないケースが多い。実際にワイヤレスイヤホンに接続したところ、メーカー独自プロファイルのみだった。メーカー SDK がある場合はそちらを使う方が現実的
まとめ
bluetooth_low_energy パッケージを使い、Central(スキャン・接続・Read)から Peripheral(アドバタイズ・応答)、さらに両方を組み合わせたカウンターリモコンまで、段階的に実装・動作確認できました。
- BLE のデータ構造は REST API に例えるとイメージしやすい
- 実装自体はシンプルだが、iOS / Android のプラットフォーム差異が最大のハマりポイント
- プロダクトで活用する場合は、データ量の制約や外部デバイスの SDK 対応状況を事前に確認しておくとよい






