Il problema dei MethodChannel scritti a mano

Chiunque abbia integrato codice nativo in Flutter conosce la trafila: si apre un MethodChannel, si sceglie un nome di canale, si inventano delle stringhe per i metodi e si scambiano Map<String, dynamic> tra Dart e la piattaforma. Funziona, ma il compilatore non ti aiuta in nessun punto della catena:

// Approccio classico: tutto è dynamic, tutto può rompersi a runtime
final channel = MethodChannel('com.example/battery');
final result = await channel.invokeMethod('getBatteryInfo');
final level = result['level'] as int; // e se il nativo manda 'batteryLevel'?

Basta un refactor lato Kotlin, un typo in una chiave o un campo che diventa null e l'app crasha in produzione. Pigeon, il generatore di codice mantenuto dal team Flutter, risolve il problema alla radice: descrivi l'interfaccia una sola volta in un file Dart, lanci un comando e ottieni il codice di serializzazione per Dart, Kotlin, Swift (e opzionalmente C++, Objective-C, Java, GObject). Niente stringhe magiche, niente cast, errori di compilazione invece che crash a runtime.

Installazione e primo setup

Pigeon è un tool di sviluppo: non finisce nel bundle dell'app, quindi va in dev_dependencies.

dev_dependencies:
  pigeon: ^22.7.0

Crea una cartella pigeons/ nella root del progetto (per convenzione fuori da lib/, così il file di definizione non viene compilato nell'app) e aggiungi pigeons/messages.dart:

import 'package:pigeon/pigeon.dart';

@ConfigurePigeon(PigeonOptions(
  dartOut: 'lib/src/platform/messages.g.dart',
  dartTestOut: 'test/platform/test_messages.g.dart',
  kotlinOut:
      'android/app/src/main/kotlin/com/example/myapp/Messages.g.kt',
  kotlinOptions: KotlinOptions(package: 'com.example.myapp'),
  swiftOut: 'ios/Runner/Messages.g.swift',
  dartPackageName: 'myapp',
))
library;

Il blocco @ConfigurePigeon evita di passare decine di flag da riga di comando: la configurazione vive nel repository ed è riproducibile da chiunque.

Definire i modelli e le API

Nello stesso file descrivi i tipi di dato e le interfacce. Le classi di Pigeon sono solo contenitori di campi: niente costruttori custom, niente metodi, niente logica.

enum ChargingState { charging, discharging, full, unknown }

class BatteryInfo {
  BatteryInfo({
    required this.level,
    required this.state,
    this.temperatureCelsius,
  });

  int level;
  ChargingState state;
  double? temperatureCelsius;
}

class DeviceQuery {
  DeviceQuery({required this.includeTemperature, this.tags});

  bool includeTemperature;
  List<String>? tags;
}

Ora le due interfacce fondamentali:

/// Metodi implementati dalla piattaforma e chiamati da Dart.
@HostApi()
abstract class BatteryHostApi {
  BatteryInfo getBatteryInfo(DeviceQuery query);

  /// `@async` genera una callback lato nativo: utile per operazioni
  /// che non possono rispondere in modo sincrono.
  @async
  bool requestIgnoreBatteryOptimizations();

  void setLowPowerThreshold(int percentage);
}

/// Metodi implementati in Dart e chiamati dalla piattaforma.
@FlutterApi()
abstract class BatteryFlutterApi {
  void onChargingStateChanged(ChargingState state);
  void onCriticalLevel(int level);
}

La distinzione è il cuore di Pigeon:

  • @HostApi → Flutter chiama il nativo (Dart ottiene una classe concreta, il nativo un'interfaccia da implementare).
  • @FlutterApi → il nativo chiama Flutter (Dart ottiene una classe astratta da implementare, il nativo una classe concreta da invocare).

Generare il codice

dart run pigeon --input pigeons/messages.dart

Il comando produce i file indicati in PigeonOptions. I file generati vanno committati: il codice nativo li referenzia direttamente e la build Gradle/Xcode non esegue Pigeon. Conviene aggiungere uno script al progetto:

# tool/generate_pigeons.sh
#!/usr/bin/env bash
set -e
for file in pigeons/*.dart; do
  dart run pigeon --input "$file"
done
dart format lib/src/platform test/platform

Se usi analysis_options severo, escludi i generati:

analyzer:
  exclude:
    - "**/*.g.dart"

Implementazione su Android (Kotlin)

Pigeon genera l'interfaccia BatteryHostApi e la funzione statica setUp. Si implementa così:

import android.content.Context
import android.os.BatteryManager

class BatteryHostApiImpl(private val context: Context) : BatteryHostApi {

  override fun getBatteryInfo(query: DeviceQuery): BatteryInfo {
    val manager = context.getSystemService(Context.BATTERY_SERVICE)
        as BatteryManager
    val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)

    if (level < 0) {
      // Diventa una PlatformException lato Dart
      throw FlutterError(
        code = "battery_unavailable",
        message = "Livello batteria non disponibile",
        details = null,
      )
    }

    return BatteryInfo(
      level = level.toLong(),
      state = ChargingState.CHARGING,
      temperatureCelsius = if (query.includeTemperature) 31.5 else null,
    )
  }

  override fun requestIgnoreBatteryOptimizations(
    callback: (Result<Boolean>) -> Unit
  ) {
    // operazione asincrona: la callback può essere invocata più tardi
    callback(Result.success(true))
  }

  override fun setLowPowerThreshold(percentage: Long) {
    // ...
  }
}

Nota un dettaglio importante: gli int di Dart diventano Long in Kotlin e Int64 in Swift, perché il protocollo di trasporto usa interi a 64 bit.

La registrazione va fatta dove hai accesso al BinaryMessenger:

class MainActivity : FlutterActivity() {
  private var flutterApi: BatteryFlutterApi? = null

  override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)
    val messenger = flutterEngine.dartExecutor.binaryMessenger

    BatteryHostApi.setUp(messenger, BatteryHostApiImpl(this))
    flutterApi = BatteryFlutterApi(messenger)
  }

  private fun notifyCharging(state: ChargingState) {
    // chiamata dal nativo verso Dart
    flutterApi?.onChargingStateChanged(state) { }
  }
}

Implementazione su iOS (Swift)

import Flutter
import UIKit

class BatteryHostApiImpl: BatteryHostApi {

  func getBatteryInfo(query: DeviceQuery) throws -> BatteryInfo {
    UIDevice.current.isBatteryMonitoringEnabled = true
    let level = UIDevice.current.batteryLevel

    guard level >= 0 else {
      throw PigeonError(
        code: "battery_unavailable",
        message: "Livello batteria non disponibile",
        details: nil
      )
    }

    return BatteryInfo(
      level: Int64(level * 100),
      state: UIDevice.current.batteryState == .charging
        ? .charging : .discharging,
      temperatureCelsius: query.includeTemperature ? 31.5 : nil
    )
  }

  func requestIgnoreBatteryOptimizations(
    completion: @escaping (Result<Bool, Error>) -> Void
  ) {
    completion(.success(false)) // non esiste su iOS
  }

  func setLowPowerThreshold(percentage: Int64) throws {}
}

Registrazione nell'AppDelegate:

@main
@objc class AppDelegate: FlutterAppDelegate {
  var flutterApi: BatteryFlutterApi?

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions:
      [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller = window?.rootViewController as! FlutterViewController
    BatteryHostApiSetup.setUp(
      binaryMessenger: controller.binaryMessenger,
      api: BatteryHostApiImpl()
    )
    flutterApi = BatteryFlutterApi(binaryMessenger: controller.binaryMessenger)

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

Usare l'API dal lato Dart

Il codice generato è completamente tipizzato: niente dynamic, niente chiavi stringa.

import 'package:myapp/src/platform/messages.g.dart';

class BatteryRepository implements BatteryFlutterApi {
  BatteryRepository() {
    // registra l'implementazione Dart chiamata dal nativo
    BatteryFlutterApi.setUp(this);
  }

  final _host = BatteryHostApi();
  final _stateController = StreamController<ChargingState>.broadcast();

  Stream<ChargingState> get chargingState => _stateController.stream;

  Future<BatteryInfo> load() async {
    try {
      return await _host.getBatteryInfo(
        DeviceQuery(includeTemperature: true, tags: const ['ui']),
      );
    } on PlatformException catch (e) {
      // il FlutterError/PigeonError nativo arriva qui
      throw BatteryUnavailableException(e.message);
    }
  }

  @override
  void onChargingStateChanged(ChargingState state) =>
      _stateController.add(state);

  @override
  void onCriticalLevel(int level) {
    // mostra un avviso
  }
}

Se domani aggiungi un campo a BatteryInfo e rigeneri, Kotlin e Swift non compilano finché non lo gestisci. È esattamente la rete di sicurezza che manca ai MethodChannel manuali.

Stream nativi con @EventChannelApi

Per flussi continui di eventi (posizione, sensori, stato batteria) le @FlutterApi funzionano, ma dalle versioni recenti Pigeon offre un'annotazione dedicata che genera un vero EventChannel tipizzato:

@EventChannelApi()
abstract class BatteryEventApi {
  ChargingState streamChargingState();
  int streamLevel();
}

Lato Dart ottieni funzioni che restituiscono Stream<ChargingState> e Stream<int>; lato nativo devi fornire uno StreamHandler generato:

final sub = streamChargingState().listen((state) {
  debugPrint('Nuovo stato: $state');
});
class ChargingStateHandler : StreamChargingStateStreamHandler() {
  override fun onListen(p0: Any?, sink: PigeonEventSink<ChargingState>) {
    sink.success(ChargingState.CHARGING)
    // sink.endOfStream() quando il flusso termina
  }
}

// registrazione
BatteryEventApi.register(messenger, ChargingStateHandler(), "streamChargingState")

È una feature relativamente nuova: verifica il changelog della versione che usi, perché le firme generate sono cambiate tra una release e l'altra.

Testare senza dispositivo

L'opzione dartTestOut genera, per ogni @HostApi, una classe TestBatteryHostApi che puoi estendere nei test: intercetta i messaggi binari e risponde senza bisogno di codice nativo.

import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/src/platform/messages.g.dart';
import 'package:myapp/test/platform/test_messages.g.dart';

class FakeBatteryHost extends TestBatteryHostApi {
  @override
  BatteryInfo getBatteryInfo(DeviceQuery query) => BatteryInfo(
        level: 42,
        state: ChargingState.discharging,
        temperatureCelsius: query.includeTemperature ? 30 : null,
      );

  @override
  Future<bool> requestIgnoreBatteryOptimizations() async => true;

  @override
  void setLowPowerThreshold(int percentage) {}
}

void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  setUp(() => TestBatteryHostApi.setUp(FakeBatteryHost()));

  test('legge il livello dalla piattaforma', () async {
    final info = await BatteryRepository().load();
    expect(info.level, 42);
    expect(info.state, ChargingState.discharging);
  });
}

Così la logica Dart che dipende dalla piattaforma è coperta da unit test veloci, senza emulatori.

Dettagli che fanno la differenza

Più istanze dello stesso canale. Se la tua API deve servire più oggetti nativi (per esempio una API per ogni connessione), usa messageChannelSuffix:

@HostApi(dartHostTestHandler: 'TestBatteryHostApi')
abstract class BatteryHostApi { /* ... */ }

e lato Dart BatteryHostApi(messageChannelSuffix: sessionId), passando lo stesso suffisso al setUp nativo.

Tipi supportati. bool, int, double, String, Uint8List, Int32List, Float64List, List, Map, Object, enum e le classi Pigeon. Nessun DateTime, nessun Duration: serializzali come int (millisecondi) o String ISO-8601.

Nullability. Pigeon rispetta la null safety su tutti i linguaggi: un campo String? diventa opzionale anche in Kotlin e Swift. È uno dei vantaggi più sottovalutati rispetto alle mappe manuali.

Thread. Le @HostApi sincrone vengono eseguite sul thread della piattaforma (UI thread su Android). Se l'operazione è costosa usa @async e sposta il lavoro su un executor o su una DispatchQueue in background, invocando la callback al termine.

Plugin federati. Se stai scrivendo un package, Pigeon è perfetto per l'implementazione di piattaforma: imposta dartOut dentro lib/src/ del package e tieni le classi generate private, esponendo una API pubblica scritta a mano.

Quando non serve

Pigeon risolve la comunicazione a messaggi. Non sostituisce:

  • Dart FFI, più adatto a chiamate sincrone verso librerie C/C++ ad alta frequenza;
  • le Platform View, quando devi incorporare una view nativa nell'albero dei widget;
  • i casi banali con una sola chiamata senza parametri, dove un MethodChannel di cinque righe è ancora accettabile (anche se la coerenza del progetto spesso vale più della brevità).

Conclusione

Pigeon trasforma il punto più fragile di un'app Flutter — il confine con il codice nativo — in un contratto verificato dal compilatore su tre linguaggi. Il costo di adozione è una cartella pigeons/, uno script di generazione e l'abitudine a rigenerare dopo ogni modifica; il ritorno è l'eliminazione di un'intera categoria di bug runtime, più test unitari che girano senza dispositivo. Se hai un'app con integrazioni native non banali, migrare un canale alla volta è un investimento che si ripaga al primo refactor.