Flutter × Bluetooth Low Energy (BLE)  端末間リアルタイム通信を実装してみた

Flutter × Bluetooth Low Energy (BLE) 端末間リアルタイム通信を実装してみた

2台のスマホで BLE 双方向通信するカウンターリモコンを Flutter で作りました。Bluetooth Classic と BLE の違い、GATT の仕組みを REST API に例えた解説、iOS / Android 間のプラットフォーム差異の対処法をまとめています。
2026.04.05

はじめに

Flutter で BLE(Bluetooth Low Energy)を使って、スマホ同士でリアルタイムにデータをやりとりするデモアプリを作ってみました。

bluetooth_low_energy パッケージを使い、以下の機能を iOS / Android の実機2台で動作確認しています。

  • BLE スキャン(Central): 受信側として周囲の BLE デバイスを発見して一覧表示
  • GATT ブラウザ(Central): デバイスに接続して、提供されている機能の一覧(サービス / キャラクタリスティック)を閲覧・データ取得
  • アドバタイズ(Peripheral): 自分のスマホを BLE デバイスとして発信側にする
  • カウンターリモコン(Central + Peripheral): 2台の端末間で書き込み(Write)/ 通知(Notify: プッシュ送信)による双方向リアルタイム通信

最終的に作ったカウンターリモコンのデモです。片方の端末でボタンを押すと、もう片方のカウンターがリアルタイムに変わります。

画面収録 2026-04-05 18.55.50

ソースコードは以下のリポジトリで公開しています。

https://github.com/abe-tk/playground/tree/main/lib/feature/ble

この記事で伝えたいこと・解決したい課題

Flutter で BLE を使いたいが、こんな壁がある:

  • BLE の概念(Central / Peripheral、GATT)が抽象的でイメージしづらい
  • iOS / Android で権限や API サポートが異なり、片方で動いてもう片方で動かない
  • 動くデモまでたどり着けない

本記事では、BLE の概念を REST API に例えて整理し、実際に動くデモまで段階的に実装することでこれらの課題に取り組みました。特にプラットフォーム差異は最大のハマりポイントだったので、対処法を詳しくまとめています。

BLE の基本概念

Bluetooth Classic と BLE

Bluetooth には Bluetooth ClassicBluetooth 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 のプッシュ

典型的なデータのやりとりは以下の流れです。

  1. Central から Write で Peripheral にデータを送る
  2. 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 対応状況を事前に確認しておくとよい

この記事をシェアする

関連記事