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
MethodChanneldi 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.