Manipulation de Dart FFI avec Flutter

post-thumb

Dans ce post, nous allons utiliser Dart FFI pour faire des appels à des librairies natives. Par exemple une interopérabilité avec une API écrite en C.

Même si Dart est un super language, il est possible que nous ayons besoin de développer certains aspects dans notre application avec un autre language.

Par exemple votre application peut contenir du code dont les performances sont critiques et qui gagnerait à être écrit dans un language de plus bas niveau comme C, C++ ou Rust. Aussi, vous pourriez avoir besoin d’utiliser une librairie externe spécifique comme TensorFlow.

La version Dart 2.13 nous propose maintenant en version stable , le Foreign function interface ou FFI qui nous permet d’interfacer deux languages différents.

Nous allons construire une petite API de NBA (National Basketball Association - aux USA) écrite en C que nous allons appeler dans une application Flutter. Cet exemple nous permettra de comprendre les points suivants :

  • Comment invoquer du code natif avec le FFI
  • Comprendre comment FFI se distingue du mécanisme de Platform Channel
  • Comment compiler et lier automatiquement du code C lors du build de votre application Flutter

Sous le capot de votre application Flutter

Dans une application Flutter, le code Dart s’exécute dans l’environnement proposé par le Flutter Engine , calculant la taille des widgets et les dessinant à l’écran à environ 60 FPS ( frames per second ou images par seconde ):

app_dart_flutter

Le code Dart peut utiliser toutes les fonctionnalités disponibles dans la bibliothèque standard, comme par exemple accéder au système de fichier.

app_dart_flutter_fs

Toutefois le code Dart n’expose qu’une petite partie des fonctionnalités disponibles pour les appareils ou les systèmes d’exploitation. Par exemple, nous ne pourrons pas utiliser le code Dart pour manipuler la caméra ou le microphone. Pour travailler directement avec les appels bas niveaux, il nous faudra communiquer avec du code Java/Kotlin sur Android ou du code Objective-C/Swift sur iOS. On parle dans ce cas de code platform

Mais en réalité, en tant que développeur d’applicatons, nous utilisons directement des plugins ou des packages externes qui nous permettent d’écrire du code Dart pour travailler indirectement avec ces périphériques bas niveaux. En fait, ces plugins disponibles sur pub.dev exposent une interface Dart que nous utilisons pour invoquer notre code platform.

A. Les Platform Channels

Le code Dart n’interagit pas directement avec la plateforme. C’est à dire que nous ne pouvons pas directement utiliser les signatures de méthodes fournies par la plateforme.

Flutter résoud cette problématique avec des plugins et le mécanisme de platform channels.

app_dart_flutter_fs_native

B. Code Natif

Certaines applications nécessitent un contrôle fin comme la gestion de la mémoire ou du garbage collector, les applications manipulant la 3D ou encore faisant appel au machine learning.

Souvent ces applications sont écrites dans des languages comme C, C++ ou Rust, qualifiés de code natif.

Si nous voulons développer une application Dart/Flutter qui s’interfacent directement avec ce code natif, nous ne pourrions plus utiliser les platform channels. Les platform channels sont le bridge entre le code Dart et le code de la plateforme ( Java , Kotlin, Swift ou Objective-C).

Si nous voulons en revanche créer un bridge entre le code Dart et notre code natif ( C, C++ ou Rust), il nous faudra utiliser FFI

C. Utilisation du FFI

FFI pour Foreign Function Interface est le mécanisme qui permet d’écrire un code dans un language X , de l’évoquer ou créer un bridge avec un autre language Y. Donc il ne s’agit pas juste d’un concept qui existe en Dart.

En Flutter, on utilise dart:ffi et le package ffi pour implémenter la communication entre le code Dart et le code natif.

Tutorial

Code source ( branche : workshop-dart-ffi-0 )


Récupérer le projet initial ici.

Après avoir lancé les commandes nécessaires pour faire tourner l’application sur votre émulator ( flutter pub get , flutter run) vous aurez le résultat ci-dessous :

Nous avons 4 informations à proposer à notre application :

  • What is NBA ?
  • Max Players ?
  • Best player ?
  • NBA FAQ

Une implémentation native en C

Nous allons écrire du code en C pour répondre à ces quesitons. Ce code sera accessible ensuite dans notre application Flutter grace à Dart FFI. Lorsque nous allons cliquer sur les boutons, au lieu d’avoir TODO… , nous aurons le retour des réponses implémentées en C.

A. Ecriture du code en C

A la racine de votre projet, créer le code C sous src/nba.c

Vous pouvez ajouter le code ci-dessous :

src/nba.c
 1#include <stdbool.h>
 2#include <stddef.h>
 3#include <stdio.h>
 4#include <stdlib.h>
 5#include <string.h>
 6
 7double _fahrenheit_to_celsius(double temperature) {
 8  return (5.0f / 9.0f) * (temperature - 32);
 9}
10
11char *get_definition() {
12  return "The National Basketball Association, or NBA, is a professional "
13         "basketball league comprised of 30 teams across North America "
14         "featuring the best basketball players in the world.";
15}
16
17int get_max_of_players() { return 450; }
18
19char *get_best_player() {
20  char *best = "Lebron James";
21  char *best_m = malloc(strlen(best));
22  strcpy(best_m, best);
23  return best_m;
24}
25
26struct NbaFAQ {
27  double avg_temp_arena;
28  int time_quarters;
29  int players_roster;
30};
31
32struct NbaFAQ get_nba_faq(bool useCelsius) {
33  struct NbaFAQ faq;
34  faq.avg_temp_arena = 70.0f;
35  faq.time_quarters = 12;
36  faq.players_roster = 15;
37
38  if (useCelsius) {
39    faq.avg_temp_arena = _fahrenheit_to_celsius(faq.avg_temp_arena);
40  }
41  return faq;
42}

Nous y retrouvons l’implémentation des 4 informations à proposer à notre application :

  • get_definition pour répondre à la question: What is NBA ?
  • get_max_of_players pour répondre à la question: Max Players ?
  • get_best_player pour répondre à la question: Best player ?
  • get_nba_faq pour répondre à la question: NBA FAQ

B. Construction du bridge utilisant le ffi

Nous allons considérer que notre code C compilera en une librairie partagée nommée libnba.so et qu’elle sera correctement configurée pour communiquer avec l’application Flutter.

Cependant, notre application Flutter doit savoir où trouver le code C à compiler et l’appeler au runtime.

A côté du main.dart, créons un fichier ffi_bridge.dart dans lequel nous allons mettre le contenu ci-dessous:

lib/ffi_bridge.dart
 1// C function - int get_max_of_players()
 2//
 3typedef MaxOfPlayersFunction = Int32 Function();
 4typedef MaxOfPlayersFunctionDart = int Function();
 5
 6//
 7// TODO typedef declarations: C function - char *get_definition()
 8
 9//
10// TODO typedef declarations: C function - char *get_best_player()
11
12//
13// TODO Handle NbaFAQ C struct
14
15//
16// TODO typedef declarations: C function - NbaFAQ
17
18class FFIBridge {
19  MaxOfPlayersFunctionDart _getMaxOfPlayers;
20  //
21  // TODO: Add the other declarations here
22
23  FFIBridge() {
24    // 1
25    final dl = Platform.isAndroid 
26      ? DynamicLibrary.open('libnba.so') 
27      : DynamicLibrary.process();
28
29    
30    _getMaxOfPlayers = dl
31    // 2
32    .lookupFunction<
33        // 3
34        MaxOfPlayersFunction,
35        // 4 
36        MaxOfPlayersFunctionDart>('get_max_of_players');
37
38    // TODO: Assign value for the other declarations
39  }
40
41  // 5
42  int getMaxOfPlayers() => _getMaxOfPlayers();
43
44  // TODO: Retrieve the other API here
45
46}

Ca fait beaucoup de code ! Ci-dessous quelques explications :

  1. Sur Android, DynamicLibrary permettra de trouver et d’ouvrir la lib partagée libnba.so. Uniquement sur Android, car sur IOS tout se fait au runtime applicatif.

  2. On fait un lookup pour pointer sur la fonction native à appeler.

  3. MaxOfPlayersFunction définit une fonction native qui n’accepte aucun arguement et retourne un type natif C Int32

  4. la fonction native C est liée à son équivalent en Dart qui retourne le type dart int

  5. On construit la fonction Dart qui retourne le type int correspondant au code C que l’on veut appeler.

Appel dans l’application Flutter

Nous allons appeler le bridge dans le main.dart

  • On cherche le TODO // TODO- create the bridge reference et on le met à jour :
lib/ffi_bridge.dart
1...
2
3  //  create the bridge reference
4  final FFIBridge _ffiBridge = FFIBridge();
5
6...
  • On retrouve le TODO // TODO- show the answer - Max players ? et on met à jour le code associé :
lib/ffi_bridge.dart
1...
2
3  // show the answer - Max players ?
4  _show(_ffiBridge.getMaxOfPlayers());
5
6...

Avec cette implémentation, nous venons de mettre en place le bridge nous permettant depuis notre application Flutter écrite en Dart, d’appeler le code C exposé sous la méthode get_max_of_players

Essayons de lancer l’application :

app_dart_ffi_error

Pourquoi cela ne marche pas ?

Nous venons de dire à Dart FFI de chercher une librairie, mais il ne trouve pas cette librairie sur Android ni sur IOS. En fait , à aucun moment nous avons informé Android ou IOS comment faire la compilation de notre code C

Compilation du code natif C

La compilation du code C sera spécifique à chaque plateforme. Ce qui veut dire que le fichier src/nba.c aura des informations de compilation spécifique pour chaque cible ( Android ou IOS).

A. Configuration de la compilation C pour Android

  • Configurer Android pour qu’il déclenche un build natif externe. On dit à Android de faire un cmake avec CMakeLists.txt lorsqu’il build notre application Flutter.
android/app/build.gradle
 1android {
 2  // ...
 3  externalNativeBuild {
 4    // Encapsulates your CMake build configurations.
 5    cmake {
 6      // Provides a relative path to your CMake build script.
 7      path "CMakeLists.txt"
 8    }
 9  }
10  // ...
11}
  • Il faut maintenant le fichier CMakeLists.txt qui définit comment le code écrit en C devrait être compilé
 1cat > android/app/CMakeLists.txt << EOF
 2cmake_minimum_required(VERSION 3.4.1) 
 3add_library( 
 4            # The native lib
 5            nba 
 6            
 7            # Sets the library as a shared library.
 8            SHARED
 9            
10            # Provides a relative path to your source file(s).
11            ../../src/nba.c
12)
13EOF

Si vous n’êtes pas familié avec CMake, notons ici que le fichier nba.c sera compilé en une librairie partagée nommée libnba.so.

B. Configuration de la compilation C pour IOS

  • Lançer d’abord la commande flutter build ios
  • Ouvrir XCode pour configurer le projet. Il faudra ouvrir le projet ios/Runner.xcworkspace

Ci-dessous la configuration à faire dans XCode :

app_dart_ffi_screen_1 app_dart_ffi_screen_2 app_dart_ffi_screen_3 app_dart_ffi_screen_4

  1. Sélectionner le Runner
  2. Sous Targets, sélectionner Runner
  3. Sélectionner l’onglet Build Phases
  4. Sous Compile Sources on va rajouter une source
  5. On ajoute un nouveau fichier
  6. On ajoute le fichier codé en c : nba.c
  7. On confirme la présence du nouveau fichier dans la liste des fichiers à compiler

BRAVO ! A cette étape, vous devrez avoir le résultat ci-dessous. L’appel au code pour la méthode get_max_of_players devrait fonctionner.

Types Dart pour gérer des fonctions qui retournent des pointeurs

Nous allons compléter le bridge ffi_bridge.dart , pour terminer l’implémentation :

Dart FFI utilise Pointer<Utf8> pour représenter un pointeur de char.

  • On cherche le TODO // TODO typedef declarations: C function - char *get_definition() et on le met à jour :
1...
2
3// C function - char *get_definition()
4//
5typedef DefinitionFunction = Pointer<Utf8> Function();
6typedef DefinitionFunctionDart = Pointer<Utf8> Function();
7
8...
  • On cherche le TODO // TODO typedef declarations: C function - char *get_best_player() et on le met à jour :
1...
2
3// C function - char *get_best_player()
4//
5typedef BestPlayerFunction = Pointer<Utf8> Function();
6typedef BestPlayerFunctionDart = Pointer<Utf8> Function();
7
8...

Gestion des arguments et des structs

Recevoir un Struct en Dart

Il nous faut créer une classe capable de faire le mapping avec les champs définis dans le struct en C

  • On cherche le TODO // TODO Handle NbaFAQ C struct et on le met à jour :
 1...
 2
 3// Handling  NbaFAQ C struct
 4class NbaFAQ extends Struct {
 5  // 1
 6  @Double()
 7  external double get avg_temp_arena;
 8  external set avg_temp_arena(double value);
 9
10  @Int32()
11  external int get time_quarters;
12  external set time_quarters(int value);
13
14  @Int32()
15  external int get players_roster;
16  external set players_roster(int value);
17
18  @override
19  String toString() => '''NBA Frequently Asked Questions:\n
20  * average temp in arena :\n${avg_temp_arena.toStringAsFixed(1)}\n
21  * How long are the quarters?  :\n ${time_quarters.toStringAsFixed(1)} minutes\n
22  * How many players by roster? :\n ${players_roster.toStringAsFixed(1)} players\n
23  ''';
24}
25
26...
  • On cherche le TODO // TODO typedef declarations: C function - NbaFAQ et on le met à jour :
1...
2
3// 2
4typedef NbaFAQFunction = NbaFAQ Function(Uint8 useCelsius);
5typedef NbaFAQFunctionDart = NbaFAQ Function(int useCelsius);
6
7...
  • On cherche le TODO // TODO: Add the other declarations here et on le met à jour :
1...
2
3// Add the other declarations here
4DefinitionFunctionDart _getDefinition;
5BestPlayerFunctionDart _getBestPlayer;
6NbaFAQFunctionDart _getNbaFAQ;
7
8...
  • On cherche le TODO // TODO: Assign value for the other declarations et on le met à jour :
1...
2
3// Assign value for the other declarations
4_getDefinition = dl.lookupFunction<DefinitionFunction, DefinitionFunctionDart>('get_definition');
5_getBestPlayer = dl.lookupFunction<BestPlayerFunction, BestPlayerFunctionDart>('get_best_player');
6_getNbaFAQ = dl.lookupFunction<NbaFAQFunction, NbaFAQFunctionDart>('get_nba_faq');
7
8...
  • On cherche le TODO // TODO: Retrieve the other API here et on le met à jour :
 1...
 2  //  Retrieve the other API here
 3
 4  String getDefinition() => _getDefinition().toDartString();
 5
 6  //3
 7  String getBestPlayer() {
 8    final ptr = _getBestPlayer();
 9    final value = ptr.toDartString();
10    calloc.free(ptr);
11    return value;
12  }
13
14  NbaFAQ getNbaFAQ(bool useCelsius) {
15    return _getNbaFAQ(useCelsius ? 1 : 0);
16  }
17
18...
  • Dans le main.dart, on remplace les TODO // TODO- show the answer - ... en faisant les appels aux méthodes proposées par notre bridge ffi_bridge.dart

Explication :

1- L’annotation ( exemple ici @Double ) indique le type natif pour Dart FFI

2- Le typedef indique que la méthode va retourner un type de la classe NbaFAQ créé pour répresenter le struct. On prend un paramètre int (Dart) / Uint8 ( C) . Comme il n’y a pas de matching FFI avec les types booléans de Dart, on peut utiliser un int (unsigner 8-bit integer)

3- getBestPlayer appelle la fonction Dart, convertir le pointeur de retour char en type String de Dart. Mais surtout libére la mémoire allouée.

BRAVO ! Nous avons complété l’appel à notre code écrit en C et nous savons utiliser Dart FFI dans une application Flutter pour communiquer avec du code natif écrit en C.