Flutter und externe (C-)libraries

Praktisch alle modernen Sprachen bieten eine Methode, um nativen Code — meist implementiert in C, C++ oder Rust — anzuspringen. Java nennt dies das Java Native Interface (JNI), in Python gibt es dafür gleich mehrere Pakete und C# kann auch dlls anziehen. Natürlich geht das auch in Rust und hier bezeichnet man den Mechanismus mit seinem allgemeinen Namen Foreign Function Interface. Auch in Dart trägt diese Funktionalität diesen Namen und ist im Paket dart:ffi implementiert. Da Dart die Sprache der Wahl des Flutter Frameworks ist, kommt dart:ffi auch in Flutter zum Einsatz, um C-Code anzuspringen.

Das Problem

In allen Sprachen gilt es dabei ähnliche Hürden zu nehmen: Wie kann die Typsicherheit beim Aufruf sichergestellt werden? Im Falle objektorientierter Sprachen: Wie werden Objekte der Hostsprache so in den Speicher gelegt, dass sie kompatibel zu entsprechenden structs sind? Wer kümmert sich um die Speicherverwaltung? Woher kennt der Interpreter bzw. die VM der Hostsprache die Symbole der C-library?

Bei der Verwendung in Flutter spielt außerdem noch eine Rolle, dass man je nach Zielsystem (iOS, und Android und hierbei sogar noch unterschiedliche Prozessor-Architekturen) unterschiedliche Kompilate laden bzw. das jeweilige Bundle diese ausliefern muss. Lassen wir diese zusätzliche Komplexität jedoch zunächst außen vor und beschränken uns auf die Dart-Sicht.

Einbindung nativer Bibliotheken in Dart

Wie bereits erwähnt, bietet das Paket dart:ffi alle Mittel, um die Interoperabilität zwischen Dart-Code und nativer library herzustellen. Betrachten wir zunächst, wie wir die beiden Typsysteme in Übereinstimmung bringen können.

Funktionen und einfache/primitive Typen

Um eine Funktion einer library aufrufen zu können, muss diese zweimal ausgezeichnet werden. Einmal mit Dart-Typen und einmal mit speziellen, den C-Typen entsprechenden Typen des dart:ffi Pakets. Dieses bietet für die C-Primitiven entsprechende Klassen, die als Marker dienen (z.B. Uint8, Double, Pointer, Void). Unsere library könnte beispielsweise die zwei Funktionen fibonacci und rotN bereitstellen:

uint32_t fibonacci(uint8_t depth);
char* rotN(char* clearText, int8_t rotationLength);

Diese C-Funktionen müssen wir nun mit Dart-Typen beschreiben. Dazu kommen Dart typedefs zum Einsatz. Diese dienen beim späteren Symbol-lookup dazu, die Typsicherheit zu garantieren bzw. dem Marshalling mitzuteilen, wie der Speicher zu gestalten ist. Marshalling bezeichnet in unserem speziellen Fall das Abbilden von Dart-Datenstrukturen in nativen Speicher und umgekehrt.

import 'dart:ffi';

typedef FibonacciNative = Uint32 Function(Uint8 depth);
typedef Fibonacci = int Function(int depth);

Strings

Strings sind bereits eine etwas kompliziertere Angelegenheit, da sie in C kein primitiver Typ sind, sondern Null-terminierte char-Pointer und zudem nicht unbedingt als Unicode kodiert werden.

import 'dart:ffi';

typedef RotNNative = Pointer<Utf8> Function(Pointer<Utf8> clearText, Int8 rotationLength);
tyepdef RotN = Pointer<Utf8> Function(Pointer<Utf8> clearText, int rotationLength);

Man sieht, dass sowohl die native Funktion als auch die Dart-Variante einen Pointer<Utf8> erwarten, obwohl man die Funktion in Dart üblicherweise als String Function(String, int) auszeichnen würde. Es stellt sich also die Frage, wie man von einem String zu einem Pointer<Utf8> gelangt und umgekehrt.
Auch hier hat dart:ffi etwas in petto: eine extension auf class String, die daran die Methode .toNativeStringUtf8() anbringt. Somit kann mit dem Call Pointer<Utf8> nativeString = 'Hello World'.toNativeStringUtf8() ein entsprechender String auf den Heap gelegt werden. Dabei muss man beachten, dass man diesen Speicher nun manuell verwalten muss — mehr dazu später.
Wenn man sich nicht weiter kümmert, sind char * in C zwar ASCII und nicht UTF-8 kodiert, aber solange man keine nicht-ASCII-Zeichen verwendet, sind beide miteinander kompatibel. Methoden für UTF-16 stehen ebenso zur Verfügung.

Falls man den umgekehrten Weg gehen will, kann man aus einem Pointer<Utf8> den entsprechenden Dart-String einfach mit .toDartString() auslesen.

Structs

Auch structs lassen sich abbilden. Da das Äquivalent in Dart Klassen sind, existiert im dart:ffi-Paket eine Klasse Struct, von der man ableiten kann. Um für Member die Verbindung zwischen nativem Typ und Dart-Typ herzustellen, werden primitive Typen annotiert, während höhere Typen wie andere Structs direkt referenziert werden. Da die class Struct nicht instanziiert werden kann, sondern nur eine Interpretation existierenden Speichers darstellt, ist die Reihenfolge der Einträge — wie in C — entscheidend.

class Person extends Struct {
 external Pointer<Utf8> name;

  @Uint16()
  external int birthYear;

  @Float()
  external double weightInKg;

  external Address address;
}

class Address extends Struct {
  external Pointer<Utf8> street;

  @Uint32()
  external int streetNumber;
}

Hat man die entsprechen C-Header zur Verfügung, kann man mit Hilfe des Pakets ffigen aus diesen auch direkt die FFI bindings erzeugen lassen.

Symbol-Lookup

Um die Library einzubinden, muss sie zunächst natürlich selbst gebaut werden. Da dies aber stark vom jeweiligen Projekt abhängt, gehen wir hier nicht näher darauf ein. Wichtig ist jedoch, dass die Symbole nicht aus dem binary gestrippt werden, weil sie unbenutzt erscheinen, und dass sie nicht gemangled werden (extern C in C++). Beim sogenannten name-mangling werden Funktionsnamen umgeschrieben, um Namenskollisionen zu vermeiden.

Je nach laufendem Betriebssystem lautet die Dateiendung für dynamische libraries „.dll“, „.so“ oder „.dylib“.
Auch der Pfadseparator kann unterschiedlich sein und daher macht es Sinn, diesen Pfad dynamisch zu erstellen. Dies kann insbesondere später beim Testen ermöglichen, Tests auf verschiedenen Maschinen laufen zu lassen.

import 'dart:ffi';
import 'dart:io' show Platform;
import 'package:path/path.dart' as path;

DynamicLibrary _openLibrary() {
  if (Platform.environment.containsKey('FLUTTER_TEST')) {
    if (Platform.isLinux) {
      return DynamicLibrary.open(path.join('path', 'to', 'library.so'));
    } else if (Platform.isMacOS) {
      return DynamicLibrary.open(path.join('path', 'to', 'library.dylib'));
    } else {
      return DynamicLibrary.open(path.join('path', 'to', 'library.dll'));
    }
  } else {
    // Code for mobile devices when running flutter will go here
  }
}

Mit Hilfe dieser Funktion können wir die Datei nun laden und anschließend einzelne Symbole daraus an lokale Variablen binden und aufrufen:

final library = _openLibrary();

final fibonacci = library
  .lookupFunction<FibonacciNative, Fibonacci>('fibonacci');
final rotN = library
  .lookupFunction<RotNNative, RotN>('rotN');


print('The 8th fibonacci number is: ${fibonacci(8)}');
print(rotN('Gallia est omnis divisa in partes tres'.toNativeStringUtf8(), 13));

Damit sind wir eigentlich fertig. Hurra. Allerdings haben wir uns mit .toNativeStringUtf8() ein Leck in den Speicher geschlagen, das wir noch stopfen müssen. Dazu betrachten wir jetzt die Speicherverwaltung.

Speicherverwaltung

Während Dart garbage-collected ist und wir uns daher um die Verwaltung von Speicher (fast) keine Gedanken machen müssen, muss Speicher für langlebige oder große Objekte in C manuell auf dem Heap allokiert werden.
Der entsprechende POSIX-Systemaufruf calloc gibt einer Konstante im dart:ffi-Paket ihren Namen, die das Allocator Interface implementiert. Dieses schreibt Methoden vor, um Speicher auf dem Heap zu allokieren (alloc) und dort allokierten Speicher auch wieder frei zu geben (free).

alloc<T extends NativeType>() erwartet dabei den zu allokierenden Typ als Typparameter T. Falls dieser von Struct ableitet, besitzt der zurückgegebene Pointer ein Member ref, mit dem auf die ableitende Dart-Klasse zugegriffen werden kann. Diese Referenz kann dann zum Initialisieren des Speichers verwendet werden.

final addressPointer = calloc.allocate<Address>();
addressPointer.ref.streetNumber = 19;
addressPointer.ref.street = 'Wetterkreuz'.toNativeStringUtf8();

//...

calloc.free(addressPointer.ref.street);
calloc.free(addressPointer);

Der Einsatz einer Arena, die auch das Allocator interface implementiert, kann das Freigeben etwas erleichtern, indem aller mit der Arena allokierter Speicher auf einmal freigegeben wird:

final addressPointer = arena.allocate<Address>();
addressPointer.ref.streetNumber = 19;
addressPointer.ref.street = 'Wetterkreuz'.toNativeStringUtf8(allocator: arena);

//...

arena.releaseAll();

Damit haben wir nun alle Werkzeuge an der Hand, um aus Dart heraus dynamische libraries anzuziehen und ihre Schnittstellen zu verwenden. In unserer Testsuite funktioniert das auch schon, solange diese auf einem Windows-/Linux-PC oder Mac ausgeführt wird. Es fehlt uns nur noch der letzte Schritt: die Integration in Flutter und auf mobile Plattformen.

Flutter integration

Flutter unterstützt eine ganze Menge Zielplattformen; die wichtigsten sind jedoch sicher Android und iOS. Für diese Konstellation führen mehrere Wege zum Ziel.

XCode und Android Studio erlauben das Hinzufügen von vorgebauten libraries, aber der Prozess variiert leicht, je nachdem wie die library vorliegt — etwa als mehrere .so-Dateien oder einer .aar Datei unter Android bzw als .dylib oder .framework-Datei unter iOS. Hat man keinen Source-Code zur Verfügung, ist dies ein gangbarer Weg, allerdings muss man dann beachten, dass die Zielarchitekturen alle versorgt werden. Flutter unterstützt derzeit armeabi-v7a (ARM 32-bit), arm64-v8a (ARM 64-bit) und x86-64 (x86 64-bit). Dafür spart man sich das Kompilieren der library, was je nach deren Komplexität schon selbst eine Herausforderung sein kann.

Liegt die library in Form von C-Code bzw. C++-Code vor, kann man diesen auch direkt vom build-System kompilieren lassen. Sinnvollerweise legt man den Code in das iOS-Projekt eines flutter-*plugin*-Pakets (Flutter Plugin-Projekte sind speziell dafür da, nativen Code einzubinden). Der Grund dafür ist, dass CocoaPods keinen Code anzieht, der sich außerhalb des Projektfolders befindet (also oberhalb des podspec-files), während das Android-Buildsystem mit Hilfe von Cmake C-Code aus beliebigem Pfaden baut. Dazu muss allerdings das Native Development Kit (Android NDK) installiert sein. Eine entsprechende Anleitung findet sich in den flutter docs.

Egal, wie die library nun in unser Bundle kam — selbst kompiliert oder vorkompiliert —, zum Anziehen der library im Code müssen wir unsere _openLibrary()-Funktion nochmal ein wenig modifizieren. Während Android dem Entwickler das Laden von dynamischen Libraries freistellt, lädt iOS dynamische libraries direkt beim Programmstart in den Prozesskontext. Obwohl es technisch möglich ist, eine Datei zu öffnen und zu laden, macht dieses Vorgehen das Veröffentlichen im Apple App Store zum ganz individuellen Abenteuer.

DynamicLibrary _openLibrary() {
  if (Platform.environment.containsKey('FLUTTER_TEST')) {
     // Siehe oben
  } else {
    return Platform.isAndroid
      ? DynamicLibrary.open('libnative_algorithm.so')
      : DynamicLibrary.process();
  }
}

Hat man den Code selbst gebaut, bestimmt unter Android der Eintrag im CMakeFile.txt den Dateinamen, der geladen werden muss. Bei iOS muss man beachten, dass Kollisionen auftreten können, wenn Funktionsnamen durch mehrere libraries oder Systemlibraries belegt sind.

Fazit

Es kann verschiedene Gründe haben, warum man Teile einer Anwendung in nativen Code auslagert. Seien es Performance-Erwägungen, weil man seine Core-Business-Logik einmal in Rust implementiert und dann auf verschiedenen Plattformen anzieht, oder einfach nur, weil der Code schon da ist und nicht noch einmal implementiert werden soll. Dart und Flutter ermöglichen es uns jedenfalls, native libraries anzuziehen und sie in unserer Flutter App direkt zu verwenden.

 

Mehr Informationen zum Thema MobileApps & WebApps findest du hier in unserem Kompetenzfeld.

Tilman Adler
Letzte Artikel von Tilman Adler (Alle anzeigen)