☺️

pigeonに入門してみた

2024/12/19に公開

What Pigeon?

以前、MethodChannelの使い方について解説した記事を書いたのですが、pigeonの解説は省いていました。

Swift、Kotlinで実装した機能から渡されるデータを型安全に扱うことができるパッケージらしい?
型安全か...
いつも気にしてない笑
以前エラーで詰まって勉強するのやめたがもしかしたら使うかもしれないのでもう一度やってみた。

今回は公式のバッテリーの残量を取得する機能を改造して、カスタムペインターで作成したバッテリーのUIを作成して、その中に数値を表示するデモアプリ作ってみました。

https://x.com/JBOY83062526/status/1869635407975227724

もしかしたら人によってはうまくいかないかも?

完成品のソースコードを参考にしてみてください。

Androidは、application idは、人によって「com.なんとか」って違うと思うのでこの設定はいい感じでやってください。

自動生成されてるので多分名前はなんでもいい気がする?

必要なもの

実機で動作検証してるので、iPhoneかAndroidの端末が必要です。持ってる方で試してみてください。iOSだと、Apple Developerのアカウント持ってないと試せないと思うのでアカウント持ってない人は、Androidだけで試してください。

公式を翻訳してみると

PigeonはFlutterとホストプラットフォーム間の通信をタイプセーフで、簡単かつ高速にするコードジェネレーターツールです。

Pigeonは複数のプラットフォームや言語間で文字列を管理する必要性をなくします。また、一般的なメソッドチャネルパターンよりも効率が向上します。しかし最も重要なのは、Pigeonがあなたの代わりに生成してくれるため、カスタムプラットフォームチャネルのコードを書く必要がなくなることです。

使用例は Example README を参照してください。

特徴
サポートされているプラ​​ットフォーム
現在、pigeon は以下の生成をサポートしています:

Android 向け Kotlin および Java コード
iOS および macOS 向けの Swift および Objective-C コード
Windows 用 C++ コード
Linux 用 GObject コード
サポートされているデータ型
Pigeon は を使用するStandardMessageCodecため、 あらゆるデータ型のプラットフォーム チャネルをサポートします。

カスタム クラス、ネストされたデータ型、列挙型もサポートされています。

空の親クラスによる基本的な継承は、sealedSwift、Kotlin、および Dart ジェネレーターでのみ許可されます。

Objective-C で生成されたコード内の null 許容列挙型は、null 許容性を可能にするためにクラスにラップされます。

デフォルトでは、Swift のカスタム クラスは構造体として定義されます。構造体は、再帰データや Objective-C 相互運用性など、一部の機能をサポートしていません。代わりに、クラスを定義するときに @SwiftClass アノテーションを使用して、データを Swift クラスとして生成します。

同期メソッドと非同期メソッド
プラットフォーム チャネル API (ピジョン メソッドなど) 全体の呼び出しはすべて非同期ですが、ピジョン メソッドはネイティブ側で同期メソッドとして記述できるため、常に 1 回だけ応答することが簡単になります。

非同期メソッドが必要な場合は、@asyncアノテーションを使用できます。この場合、指定されたコールバックを介して結果またはエラーを返す必要があります。例。

エラー処理
Kotlin、Java、Swift
すべての Host API 例外は Flutter に変換されますPlatformException。

同期メソッドの場合、スローされた例外はキャッチされ、変換されます。
非同期メソッドの場合、デフォルトの例外処理はありません。エラーは提供されたコールバックを介して返される必要があります。
PlatformExceptionエラー処理のためにカスタム詳細を渡すには、FlutterErrorホスト API で を使用します。例。

Swift では、エラーをスローするときにPigeonErrorの代わりにを使用します。詳細については、Example#Swift を参照してください。FlutterError

Objective-C と C++
FlutterErrorホスト API エラーは、提供されたクラス ( に変換)を使用して送信できますPlatformException。

同期メソッドの場合:

Objective-C -error引数を参照に設定しますFlutterError。
C++ - を返しますFlutterError。
非同期メソッドの場合:

FlutterError指定されたコールバックを通じてを返します。
タスクキュー
TaskQueue API をサポートする Flutter バージョンをターゲットとする場合、 HostApi メソッドを処理するためのスレッド モデルを TaskQueueアノテーションで選択できます。

マルチインスタンスのサポート
Host および Flutter API では、複数のインスタンスを作成し、並行して動作できるように、API に一意のメッセージ チャネル サフィックス文字列を提供する機能がサポートされるようになりました。

使用法
鳩を として追加しますdev_dependency。
通信インターフェースを定義するために、「lib」ディレクトリの外部に「.dart」ファイルを作成します。
「.dart」ファイルで pigeon を実行して、必要な Dart およびホスト言語コードを生成します。flutter pub get次に、dart run pigeon 適切な引数を指定します。例。
生成された Dart コードを./libコンパイル用に追加します。
ホスト言語コードを実装し、ビルドに追加します (以下を参照)。
生成された Dart メソッドを呼び出します。
通信インターフェースを定義するためのルール

ファイルにはメソッドや関数の定義は含まれず、宣言のみが含まれます。
API で使用されるカスタム クラスは、サポートされているデータ型のフィールドを持つクラスとして定義されます (サポートされているデータ型のセクションを参照)。
API は、または メタデータabstract classを使用してとして定義する必要があります。 はホスト プラットフォームで定義されるプロシージャ用であり、 はDart で定義されるプロシージャ用です。@HostApi()@FlutterApi()@HostApi()@FlutterApi()
API クラスのメソッド宣言には、ファイルで定義されている型、サポートされているデータ型、またはである引数と戻り値が必要です void。
イベント チャネルは、Swift、Kotlin、および Dart ジェネレーターでのみサポートされます。
イベント チャネル メソッドは、abstract classメタデータを含むでラップする必要があります@EventChannelApi。
イベント チャネルの定義にはStream戻り値の型は含めず、ストリーミングされる型のみを含める必要があります。
@ObjCSelectorObjective-C と Swift には、それぞれと で利用できる特別な命名規則があります @SwiftFunction。
Flutter から iOS への呼び出し手順
生成された Objective-C または Swift コードをコンパイルのために Xcode プロジェクトに追加します (例:ios/Runner.xcworkspaceまたは.podspec)。
iOS での通話を処理するために生成されたプロトコルを実装し、メッセージのハンドラーとして設定します。
Flutter から Android への呼び出し手順
生成された Java または Kotlin コードを./android/app/src/main/javaコンパイル用のディレクトリに追加します。
Android での呼び出しを処理するために生成された Java または Kotlin インターフェースを実装し、メッセージのハンドラーとして設定します。
Flutter から Windows への呼び出し手順
生成された C++ コードを./windowsコンパイル用のディレクトリとwindows/CMakeLists.txtファイルに追加します。
Windows での呼び出しを処理するために生成された C++ 抽象クラスを実装し、メッセージのハンドラーとして設定します。
Flutter から macOS への呼び出し手順
生成された Objective-C または Swift コードをコンパイルのために Xcode プロジェクトに追加します (例:macos/Runner.xcworkspaceまたは.podspec)。
macOS での呼び出しを処理するために生成されたプロトコルを実装し、メッセージのハンドラーとして設定します。
Flutter から Linux への呼び出し手順
生成された GObject コードを./linuxコンパイル用のディレクトリとlinux/CMakeLists.txtファイルに追加します。
Linux 上で呼び出しを処理するために生成されたプロトコルを実装し、それを API オブジェクトの vtable として設定します。
ホストプラットフォームから Flutter を呼び出す
Pigeon は逆方向の呼び出しもサポートしています。手順は似ていますが逆になります。詳細については、@FlutterApi()Flutter 内に存在するがホスト プラットフォームから呼び出される API を示す注釈を参照してください。

ドキュメントだけだとわからなかった

あれだけ見てわかったら天才ですね😇
実験するためにかなり詰まったので手順書作りました。iOSは、xcodeで自動生成されたファイルを読み込む設定が必要なのでここは厄介でしたね。

こちら参考にしてみてください👇

Pigeon Demo - バッテリー残量取得アプリ

概要

FlutterでiOSとAndroidのネイティブAPIを使用してバッテリー残量を取得し、アニメーション付きで表示するデモアプリです。

セットアップ手順

1. プロジェクト作成

flutter create pigeon_demo
cd pigeon_demo

2. 依存関係の追加

pubspec.yamlに以下を追加:

dev_dependencies:
  pigeon: ^22.7.0  # 最新バージョンを使用

3. ディレクトリ構造の作成

mkdir pigeons

4. Pigeon API定義

pigeons/api.dartを作成:

import 'package:pigeon/pigeon.dart';

(PigeonOptions(
  dartOut: 'lib/api.g.dart',
  dartOptions: DartOptions(),
  kotlinOut: 'android/app/src/main/kotlin/com/jboycode/pigeon_demo/Api.g.kt',
  kotlinOptions: KotlinOptions(
    package: 'com.jboycode.pigeon_demo'
  ),
  swiftOut: 'ios/Runner/BatteryApi.swift',
))

()
abstract class BatteryApi {
  int getBatteryLevel();
}

5. Android設定

私は設定しなくてもできたが、最近は23以上にしないとエラーが出ることがある。

  1. android/app/build.gradleの設定:
android {
    namespace = "com.jboycode.pigeon_demo"
    compileSdk = flutter.compileSdkVersion
    
    defaultConfig {
        minSdkVersion 23
        targetSdkVersion flutter.targetSdkVersion
    }
}
  1. MainActivity.ktの実装:
package com.jboycode.pigeon_demo

import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine

class MainActivity: FlutterActivity(), BatteryApi {
    override fun getBatteryLevel(): Long {
        val batteryLevel: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
            batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
        } else {
            val intent = ContextWrapper(applicationContext).registerReceiver(
                null,
                IntentFilter(Intent.ACTION_BATTERY_CHANGED)
            )
            intent?.let { batteryIntent ->
                val level = batteryIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
                val scale = batteryIntent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
                level * 100 / scale
            } ?: -1
        }
        return batteryLevel.toLong()
    }

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        BatteryApi.setUp(flutterEngine.dartExecutor.binaryMessenger, this)
    }
}

6. iOS設定

xcodeは設定が独特でして、手動で設定しないとファイル存在するけど認識してくれないので自動生成されたファイルを読み込めるように設定してください。

ファイルの再追加:

Runnerグループを右クリック
"Add Files to 'Runner'..."を選択
ios/Runner/BatteryApi.swiftを探して選択
以下のオプションを確認:

✓ "Copy items if needed"
✓ "Create groups"
✓ Add to targets: Runnerを選択



  1. ios/Runner/Info.plistに権限を追加:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Need BLE permission for device scanning</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>Need BLE permission for device scanning</string>
  1. AppDelegate.swiftの実装:
import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate, BatteryApi {
    func getBatteryLevel() -> Int64 {
        UIDevice.current.isBatteryMonitoringEnabled = true
        let batteryLevel = UIDevice.current.batteryLevel
        UIDevice.current.isBatteryMonitoringEnabled = false
        
        if batteryLevel < 0 {
            return -1
        }
        
        return Int64(batteryLevel * 100)
    }
    
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        BatteryApiSetup.setUp(binaryMessenger: window?.rootViewController as! FlutterBinaryMessenger, api: self)
        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}

7. ビルドと実行手順

  1. Makefileの作成(オプション):
.PHONY: setup
setup:
	@flutter clean
	@flutter pub get

.PHONY: pigeon
pigeon:
	@dart run pigeon --input pigeons/api.dart
  1. コマンド実行:
# プロジェクトのセットアップ
make setup

# Pigeonコードの生成
make pigeon

Flutterのソースコードはこちら

全体のコード
import 'package:flutter/material.dart';
import 'package:pigeon_demo/api.g.dart';
import 'dart:math' as math;

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

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Battery Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        useMaterial3: true,
      ),
      home: const BatteryPage(),
    );
  }
}

class BatteryPainter extends CustomPainter {
  final double batteryLevel;
  final Color batteryColor;
  final double animationValue;

  BatteryPainter({
    required this.batteryLevel,
    required this.batteryColor,
    required this.animationValue,
  });

  
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.grey[300]!
      ..style = PaintingStyle.stroke
      ..strokeWidth = 3.0;

    // バッテリー本体の描画
    final RRect batteryBody = RRect.fromRectAndRadius(
      Rect.fromLTWH(0, size.height * 0.1, size.width * 0.9, size.height * 0.8),
      Radius.circular(10),
    );
    canvas.drawRRect(batteryBody, paint);

    // バッテリー端子の描画
    final terminalPath = Path()
      ..addRect(Rect.fromLTWH(
        size.width * 0.9,
        size.height * 0.35,
        size.width * 0.1,
        size.height * 0.3,
      ));
    canvas.drawPath(terminalPath, paint);

    // バッテリー残量の描画
    final levelPaint = Paint()
      ..color = batteryColor.withOpacity(0.8)
      ..style = PaintingStyle.fill;

    final levelWidth = (size.width * 0.85) * (batteryLevel / 100) * animationValue;
    final levelRect = RRect.fromRectAndRadius(
      Rect.fromLTWH(5, size.height * 0.15, levelWidth, size.height * 0.7),
      Radius.circular(7),
    );
    canvas.drawRRect(levelRect, levelPaint);

    // パーセント表示
    final textPainter = TextPainter(
      text: TextSpan(
        text: '${(batteryLevel * animationValue).toInt()}%',
        style: TextStyle(
          color: Colors.black,
          fontSize: size.height * 0.3,
          fontWeight: FontWeight.bold,
        ),
      ),
      textDirection: TextDirection.ltr,
    );
    textPainter.layout();
    textPainter.paint(
      canvas,
      Offset(
        (size.width - textPainter.width) / 2,
        (size.height - textPainter.height) / 2,
      ),
    );
  }

  
  bool shouldRepaint(BatteryPainter oldDelegate) {
    return oldDelegate.batteryLevel != batteryLevel ||
        oldDelegate.animationValue != animationValue;
  }
}

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

  
  State<BatteryPage> createState() => _BatteryPageState();
}

class _BatteryPageState extends State<BatteryPage>
    with SingleTickerProviderStateMixin {
  final _api = BatteryApi();
  double _batteryLevel = 0.0;
  late AnimationController _controller;
  late Animation<double> _animation;

  
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 1500),
      vsync: this,
    );
    _animation = CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    );
    _getBatteryLevel();
  }

  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Future<void> _getBatteryLevel() async {
    try {
      final batteryLevel = await _api.getBatteryLevel();
      setState(() {
        _batteryLevel = batteryLevel.toDouble();
        _controller.forward(from: 0);
      });
    } catch (e) {
      setState(() {
        _batteryLevel = 0;
      });
    }
  }

  Color _getBatteryColor(double level) {
    if (level > 60) return Colors.green;
    if (level > 20) return Colors.orange;
    return Colors.red;
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Battery Level'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            AnimatedBuilder(
              animation: _animation,
              builder: (context, child) {
                return Container(
                  width: 200,
                  height: 100,
                  child: CustomPaint(
                    painter: BatteryPainter(
                      batteryLevel: _batteryLevel,
                      batteryColor: _getBatteryColor(_batteryLevel),
                      animationValue: _animation.value,
                    ),
                  ),
                );
              },
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: _getBatteryLevel,
              child: const Text('更新'),
            ),
          ],
        ),
      ),
    );
  }
}

注意点

  • iOS実機でのテスト時は、適切な署名が必要です
  • AndroidのnamespaceとPigeonのpackage名が一致している必要があります
  • 生成されたコードは手動で修正しないでください
  • 実機テストを推奨します(特にiOS)

トラブルシューティング

  • ビルドエラーが発生した場合は、flutter cleanを実行してから再度ビルドしてください
  • コードが生成されない場合は、pigeonコマンドを再実行してください
  • パッケージ名の不一致エラーが出た場合は、すべての設定ファイルで同じパッケージ名を使用しているか確認してください

感想

簡単になると聞いていたのだが、普通のMethodChannelより詰まった💦
これは本当に便利なのだろうか?

ご興味ある方はお試しあれ。

Discussion