見出し画像

【Flutter】Pigeonを使ってネイティブ側からの状態をStream風に受け取る方法を考えました。

こちらは、SHIFTグループ公式アドベントカレンダー2024【A】IT技術関連トピック Day5の記事です。
アドベントカレンダー2024【B】仕事術・キャリア・体験記の記事も毎日公開していますので、ぜひあわせてご覧ください。

★Day4のアドベントカレンダー記事
SAPサービスグループ教育チームの1年の活動」(基幹・ERPサービス部 SAPサービスグループ 八木)


はじめに


みなさんお元気ですか。 SHIFT DAAEグループ所属のsakuraiです。

Flutterアプリケーション開発において、ネイティブ側(Android, iOS)で発生する状態変更をFlutter側でリアルタイムに検知したいという要件に直面しました。

これまでFlutterとネイティブ間の連携については基本的な知識しか持ち合わせていませんでしたが、今回実装方法を詳しく調査しました。

本記事では、その過程で得られた知見と具体的な実装方法を共有させていただきます。

課題の分析と解決方法の検討


まず、調査によって以下のことがわかりました。

  • ネイティブ側からFlutter側への状態通知にはEventChannel が利用できそう。

  • ネイティブ連携の実装には、Pigeon パッケージが有効で、型安全なコードを自動生成できる。

しかし、ここで重要な問題に直面しました。

  • PigeonではEventChannelがサポートされていない関連Issue

  • なのでEventChannelを使用する場合、Pigeonの型安全性や自動生成の恩恵を受けられない。

何とかできないか、さらに詳しく調べていくと、以下のことがわかりました。

  • PigeonはBasicMessageChannel を内部で使用している

  • BasicMessageChannelを使えば、ネイティブ側から非同期に連続データを送信できる

  • Pigeonでは以下の定義を使えは双方向の通信が可能。

    • @HostApi()を利用してFlutter→ネイティブの処理を定義できる

    • @FlutterApi()を利用してネイティブ→Flutterの処理を定義できる。

これらの知見を組み合わせることで、Stream風な処理を実現できそうということがわかりました。
以下では、プロジェクトの作成から実装までの具体的な手順を説明します。

導入前提

環境は以下の通りです。

% flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.24.3, on macOS 14.6.1 23G93 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.0)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.1)
[✓] VS Code (version 1.95.1)
[✓] Connected device (6 available)
[✓] Network resources

プラグインの作成

ネイティブへの操作を別プラグインとして切り出したかったため、Flutterプラグインとして作成しています。 (Flutterプロジェクトへ適用する場合も、少しFlutter側のファイル配置を変更すればそのまま利用可能です。)

% cd ~
% flutter create --org com.example.sakurai --template=plugin --platforms=android,ios -a kotlin -i swift pigeon_sample
% cd pigeon_sample

作成後、続いてdev_dependenciesにpigeonを追加します。

dart pub add --dev pigeon

つぎにpigeonで行う連携を定義したファイル作成します。

% mkdir pigeon                 
% touch pigeon/messages.dart

以下の通り、Flutterとネイティブ間の通信を定義します。

import 'package:pigeon/pigeon.dart';

@ConfigurePigeon(
  PigeonOptions(
    dartOut: 'lib/src/pigeon/messages.g.dart',
    dartOptions: DartOptions(),
    kotlinOut:
        'android/src/main/kotlin/com/example/sakurai/pigeon_sample/Messages.kt',
    kotlinOptions: KotlinOptions(),
    swiftOut: 'ios/Classes/Messages.swift',
    swiftOptions: SwiftOptions(),
  ),
)
@HostApi()
abstract class NativeStateApi {
  void startListeningNativeState();
  void stopListeningNativeState();
}

@FlutterApi()
abstract class NativeStateEvents {
  void onStateChanged(NativeState nativeState);
}

enum NativeState {
  stateA,
  stateB,
  stateC,
}

以下のコマンドを実行し、連携用のソースコードを生成します。

dart run pigeon --input pigeon/messages.dart

生成されたファイルは以下の場所に格納されます。

.
├── android
│   └── src
│       └── main
│           └── kotlin
│               └── com
│                   └── example
│                       └── sakurai
│                           └── pigeon_sample
│                               ├── Messages.kt ※生成したファイル
│                               └── PigeonSamplePlugin.kt ※ここにpigeonを利用した処理を記載する 
├── ios
│   └── Classes
│       ├── Messages.swift ※生成したファイル
│       └── PigeonSamplePlugin.swift ※ここにpigeonを利用した処理を記載する 
└── lib
    └── src
        └── pigeon
            └── messages.g.dart ※生成したファイル。Flutterでの呼び出しで利用

次にFlutter側の実装を行います。
Flutter側でネイティブ側の操作を行うマネージャークラスを作成しました。
ここでFlutter→ネイティブ、ネイティブ→FlutterのAPIを組み合わせることでStreamっぽい処理を実現しています。

% touch lib/src/pigeon_sample_manager.dart
import 'package:pigeon_sample/src/pigeon/messages.g.dart';

class PigeonSampleManager {
  PigeonSampleManager._internal();

  static final PigeonSampleManager _instance = PigeonSampleManager._internal();

  static PigeonSampleManager get instance => _instance;

  final NativeStateApi _api = NativeStateApi();

  static void Function(NativeState)? _onStateChangedCallback;

  void startListeningNativeState(
      void Function(NativeState nativeState) onDeviceFound) {
    _onStateChangedCallback = onDeviceFound;
    NativeStateEvents.setUp(_NativeStateEventsHandler());
    _api.startListeningNativeState();
  }

  void stopListeningNativeState() {
    _api.stopListeningNativeState();
    _onStateChangedCallback = null;
  }
}

class _NativeStateEventsHandler extends NativeStateEvents {
  @override
  void onStateChanged(NativeState nativeState) {
    PigeonSampleManager._onStateChangedCallback?.call(nativeState);
  }
}

次にAndroidとiOSでネイティブの状態の変更を通知する処理を作成します。
3秒ごとにランダムに状態の変更を通知する処理をサンプルとして作成しました。
Pigeonで生成されたファイルを以下のように更新してください。

Androidの処理

package com.example.sakurai.pigeon_sample

import NativeState
import NativeStateApi
import NativeStateEvents
import android.content.Context
import android.os.Handler
import android.os.Looper

import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import kotlin.random.Random

class PigeonSamplePlugin : FlutterPlugin {
    private lateinit var context: Context
    private lateinit var binaryMessenger: BinaryMessenger

    override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
        context = flutterPluginBinding.applicationContext
        binaryMessenger = flutterPluginBinding.binaryMessenger
        NativeStateApi.setUp(binaryMessenger, NativeStateApiImpl(binaryMessenger))
    }

    override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {

    }
}

class NativeStateApiImpl(
    binaryMessenger: BinaryMessenger
) :
    NativeStateApi {

    private var nativeStateEvents: NativeStateEvents =
        NativeStateEvents(binaryMessenger)

    private val handler = Handler(Looper.getMainLooper())
    private var isListening = false


  private val stateRunnable = object : Runnable {
    override fun run() {
      if (isListening) {
          val state = getRandomState()
          nativeStateEvents.onStateChanged(state) { _ -> }
        handler.postDelayed(this, 3000) 
      }
    }
  }

    override fun startListeningNativeState() {
        isListening = true
        handler.post(stateRunnable)
    }

    override fun stopListeningNativeState() {
        isListening = false
        handler.removeCallbacks(stateRunnable)
    }

    private fun getRandomState(): NativeState {
        return when (Random.nextInt(3)) {
            0 -> NativeState.STATE_A
            1 -> NativeState.STATE_B
            else -> NativeState.STATE_C
        }
    }
}

iOSの処理

import Flutter
import UIKit

public class PigeonSamplePlugin: NSObject, FlutterPlugin{
    private var binaryMessenger: FlutterBinaryMessenger?
    
    
    public static func register(with registrar: FlutterPluginRegistrar) {
        let binaryMessenger = registrar.messenger()
        
        NativeStateApiSetup.setUp(binaryMessenger: binaryMessenger,api:NativeStateApiImpl(binaryMessenger: binaryMessenger))
    }
}

class NativeStateApiImpl: NSObject, NativeStateApi {
    private var nativeStateEvents: NativeStateEvents
    private var timer: Timer?
    
    init(binaryMessenger: FlutterBinaryMessenger) {
        nativeStateEvents = NativeStateEvents(binaryMessenger: binaryMessenger)
    }
    
    func startListeningNativeState() {
        stopListeningNativeState()
        
        timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
            guard let self = self else { return }
            
            let state = self.getRandomState()
            self.nativeStateEvents.onStateChanged(nativeState: state) { result in
                switch result {
                case .success:
                    print("State changed event sent: \(state)")
                case .failure:
                    print("Failed to send state changed event")
                }
            }
        }
    }
    
    func stopListeningNativeState() {
        timer?.invalidate()
        timer = nil
    }
    
    private func getRandomState() -> NativeState {
        let randomNumber = Int.random(in: 0...2)
        switch randomNumber {
        case 0:
            return .stateA
        case 1:
            return .stateB
        default:
            return .stateC
        }
    }
}

最後に/pigeon_sample/lib/pigeon_sample.dartを変更し、PigeonSampleManager経由でのみネイティブ連携を行えるよう公開の設定を行います。

export 'package:pigeon_sample/src/pigeon/messages.g.dart' show NativeState;
export 'package:pigeon_sample/src/pigeon_sample_manager.dart';

以上でネイティブ連携の準備が完了しました!

次に/example/lib/main.dartから動作確認を行います。
mainの処理を以下のように更新します。

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

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  NativeState? currentState;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Plugin example app'),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              ElevatedButton(
                  onPressed: () => PigeonSampleManager.instance
                          .startListeningNativeState((nativeState) {
                        setState(() {
                          currentState = nativeState;
                        });
                      }),
                  child: const Text('Start Listening')),
              const SizedBox(height: 16),
              ElevatedButton(
                  onPressed: () =>
                      PigeonSampleManager.instance.stopListeningNativeState,
                  child: const Text('Stop Listening')),
              const SizedBox(height: 16),
              Text('currentState: $currentState'),
            ],
          ),
        ),
      ),
    );
  }
}

最後の最後にflutter createコマンド実行時に作成されたテンプレートファイルを削除して、エラーが出ないようにします。

% rm -r lib/pigeon_sample_method_channel.dart 
% rm -f lib/pigeon_sample_platform_interface.dart
% rm -f test/pigeon_sample_method_channel_test.dart
% rm -f test/pigeon_sample_test.dart     
% rm -rf example/integration_test/plugin_integration_test.dart

/example/lib/main.dartからアプリを起動し
3秒ごとにネイティブの状態の変更を確認することができました!

おわりに


型安全にストリームっぽくネイティブと連携できているので、EventChannelで実装するよりはメンテナンスしやすくなっていると思います。
今回は実装しませんでしたが、PigeonSampleManagerを変更すれば実際にStreamを利用した処理も実装できそうです。

Pigeonを使用した同期処理の参考情報は多く見つけることはできたのですが非同期の連続データ処理に関する情報は比較的少なそうでしたので、本記事が同様の課題に直面している方々の参考になれば幸いです。
最後までお読みいただきありがとうございました。


執筆者プロフィール:sakurai
SHIFT DAAEグループ所属の開発エンジニアです。
New Orderのアルバムを1から順番に聴く活動を行っています。

SHIFTグループ公式アドベントカレンダー2024【A】 IT技術関連トピック Day6は「エンジニアと共創するDevRelのこだわり」(DevRelグループ長 あやなる)

お問合せはお気軽に

SHIFTについて(コーポレートサイト)

SHIFTのサービスについて(サービスサイト)

SHIFTの導入事例

お役立ち資料はこちら

SHIFTの採用情報はこちら