Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Flutter × BLE Centralを自前Pluginで実装する設計パターン - Met...

Flutter × BLE Centralを自前Pluginで実装する設計パターン - MethodChannel / EventChannelで作る双方向ブリッジの実践 / Building Custom Flutter BLE Central Plugins: Bidirectional Bridging with Method & Event Channels

登壇者名:酒井文也
登壇したイベントタイトル:Flutter × BLE Centralを自前Pluginで実装する設計パターン
登壇したイベントのURL:https://potatotips.connpass.com/event/400003/

More Decks by 株式会社ビットキー / Bitkey Inc.

Other Decks in Technology

Transcript

  1. 自己紹介 酒井 文也 Sakai Fumiya @fumiyasac 2 ※長いのでモバイルアプリエンジニアになってからを抜粋して紹介します 2016.09 30歳の誕生日からiOSアプリ開発の勉強をスタート

    この時はまだWebエンジニアをしていました ソーシャルゲームやWebサービスの開発に従事 仕事→iOSアプリ開発の勉強 or 勉強会参加の毎日 2019.03 数社でiOSアプリ開発を経験した後に独立 iOSアプリ開発を軸に据えて様々な開発現場を経験 2021年からはAndroidアプリ開発にも挑戦 2023.01 国内最大級のハンドメイドマーケットアプリの開発に シニアエンジニアとして従事 iOS&Androidアプリ開発を中心に幅広く新機能開発や 既存機能改善に携わっていました Now 2026年2月にビットキーに入社 homehub開発部 homehub Mobileに所属 現在は「homehub」のモバイルアプリ開発を担当 Flutter歴 = 社歴
  2. 今日お話しすること FlutterでIoTデバイスとBLE 通信するために、 汎用プラグインを使わず自前で Pluginを実装した 設計パターンを紹介します。 6 1. なぜ自前 Pluginなのか

    - 汎用プラグインの限界と設計判断 2. 双方向ブリッジの全体設計 - MethodChannel × EventChannel 3. Dart側の設計パターン - async*ジェネレータの活用 4. Android側の設計パターン - Coroutines・リフレクション・ Hilt DI 5. ハマった落とし穴 - Gradleの動的バージョン問題
  3. なぜ自前Pluginなのか Flutter × BLEの選択肢 FlutterでBLE Centralを扱う場合、まず候補に挙がるのが汎用 BLEプラグインである 。 • パッと頭に浮かんだのはこの

    2つ 汎用プラグイン 提供する機能 flutter_blue_plus スキャン・接続・ GATT読み書き flutter_reactive_ble リアクティブな接続管理・ GATT操作 しかし、IoTプロダクトによっては、以下の要件により 汎用プラグインだけでは不足する 場合もある。 • 8 例えばどの様な場合か? • 独自プロトコル : GATTの単純なread/writeではなく、独自パケットフォーマット+暗号化通信が必要 • DFU(ファームウェア更新) : Nordic DFU SDKを直接呼ぶ必要がある ※汎用プラグインのスコープ外 • 接続プール管理 : バックグラウンドスキャン継続・特殊なデバイス状態への対応
  4. なぜ自前Pluginなのか 自前Pluginにした理由 汎用プラグイン + 自前ラッパー + DFU用Pluginで5層 → 3層に圧縮可能。 •

    汎用プラグインを使った場合 アプリ層 • 自前Pluginの場合 アプリ層 独自プロトコル・接続管理 自前Plugin 自前ラッパー層 👈 肥大化 汎用BLE Plugin BLE + DFU + 接続管理 すべて一体 ネイティブBLEライブラリ DFU専用Plugin (別途) レイヤ間の変換コストを削減し、ネイティブ BLE ライ ネイティブBLEライブラリ 9 ブラリの機能を 100%活用できる。
  5. 双方向ブリッジの全体設計 MethodChannel × EventChannelの双方向ブリッジ Flutter (Dart) MethodChannel (Dart → Native)

    EventChannel (Native → Dart) リクエスト /レスポンス イベントストリーム scan / connect | dfu / command 約50種のイベントを統一JSON形式で受信 Android (Kotlin) / iOS (Swift) Android: nRF BLE LibraryベースのBLE操作 / Kotlin Coroutines ChannelでEventSinkにブリッジ / Hilt DIで2段階スコープ管理 iOS: CoreBluetoothでBLE操作 / ObserverパターンでEventSink に送出 11 1. MethodChannel = Dart → Nativeのコマンド送信 👉 `Future`で結果を返す 2. EventChannel = Native → Dartのイベントストリーム 👉 `Stream`として購読
  6. 双方向ブリッジの全体設計 チャネル設計の選択 機能ごとにチャネルを分ける案 Aと、1本に統合する案 Bを比較する。 • 案A: 機能ごとに分離 • 案B:

    1本に統合 MethodChannel 1本: "Scanner.startScanSession" "Connector.connect" "DFU.execute" MethodChannel x N本: scanner/ connector/ dfu/ ... 👉 メソッド名でルーティング EventChannel x N本: scanner/stream connector/stream dfu/stream ... EventChannel 1本: 😖 問題: 機能追加のたびにチャネル登録コードが増殖 😆 利点: インフラ固定&機能追加はメソッド名追加のみ 👉 Dart側で is チェックでフィルタ メソッド名の命名規約 "ControllerName.methodName"で論理的にルーティングする チャネルのインフラは固定のまま機能だけをスケールさせられる 12
  7. Dart側の設計パターン EventChannelの遅延初期化: PublishSubject + doOnListen RxDartの`PublishSubject`と`doOnListen`で、最初の購読者が現れるまで EventChannelを起動しない 設計。 SDK.initialize() 最初のスキャン開始

    デバイス発見イベント SDK構築 scanner.start() Native 👉 Event Subject作成 await forでlisten 👉 Subject 👉 yield doOnListen発火! receiveBroadcastStream()起動 この間はEventChannelは未起動 = リソース節約 15
  8. Dart側の設計パターン invoke → await for → yieldのシーケンス async* 関数内部 Dart

    (呼び出し側 ) await for MethodChannel EventChannel Native ① invoke 開始指示 Future解決 = 受理確認 ② await for (eventStream) ︙ 待機中 17 ③ イベント到着 (is Discovered → yield) デバイスA yield A イベント到着 (is Discovered → yield) デバイスB yield B Stream終了 is Succeeded → return • `invoke()`: MethodChannel でコマンド送信。戻り値は受理確認 でありBLE操作の結果ではない • `await for`: EventChannelのストリームを待機し続ける • イベント到着: 種別を`is`チェックで分岐し`yield` / `return` / `throw` 終了
  9. Android側の設計パターン Coroutines Channel → EventSinkブリッジ Kotlin Coroutinesの`Channel<T>`とFlutterの`EventChannel.EventSink`を接続するブリッジ。 • 3つの設計ポイント •

    `Dispatchers.Main`指定 ◦ `EventSink.success()`はU スレッド必須。 忘れると`CalledFromWrongThreadException` 👉 UIスレッド必須 • `Channel<T>`を使用 ◦ 複数 producer → 単一consumerのfan-inパターン。 バックプレッシャー制御付き • `onCancel`で`job?.cancel()` ◦ 20 Dart側の購読解除時にリソースリークを防止
  10. Android側の設計パターン asEncodable(): Kotlin型 → StandardMessageCodec互換 Kotlin固有の数値型は `StandardMessageCodec`がそのまま扱えない 。リフレクションで任意のオブジェクトを再帰的に変換する。 コードの続き •

    `Byte`の符号問題 • Kotlinの`Byte`は符号付き -128〜127。BLEデバイスが返すバッテリー残量`0xC8`はKotlin上では`-56`になる。そのまま`toInt()`で Flutterに渡すと、UIに バッテリー-56%が表示される。`and 0xFF`で`-56` → `200`に復元する必要がある。 ◦ 21 厄介なのは0〜127の範囲では正しく動くこと。テスト中に気づかず、特定の値で初めて壊れる。
  11. Android側の設計パターン @Export + リフレクション: 自動メソッドルーティング 各コントローラの公開メソッドに `@Export`を付けるだけで、 Plugin起動時にリフレクションが メソッドマップを自動構築 する。

    • 3つの実装ポイント • `@Export`はランタイム保持のアノテーション。`name`を省略すると メソッド名がそのまま使われる。 • `createMethodRoutes()`は"prefix.methodName"をキーとする `HashMap`を返す • `BindFunction`はコントローラのインスタンスと`KFunction`のペアを 保持する iOS側はSwiftの`Mirror`APIの制約から`switch`文による手動ルーティングを採用。ここが Android / iOSの実装アプローチの最大の分岐点 。 22
  12. Android側の設計パターン @Exportメソッドルーティングの流れ • Plugin起動時 👉 リフレクションでメソッドマップを構築 ScannerModel ConnectorModel @Export start()

    @Export connect() @Export stop() @Export disconnect() • MethodCall到着時 👉 マップから引き当てて呼び出し MethodCall("Scanner.start", args) method = "Scanner.start" map["Scanner.start"] createMethodRoutes() BindFunction(scanner, fn) を取得 HashMap<String, BindFunction> 23 "Scanner.start" BindFunction(fn) "Scanner.stop" BindFunction(fn) "Connector.connect" BindFunction(fn) "Connector.disconnect" BindFunction(fn) fn.callSuspendWith(scanner, args) result.success(返却値)
  13. Android側の設計パターン Hilt DIの2段階スコープ: Flutter Plugin特有の設計 Flutter Pluginは`ComponentActivity`を継承しない Hilt 標準の`@AndroidEntryPoint`が使えない `@DefineComponent`でカスタムコンポーネントを定義して解決。

    PluginComponent (Engineの寿命 = アプリと一致) - BleScanner, BleConnectionPool 👉 UIスレッド必須 - ActivityAwarePluginComponent (Activityの寿命) - DfuController, Activity 👉 Activity依存のもの 画面回転でActivityが壊れてもBLE 接続は切れない 24 これが2段階に分ける狙い
  14. Android側の設計パターン Engine / Activityライフサイクルの時間軸 Engineスコープの中で Activityが複数回作り直される様子を時間軸で見ると以下のようになる。 時間軸 Engineスコープ onAttachedToEngine (PluginComponent)

    onDetachedFromEngine ずっと生きている BleScanner: BleConnectionPool: 時間軸 Activityスコープ onAttached onDetached ♻画面回転 DfuController: ❌ Activity: ❌ 1回目 25 onReattached onDetached (ActivityAware PluginComponent) 破棄 👉再生成 2回目
  15. Android側の設計パターン Android / iOS実装の比較 観点 Android (Kotlin) iOS (Swift) BLEライブラリ

    nRF BLE Libraryベース CoreBluetooth (OS標準) イベント送出 Coroutines `Channel` → `EventSink` Observerパターン → `FlutterEventSink` DI Hiltカスタムコンポーネント( 2段階スコープ) 手動DI(initialize 時にインスタンス生成) メソッドルーティング `@Export` + リフレクション(自動) `switch`文(手動) スレッド管理 `Dispatchers.Main`を明示指定 メインスレッドから直接呼び出し DFUライブラリ NordicDFU (Gradle) NordicDFU (CocoaPods) イベントフィルタ なし(Dart側でフィルタ) `eventFilterRegex`でネイティブ層フィルタ Dart側API 同一 同一 ネイティブ実装は Android / iOSで大きく異なるが、 Dart 側のAPI は完全に同一 。`async*`で書かれたスキャンや DFUのコードは、プラットフォーム の違いを一切意識しない。 26
  16. ハマった落とし穴 動的バージョン`3.+`が引き起こしたNoSuchMethodError 自前BLE Pluginが `ble-central: 3.+`(動的)で依存し、別の Pluginが`ble-central: 3.3.0`(固定・`api`スコープ)で依存していた。 Flutter Plugin

    A Flutter Plugin B (自前 BLE Plugin) (別チーム管理) build.gradle: ble-central: 3.+(動的バージョン) build.gradle: ble-central: 3.3.0(api で公開) Gradle 依存解決 3.+ → 3.4.0 (最新) vs 3.3.0 (固定) 👉 最新バージョン優先で 3.4.0 を採用 Plugin Bのバイトコード: runSecureDFU(String, String) ← 旧API 3.4.0 の実際のメソッド: ランタイムでNoSuchMethodError ビルドは成功する。 JVMのリンクはランタイムで行われるため、 DFUを実行した瞬間にクラッシュ。 28 runSecureDFU(String, String, Boolean)
  17. ハマった落とし穴 ある日突然コードを一切変更していないのにクラッシュした 時間軸と発覚した経緯はこの様な感じです。 Release A ble-central 更新 Release B 🙆

    ビルドは成功 Version 3.4.0 🙆 ビルドは成功 動作正常 ⭕ Mavenに公開 DFUでクラッシュ 💥 時間軸 29 Gradle: Gradle: 3.+ 👉 3.3.0 に解決 3.+ 👉 3.4.0 に解決 (Plugin Bと同じ) (Plugin Bは3.3.0のバイトコードのまま)
  18. まとめ&伝えたいメッセージ まとめ&伝えたいメッセージ # ポイント 内容 1 MethodChannel × EventChannel BLE

    の「コマンド → イベント応答」モデルに自然にフィット 2 async*でBLE イベントを Stream に 呼び出し側は MethodChannel / EventChannelを意識しないで済む 3 ネイティブ実装は Platform 最適化& Dart APIは統一 プラットフォームに最適な技術を採用しつつ、 Dart APIは完全同一 4 ネイティブ依存のバージョンは固定 特に複数Pluginが同じライブラリに依存する場合は要注意 Flutter × BLE Centralを自前Pluginで実装する設計パターンにおけるメッセージはこちら 💁 Platform Channelを使った自前Pluginは実装コストがかかる。 しかしながら、ネイティブBLEライブラリの能力を100%引き出しつつ、Dart側にはシンプルなStream APIを提供できる。 「汎用プラグインでは要件を満たせない」と感じたら、自前Pluginも選択肢のひとつとして検討してみてください。 ご清聴ありがとうございました!質疑応答・感想などお気軽にどうぞ! 30