📶

MethodChannelでBLE通信を実装してみる。

2024/12/19に公開

🤔やってみたいこと

パッケージを使わずに、SwiftとKotlinでBLE通信をする機能を実装して、Flutterから呼び出して通信する機能を実装してみました。

周囲の通信できる端末の情報は表示されていたらからOK?

こちらが完成品

設定ができていれば、iOSとAndroid両方のOSで動作しました。
https://x.com/JBOY83062526/status/1869555286807339373

実機の端末が必要なのでご用意ください。iPhone/Androidの準備。

🚀やってみたこと

OSごとの設定をする。とはいえネイティブの設定はAIに手伝ってもらった💦
意外と難しい。

iOS/Android BLE実装の設定手順と注意点

iOS設定手順

  1. Info.plistの設定
<!-- Bluetoothの使用目的を記述 -->
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Need BLE permission for device scanning</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>Need BLE permission for device scanning</string>
  1. アプリケーション権限の確認
  • 設定 > プライバシーとセキュリティ > Bluetooth
  • アプリの権限が許可されているか確認
  1. デバッグ時の注意点
  • 実機テストが必須(シミュレータではBLE機能は使用できない)
  • Bluetoothがオンになっているか確認
  • XcodeでSigning & Capabilitiesが正しく設定されているか確認

BLE通信のために、iOS側とFlutter側のコードを修正していきましょう。

まずiOS側の実装から始めます:

import Flutter
import UIKit
import CoreBluetooth

@main
@objc class AppDelegate: FlutterAppDelegate, CBCentralManagerDelegate {
    private var centralManager: CBCentralManager!
    private var methodChannel: FlutterMethodChannel!
    private var discoveredDevices: [CBPeripheral] = []
    
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
        let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
        
        methodChannel = FlutterMethodChannel(name: "com.jboycode/platforms",
                                           binaryMessenger: controller.binaryMessenger)
        
        centralManager = CBCentralManager(delegate: self, queue: nil)
        
        methodChannel.setMethodCallHandler { [weak self]
            (call, result) in
            switch call.method {
            case "startScan":
                self?.startScan()
                result(nil)
            case "stopScan":
                self?.stopScan()
                result(nil)
            case "getDevices":
                let devices = self?.discoveredDevices.map { 
                    ["name": $0.name ?? "Unknown", "id": $0.identifier.uuidString] 
                }
                result(devices)
            default:
                result(FlutterMethodNotImplemented)
            }
        }
        
        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
    
    // CBCentralManagerDelegate methods
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOn:
            methodChannel.invokeMethod("bluetoothState", arguments: "on")
        case .poweredOff:
            methodChannel.invokeMethod("bluetoothState", arguments: "off")
        default:
            methodChannel.invokeMethod("bluetoothState", arguments: "unavailable")
        }
    }
    
    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
        if !discoveredDevices.contains(peripheral) {
            discoveredDevices.append(peripheral)
            let deviceInfo = ["name": peripheral.name ?? "Unknown", "id": peripheral.identifier.uuidString]
            methodChannel.invokeMethod("deviceFound", arguments: deviceInfo)
        }
    }
    
    private func startScan() {
        discoveredDevices.removeAll()
        centralManager.scanForPeripherals(withServices: nil)
    }
    
    private func stopScan() {
        centralManager.stopScan()
    }
}

そして、Flutter側のコードを修正します:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});
  final String title;

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final platform = const MethodChannel("com.jboycode/platforms");
  bool isScanning = false;
  String bluetoothState = "unknown";
  List<Map<String, dynamic>> devices = [];

  
  void initState() {
    super.initState();
    _setupMethodCallHandler();
  }

  void _setupMethodCallHandler() {
    platform.setMethodCallHandler((call) async {
      switch (call.method) {
        case "bluetoothState":
          setState(() {
            bluetoothState = call.arguments as String;
          });
          break;
        case "deviceFound":
          setState(() {
            devices.add(Map<String, dynamic>.from(call.arguments));
          });
          break;
      }
    });
  }

  Future<void> _toggleScan() async {
    try {
      if (isScanning) {
        await platform.invokeMethod("stopScan");
      } else {
        setState(() {
          devices.clear();
        });
        await platform.invokeMethod("startScan");
      }
      setState(() {
        isScanning = !isScanning;
      });
    } on PlatformException catch (e) {
      print("Error: ${e.message}");
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text('Bluetooth: $bluetoothState'),
                Switch(
                  value: isScanning,
                  onChanged: (_) => _toggleScan(),
                ),
              ],
            ),
          ),
          Expanded(
            child: ListView.builder(
              itemCount: devices.length,
              itemBuilder: (context, index) {
                final device = devices[index];
                return ListTile(
                  title: Text(device['name'] ?? 'Unknown Device'),
                  subtitle: Text(device['id']),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

このコードでは以下の機能を実装しています:

  1. iOS側:
  • CoreBluetoothを使用したBLEスキャン機能
  • デバイスの検出と管理
  • Bluetoothの状態監視
  • Method Channelを通じたFlutterとの通信
  1. Flutter側:
  • スキャンのON/OFF切り替えスイッチ
  • 検出されたデバイスのリスト表示
  • Bluetoothの状態表示
  • iOS側からのコールバック処理

スキャンするだけですね💦
こんなことできるのだなと体験してもらうのが目的です🙇

Android設定手順

  1. build.gradleの設定
android {
    defaultConfig {
        minSdkVersion 21  // BLE必須
        targetSdkVersion 33
    }
}
  1. AndroidManifest.xmlの設定
<!-- 基本的なBluetooth権限 -->
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>

<!-- Android 12以降の権限 -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" 
    android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>

<!-- BLE対応の宣言 -->
<uses-feature
    android:name="android.hardware.bluetooth_le"
    android:required="true"/>

Githubのソースコード

  1. 権限の動的リクエスト実装
  • Android 12以降とそれ以前で必要な権限が異なることに注意
  • 位置情報の権限も必要

共通の注意点

  1. デバッグ環境
  • 実機テストを推奨(特にiOS)
  • Bluetoothが有効になっていることを確認
  • デバイスの位置情報サービスが有効になっていることを確認
  1. エラーハンドリング
  • Bluetooth無効時の処理
  • 権限拒否時の処理
  • スキャン失敗時の処理
  • デバイス切断時の処理
  1. バッテリー消費
  • スキャン時間の制限を検討
  • バックグラウンド動作時の考慮
  1. デバイス互換性
  • 異なるAndroidバージョンでのテスト
  • 異なるiOSバージョンでのテスト
  • メーカー固有の制限の確認
  1. セキュリティ
  • BLE通信時のデータ暗号化検討
  • 位置情報の取り扱いに注意
  1. アプリのライフサイクル
  • アプリ終了時のBLE切断処理
  • バックグラウンド移行時の処理
  • フォアグラウンド復帰時の再接続処理

デバッグのヒント

  1. iOS
# Xcodeのデバッグコンソールでログを確認
print("BLE Debug: [状況の説明]")
  1. Android
# LogcatでBluetoothログを確認
adb logcat | grep -i bluetooth
  1. テスト項目
  • アプリ起動時のBLE初期化
  • 権限リクエストの動作
  • デバイススキャンの開始/停止
  • デバイス情報の表示
  • エラー時の表示内容
  • バックグラウンド遷移時の動作

これらの点に注意して実装することで、安定したBLE通信機能を実現できます。

package com.jboycode.methld_chanels_demo

import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity: FlutterActivity() {
    private val channel = "com.jboycode/platforms"
    private lateinit var methodChannel: MethodChannel
    private var bluetoothAdapter: BluetoothAdapter? = null
    private var bluetoothLeScanner: BluetoothLeScanner? = null
    private var isScanning = false

    private val PERMISSION_REQUEST_CODE = 123
    private val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        arrayOf(
            Manifest.permission.BLUETOOTH_SCAN,
            Manifest.permission.BLUETOOTH_CONNECT,
            Manifest.permission.ACCESS_FINE_LOCATION
        )
    } else {
        arrayOf(
            Manifest.permission.ACCESS_FINE_LOCATION,
            Manifest.permission.BLUETOOTH,
            Manifest.permission.BLUETOOTH_ADMIN
        )
    }

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        // BluetoothManagerの初期化
        val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
        bluetoothAdapter = bluetoothManager.adapter
        bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner

        methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
        methodChannel.setMethodCallHandler { call, result ->
            when (call.method) {
                "callNative" -> {
                    result.success("Android ${android.os.Build.VERSION.RELEASE}")
                }
                "startScan" -> {
                    if (checkPermissions()) {
                        startScan()
                        result.success(null)
                    } else {
                        requestPermissions()
                        result.error(
                            "PERMISSION_DENIED",
                            "BLE permissions not granted",
                            null
                        )
                    }
                }
                "stopScan" -> {
                    stopScan()
                    result.success(null)
                }
                else -> result.notImplemented()
            }
        }
    }

    private fun checkPermissions(): Boolean {
        return requiredPermissions.all {
            ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
        }
    }

    private fun requestPermissions() {
        ActivityCompat.requestPermissions(this, requiredPermissions, PERMISSION_REQUEST_CODE)
    }

    private val scanCallback = object : ScanCallback() {
        override fun onScanResult(callbackType: Int, result: ScanResult) {
            val device = result.device
            val deviceInfo = mapOf(
                "name" to (device.name ?: "Unknown"),
                "id" to device.address,
                "rssi" to result.rssi
            )
            runOnUiThread {
                methodChannel.invokeMethod("deviceFound", deviceInfo)
            }
        }

        override fun onScanFailed(errorCode: Int) {
            super.onScanFailed(errorCode)
            runOnUiThread {
                methodChannel.invokeMethod("scanError", "Scan failed with error code: $errorCode")
            }
        }
    }

    private fun startScan() {
        if (!isScanning && checkPermissions()) {
            bluetoothLeScanner?.startScan(scanCallback)
            isScanning = true
            methodChannel.invokeMethod("bluetoothState", "on")
        }
    }

    private fun stopScan() {
        if (isScanning && checkPermissions()) {
            bluetoothLeScanner?.stopScan(scanCallback)
            isScanning = false
        }
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == PERMISSION_REQUEST_CODE) {
            if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
                startScan()
            } else {
                methodChannel.invokeMethod(
                    "permissionError",
                    "Required permissions were not granted"
                )
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        stopScan()
    }
}

公式のBLE実装ドキュメントのURLを共有します:

iOS (Apple)

Android (Google)

特に注意すべきポイントとして、これらの公式ドキュメントは定期的に更新されており、特にAndroid 12以降でのBluetooth権限の扱いが大きく変更されていますので、最新の情報を確認することをお勧めします。

🙂最後に

BLE通信を体験する方法について記事にしてみました。昔、Xamarinなるものでやるプロジェクトがあったのですが、多分Flutterでやった方が簡単そう?
Flutterはこの辺の機能が弱いらしいが?
この記事が何かの参考になると良いですが。

そういえば、ビーコンみたいなもの実験用に買ったけど通信する実験は、スマートフォンやタブレットでもできたの後で気づきました💦
お金がもったいない💰

Discussion