Platform Channel in Flutter: chiamare codice nativo Android e iOS

Foto di Silvio Wiggelinghoff su Unsplash

GuideAvanzato55 min Flutter 3.x

Platform Channel in Flutter: chiamare codice nativo Android e iOS

Prima o poi ogni progetto Flutter incontra un muro: una funzionalità che il framework non espone e per cui non esiste un plugin affidabile su pub.dev. Livello della batteria, impostazioni di sistema, un SDK di terze parti distribuito solo come libreria nativa, un sensore proprietario.

La risposta di Flutter sono i platform channel: un ponte asincrono e bidirezionale tra il codice Dart e il codice nativo (Kotlin/Java su Android, Swift/Objective-C su iOS). I messaggi viaggiano serializzati sul binary messenger dell'engine, quindi ogni chiamata è asincrona per definizione.

In questo tutorial costruiamo un piccolo bridge DeviceBridge che:

  • legge il livello della batteria tramite MethodChannel;
  • riceve gli aggiornamenti di stato di carica in streaming tramite EventChannel;
  • gestisce correttamente PlatformException, MissingPluginException e il lavoro su thread secondari;
  • è testabile senza dispositivo grazie al mock del binary messenger;
  • può evolvere verso Pigeon per generare codice type-safe ed eliminare le stringhe magiche.

Prerequisiti: Flutter 3.x, Android Studio con toolchain Android e (per la parte iOS) Xcode su macOS. Serve dimestichezza con Kotlin e Swift di base.

  1. 1

    Progettare il contratto e creare il wrapper Dart

    Il primo errore da evitare è spargere MethodChannel in giro per l'app. Il canale è un dettaglio infrastrutturale: va incapsulato in una classe di dominio che espone metodi Dart tipizzati e traduce gli errori nativi in eccezioni applicative.

    Regole per il contratto:

    1. Nome del canale univoco e con namespace: usa il tuo dominio inverso, ad esempio dev.miosito.app/device. Due canali con lo stesso nome (magari introdotti da un plugin) si sovrascrivono a vicenda.
    2. Payload solo con tipi supportati dal StandardMessageCodec: null, bool, int, double, String, Uint8List, Int32List, Int64List, Float64List, List e Map. Niente oggetti custom: serializzali in Map<String, Object?>.
    3. Argomenti sempre come mappa, anche se oggi ne passi uno solo: aggiungere un parametro domani non romperà il codice nativo.

    Creiamo lib/platform/device_bridge.dart con il canale, un metodo getBatteryLevel() e una eccezione di dominio.

    import 'package:flutter/services.dart';
    
    /// Errore di dominio: nasconde al resto dell'app i dettagli del canale.
    class DeviceBridgeException implements Exception {
      const DeviceBridgeException(this.code, this.message);
    
      final String code;
      final String message;
    
      @override
      String toString() => 'DeviceBridgeException($code): $message';
    }
    
    class DeviceBridge {
      DeviceBridge({MethodChannel? channel})
          : _channel = channel ?? const MethodChannel(_channelName);
    
      static const String _channelName = 'dev.miosito.app/device';
    
      final MethodChannel _channel;
    
      /// Ritorna il livello batteria in percentuale (0-100).
      Future<int> getBatteryLevel() async {
        try {
          final int? level = await _channel.invokeMethod<int>('getBatteryLevel');
          if (level == null) {
            throw const DeviceBridgeException(
              'NULL_RESULT',
              'Il lato nativo ha restituito null.',
            );
          }
          return level;
        } on PlatformException catch (e) {
          throw DeviceBridgeException(e.code, e.message ?? 'Errore nativo');
        } on MissingPluginException {
          // Succede su piattaforme dove non abbiamo implementato il canale
          // (web, desktop) oppure dopo un hot restart senza rebuild nativo.
          throw const DeviceBridgeException(
            'UNSUPPORTED_PLATFORM',
            'Funzionalità non disponibile su questa piattaforma.',
          );
        }
      }
    
      /// Esempio di chiamata con argomenti tipizzati.
      Future<void> setKeepScreenOn({required bool enabled}) async {
        await _channel.invokeMethod<void>('setKeepScreenOn', <String, Object?>{
          'enabled': enabled,
        });
      }
    }

    Risultato atteso

    Il progetto compila e il resto dell'app dipende solo da `DeviceBridge`, mai da `MethodChannel`. Le chiamate lanciano ancora `UNSUPPORTED_PLATFORM` perché il lato nativo non esiste.

  2. 2

    Implementare il lato Android in Kotlin

    Apri la cartella android/ come progetto in Android Studio (così hai autocompletamento e analisi Kotlin) e modifica MainActivity.kt.

    Punti chiave:

    • Registra l'handler dentro configureFlutterEngine, non in onCreate: è il momento in cui il binaryMessenger è disponibile.
    • L'handler viene invocato sul main thread di Android. Se l'operazione è lenta (I/O, rete, crittografia) spostala su un thread secondario e rispondi sempre sul main thread, altrimenti l'app può crashare in modo non deterministico.
    • Usa result.error(code, message, details) per gli errori e result.notImplemented() per i metodi sconosciuti: quest'ultimo produce una MissingPluginException lato Dart, utile per capire subito un disallineamento di contratto.
    • Ricorda flutterEngine.dartExecutor.binaryMessenger come messenger.

    Se il tuo canale serve più feature, estrai una classe dedicata (es. DevicePlugin) invece di gonfiare MainActivity.

    package dev.miosito.app
    
    import android.content.Context
    import android.content.ContextWrapper
    import android.os.BatteryManager
    import android.os.Build
    import android.view.WindowManager
    import io.flutter.embedding.android.FlutterActivity
    import io.flutter.embedding.engine.FlutterEngine
    import io.flutter.plugin.common.MethodCall
    import io.flutter.plugin.common.MethodChannel
    import kotlinx.coroutines.CoroutineScope
    import kotlinx.coroutines.Dispatchers
    import kotlinx.coroutines.launch
    import kotlinx.coroutines.withContext
    
    class MainActivity : FlutterActivity() {
    
        private val scope = CoroutineScope(Dispatchers.Main)
    
        override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
            super.configureFlutterEngine(flutterEngine)
    
            MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
                .setMethodCallHandler { call, result -> handle(call, result) }
        }
    
        private fun handle(call: MethodCall, result: MethodChannel.Result) {
            when (call.method) {
                "getBatteryLevel" -> scope.launch {
                    // Lavoro potenzialmente lento fuori dal main thread...
                    val level = withContext(Dispatchers.Default) { readBatteryLevel() }
                    // ...ma risposta SEMPRE sul main thread.
                    if (level >= 0) {
                        result.success(level)
                    } else {
                        result.error(
                            "BATTERY_UNAVAILABLE",
                            "Impossibile leggere il livello della batteria",
                            null,
                        )
                    }
                }
    
                "setKeepScreenOn" -> {
                    val enabled = call.argument<Boolean>("enabled")
                    if (enabled == null) {
                        result.error("BAD_ARGS", "Parametro 'enabled' mancante", null)
                        return
                    }
                    if (enabled) {
                        window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
                    } else {
                        window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
                    }
                    result.success(null)
                }
    
                else -> result.notImplemented()
            }
        }
    
        private fun readBatteryLevel(): Int {
            val manager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
            return manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
        }
    
        companion object {
            private const val CHANNEL = "dev.miosito.app/device"
        }
    }

    Risultato atteso

    Su un dispositivo o emulatore Android, `DeviceBridge().getBatteryLevel()` restituisce un intero tra 0 e 100. Nota: dopo aver toccato codice nativo serve un **full restart** (`flutter run`), l'hot reload non ricompila Kotlin.

  3. 3

    Implementare il lato iOS in Swift

    Apri ios/Runner.xcworkspace con Xcode e modifica AppDelegate.swift.

    Differenze rispetto ad Android:

    • Il canale si aggancia al FlutterViewController ottenuto da window?.rootViewController.
    • Il codice degli handler gira sul main thread (UI thread); vale la stessa regola: lavoro pesante su una DispatchQueue in background, result(...) di ritorno su DispatchQueue.main.
    • Per gli errori si usa FlutterError(code:message:details:); per i metodi sconosciuti FlutterMethodNotImplemented.
    • I tipi Dart mappano su tipi Foundation: intNSNumber, Map[String: Any]. Fai sempre il cast difensivo degli argomenti.

    Nel nostro esempio il livello batteria richiede di abilitare isBatteryMonitoringEnabled; su simulatore può restituire -1, motivo per cui gestiamo esplicitamente il caso di errore.

    import UIKit
    import Flutter
    
    @main
    @objc class AppDelegate: FlutterAppDelegate {
    
      private let channelName = "dev.miosito.app/device"
    
      override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
      ) -> Bool {
        let controller = window?.rootViewController as! FlutterViewController
        let channel = FlutterMethodChannel(
          name: channelName,
          binaryMessenger: controller.binaryMessenger
        )
    
        channel.setMethodCallHandler { [weak self] call, result in
          guard let self = self else { return }
          switch call.method {
          case "getBatteryLevel":
            self.handleBatteryLevel(result: result)
    
          case "setKeepScreenOn":
            guard let args = call.arguments as? [String: Any],
                  let enabled = args["enabled"] as? Bool else {
              result(FlutterError(code: "BAD_ARGS",
                                  message: "Parametro 'enabled' mancante",
                                  details: nil))
              return
            }
            UIApplication.shared.isIdleTimerDisabled = enabled
            result(nil)
    
          default:
            result(FlutterMethodNotImplemented)
          }
        }
    
        GeneratedPluginRegistrant.register(with: self)
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
      }
    
      private func handleBatteryLevel(result: @escaping FlutterResult) {
        let device = UIDevice.current
        device.isBatteryMonitoringEnabled = true
        let level = device.batteryLevel
        if level < 0 {
          result(FlutterError(code: "BATTERY_UNAVAILABLE",
                              message: "Livello batteria non disponibile (simulatore?)",
                              details: nil))
        } else {
          result(Int(level * 100))
        }
      }
    }

    Risultato atteso

    Su dispositivo iOS reale la percentuale viene restituita correttamente; su simulatore ottieni un `DeviceBridgeException('BATTERY_UNAVAILABLE', ...)` gestito in modo pulito dall'app.

  4. 4

    Streaming di eventi nativi con EventChannel

    MethodChannel è request/response. Quando il nativo deve spingere dati verso Dart in modo continuo (sensori, stato batteria, connessioni BLE, progressi di un download nativo) si usa EventChannel, che espone lato Dart uno Stream.

    Il ciclo di vita è importante: onListen viene chiamato quando Dart si iscrive, onCancel quando annulla la sottoscrizione. Registra le risorse native in onListen e liberale in onCancel, altrimenti i broadcast receiver o gli osservatori restano attivi e consumano batteria.

    Aggiungiamo al wrapper Dart lo stream e implementiamolo su Android con un BroadcastReceiver.

    // --- Dart: aggiunta a DeviceBridge -----------------------------------
    static const EventChannel _chargingChannel =
        EventChannel('dev.miosito.app/device/charging');
    
    /// Emette true quando il dispositivo è in carica.
    Stream<bool> get chargingStatus => _chargingChannel
        .receiveBroadcastStream()
        .map((dynamic event) => event as bool)
        .handleError((Object error) {
          final e = error as PlatformException;
          throw DeviceBridgeException(e.code, e.message ?? 'Errore stream');
        });
    
    // Uso nella UI:
    // StreamBuilder<bool>(
    //   stream: bridge.chargingStatus,
    //   builder: (context, snapshot) => Text(snapshot.data == true ? 'In carica' : 'A batteria'),
    // );

    Risultato atteso

    Il wrapper Dart espone `chargingStatus`. Manca ancora l'implementazione nativa dell'EventChannel.

  5. 5

    Implementare lo StreamHandler nativo (Android)

    Sul lato Android l'EventChannel richiede un EventChannel.StreamHandler. Nel nostro caso registriamo un BroadcastReceiver sull'action ACTION_POWER_CONNECTED/ACTION_POWER_DISCONNECTED e inoltriamo l'evento con events.success(...).

    Attenzione a due dettagli spesso trascurati:

    1. EventSink non è thread-safe: se generi eventi da un thread di background, inoltrali al main thread con un Handler(Looper.getMainLooper()).
    2. Deregistra il receiver in onCancel e gestisci il caso di doppia cancellazione (imposta il riferimento a null).

    Lo stesso pattern su iOS si ottiene implementando il protocollo FlutterStreamHandler con i metodi onListen(withArguments:eventSink:) e onCancel(withArguments:), tipicamente osservando UIDevice.batteryStateDidChangeNotification.

    package dev.miosito.app
    
    import android.content.BroadcastReceiver
    import android.content.Context
    import android.content.Intent
    import android.content.IntentFilter
    import android.os.Handler
    import android.os.Looper
    import io.flutter.plugin.common.EventChannel
    
    class ChargingStreamHandler(
        private val context: Context,
    ) : EventChannel.StreamHandler {
    
        private var receiver: BroadcastReceiver? = null
        private val mainHandler = Handler(Looper.getMainLooper())
    
        override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
            if (events == null) return
    
            val broadcastReceiver = object : BroadcastReceiver() {
                override fun onReceive(ctx: Context?, intent: Intent?) {
                    val charging = intent?.action == Intent.ACTION_POWER_CONNECTED
                    // EventSink va usato sul main thread.
                    mainHandler.post { events.success(charging) }
                }
            }
    
            val filter = IntentFilter().apply {
                addAction(Intent.ACTION_POWER_CONNECTED)
                addAction(Intent.ACTION_POWER_DISCONNECTED)
            }
            context.registerReceiver(broadcastReceiver, filter)
            receiver = broadcastReceiver
        }
    
        override fun onCancel(arguments: Any?) {
            receiver?.let { context.unregisterReceiver(it) }
            receiver = null
        }
    }
    
    // In MainActivity.configureFlutterEngine:
    // EventChannel(flutterEngine.dartExecutor.binaryMessenger, "dev.miosito.app/device/charging")
    //     .setStreamHandler(ChargingStreamHandler(applicationContext))

    Risultato atteso

    Collegando e scollegando il cavo (o cambiando lo stato di carica dell'emulatore) lo `StreamBuilder` aggiorna la UI in tempo reale. Uscendo dalla schermata il receiver viene deregistrato senza leak.

  6. 6

    Testare i platform channel senza dispositivo

    Il codice nativo non è raggiungibile dai test Dart, ma il binary messenger è sostituibile: possiamo intercettare le chiamate del canale e restituire risposte finte. È il modo corretto per testare il wrapper, la mappatura degli errori e la logica di dominio che ne dipende.

    Da Flutter 3.x l'API raccomandata è TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(...) (le vecchie MethodChannel.setMockMethodCallHandler sono deprecate).

    Ricorda sempre di azzerare l'handler in tearDown, altrimenti i mock si propagano tra i test.

    import 'package:flutter/services.dart';
    import 'package:flutter_test/flutter_test.dart';
    import 'package:mia_app/platform/device_bridge.dart';
    
    void main() {
      TestWidgetsFlutterBinding.ensureInitialized();
    
      const channel = MethodChannel('dev.miosito.app/device');
      final messenger =
          TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
      final bridge = DeviceBridge();
    
      tearDown(() {
        messenger.setMockMethodCallHandler(channel, null);
      });
    
      test('getBatteryLevel restituisce il valore nativo', () async {
        final calls = <MethodCall>[];
        messenger.setMockMethodCallHandler(channel, (call) async {
          calls.add(call);
          return 73;
        });
    
        expect(await bridge.getBatteryLevel(), 73);
        expect(calls.single.method, 'getBatteryLevel');
      });
    
      test('PlatformException viene tradotta in DeviceBridgeException', () async {
        messenger.setMockMethodCallHandler(channel, (call) async {
          throw PlatformException(
            code: 'BATTERY_UNAVAILABLE',
            message: 'non disponibile',
          );
        });
    
        expect(
          () => bridge.getBatteryLevel(),
          throwsA(isA<DeviceBridgeException>()
              .having((e) => e.code, 'code', 'BATTERY_UNAVAILABLE')),
        );
      });
    
      test('metodo non implementato => UNSUPPORTED_PLATFORM', () async {
        // Nessun handler registrato: il canale lancia MissingPluginException.
        expect(
          () => bridge.getBatteryLevel(),
          throwsA(isA<DeviceBridgeException>()
              .having((e) => e.code, 'code', 'UNSUPPORTED_PLATFORM')),
        );
      });
    }

    Risultato atteso

    `flutter test` passa i tre test in pochi secondi, su qualsiasi macchina, senza emulatori: il contratto del canale è ora protetto da regressioni.

  7. 7

    Eliminare le stringhe magiche con Pigeon

    I MethodChannel scritti a mano hanno tre punti deboli: nomi dei metodi come stringhe, argomenti non tipizzati e nessun controllo a compile-time tra Dart e nativo. Pigeon risolve tutto generando l'interfaccia Dart, Kotlin e Swift a partire da un unico file di definizione.

    Passi operativi:

    1. dev_dependencies: pigeon: ^22.0.0 in pubspec.yaml.
    2. Crea pigeons/device_api.dart con la definizione (vedi codice).
    3. Esegui la generazione:
      dart run pigeon --input pigeons/device_api.dart
    4. Implementa in Kotlin DeviceApi (interfaccia generata) e registrala con DeviceApi.setUp(binaryMessenger, MyDeviceApi()); in Swift DeviceApiSetup.setUp(binaryMessenger:api:).

    Da quel momento un rename di un metodo rompe la build nativa invece di fallire a runtime. Nota: Pigeon copre chiamate request/response (in entrambe le direzioni con @FlutterApi), mentre per lo streaming continuo resta valido EventChannel (o gli event channel generati nelle versioni recenti di Pigeon).

    Checklist finale di produzione

    • Ogni metodo nativo risponde esattamente una volta (result.success/result.error): rispondere due volte fa crashare l'engine.
    • Non bloccare mai il main thread nativo.
    • Se il canale serve fuori dalla UI (background isolate, FlutterEngineGroup), usa un FlutterEngine dedicato con il proprio messenger.
    • Se la funzionalità è riusabile, impacchettala in un federated plugin (flutter create --template=plugin) invece di lasciarla in MainActivity/AppDelegate.
    // pigeons/device_api.dart (NON viene compilato nell'app)
    import 'package:pigeon/pigeon.dart';
    
    @ConfigurePigeon(PigeonOptions(
      dartOut: 'lib/platform/device_api.g.dart',
      kotlinOut:
          'android/app/src/main/kotlin/dev/miosito/app/DeviceApi.g.kt',
      kotlinOptions: KotlinOptions(package: 'dev.miosito.app'),
      swiftOut: 'ios/Runner/DeviceApi.g.swift',
      dartPackageName: 'mia_app',
    ))
    class BatteryInfo {
      BatteryInfo({required this.level, required this.isCharging});
      final int level;
      final bool isCharging;
    }
    
    @HostApi()
    abstract class DeviceApi {
      BatteryInfo getBatteryInfo();
      void setKeepScreenOn(bool enabled);
    }
    
    // Uso lato Dart dopo la generazione:
    // final api = DeviceApi();
    // final info = await api.getBatteryInfo(); // tipizzato, niente stringhe

    Risultato atteso

    Dopo `dart run pigeon` trovi i file generati in Dart, Kotlin e Swift: le chiamate native diventano type-safe e gli errori di contratto emergono a compile-time invece che in produzione.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!