BLE制御するC++製iOSアプリをPigeon使ってFlutterアプリ化した
たまには自慢したい
企業に属する請負開発屋なので10年以上アプリ開発やっていても「そのアプリ実はコード書いてるの俺」と言うことはほとんどありません。そんな中、初版リリース時から「プログラマはdaisuke7」と書いちゃっても良い許可をいただいている貴重なアプリがあります。株式会社サンスターストロボ様の「SSScontrol for MONOSTAR C4(App Storeリンク)」。今回このアプリが刷新することになり、その開発担当として再びお誘いいただきました(感謝!)。アップデート版はすでに無事審査も通って公開されています。
さてこのアプリ、6年前の初版はCocos2d-xというゲームエンジンを使ってC++とObjective-Cでコードを書いていましたが、今回はエンジンをFlutterに改め、コードの半分以上をDartで書き直しました。同様のケースは滅多にないかもしれませんが、今回の刷新作業の肝を書き記しておきます。
Flutter≒ゲームエンジン
もともとCocos2d-xでアプリを実装していたのは、アプリの性格上ゲームエンジンと相性が良かったためです。だからCocos2d-xから乗り換えるとしてもゲームエンジン、あるいはゲームエンジンっぽいフレームワークを採用したい。
最近はFlutter用ゲームエンジンも公式サポートされていますが、専用パッケージを使うまでもなくFlutterはゲームエンジン的な能力を持っています。少なくともスプライトエンジンとして捉えると以下の特徴があるので必要十分。
- 独自の描画エンジンで高速描画している。
- 画像表示はサイズ拡大縮小も回転も自由自在。
- StackやPositionedなどを使えば位置も重ね合わせも好き放題。
たいていのゲームエンジンはTick()あるいはUpdate()と呼ばれるフレーム間処理イベントを用意しています。旧Cocos2d-x版アプリもupdate()が呼ばれるたびに経過時間をチェックして、100ミリ秒精度で細かい動きや操作感などを調整していました。新バージョンはコード全面書き換えですが、基本的なUIはそのままなので当然操作感も可能な限り同じままにしなければなりません。そのため以下のようにして擬似的なTickイベント処理を作りました。
- AnimationControllerを使って50ミリ秒ごとのイベントを作る。
- イベント間の経過時間deltaを取得して、onTick(delta)として必要な処理を呼び出す。
- 決してゲームではないのでonTick()と描画が同期しなくても良いと割り切って、onTick()起因でステートを変更したら、そのステート変更によってRiverpodがWidgetをリビルドする。
こうすることで、Widgetのbuild()はStack多用を除けばごく一般的なRiverpodを使うFlutterコードになり、onTick()内では旧版アプリで調整しまくったタイミング処理をDartコードに移植することが出来ました。
BLE制御コードはそのままで
旧版アプリはCocos2d-xアプリと言っても実質iOS専用だったので、BLE制御関連のコードはObjective-C++で書いていました。C++製のストロボ制御部との繋がりは以下のようなイメージでした。
このアプリは「複数台ストロボをいきなりペアリング」のような他のBLE制御アプリにはない能力を実現していて、その実現のためにBLE制御部は純粋なObjective-Cではなく(キメラ言語の)Objective-C++でコードを書き、C++のスレッドとかqueueとかmutexとかを躊躇なく使っています。実装当時は動作を安定させるためにかなり苦労した記憶が残っていて、FlutterアプリだからBLE制御も可能な限りDartで行こう!、なんて夢は検討開始数分で諦めました。
BLE制御部をSwiftで書き直すのも工数の無駄でしかなく、Objective-C++コードのまま取り込んでビルドするのが最善・最短、そこでMethod Channelを使ってDartコードと通信することにします。
公開パッケージを作るわけでもないのにMethod Channelを自前で扱うのはひたすら面倒なだけです。そんな面倒なことはしたくないので楽するためにPigeonを使いました。
Pigeonで繋げる
PigeonはMethod Channel周りのコードを自動生成するコードジェネレータです。「Dartから呼び出すネイティブコード側API(@HostApi)」および「ネイティブコードから呼び出すDart側API(@FlutterApi)」をDartのclassやenumなどで定義することが出来ます。定義したDartファイルからDart実装、iOS用Objective-C、Android用Javaのコードを生成するためには以下のようなコマンドを実行します。(実際に使っていたものを手修正しています。また、開発当時はSwiftコード出力がなかったのですが、今はサポートしているようです。)
flutter pub run pigeon --input lib/pigeon/scheme.dart --dart_out lib/pigeon/native_api.dart --objc_source_out ios/Runner/Pigeon.m --objc_header_out ios/Runner/Pigeon.h --objc_prefix XXX --java_out android/app/src/main/java/xxx/PigeonNativeHost.java --java_package x.y.z
生成した各言語向けインタフェースを実装するクラスを書くだけでDartとネイティブコードで相互呼び出しが出来るようになります。
「Dartから呼び出すネイティブコード側API(@HostApi)」を実装したらAppDelegateで登録しなければなりません。このあたりは公式サンプルコードを読めばわかります。
登録に使うsetupメソッドはコードジェネレータが自動作成しています。
「ネイティブコードから呼び出すDart側API(@FlutterApi)」の場合はDart側でsetup()を読んで登録します。
pigeonが作成したコードと自分で追加実装したコードを組み込むと、C++からDartに変わったストロボ制御部と今までのObjective-C++製制御部との繋がりは以下のようなイメージに変わります。
内部BLE関数群インタフェースが提供していたものとほぼ同等な機能をHostApiとして定義することで、単純な変換コードを実装する程度で元のBLE制御コードをDartから呼び出すことが出来ました。BLE制御コード本体もほとんど修正せずに済ませた(ヘッダファイル変更と通知イベント送信周りぐらい)ので、短期間で品質を落とさず元のコードを取り込む作戦は成功です。
BLE制御側からの通知もFlutterApi経由でDart側に伝えることが出来ました。ただしBLE制御側は別スレッドで動いているので、FlutterApi側のコード内でawaitとかすると容赦なく再入したりします。したがって届いた通知はその場で処理せず一旦streamに流すだけにして、別の場所でstreamから取り出して順次処理していくようにしています。
まとめ
Pigeon便利です。Flutterアプリでネイティブコードがどうしても必要ならPigeonは検討の価値ありです。Method Channel周りは生成コードが勝手に対応してくます。Pigeonを扱う解説記事をあまり見つけられないのが難点ですが、コード生成方法だけ理解して自分で色々試していけばなんとかなると思います。
まとめると、こんなことも出来るプログラマですので、アプリ開発案件などあればぜひお声掛けください。お待ちしております。
おまけ
記事中のダイアグラムはMermaidで書いています。mermaid便利。(Zennはすぐmermaid使えるのもZennでこの記事書いた大きな理由)
最初のダイアグラムはこんな感じ。後半のstyle除いたらかなり簡素。素敵。
graph TB
ui(UI部/C++) --> control(ストロボ制御部/C++)
control --> ui
control -- ストロボ制御指示 --> api(内部BLE関数群インタフェース/C++)
api --> ble(内部BLE関数群実装/Objective-C++)
ble -- ストロボからの通知 --> control
ble --> cb(CoreBluetooth/iOS)
cb --> ble
style ui fill:#87cefa,stroke:#333,stroke-width:2px,color:#000
style control fill:#66cdaa,stroke:#333,stroke-width:2px,color:#000
style api fill:#f0e68c,stroke:#8b4513,stroke-width:2px,color:#000,stroke-dasharray: 5 5
style ble fill:#f08080,stroke:#333,stroke-width:2px,color:#000
style cb fill:#ffa07a,stroke:#333,stroke-width:2px,color:#000
Discussion