COMMENT UTILISER DU CODE NATIF AVEC FLUTTER ?

Si vous n’avez jamais entendu parler de Flutter, voici un petit descriptif : Flutter est un framework open source créé par Google pour réaliser des applications multiplateformes (Android, iOS, Linux, Mac, Windows, Google Fuschia et web) à partir d’une seule base de code. C’est un framework qui repose sur le langage Dart, de quelques années son aîné. Ce langage qui parlera peut-être à ceux qui en ont entendu parler en 2013, a été repensé dans une version 2.0 pour la sortie de Flutter, n’ayez donc plus crainte. Flutter se différencie des autres solutions multiplateformes notamment grâce à la création d’applications qui n’utilisent pas de composants natifs ou de webview. Voici un lien vers un article très sympathique, si vous souhaitez en savoir plus.

Dans cet article, nous allons nous concentrer sur les limites liées aux plateformes Android et iOS et le développement de code natif sur Flutter. Si vous souhaitez savoir si Flutter correspond au besoin de votre projet, je ne saurais assez vous recommander d’aller sur un moteur de recherche adéquat afin de trouver une réponse appropriée (même si Flutter > React Native)Mais vous pouvez vous en faire une idée ici ou .

Les éléments exposés dans cet article reposent sur Flutter 1.20.X. Des variations peuvent exister avec les versions précédentes ou suivantes de Flutter.

Quelles sont les limites natives d’un développement avec Flutter ?

Flutter est fait (enfin, ce n’est probablement pas sa fonction principale) pour éviter d’avoir à ajouter du code natif pour votre application. Cependant, malgré un nombre important de fonctionnalités disponibles nativement ou en option (via des plugins développés par les équipes de Google pour éviter de surcharger Flutter), il reste fort probable que vous ayez besoin de fonctionnalités non-présentes dans Flutter.

On peut différencier 2 cas :

  • Les fonctionnalités liées aux plateformes comme par exemple la géolocalisation ou le niveau de batterie. Lorsque l’on va avoir à faire à ce genre de particularités, nous devrons passer par des plugins qui permettront de relier des fonctionnalités natives à du code Dart.
  • Les autres fonctionnalités, qui pourront être résolues par du code Dart.

Nous allons aborder ces 2 cas différents pour l’exécution de code spécifique à iOS ou Android dans cet article.

Chez Liksi, nous avons réalisé une application hybride pour Android et iOS. Cette application ne cible aucun fonctionnement spécifique à l’une ou l’autre des 2 plateformes, c’est pourquoi nous avons choisi Flutter pour la développer.

Le use-case simplifié est le suivant : l’utilisateur peut choisir une vidéo qu’il pourra ensuite uploader vers un serveur en utilisant un protocole de transfert avec reprise : TUS.

 Malgré le nombre important de fonctionnalités disponibles avec Flutter, TUS n’en fait pas partie et nous avons donc eu recours à l’utilisation de code natif. 

La solution plugin

Petit point définition sur les packages en dart :

  •  Les Dart packages sont des packages écrits en Dart, qui peuvent contenir des fonctionnalités spécifiques à Flutter et sont dépendants du framework Flutter 
  • Les Plugin packages sont des packages spécialisés qui contiennent du code en Dart et une (ou plus) implémentation spécifique à une plateforme.

Pour intégrer du code différent en fonction de la plateforme, on peut avoir recours aux plugins. La prise en charge, intégrée à Flutter, des API spécifiques à chaque plateforme ne repose pas sur la génération de code mais plutôt sur un système d’échange de messages flexibles.

La solution à un problème n’est pas toujours accessible directement via du code en Dart. Si elle nécessite l’utilisation de code spécifique à la plateforme (java / kotlin ou obj-c / swift), Flutter offre la possibilité d’invoquer des canaux de plateforme via la classe MethodChannel.

Une MethodChannel est une classe utilisée pour communiquer avec les plugins des plateformes en utilisant des méthodes asynchrones.

Voici un diagramme qui résume la façon dont les messages sont envoyés entre le client (UI) et l’hôte (plateforme) via les canaux de plateforme.

On va pouvoir faire appel à des méthodes en dart, qui vont s’interfacer avec des méthodes conçues pour Android ou iOS. On utilise les plugins afin de sortir au maximum le code spécifique aux plateformes du code de l’application.

Cela nous permet également d’appeler des méthodes natives, comme le type de connexion utilisé actuellement par le téléphone ou la localisation du téléphone.

Dans notre cas, nous avons utilisé (parmi d’autres) un plugin pour TUS et nous allons donc l’utiliser pour illustrer cette section. Ce plugin repose sur l’utilisation des clients officiels Android et iOS de TUS. On aurait pu recréer un fonctionnement équivalent en dart, mais comme l’implémentation de code natif est possible avec les plugins, on peut à la place utiliser les clients officiels que nous n’avons pas à maintenir et créer une interface autour, permettant de faire correspondre les valeurs Android et iOS à ce que l’on souhaite avec Flutter.

Comme expliqué précédemment, notre application sert à télécharger des vidéos sur un serveur TUS, nous allons donc commencer par créer la méthode qui va permettre d’envoyer le fichier au serveur dans un fichier tus.dart.

class Tus {
  ...

  Future<dynamic> createUploadFromFile(String fileToUpload, {Map<String, String> metadata}) async {
    if (!isInitialized) {
      await initializeWithEndpoint();
    }

    if (metadata == null) {
      metadata = Map<String, String>();
    }

    try {
      var result = await _channel.invokeMapMethod("createUploadFromFile", <String, dynamic>{
        "endpointUrl": endpointUrl,
        "fileUploadUrl": fileToUpload,
        "retry": retry.toString(),
        "headers": headers,
        "metadata": metadata,
      });

      if (result.containsKey("error")) {
        throw Exception("${result["error"]} { ${result["reason"]} }");
      }

      return result;
    } catch (e) {
      ... // Renvoi d'une erreur
    }
  }
}

On ne va pas détailler le contenu de ce bout de code, ce qui nous intéresse est le bloc try – catch (ligne 13). On peut noter la présence de _channel qui n’est pas déclaré dans createUploadFromFile

class Tus {
  static const MethodChannel _channel = const MethodChannel('io.tus.flutter_service');
  ...
}

Lorsque l’on déclare un canal, il faut s’assurer de lui donner un identifiant unique, ici io.tus.flutter_service. Cet identifiant va permettre à notre application de relier correctement les appels aux MethodChannel à leur déclaration respective.

La première ligne du bloc try-catch fait appel à invokeMapMethod. Cette méthode n’est autre qu’un mapping pour récupérer une Map typée sur la méthode invokeMethod. Allons la voir de plus près.

  /// Invokes a [method] on this channel with the specified [arguments].
  ///
  /// The static type of [arguments] is `dynamic`, but only values supported by
  /// the [codec] of this channel can be used. The same applies to the returned
  /// result. The values supported by the default codec and their platform-specific
  /// counterparts are documented with [StandardMessageCodec].
  ///
  /// The generic argument `T` of the method can be inferred by the surrounding
  /// context, or provided explicitly. If it does not match the returned type of
  /// the channel, a [TypeError] will be thrown at runtime. `T` cannot be a class
  /// with generics other than `dynamic`. For example, `Map<String, String>`
  /// is not supported but `Map<dynamic, dynamic>` or `Map` is.
  ///
  /// Returns a [Future] which completes to one of the following:
  ///
  /// * a result (possibly null), on successful invocation;
  /// * a [PlatformException], if the invocation failed in the platform plugin;
  /// * a [MissingPluginException], if the method has not been implemented by a
  ///   platform plugin.
  ///
  @optionalTypeArgs
  Future<T> invokeMethod<T>(String method, [ dynamic arguments ]) {
    return _invokeMethod<T>(method, missingOk: false, arguments: arguments);
  }

C’est cette méthode qui va nous permettre d’appeler des méthodes spécifiques aux plateformes. On va donc pouvoir voir comment appeler notre méthode createUploadFromFile sur Android et iOS.

On va commencer par la version java :

public class TusPlugin implements FlutterPlugin, MethodCallHandler {
    private static final String CHANNEL = "io.tus.flutter_service";
    ...
    @Override
    public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) {
        final MethodChannel channel = new MethodChannel(flutterPluginBinding.getFlutterEngine().getDartExecutor(), CHANNEL);
        TusPlugin tusPlugin = new TusPlugin();
        tusPlugin.methodChannel = channel;
        tusPlugin.sharedPreferences = flutterPluginBinding.getApplicationContext().getSharedPreferences("tus", 0);
        channel.setMethodCallHandler(tusPlugin);
    }
    ...
    @Override
    public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
      if (...) {
					...
      } else if (call.method.equals("createUploadFromFile")) {
        HashMap<String, Object> arguments = (HashMap<String, Object>) call.arguments;

          final String endpointUrl = (String) arguments.get("endpointUrl");
          final TusClient client = this.clients.get(endpointUrl);
          if (client == null) {
              ... // Renvoi d'une erreur
          }

          String fileUploadUrl = (String) arguments.get("fileUploadUrl");
          if (fileUploadUrl.isEmpty()) {
              ... // Renvoi d'une erreur 
          }

          HashMap<String, String> headers = new HashMap<>();
          if (arguments.containsKey("headers")) {
              headers = (HashMap<String, String>) arguments.get("headers");
          }
          
          client.setHeaders(headers);

          HashMap<String, String> metadata = new HashMap<>();
          if (arguments.containsKey("metadata")) {
              metadata = (HashMap<String, String>) arguments.get("metadata");
          }

          HandleFileUpload b = new HandleFileUpload(result, client, fileUploadUrl, methodChannel, endpointUrl, metadata);
          try {
              b.execute();
          } catch (Exception e) {
              ... // opérations sur l'erreur
          }
      } else {
          result.notImplemented();
      }
  }
  ...
}

On retrouve dans cette classe une constante CHANNEL (ligne 2) qui est l’identifiant unique que l’on a déclaré dans notre fichier tus.dart. Cette constante est utilisée par la méthode onAttachedToEngine pour enregistrer notre MethodChannel au moteur Flutter et permettre les échanges entre notre code Dart et les plateformes.

On retrouve également un override sur la méthode onMethodCall qui va « écouter » les appels fait par invokeMethod sur l’identifiant unique de notre classe.

On peut voir dans ce bout de code qu’on peut intercepter les appels aux méthodes via des switchs ou des if / else if / else. Dans notre fichier, nous avions donné comme nom createUploadFromFile, c’est donc ce nom de méthode qu’on va écouter pour effectuer notre action sur Android.

L’intégralité du contenu de la méthode n’est pas détaillé puisque c’est l’appel à createUploadFromFile qui nous intéresse.

À la ligne 12, on instancie un TusClient qui est importé du client Tus officiel Java. La roue n’est pas réinventée, on y applique juste des chaînes pour atteindre la station de ski !

On retrouve au sein du bloc else if le traitement des différents paramètres envoyés à la méthode, à savoir :

  • l’url sur laquelle envoyer une vidéo ;
  • le chemin du fichier à envoyer ;
  • les headers pour l’appel ;
  • des métadonnées associées à l’appel.

Cette méthode se termine par un bloc try – catch qui va exécuter le téléchargement du fichier et catcher toute exception qui pourrait survenir.

On va désormais se pencher sur la version Objective-C qui reprend le même fonctionnement :

#import "TusPlugin.h"

static NSString *const CHANNEL_NAME = @"io.tus.flutter_service";
...
@implementation TusPlugin
...
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
  FlutterMethodChannel* channel = [FlutterMethodChannel
      methodChannelWithName:CHANNEL_NAME
            binaryMessenger:[registrar messenger]];
  TusPlugin* instance = [[TusPlugin alloc] init];
    instance.channel = channel;
  [registrar addMethodCallDelegate:instance channel:channel];
}
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
    NSDictionary *arguments = [call arguments];
    NSDictionary *options = [arguments[@"options"] isKindOfClass:[NSDictionary class]] ? arguments[@"options"] : nil;

    if (...) {
        ...    
    } else if ([@"createUploadFromFile" isEqualToString:call.method]) {
        NSString *endpointUrl = arguments[@"endpointUrl"];
        TUSSession *localTusSession = [self.tusSessions objectForKey:endpointUrl];
        if (localTusSession == nil ) {
            ... // Renvoi d'une erreur
        }

        NSString *fileUploadUrl = arguments[@"fileUploadUrl"];
        if(fileUploadUrl == nil) {
            ... // Renvoi d'une erreur
        }

        NSDictionary *headers = [arguments[@"headers"] isKindOfClass:[NSDictionary class]] ? arguments[@"headers"] : @{};
        NSDictionary *metadata = [arguments[@"metadata"] isKindOfClass:[NSDictionary class]] ? arguments[@"metadata"]  : @{};
        NSURL *uploadUrl = [arguments[@"uploadUrl"] isKindOfClass:[NSString class]] ? [[NSURL alloc]initWithString:arguments[@"uploadUrl"]] : nil;

        @try {
            NSURL *uploadFromFile = [NSURL fileURLWithPath:fileUploadUrl];
            TUSResumableUpload *upload = [localTusSession createUploadFromFile:uploadFromFile retry:retryCount headers:headers metadata:metadata ];

            upload.progressBlock = ^(int64_t bytesWritten, int64_t bytesTotal) {
                ... // Opérations réalisées pendant l'upload
            };
            upload.resultBlock = ^(NSURL *fileUrl) {
                ... // Opérations réalisées à la fin de l'upload
            };
            upload.failureBlock = ^(NSError * _Nonnull error) {
                ... // Opérations réalisées lors d'une erreur de l'upload
            };

            [upload resume];

            NSMutableDictionary *inResult = [[NSMutableDictionary alloc]init];
            [inResult setValue:@"true" forKey:@"inProgress"];
            result(inResult);
        }
        @catch (NSException *exception){
            ... // Renvoi d'une erreur
        }

    } else {
      result(FlutterMethodNotImplemented);
    }
}

Cette version est, elle aussi, basée sur le client Tus officiel de la plateforme, à savoir TUSKit.

La seule différence notable ici est la réalisation de l’upload sans passer par une fonction handleUpload à part : tout est géré dans la méthode createUploadFromFile. On pourra également noter que sur iOS, les MethodChannel sont renommées FlutterMethodChannel.

C’est bien cool tout ça, mais on n’a toujours pas vu comment utiliser ce client Tus dans notre application… Ça tombe bien, c’est la prochaine étape !

import 'package:tus/tus.dart';

String tusTestEndpoint = "https://master.tus.io/files/";
var tusD = Tus(tusTestEndpoint);

tusD.headers = <String, String>{
  "Authorization": "Bearer $accessToken",
};

var response = await tusD.initializeWithEndpoint();
response.forEach((dynamic key, dynamic value) {
  print("[$key] $value");
});

tusD.onError = (String error, Tus tus) {
  print(error);
  setState(() {
    progressBar = 0.0;
    inProgress = false;
    resultText = error;
  });
};

tusD.onProgress =
    (int bytesWritten, int bytesTotal, double progress, Tus tus) {
  setState(() {
    inProgress = true;
    progressBar = (bytesWritten / bytesTotal);
  });
};

tusD.onComplete = (String result, Tus tus) {
  print("File can be found: $result");
  setState(() {
    inProgress = false;
    progressBar = 1.0;
    resultText = result;
  });
};

await tusD.createUploadFromFile(
    filePath,
    metadata: <String, String>{ 
      "uuid": file.id,
    },
);

Dans ce code d’exemple, on instancie d’abord un client (ligne 4) sur l’endpoint de test de TUS.

On vient ensuite ajouter des headers (ligne 6), dans notre cas, on a également besoin d’un apiKey, non affiché ici.

Après l’ajout des headers, on vient ensuite initialiser la connexion avec le endpoint via initializeWithEndpoint() (ligne 10).

On a ensuite besoin d’ajouter des callbacks (ligne 15, 24 et 32) lorsque l’upload est : en erreur, en cours et terminé. Dans cet exemple, on met à jour l’état de l’application avec le pourcentage de progrès et un message lorsque l’upload a échoué ou est terminé.

Les étapes d’initialisation et d’ajout des callbacks peuvent être inversées mais doivent être déclarées avant l’appel à la méthode createUploadFromFile (ligne 41), que l’on avait précédemment vue dans le fichier tus.dart du plugin. Ici, on rajoute l’UUID en métadonnées de l’upload pour qu’il soit accepté, mais cela dépend de la manière dont est configuré le serveur qui recevra les fichiers.

Voila, vous pouvez désormais utiliser TUS dans une application Flutter ou tout autre logiciel en Dart !

Si vous souhaitez effectuer des modifications au plugin TUS, il ne faudra pas oublier de modifier le fichier pubspec.yaml pour que le plugin pointe vers votre modification.

dependencies:
  flutter:
    sdk: flutter
  # Autres plugins
  ...
  # Si la modification est locale
  tus:
    path: ../path/to/tus-flutter-client
  # Si la modification est poussée sur github
  tus:
    git:
      url: https://github.com/url/de/votre/repo
      ref: master
  # Si vous avez publié les modifications sous un plugin pub spécifique
  # nom-du-plugin: numéro-de-version
  tus: 0.0.5

On retrouve 3 modes de fonctionnement pour les plugins :

  • On peut les créer localement, et y faire référence par la propriété path (ligne 8). C’est également pratique lorsque vous voulez travailler avec des versions que vous modifiez actuellement.
  • On peut les créer et les publier sur un repo git, et y faire référence avec la propriété git  (ligne 11). Le propriété ref (ligne 13) peut être une référence à un nom de branche, ou a un commit particulier via son id.
  • Pour les plugins hebergés, vous pouvez mettre directement son numéro de version, flutter se chargera d’aller le récupérer.

Vous pouvez trouver plus d’informations sur l’utilisation des plugins au sein de votre application ici.

La solution dans le code

Le plus dur est derrière nous. Si la solution plugin est un poil trop complexe à vos yeux, parce que vous n’avez besoin que de changer une String en fonction de la plateforme, ou adapter une valeur de retour, la solution « dans le code » est probablement la plus adaptée.

Et pour cela, null besoin d’en faire trop, un simble booléen suffit :

import 'dart:io';

String message;
if (Platform.isAndroid) {
  message = "Votre application Android n'est pas à jour";
} else if (Platform.isIos) {
  message = "Votre application iOS n'est pas à jour";
}

Le tour est joué !

Les booléens accessibles sont les suivants : isLinux, isMacOS, isWindows, isAndroid, isIos, isFuschia. Si vous souhaitez effectuer une opération sur un environnement web, un autre booléen est disponible : kIsWeb.

import 'package:flutter/foundation.dart' show kIsWeb;

if (kIsWeb) {
  // faire quelque chose sur le web
} else {
  // faire quelque chose PAS sur le web
}

Conclusion

Tada ! Vous êtes désormais « platform-proof ». Rien ne pourra plus vous resister.

Avec ces 2 solutions abordées dans cet article, vous devriez désormais aborder les problèmes liés au code natif avec plus de sérénité ! Au besoin, de nombreux plugins existent sur pub.dev, pour vous éviter d’avoir à tout faire par vous-même.

Liens