Flutter : interagir avec du code natif (avec les method channels)

Introduction :

Flutter est un outil puissant pour créer et développer des applications mobiles cross-platform avec une seule base de code. Cependant, il arrive qu’on ait besoin de faire appel à du code natif, notamment pour accéder aux APIs natives. On va alors passer par ce que l’on appelle les platform channels. On peut les voir comme une passerelle entre une application Flutter et les environnements natifs.

Cet article se base sur la documentation Flutter :
https://docs.flutter.dev/platform-integration/platform-channels

Appeler du code natif depuis Flutter (Method Channel)

Pour illustrer la mise en place d’une communication entre du code Flutter et du code natif, on va prendre comme exemple une application qui affiche le niveau de la batterie du téléphone.

Dans un premier temps, on va s’occuper de récupérer la donnée souhaitée au clique sur un bouton. On va ainsi avoir besoin d'un widget composé d’un bouton et d’un texte affichant le résultat.

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

  @override
  State<BatteryLevelWidget> createState() => _BatteryStatusWidgetState();
}

class _BatteryStatusWidgetState extends State<BatteryLevelWidget> {
  static const channel = MethodChannel('battery_level');

  int? _batteryLevel;

  @override
  Widget build(BuildContext context) {
    return Material(
      child: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            ElevatedButton(
              onPressed: _getBatteryLevel,
              child: const Text('Get Battery Level'),
            ),
            const SizedBox(height: 20),
            Text(
              _batteryLevel != null
                ? "Battery level at $_batteryLevel %"
                : "Unknown battery level"
            ),
          ],
        ),
      ),
    );
  }
}

Vue à l’initialisation

Vue après récupération de la donnée

Appel au code natif depuis Flutter :

On va maintenant s’intéresser à ce qui se passe lorsqu’on clique sur le bouton pour récupérer le niveau de batterie du téléphone.

La première chose à faire lorsqu’on souhaite communiquer avec du code natif est de créer ce qu’on appelle un MethodChannel. Cela s’apparente à un canal de communication permettant d’interagir entre du code Dart et du code natif. Chaque MethodChannel est accompagné d’un identifiant unique, dans cet exemple on a choisi l’identifiant battery_level.

Dès lors que le canal de communication est mis en place, on peut faire appel à l’une de ses méthodes. Dans notre exemple, on utilise la méthode getBatteryLevel qui, on le verra par la suite, est définie dans le code natif (Android et iOS). Pour faire cet appel, on utilise invokeMethod depuis notre channel. On peut lui indiquer le type de retour souhaité (ici int).

Enfin, il ne nous reste plus qu’à utiliser le résultat de cet appel, ou traiter l’exception retournée s’il y en a une.

static const channel = MethodChannel('battery_level');

Future<void> _getBatteryLevel() async {
  int? batteryLevel;
  try {
    batteryLevel = await channel.invokeMethod<int>('getBatteryLevel');
  } on PlatformException catch (error) {
    // deal with the error
  }

  setState(() => _batteryLevel = batteryLevel);
}

Maintenant qu’on a vu comment on peut faire appel aux method channels depuis l’application Flutter, on va pouvoir se concentrer sur ce qui se passe du côté du code natif pour obtenir la réponse souhaitée.

Réponse côté Android :

Concernant la partie native liée à Android, il y a quelques étapes à suivre pour pouvoir répondre à l’appel fait du côté du code Dart. Toutes ces étapes se font dans l’activité FlutterActivity présente dans le fichier MainActivity.kt.

Tout d’abord, il est nécessaire de terminer la création du canal de communication qui a été entamée. En effet, la partie native doit s’aligner avec Flutter pour que chaque partie réussisse à communiquer avec l’autre.
Ainsi, on vient définir ce canal avec le même identifiant battery_level.

private val BATTERY_LEVEL_CHANNEL = "battery_level"

Du côté d’Android, la définition d’un canal se fait aussi sous forme de MethodChannel. La différence avec celui qu’on a défini précédemment est qu’on doit également lui indiquer qu’il sert à communiquer avec du code Dart. C’est pourquoi on lui passe le paramètre flutterEngine.dartExecutor.binaryMessenger.

MethodChannel(
    flutterEngine.dartExecutor.binaryMessenger,
    BATTERY_LEVEL_CHANNEL
)

Point important, ce canal doit être défini dans la fonction configureFlutterEngine. Le rôle de cette fonction est de définir la manière dont Flutter doit interagir avec l'interface native Android grâce notamment aux MethodChannel.

Ensuite, on indique au canal de communication comment il doit gérer les appels à une méthode en lui ajoutant un handler setMethodCallHandler. Ici, on lui dit de renvoyer le niveau de batterie actuel du téléphone lorsqu’il reçoit un appel de la méthode getBatteryLevel. Ce retour se fait via result.success en cas de réussite ou via result.error en cas d’erreur. On peut aussi retourner une erreur indiquant que la méthode n’est pas implémentée avec result.notImplemented.

MethodChannel(
    flutterEngine.dartExecutor.binaryMessenger,
    BATTERY_LEVEL_CHANNEL
).setMethodCallHandler { call, result ->
    if (call.method != "getBatteryLevel") {
        result.notImplemented()
        return@setMethodCallHandler
    }

    val batteryLevel = getBatteryLevel()
    if (batteryLevel == -1) {
        result.error("UNAVAILABLE", "Battery level not available.", null)
        return@setMethodCallHandler
    }

    result.success(batteryLevel)
};

Enfin, il ne reste plus qu’à écrire le bout de code natif permettant de répondre au besoin initial, à savoir récupérer le niveau de batterie du téléphone. Pour cela, on va créer une méthode getBatteryLevel, toujours dans FlutterActivity mais en dehors de la fonction configureFlutterEngine.

private fun getBatteryLevel(): Int {
    val batteryLevel: Int
    if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
        val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
        batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
        return batteryLevel
    }
        
    val intent = ContextWrapper(applicationContext).registerReceiver(
        null,
        IntentFilter(Intent.ACTION_BATTERY_CHANGED)
    )
    val level = intent!!.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
    val scale = intent!!.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
    batteryLevel = level * 100 / scale
    return batteryLevel
}

Code complet côté Android

import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES


class MainActivity: FlutterActivity() {
    private val BATTERY_LEVEL_CHANNEL = "battery_level"

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        // Setup Method Channel
        MethodChannel(
            flutterEngine.dartExecutor.binaryMessenger,
            BATTERY_LEVEL_CHANNEL
        ).setMethodCallHandler { call, result ->
            if (call.method != "getBatteryLevel") {
                result.notImplemented()
                return@setMethodCallHandler
            }

            val batteryLevel = getBatteryLevel()
            if (batteryLevel == -1) {
                result.error("UNAVAILABLE", "Battery level not available.", null)
                return@setMethodCallHandler
            }

            result.success(batteryLevel)
        };
    }

    private fun getBatteryLevel(): Int {
        val batteryLevel: Int
        if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
            val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
            batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
            return batteryLevel
        }
        
        val intent = ContextWrapper(applicationContext).registerReceiver(
            null,
            IntentFilter(Intent.ACTION_BATTERY_CHANGED)
        )
        val level = intent!!.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
        val scale = intent!!.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
        batteryLevel = level * 100 / scale
        return batteryLevel
    }
}

Schéma récapitulatif

Réponse côté iOS :

Du côté d’iOS, on retrouve le même principe que pour Android à savoir le besoin de créer la partie native du canal de communication mis en place du côté de Flutter. Pour iOS, tout se passe dans le fichier AppDelegate.swift et plus particulièrement dans la classe AppDelegate.

Tout d’abord, il est nécessaire de récupérer le contrôleur de la vue principale de l'application Flutter. On en a besoin pour créer le canal de communication côté iOS.

let controller : FlutterViewController = window?.rootViewController as! FlutterViewController

Ensuite, à l’instar de ce qu’on a fait pour la partie Android, on crée un FlutterMethodChannel avec le même identifiant que celui qu’on a utilisé auparavant. On vient aussi indiquer que la communication entre le natif et l’application Flutter se fait via le contrôleur qu’on a récupéré juste avant avec controler.binaryMessenger.

let batteryChannel = FlutterMethodChannel(
    name: "battery_level",
    binaryMessenger: controller.binaryMessenger
)

Une fois le canal créé, on lui assigne un MethodCallHandler pour qu’il puisse écouter les appels de méthodes qui lui sont faits. Ainsi, lorsque l’application Flutter appelle une méthode, on demande au canal de vérifier si la méthode appelée est bien getBatteryLevel. Si c’est bien elle, on renvoie le résultat de la fonction receiveBatteryLevel, sinon on renvoie une erreur indiquant que la méthode n’est pas implémentée.

batteryChannel.setMethodCallHandler({
    [weak self] (call: FlutterMethodCall, result: FlutterResult) -> Void in
    guard call.method == "getBatteryLevel" else {
        result(FlutterMethodNotImplemented)
        return
    }
    self?.receiveBatteryLevel(result: result)
})

Enfin, il ne reste plus qu’à écrire le contenu de la fonction receiveBatteryLevel propre à notre besoin et la placer dans la classe AppDelegate.

private func receiveBatteryLevel(result: FlutterResult) {
    let device = UIDevice.current
    if device.batteryState == UIDevice.BatteryState.unknown {
        result(0)
    }
    
    result(Int(device.batteryLevel * 100))
}

Code complet côté iOS

import Flutter
import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    
    // Setup Method Channel
    let batteryChannel = FlutterMethodChannel(
      name: "battery_level",
      binaryMessenger: controller.binaryMessenger
    )

    batteryChannel.setMethodCallHandler({
      [weak self] (call: FlutterMethodCall, result: FlutterResult) -> Void in
      guard call.method == "getBatteryLevel" else {
        result(FlutterMethodNotImplemented)
        return
      }
      self?.receiveBatteryLevel(result: result)
    })

    // Needed to ensure battery status monitoring
    UIDevice.current.isBatteryMonitoringEnabled = true

    // Needed to ensure all Flutter plugins are registered
    GeneratedPluginRegistrant.register(with: self)

    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  // Set baterry level receiver
  private func receiveBatteryLevel(result: FlutterResult) {
    let device = UIDevice.current
    if device.batteryState == UIDevice.BatteryState.unknown {
      result(0)
    }
    
    result(Int(device.batteryLevel * 100))
  }
}

Schéma récapitulatif

Subscribe à un stream natif depuis Flutter (Event Channel)

Maintenant qu’on a vu un cas de figure basique, on peut s’intéresser à un cas un peu plus complexe à savoir récupérer un flux de données continu.
Pour illustrer ce principe, on va reprendre le même exemple de la récupération du niveau de batterie du téléphone. La principale différence se fait au niveau du type de retour puisqu’on va cette fois-ci récupérer l’information sous forme de stream.

Pour ce faire, on va avoir besoin d’un nouveau widget capable d’écouter un flux de données et d’afficher la valeur observée en temps réel.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

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

  @override
  State<BatteryLevelFromStreamWidget> createState() => _BatteryStatusWidgetState();
}

class _BatteryStatusWidgetState extends State<BatteryLevelFromStreamWidget> {
  static const EventChannel _batteryEventChannel = EventChannel('battery_level_stream');

  Stream<int> get batteryStateStream {
    return _batteryEventChannel.receiveBroadcastStream().map((event) => event as int);
  }

  StreamSubscription<int>? _subscription;
  int? _batteryLevel = 0;

  @override
  void initState() {
    super.initState();
    _subscription = batteryStateStream.listen((state) {
      setState(() => _batteryLevel = state);
    });
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text("Live : Battery level at $_batteryLevel %"),
    );
  }
}

Vue à l’initialisation

Vue après changement du niveau de batterie

Appel au code natif depuis Flutter :

Dans cet exemple, on ne récupère pas de données de manière ponctuelle mais en temps réel. Utiliser un MethodChannel n’est donc pas adapté puisqu’il permet uniquement de faire appel à une méthode puis d’en récupérer le résultat à un instant T.
Pour ce nouveau besoin, on va se servir d’un EventChannel. C’est un type de canal qui permet de gérer la communication entre le natif et Flutter via un stream de données. On va donc en créer un en prenant bien soin de lui donner un identifiant unique, pour les mêmes raisons que pour un MethodChannel.

Ensuite, on vient écouter le flux de données natif mis à disposition par l’EventChannel qu’on vient de créer avec la méthode receiveBroadcastStream. Dans cet exemple, on vient indiquer que notre stream gère des int car c’est le type de donnée qu’on souhaite recevoir. Cependant, il est tout à fait possible de convertir le flux en un autre type de donnée selon le besoin.

Enfin, il ne reste plus qu’à s’abonner au stream via la méthode listen et à insérer la valeur dans notre widget.

À noter, il est important de bien annuler l’abonnement en surchargeant la fonction dispose du widget pour libérer les ressources lorsqu’on a plus besoin de notre information.

static const EventChannel _batteryEventChannel = EventChannel('battery_level_stream');

Stream<int> get batteryStateStream {
  return _batteryEventChannel.receiveBroadcastStream().map((event) => event as int);
}

@override
void initState() {
  super.initState();
  _subscription = batteryStateStream.listen((state) {
    setState(() => _batteryLevel = state);
  });
}

@override
void dispose() {
  _subscription?.cancel();
  super.dispose();
}

Réponse côté Android :

Concernant la partie native, on garde les mêmes principes que pour les MethodChannel à quelques détails près.

Pour gérer un EventChannel côté Android, il nous faut tout d’abord définir un EventSink. C’est ce qui permet à la partie native d’émettre des données en continu et de les envoyer vers notre application Flutter.

private var eventSink: EventChannel.EventSink? = null

Ensuite, on crée un EventChannel pour aller de paire avec celui que l’on a créé en Dart en s’assurant de renseigner le même identifiant.

EventChannel(
    flutterEngine.dartExecutor.binaryMessenger,
    BATTERY_LEVEL_STREAM_CHANNEL
)

Puis, on le configure en lui assignant un StreamHandler qui va gérer son comportement.

EventChannel(
    flutterEngine.dartExecutor.binaryMessenger,
    BATTERY_LEVEL_STREAM_CHANNEL
).setStreamHandler(
    object : EventChannel.StreamHandler {
        override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
            eventSink = events
            val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
            registerReceiver(batteryLevelReceiver, filter)
        }

        override fun onCancel(arguments: Any?) {
            eventSink = null
            unregisterReceiver(batteryLevelReceiver)
        }
    }
)

Ainsi, dans le onListen on indique au canal de rester en écoute et de surveiller les changements du niveau de la batterie. En effet, le canal vient initialiser un BroadcastReceiver dont le rôle est de récupérer le niveau de batterie du téléphone dès qu’un changement est détecté et de le renvoyer via l’EventSink. C’est le bout de code IntentFilter(Intent.ACTION_BATTERY_CHANGED) qui permet d’indiquer qu’il ne faut trigger la récupération du niveau de la batterie qu’après un changement de valeur.

Le onCancel sert quant à lui à déconnecter le BroadcastReceiver lorsque l’application Flutter arrête d’écouter le flux de données. Cela permet de libérer les ressources qui ne sont plus utiles.

Enfin, on définit un BroadcastReceiver pour écouter les changements d’état de la batterie et notifier notre application Flutter via l’EvenSink avec le résultat de la fonction getBatteryLevel.

private val batteryLevelReceiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context?, intent: Intent?) {
        if (eventSink == null) return
        val batteryLevel = getBatteryLevel()
        eventSink?.success(batteryLevel)
    }
}

Code complet côté Android

package com.example.platform_specific_app

import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.content.BroadcastReceiver
import io.flutter.plugin.common.EventChannel

class MainActivity: FlutterActivity() {
    private val BATTERY_LEVEL_STREAM_CHANNEL = "battery_level_stream"
    private var eventSink: EventChannel.EventSink? = null

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        // Setup Event Channel
        EventChannel(
            flutterEngine.dartExecutor.binaryMessenger,
            BATTERY_LEVEL_STREAM_CHANNEL
        ).setStreamHandler(
            object : EventChannel.StreamHandler {
                override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
                    eventSink = events
                    val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
                    registerReceiver(batteryLevelReceiver, filter)
                }

                override fun onCancel(arguments: Any?) {
                    eventSink = null
                    unregisterReceiver(batteryLevelReceiver)
                }
            }
        )
    }

    private fun getBatteryLevel(): Int {
        val batteryLevel: Int
        if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
            val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
            batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
            return batteryLevel
        }

        val intent = ContextWrapper(applicationContext).registerReceiver(
            null,
            IntentFilter(Intent.ACTION_BATTERY_CHANGED)
        )
        val level = intent!!.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
        val scale = intent!!.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
        batteryLevel = level * 100 / scale
        return batteryLevel
    }

    // Set battery receiver
    private val batteryLevelReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            if (eventSink == null) return
            val batteryLevel = getBatteryLevel()
            eventSink?.success(batteryLevel)
        }
    }

}

Schéma récapitulatif

Réponse côté iOS :

Du côté d’iOS, on retrouve le même principe avec la création d’un FlutterEventSink ainsi que d’un FlutterEventChannel. Ce sont les équivalents de ce qu’on a vu précédemment pour la partie Android.

var eventSink: FlutterEventSink?
let batteryEventChannel = FlutterEventChannel(
    name: "battery_level_stream",
    binaryMessenger: controller.binaryMessenger
)

Ensuite, on assigne AppDelegate comme le gestionnaire du flux de données continu. Cela permet à la partie native iOS d'envoyer des données vers Flutter sous forme de stream.

batteryEventChannel.setStreamHandler(self)

À la différence d’Android, la gestion du stream ne se fait pas dans la classe AppDelegate, l’équivalent de MainActivity, mais dans une extension de cette même classe. Cela lui permet de savoir comment elle doit gérer les flux de données vers Flutter.

extension AppDelegate: FlutterStreamHandler {
    func onListen(
        withArguments arguments: Any?,
        eventSink events: @escaping FlutterEventSink
    ) -> FlutterError? {
        self.eventSink = events
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(batteryStateDidChange),
            name: UIDevice.batteryLevelDidChangeNotification,
            object: nil
        )
        sendBatteryLevel()
        return nil
    }

    func onCancel(withArguments arguments: Any?) -> FlutterError? {
        self.eventSink = nil
        NotificationCenter.default.removeObserver(
            self,
            name: UIDevice.batteryLevelDidChangeNotification,
            object: nil
        )
        return nil
    }

    @objc private func batteryStateDidChange(notification: Notification) {
        sendBatteryLevel()
    }

    private func sendBatteryLevel() {
        guard let sink = eventSink else { return }
        receiveBatteryLevel(result: sink)
    }
}

Ainsi, on retrouve les fonctions onListen et onCancel qui ont le même rôle que leurs homologues d’Android.

Le onListen est appelé lorsque Flutter commence à écouter l’EventChannel afin de recevoir des mises à jour lorsque le niveau de batterie du téléphone évolue. Pour ce faire, on configure un observateur qui fait appel à la fonction batteryStateDidChange dès q’un changement est détecté.

Le onCancel est quant à lui appelé lorsque Flutter arrête d’écouter le flux d’événements. Il se charge de libérer les ressources en réinitialisant l’eventSink, ce qui a pour effet de désactiver l’envoi d’événements, et en supprimant l’observateur mis en place dans le onListen.

Enfin, la fonction batteryStateDidChange s’occupe d’appeler sendBatteryLevel dès que le niveau de batterie du téléphone change afin d’envoyer la nouvelle valeur à l’application Flutter via l’EventSink.

Code complet côté iOS

import Flutter
import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate {
  var eventSink: FlutterEventSink?
  
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController

    // Setup Event Channel
    let batteryEventChannel = FlutterEventChannel(
      name: "battery_level_stream",
      binaryMessenger: controller.binaryMessenger
    )
    batteryEventChannel.setStreamHandler(self)
    
    // Needed to ensure battery status monitoring
    UIDevice.current.isBatteryMonitoringEnabled = true

    // Needed to ensure all Flutter plugins are registered
    GeneratedPluginRegistrant.register(with: self)

    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  // Set baterry level receiver
  private func receiveBatteryLevel(result: FlutterResult) {
    let device = UIDevice.current
    guard device.batteryState != UIDevice.BatteryState.unknown else {
        result(0)
        return
    }
    result(Int(device.batteryLevel * 100))
  }
}

extension AppDelegate: FlutterStreamHandler {
    func onListen(
        withArguments arguments: Any?,
        eventSink events: @escaping FlutterEventSink
    ) -> FlutterError? {
        self.eventSink = events
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(batteryStateDidChange),
            name: UIDevice.batteryLevelDidChangeNotification,
            object: nil
        )
        sendBatteryLevel()
        return nil
    }

    func onCancel(withArguments arguments: Any?) -> FlutterError? {
        self.eventSink = nil
        NotificationCenter.default.removeObserver(
            self,
            name: UIDevice.batteryLevelDidChangeNotification,
            object: nil
        )
        return nil
    }

    @objc private func batteryStateDidChange(notification: Notification) {
        sendBatteryLevel()
    }

    private func sendBatteryLevel() {
        guard let sink = eventSink else { return }
        receiveBatteryLevel(result: sink)
    }
}

Schéma récapitulatif

Les types de données acceptés par les platform channels et leur correspondance en natif :

Lorsqu’on communique avec du code natif via les platform channels, il y a nécessairement besoin de sérialiser et désérialiser les données que l’on y passe. Ce mécanisme est automatiquement géré par les canaux pour les types de données de base.

Voici un tableau récapitulant les types de données directement pris en compte par les platform channels avec leur correspondances entre Dart et les langages natifs :

Dart Kotlin Swift
null null nil (NSNull when nested)
bool Boolean NSNumber(value: Bool)
int (<=32 bits) Int NSNumber(value: Int32)
int (>32 bits) Long NSNumber(value: Int)
double Double NSNumber(value: Double)
String String String
Uint8List ByteArray FlutterStandardTypedData(bytes: Data)
Int32List IntArray FlutterStandardTypedData(int32: Data)
Int64List LongArray FlutterStandardTypedData(int64: Data)
Float32List FloatArray FlutterStandardTypedData(float32: Data)
Float64List DoubleArray FlutterStandardTypedData(float64: Data)
List List Array
Map HashMap Dictionary

https://docs.flutter.dev/platform-integration/platform-channels

À noter qu’il est possible de faire appel aux platform channels avec les langages Java (Android) et Objective-C (iOS), selon les préférences.

Bien que les platform channels ne prennent en charge que les types de données de base, il est tout à fait possible d’utiliser des types de données plus complexes. La seule contrainte est de les convertir en types supportés par le natif. Pour cela il existe plusieurs façon de procéder dont :

  • L’encodage JSON : il est possible de transformer un objet complexe en String en passant par la fonction jsonEncode. Ensuite, du côté du natif, il suffit de parser le JSON reçu pour le transformer à nouveau en objet.
  • L’utilisation de Map : un objet peut tout à fait être converti en une Map (Kotlin) ou en Dictionnaire (Swift). Ainsi, comme pour l’encodage JSON, l’objet complexe a juste besoin d’être remanié en entrée et sortie des canaux de communication.

Cependant, bien qu’il soit possible de communiquer avec les platform channels avec des types de données complexes, on peut rencontrer quelques limites. En effet, l’encodage et le décodage des données en entrée et sortie de canal peut devenir compliqué si on a des types de données imbriqués ou que les objets utilisés ont des relations trop complexes.

Conclusion :

L’utilisation des platform channels permet aux développeurs d’applications Flutter de communiquer avec les environnements natifs Android & iOS et ainsi accéder à des outils qui ne sont pas disponibles autrement. Dans cet article, on a vu comment on pouvait interagir de manière classique avec les APIs natives.
À noter qu’il existe des outils tels que Pigeon (https://pub.dev/packages/pigeon) pour simplifier les communications avec le natif.
Aussi, il est tout à fait possible de voir plus loin avec l’intégration de SDKs ou de librairies natives afin d’y avoir accès via les platform channels (https://docs.flutter.dev/packages-and-plugins/developing-packages).