Creare un plugin Flutter federato multipiattaforma e pubblicarlo su pub.dev

Foto di Pankaj Patel su Unsplash

GuideAvanzato75 min Flutter 3.x (3.22+)

Creare un plugin Flutter federato multipiattaforma e pubblicarlo su pub.dev

Quando un plugin Flutter deve funzionare su Android, iOS, Web e desktop, mettere tutto dentro un singolo package diventa rapidamente ingestibile: ogni nuova piattaforma obbliga a toccare il codice condiviso e chi mantiene una singola piattaforma deve avere i permessi sull'intero repository.

La risposta ufficiale del team Flutter è la federated plugin architecture: il plugin viene spezzato in più package con responsabilità separate.

  • app-facing package: l'API pubblica che importano gli sviluppatori (device_battery).
  • platform interface package: il contratto astratto che tutte le implementazioni devono rispettare (device_battery_platform_interface).
  • platform implementation packages: una per piattaforma (device_battery_android, device_battery_ios, device_battery_web, ...).

In questo tutorial costruiremo passo passo un plugin federato realistico che legge il livello della batteria e notifica i cambi di stato, con codice Kotlin, Swift e JS interop, test con mock della platform interface e pubblicazione su pub.dev.

Prerequisiti: buona conoscenza di Dart, dei MethodChannel/EventChannel, di Gradle e di CocoaPods. Flutter 3.22 o superiore (Dart 3.4+, dart:js_interop).

  1. 1

    Progettare la struttura del monorepo e generare gli scheletri

    Partiamo creando una cartella radice che conterrà tutti i package del plugin. Useremo flutter create con il template plugin per le implementazioni native e con il template package per la platform interface (che è puro Dart più plugin_platform_interface).

    La struttura finale sarà questa:

    device_battery/
      packages/
        device_battery/                      # app-facing
        device_battery_platform_interface/   # contratto
        device_battery_android/              # Kotlin
        device_battery_ios/                  # Swift
        device_battery_web/                  # JS interop
    

    Regole di dipendenza da rispettare (sono il cuore dell'architettura):

    1. L'app-facing dipende solo dalla platform interface.
    2. Ogni implementazione dipende solo dalla platform interface.
    3. Nessuna implementazione dipende da un'altra implementazione né dall'app-facing.

    In locale, per far vedere ai package le versioni non ancora pubblicate, usiamo dependency_overrides con path relativi: verranno ignorati in fase di pubblicazione ma permettono di sviluppare senza pubblicare nulla.

    mkdir device_battery && cd device_battery && mkdir packages && cd packages
    
    # Contratto astratto (puro Dart)
    flutter create --template=package device_battery_platform_interface
    
    # App-facing package: non contiene codice nativo
    flutter create --template=plugin --platforms=android,ios,web \
      --org it.example --project-name device_battery device_battery
    
    # Implementazioni per piattaforma
    flutter create --template=plugin --platforms=android -a kotlin \
      --org it.example --project-name device_battery_android device_battery_android
    flutter create --template=plugin --platforms=ios -i swift \
      --org it.example --project-name device_battery_ios device_battery_ios
    flutter create --template=plugin --platforms=web \
      --org it.example --project-name device_battery_web device_battery_web

    Risultato atteso

    Cinque package dentro `packages/`, ognuno con il proprio `pubspec.yaml` e, dove previsto, le cartelle native `android/` e `ios/`.

  2. 2

    Definire la platform interface con PlatformInterface e token

    La platform interface è un contratto astratto che non va implementato con implements ma con extends: in questo modo, aggiungendo un nuovo metodo in una minor release, le implementazioni esistenti continuano a compilare (ereditano il default che lancia UnimplementedError).

    Il meccanismo di sicurezza è il token: PlatformInterface.verifyToken impedisce che qualcuno implementi l'interfaccia con implements, evitando rotture silenziose.

    Aggiungi al pubspec.yaml del package:

    dependencies:
      flutter:
        sdk: flutter
      plugin_platform_interface: ^2.1.8
    

    Nel file lib/device_battery_platform_interface.dart definiamo il contratto, i modelli condivisi e l'implementazione di default basata su MethodChannel.

    // packages/device_battery_platform_interface/lib/src/battery_state.dart
    enum BatteryState { full, charging, discharging, unknown }
    
    // packages/device_battery_platform_interface/lib/src/device_battery_platform.dart
    import 'package:plugin_platform_interface/plugin_platform_interface.dart';
    
    import 'battery_state.dart';
    import 'method_channel_device_battery.dart';
    
    abstract class DeviceBatteryPlatform extends PlatformInterface {
      DeviceBatteryPlatform() : super(token: _token);
    
      static final Object _token = Object();
    
      static DeviceBatteryPlatform _instance = MethodChannelDeviceBattery();
    
      static DeviceBatteryPlatform get instance => _instance;
    
      /// Le implementazioni per piattaforma registrano qui se stesse.
      static set instance(DeviceBatteryPlatform instance) {
        PlatformInterface.verifyToken(instance, _token);
        _instance = instance;
      }
    
      /// Livello batteria in percentuale (0-100).
      Future<int> getBatteryLevel() {
        throw UnimplementedError('getBatteryLevel() non implementato.');
      }
    
      /// Stream dei cambi di stato di carica.
      Stream<BatteryState> get onBatteryStateChanged {
        throw UnimplementedError('onBatteryStateChanged non implementato.');
      }
    }

    Risultato atteso

    Il package espone `DeviceBatteryPlatform` con getter/setter statici protetti da token e i metodi con default che lanciano `UnimplementedError`.

  3. 3

    Implementare il default basato su MethodChannel ed EventChannel

    L'implementazione di default vive nella platform interface e serve da fallback: una piattaforma che parla lo stesso protocollo di canali non ha bisogno di scrivere Dart, le basta registrare la classe nativa.

    Due accortezze importanti:

    • Visibilità per i test: marca il channel con @visibleForTesting così i test possono intercettarlo.
    • Broadcast stream: l'EventChannel.receiveBroadcastStream() va creato una sola volta e memorizzato, altrimenti ogni listen apre un nuovo stream nativo.

    Normalizza sempre i dati provenienti dal lato nativo: le mappe arrivano come Map<Object?, Object?> e i valori mancanti vanno gestiti esplicitamente.

    // packages/device_battery_platform_interface/lib/src/method_channel_device_battery.dart
    import 'package:flutter/foundation.dart';
    import 'package:flutter/services.dart';
    
    import 'battery_state.dart';
    import 'device_battery_platform.dart';
    
    class MethodChannelDeviceBattery extends DeviceBatteryPlatform {
      @visibleForTesting
      final MethodChannel methodChannel =
          const MethodChannel('it.example/device_battery');
    
      @visibleForTesting
      final EventChannel eventChannel =
          const EventChannel('it.example/device_battery/state');
    
      Stream<BatteryState>? _stateStream;
    
      @override
      Future<int> getBatteryLevel() async {
        final level = await methodChannel.invokeMethod<int>('getBatteryLevel');
        if (level == null) {
          throw PlatformException(
            code: 'NO_DATA',
            message: 'Il livello batteria non è disponibile su questo dispositivo.',
          );
        }
        return level;
      }
    
      @override
      Stream<BatteryState> get onBatteryStateChanged {
        return _stateStream ??= eventChannel
            .receiveBroadcastStream()
            .map((event) => _parseState(event as String?))
            .asBroadcastStream();
      }
    
      BatteryState _parseState(String? raw) => switch (raw) {
            'full' => BatteryState.full,
            'charging' => BatteryState.charging,
            'discharging' => BatteryState.discharging,
            _ => BatteryState.unknown,
          };
    }

    Risultato atteso

    `DeviceBatteryPlatform.instance` restituisce di default un `MethodChannelDeviceBattery` funzionante su qualsiasi piattaforma che implementi i due canali.

  4. 4

    Implementazione Android in Kotlin con registrazione dichiarativa

    Nel package device_battery_android eliminiamo il file Dart generato da flutter create (non serve: riusiamo l'implementazione MethodChannel) e dichiariamo nel pubspec.yaml solo la pluginClass Android più il campo dartPluginClass se volessimo codice Dart specifico.

    Il pubspec.yaml dell'implementazione Android:

    name: device_battery_android
    flutter:
      plugin:
        implements: device_battery        # chiave dell'architettura federata
        platforms:
          android:
            package: it.example.device_battery_android
            pluginClass: DeviceBatteryPlugin
    dependencies:
      flutter:
        sdk: flutter
      device_battery_platform_interface: ^1.0.0
    

    La direttiva implements: dice a Flutter che questo package è un'implementazione del plugin device_battery. Nel codice Kotlin implementiamo sia il MethodCallHandler sia lo StreamHandler per l'EventChannel, registrando un BroadcastReceiver per gli eventi di carica e deregistrandolo in onCancel/onDetachedFromEngine per evitare leak.

    package it.example.device_battery_android
    
    import android.content.BroadcastReceiver
    import android.content.Context
    import android.content.Intent
    import android.content.IntentFilter
    import android.os.BatteryManager
    import io.flutter.embedding.engine.plugins.FlutterPlugin
    import io.flutter.plugin.common.EventChannel
    import io.flutter.plugin.common.MethodCall
    import io.flutter.plugin.common.MethodChannel
    
    class DeviceBatteryPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
        EventChannel.StreamHandler {
    
        private lateinit var context: Context
        private lateinit var methodChannel: MethodChannel
        private lateinit var eventChannel: EventChannel
        private var receiver: BroadcastReceiver? = null
    
        override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
            context = binding.applicationContext
            methodChannel = MethodChannel(binding.binaryMessenger, "it.example/device_battery")
            methodChannel.setMethodCallHandler(this)
            eventChannel = EventChannel(binding.binaryMessenger, "it.example/device_battery/state")
            eventChannel.setStreamHandler(this)
        }
    
        override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
            when (call.method) {
                "getBatteryLevel" -> {
                    val manager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
                    val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
                    if (level >= 0) result.success(level)
                    else result.error("NO_DATA", "Livello batteria non disponibile", null)
                }
                else -> result.notImplemented()
            }
        }
    
        override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
            receiver = object : BroadcastReceiver() {
                override fun onReceive(ctx: Context?, intent: Intent?) {
                    val status = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
                    val state = when (status) {
                        BatteryManager.BATTERY_STATUS_FULL -> "full"
                        BatteryManager.BATTERY_STATUS_CHARGING -> "charging"
                        BatteryManager.BATTERY_STATUS_DISCHARGING -> "discharging"
                        else -> "unknown"
                    }
                    events?.success(state)
                }
            }
            context.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
        }
    
        override fun onCancel(arguments: Any?) {
            receiver?.let { context.unregisterReceiver(it) }
            receiver = null
        }
    
        override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
            methodChannel.setMethodCallHandler(null)
            eventChannel.setStreamHandler(null)
            onCancel(null)
        }
    }

    Risultato atteso

    Su Android l'app di esempio legge il livello batteria e riceve gli eventi di carica senza che l'app-facing package conosca il codice nativo.

  5. 5

    Implementazione iOS in Swift e implementazione Web con dart:js_interop

    iOS: il pubspec.yaml di device_battery_ios dichiara implements: device_battery e la pluginClass: DeviceBatteryPlugin. Ricorda di abilitare UIDevice.current.isBatteryMonitoringEnabled = true, altrimenti batteryLevel restituisce -1.

    Web: qui non esistono MethodChannel nativi, quindi l'implementazione Dart è obbligatoria. Si usa il campo pluginClass insieme a fileName nel pubspec.yaml:

    name: device_battery_web
    flutter:
      plugin:
        implements: device_battery
        platforms:
          web:
            pluginClass: DeviceBatteryWeb
            fileName: device_battery_web.dart
    

    Flutter genera automaticamente il registrant che chiama il metodo statico registerWith(Registrar): è lì che sovrascriviamo DeviceBatteryPlatform.instance. Per parlare con l'API navigator.getBattery() usiamo dart:js_interop con le extension type (Dart 3.3+), evitando il deprecato dart:html.

    // packages/device_battery_ios/ios/Classes/DeviceBatteryPlugin.swift
    import Flutter
    import UIKit
    
    public class DeviceBatteryPlugin: NSObject, FlutterPlugin {
      public static func register(with registrar: FlutterPluginRegistrar) {
        let channel = FlutterMethodChannel(name: "it.example/device_battery",
                                           binaryMessenger: registrar.messenger())
        let instance = DeviceBatteryPlugin()
        registrar.addMethodCallDelegate(instance, channel: channel)
      }
    
      public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
        switch call.method {
        case "getBatteryLevel":
          let device = UIDevice.current
          device.isBatteryMonitoringEnabled = true
          if device.batteryLevel < 0 {
            result(FlutterError(code: "NO_DATA", message: "Batteria non disponibile", details: nil))
          } else {
            result(Int(device.batteryLevel * 100))
          }
        default:
          result(FlutterMethodNotImplemented)
        }
      }
    }
    
    /* -------- packages/device_battery_web/lib/device_battery_web.dart --------
    import 'dart:js_interop';
    
    import 'package:device_battery_platform_interface/device_battery_platform_interface.dart';
    import 'package:flutter_web_plugins/flutter_web_plugins.dart';
    
    @JS('navigator.getBattery')
    external JSPromise<_BatteryManager> _getBattery();
    
    extension type _BatteryManager(JSObject _) implements JSObject {
      external double get level;
      external bool get charging;
    }
    
    class DeviceBatteryWeb extends DeviceBatteryPlatform {
      static void registerWith(Registrar registrar) {
        DeviceBatteryPlatform.instance = DeviceBatteryWeb();
      }
    
      @override
      Future<int> getBatteryLevel() async {
        final manager = await _getBattery().toDart;
        return (manager.level * 100).round();
      }
    
      @override
      Stream<BatteryState> get onBatteryStateChanged async* {
        final manager = await _getBattery().toDart;
        yield manager.charging ? BatteryState.charging : BatteryState.discharging;
      }
    }
    -------------------------------------------------------------------------- */

    Risultato atteso

    Su iOS il plugin risponde via MethodChannel; su Web `DeviceBatteryWeb.registerWith` viene invocato automaticamente all'avvio e sostituisce l'istanza di default.

  6. 6

    Scrivere l'app-facing package ed effettuare l'endorsement

    L'app-facing package è volutamente sottile: espone un'API ergonomica e delega tutto a DeviceBatteryPlatform.instance. Non deve contenere logica di piattaforma né if (Platform.isAndroid).

    Il punto cruciale è l'endorsement: nel suo pubspec.yaml l'app-facing dichiara quali package implementano ciascuna piattaforma. Così l'utente finale aggiunge solo device_battery e le implementazioni vengono risolte automaticamente da pub.

    name: device_battery
    version: 1.0.0
    flutter:
      plugin:
        platforms:
          android:
            default_package: device_battery_android
          ios:
            default_package: device_battery_ios
          web:
            default_package: device_battery_web
    dependencies:
      flutter:
        sdk: flutter
      device_battery_platform_interface: ^1.0.0
      device_battery_android: ^1.0.0
      device_battery_ios: ^1.0.0
      device_battery_web: ^1.0.0
    
    dependency_overrides:   # solo in sviluppo locale
      device_battery_platform_interface:
        path: ../device_battery_platform_interface
      device_battery_android:
        path: ../device_battery_android
    

    Se un domani qualcuno pubblica device_battery_windows senza il tuo coinvolgimento, l'utente potrà aggiungerlo manualmente: è il vantaggio dell'architettura federata (implementazione non endorsed).

    // packages/device_battery/lib/device_battery.dart
    import 'package:device_battery_platform_interface/device_battery_platform_interface.dart';
    
    export 'package:device_battery_platform_interface/device_battery_platform_interface.dart'
        show BatteryState;
    
    /// API pubblica del plugin: nessun riferimento a Platform o ai channel.
    class DeviceBattery {
      const DeviceBattery();
    
      DeviceBatteryPlatform get _platform => DeviceBatteryPlatform.instance;
    
      /// Livello batteria in percentuale (0-100).
      Future<int> get level => _platform.getBatteryLevel();
    
      /// Stream dei cambi di stato di carica.
      Stream<BatteryState> get stateChanges => _platform.onBatteryStateChanged;
    
      /// Helper di alto livello: true se la batteria è sotto la soglia indicata.
      Future<bool> isLow({int threshold = 20}) async => await level <= threshold;
    }

    Risultato atteso

    Nell'app di esempio basta `import 'package:device_battery/device_battery.dart';` e `await const DeviceBattery().level` per ottenere il valore su Android, iOS e Web.

  7. 7

    Testare con mock della platform interface e pubblicare su pub.dev

    Test

    Per testare l'app-facing package non serve un dispositivo: basta sostituire DeviceBatteryPlatform.instance con un fake che usa MockPlatformInterfaceMixin (aggira la verifica del token, pensato proprio per i test). Per l'implementazione MethodChannel, invece, intercetta il channel con TestDefaultBinaryMessengerBinding.

    Checklist prima della pubblicazione

    1. description di almeno 60 caratteri, homepage/repository valorizzati e LICENSE presente in ogni package.
    2. CHANGELOG.md con la versione corrente in cima.
    3. Rimuovi i dependency_overrides prima di pubblicare, altrimenti pub li segnala.
    4. Ordine di pubblicazione: prima la platform interface, poi le implementazioni, infine l'app-facing (ognuno deve trovare le dipendenze già online).
    5. Verifica ogni package con dart pub publish --dry-run e controlla il punteggio previsto con dart pub global run pana.

    Versionamento

    La platform interface segue regole severe: aggiungere un metodo con implementazione di default è una minor; renderlo astratto o cambiarne la firma è una major che obbliga a pubblicare nuove major di tutte le implementazioni. Documenta questa policy nel README del package.

    // packages/device_battery/test/device_battery_test.dart
    import 'package:device_battery/device_battery.dart';
    import 'package:device_battery_platform_interface/device_battery_platform_interface.dart';
    import 'package:flutter_test/flutter_test.dart';
    import 'package:plugin_platform_interface/plugin_platform_interface.dart';
    
    class _FakeDeviceBattery extends DeviceBatteryPlatform
        with MockPlatformInterfaceMixin {
      _FakeDeviceBattery(this.value);
      final int value;
    
      @override
      Future<int> getBatteryLevel() async => value;
    
      @override
      Stream<BatteryState> get onBatteryStateChanged =>
          Stream.value(BatteryState.charging);
    }
    
    void main() {
      TestWidgetsFlutterBinding.ensureInitialized();
    
      test('level delega alla platform interface', () async {
        DeviceBatteryPlatform.instance = _FakeDeviceBattery(42);
        expect(await const DeviceBattery().level, 42);
      });
    
      test('isLow usa la soglia indicata', () async {
        DeviceBatteryPlatform.instance = _FakeDeviceBattery(15);
        expect(await const DeviceBattery().isLow(), isTrue);
        expect(await const DeviceBattery().isLow(threshold: 10), isFalse);
      });
    
      test('una implementazione con implements viene rifiutata', () {
        expect(
          () => DeviceBatteryPlatform.instance = _BadImplementation(),
          throwsA(isA<AssertionError>()),
        );
      });
    }
    
    // Implementa (male) il contratto: viene bloccata dal token.
    class _BadImplementation implements DeviceBatteryPlatform {
      @override
      noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
    }

    Risultato atteso

    `flutter test` passa in tutti i package, `dart pub publish --dry-run` non segnala warning e i cinque package sono pronti per essere pubblicati nell'ordine corretto su pub.dev.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!