Implementierung eines Flutter-Plugins mit nativer OpenCV-Unterstützung über dart::ffi - Teil 1/2

In diesem Artikel werden wir ein Flutter-Plugin mit Kamerafunktionalität für Android und iOS implementieren. Diesem einfachen Kamerastream wollen wir mit Computer Vision etwas Leben einhauchen. Dazu werden wir die OpenCV -Library verwenden. Anstelle der in Flutter üblichen Platform-Channels werden wir dafür eine anspruchsvollere - und leistungsfähigere - Technologie verwenden: die dart::ffi - Library.

app store

Das Tutorial wird fortgesetzt in Teil 2.

Einleitung

Das Wichtigste zuerst: Was ist dart::ffi? Es ist eine Technologie, die es Flutter ermöglicht, C- und C++-Code aufzurufen, ohne plattformspezifische Bridges zu verwenden.

Vorteile:

  • dart::ffi arbeitet schneller als Platform Channels, da der Platform Layer übersprungen wird und wir direkt mit C-Code interagieren können.
  • Nativer Speicher wird durch Pointer verwaltbar.
  • Wir können vorhandene C/C++-Bibliotheken verwenden.
  • dart::ffi ist speichereffizienter.
  • Der Code-Generator erstellt Dart-Wrapper-Code für die ausgewählten Header.

Nachteile:

  • Das Tool zur Codegenerierung funktioniert von Haus aus nur für die Sprache C. Die Verwendung einer C++-Bibliothek erfordert eine Menge Wrapper-Code.
  • Der C++-Code muss mit einer extern C  Schnittstelle ummantelt werden, wofür wir viel Boilerplate-Code schreiben müssen.
  • Es gibt eine begrenzte Anzahl von verknüpfbaren Datentypen zwischen dart::ffi und C: pointer, int, float, Uint8, Uint16,  and function. Alles andere kann nur als NativeType -Typ dargestellt werden.

Voraussetzungen

  • Wir gehen davon aus, dass Sie bereits mit dem Flutter-Framework vertraut sind.
  • Wir verwenden in diesem Tutorial macOS. Windows oder Linux können mit entsprechenden Anpassungen ebenfalls verwendet werden.
  • Die folgenden Tools müssen auf macOS installiert sein:

Sobald Sie das alles eingerichtet haben, können wir unser Projekt starten. Los geht's!

Vorbereitungen

1) Erstellen Sie ein Template-Projekt für ein Flutter-Plugin

Verwenden Sie den folgenden Flutter-Befehl, um das Projekt flutter_ffi_demo zu erstellen. Dieses wird eine Vorlage für ein Flutter-Plugin sowie eine Beispiel-App enthalten:

$ flutter create --org com.example --template=plugin
--platforms=android,ios -a kotlin flutter_ffi_demo

2) Bereiten Sie OpenCV vor

Als Nächstes müssen wir die OpenCV-Quellen für iOS und Android und die zugehörigen Build-Skripte vorbereiten. Dazu benötigen wir das Tool CMake. Stellen Sie sicher, dass Sie CMake Version 3.21 oder neuer installiert haben$ cmake --version). Falls nicht, verwenden Sie die folgenden HomeBrew-Befehle, um es zu installieren und zu verknüpfen:

$ brew install cmake
$ brew unlink cmake && brew link cmake

Für das Android SDK und NDK müssen Sie die folgenden Pfade als Umgebungsvariablen definieren. Normalerweise fügen Sie diese zu Ihrer Shell-Konfigurationsdatei hinzu, z. B.  ~/.bashrc oder ~/.zshrc. Stellen Sie sicher, dass Ihre NDK-Version neuer als 23.0.75 ist.

export ANDROID_HOME=/$HOME/Library/Android/sdk
export ANDROID_NDK_HOME=$ANDROID_HOME/ndk-bundle
export ANDROID_NDK=$ANDROID_NDK_HOME
export NDK=$ANDROID_HOME/ndk/23.0.7599858

Es ist nicht einfach, OpenCV für alle Architekturen zu bauen. Daher haben wir das folgende GitHub-Repository zusammengestellt, das die Build-Skripte und alle anderen Projektdateien für dieses Tutorial enthält https://github.com/doo/flutter_ffi_demo

Kopieren Sie das gesamte src -Verzeichnis aus diesem Repository in Ihr Flutter-Projektverzeichnis flutter_ffi_demo. Unsere Build-Skripte für OpenCV finden Sie in den entsprechenden Plattform-Verzeichnissen:

  • src/android/opencv-build/build.sh
  • src/ios/opencv-build/build.sh

Diese Skripte laden die OpenCV-Quellen von GitHub herunter und bauen sie.

3) Wir erstellen OpenCV 

Nachdem wir die Build-Skripte für beide Plattformen vorbereitet haben, können wir sie über das Terminal ausführen. Mit dem Skript prebuild.sh starten wir beide:

cd src/
sh prebuild.sh

Dies wird eine ganze Weile dauern, aber man muss es nur einmal erledigen. Die Builds unseres eigentlichen Plugin-Projekts werden viel schneller laufen.

4) C++-Code 

Nachdem wir nun erfolgreich die OpenCV-Binärdateien erstellt haben, wollen wir nun C++-Code schreiben, der diese verwendet. Danach werden wir einen native dart::ffi -Layer erstellen, der die C++-Logik umhüllt. 

Hinweis: Der gesamte C++-Code sollte im Verzeichnis src/umbrella abgelegt werden, da er nicht plattformspezifisch ist. 

Zuerst werden wir eine ShapeDetector -Klasse implementieren. Diese Klasse verarbeitet die einzelnen Frames aus dem Kamera-Stream mit OpenCV und gibt dann alle darin gefundenen Kreise zurück. Wir werden nicht näher auf die Verwendung von OpenCV eingehen - verwenden Sie daher einfach den fertigen Code im src/umbrella -Ordner.


Nachdem wir unsere Klasse zur Verarbeitung von Frames implementiert haben, müssen wir einen Wrapper für sie schreiben, mit dem Dart auf die Ergebnisse des C++-Codes zugreifen kann. Die allgemeine Konvention für alle Fremdfunktionsschnittstellen (FFI) ist es, eine eindeutige öffentliche C-Sprachschnittstelle bereitzustellen. Zu diesem Zweck definieren wir ein extern C Protokoll . Dieses enthält die Initialisierung unserer C++-Bildprozessor-Implementierung und eine Methode, die Bildbytes im NV21 -Format. NV21 ist ein gängiges Format, das von OpenCV verwendet wird.

#ifdef __cplusplus
extern "C" {
#endif
flutter::Shape *processFrame(ShapeDetector *scanner, flutter::ImageForDetect *image) {
    auto img = flutter::prepareMat(image);
    auto shapes = scanner->detectShapes(img);
    // We need to map result as a linked list of items to return multiple results
    flutter::Shape *first = mapShapesFFiResultStruct(shapes);
    return first;
}
// We are returning a pointer to the instance of scanner to call it in the future
ShapeDetector *initDetector() {
    auto *scanner = new ShapeDetector();
    return scanner;
}
...
#ifdef __cplusplus
}
#endif

ShapeDetector *scanner ist ein Pointer auf die Instanz von ShapeDetector , auf der wir die Bildverarbeitung aufrufen wollen. flutter::ImageForDetect *image ist eine benutzerdefinierte Struktur, die die Bildmetadaten beschreibt. 

Mehr Details für flutter::ImageForDetect werden wir in Teil des Tutorials besprechen. Zu diesem Zeitpunkt müssen wir nur wissen, dass diese Struktur zwei verschiedene Frame-Formate abdecken muss, jeweils eines für Android und iOS. 

Um die Bildrahmen vor der Erkennung mit unserem ShapeDetector zu verarbeiten, brauchen wir noch einige zusätzliche Werkzeuge und Methoden. Diese stehen bereit in src/umbrella/Utils.

Dieser Ansatz hat zwei kleine Nachteile:

  • Wir müssen das Bild vorverarbeiten, weil das  flutter_camera  Bilder im YUV422 -Format (mehrere Ebenen) erzeugt. Für OpenCV benötigen wir jedoch cv::Mat -Objekte im NV21 -Format (nur zwei Ebenen). Wir haben diese Konvertierungsfunktionalität in der prepareMat -Methode implementiert, die unterschiedliche Logiken für iOS- und Android-Kameras handhabt (mehr dazu in Teil 2 dieses Tutorials).
  • dart::ffi unterstützt C-Arrays nicht richtig. Um mehrere Elemente zurückzugeben, müssen wir sie stattdessen als verknüpfte Liste speichern und einen Pointer auf den ersten Wert in der Liste zurückgeben. Ein Beispiel für die Ergebniszuordnung finden Sie hier in der mapShapesFFiResultStruct-Methode.

5) Dart-Code

Nun müssen wir unsere dart::ffi Schnittstelle auf der Dart-Seite implementieren. Der gesamte Dart-Code sollte sich im lib -Ordner befinden. Die wichtigsten Schritte hier sind:

  • Laden der Bibliothek für jede Plattform: iOS wird automatisch nach dem Flutter-Plugin mit dem Projektnamen flutter_ffi_demo  suchen, diese ist definiert in der  pubspec.yaml -Datei.
    Unter Android suchen wir nach einer Bibliothek mit dem Namen der Bibliotheksdatei innerhalb des Android-Projekts.
    Siehe die vollständige Datei lib/src/ffi/ffi_lookup.dart in unserem GitHub-Repository:
final sdkNative = Platform.isAndroid
    ? ffi.DynamicLibrary.open('libflutter_ffi.so')
    : ffi.DynamicLibrary.process();

Jetzt müssen wir alle Bridge-Methoden deklarieren, die in C++ bereits existieren. In Dart werden sie lookup functions (Lookup-Funktionen) genannt. Der folgende Codeausschnitt ist Teil der Datei lib/shape_detector/shape_detector.dart . Die vollständige Implementierung finden Sie hier.

final _processFrame = sdkNative
    .lookupFunction<_ProcessFrameNative, _ProcessFrame>('processFrame');

typedef _ProcessFrameNative = ffi.Pointer<_ShapeNative> Function(
    ffi.Pointer<ffi.NativeType>, ffi.Pointer<SdkImage>);
typedef _ProcessFrame = ffi.Pointer<_ShapeNative> Function(
    ffi.Pointer<ffi.NativeType>, ffi.Pointer<SdkImage>);

Werfen wir nun einen Blick auf den Hauptteil von dart::ffi für unseren Scanner. Hier deklarieren wir eine Methode _processFramewhich die zwei typedefs implementiert. Eine ist eine Flutter-Signatur - _ProcessFrameund die andere ist _ProcessFrameNative, ihre native Darstellung.

  • Jetzt können wir sie wie jede andere Flutter-Funktion aufrufen:
_processFrame(pointerToTheNativeScanner, SdkImage);

SdkImage ist eine native Struktur, die wir verwenden, um die in Ebenen getrennten Bildbyte-Daten zu behandeln. Sie deckt dieselbe Struktur ab wie flutter::ImageForDetect im C++-Code (die Details dazu werden in Teil 2 beschrieben).


pointerToTheNativeScanner ist ein Pointer auf den nativen Speicher, in dem unser Shape Detector gespeichert ist. So erhalten wir pointerToTheNativeScanner während der Scanner - Initialisierung.:

final _init = sdkNative
   .lookupFunction<_InitDetectorNative, _InitDetector>('initDetector');

typedef _InitDetectorNative = ffi.Pointer<ffi.NativeType> Function();
typedef _InitDetector = ffi.Pointer<ffi.NativeType> Function();

ffi.Pointer<ffi.NativeType> scanner = ffi.nullptr;

OpenCvShapeDetector();

Future<void> init() async {
 dispose(); //dispose if there was any native scanner initialized
scanner = _init();
 return;
}

Wir rufen die native Methode auf, die einen Pointer auf das interne Scanner-Objekt zurückgibt:

// We are returning a pointer to the instance of scanner to call it in the future
ShapeDetector *initDetector() {
    auto *scanner = new ShapeDetector();
    return scanner;
}

Dies waren die grundlegenden Schritte, die wir für die dart::ffi -Bridge implementieren mussten.


Wir müssen aber noch etwas mehr Code schreiben. Zum Beispiel:

  • Mapper von nativen Objekten zu den von dart::ffi unterstützten Objekten - und umgekehrt.
  • Bereinigung der Objekte aus dem Speicher nach jeder Erkennung.
  • Wir müssen einige Threading-Funktionen hinzufügen. Das liegt daran, dass Flutter selbst alles auf dem Haupt-Thread verarbeitet. Die rechenintensive Bildverarbeitung würde daher unsere Benutzeroberfläche komplett einfrieren.
  • Wir müssen die camera_plugin -Funktionalität verpacken, oder eine eigene Kamera-Implementierung schreiben.
  • Außerdem wollen wir ein grafisches Overlay für die Erkennungsergebnisse anzeigen.

Bis jetzt haben wir uns auf die Erstellung des nativen Teils. konzentriert. Jetzt ist es an der Zeit, alles miteinander zu verknüpfen. Wir werden sowohl die Plattform-Tools als auch die Library-Building-Funktionalität verwenden.

Scanbot SDK:
Unbegrenzte Scans zum Fixpreis

Ihre zuverlässige Datenerfassungs-Lösung für die Integration in Mobil- und Web-Apps.


Unterstützung aller gängigen Plattformen und Frameworks.

Android NDK und der Build-Prozess

Wir müssen ein Build-Skript für unsere C++-Quellen schreiben. Dafür könnten wir das Android NDK-Build-Skript Android.mk verwenden, aber eine andere Möglichkeit ist die Verwendung des CMake-Tools und der CMakeLists.txt. Wir haben uns aus zwei Gründen für den zweiten Ansatz entschieden: Er ist für die Erstellung von nativem Code gebräuchlicher, und wir können mehr Plattformen abdecken. 

Lassen Sie uns also mit CMake etwas zaubern. Das komplette Skript für Android finden Sie in src/android/CMakeLists.txt.

Definieren Sie zunächst die OpenCV-Verknüpfung mit all ihren Abhängigkeiten in CMakeLists.txt:

include(ExternalProject)
find_library(log-lib log) # Add Android logging
# GLESv2 look for OpenG Android
find_path(GLES2_INCLUDE_DIR GLES2/gl2.h
    HINTS ${ANDROID_NDK})
find_library(GLES2_LIBRARY libGLESv2.so
    HINTS ${GLES2_INCLUDE_DIR}/../lib)

# Open SSL
add_library(OpenSSL INTERFACE)
add_dependencies(OpenSSL ${openssl})
set_property(TARGET OpenSSL PROPERTY INTERFACE_INCLUDE_DIRECTORIES "${openssl}/include")
set_property(TARGET OpenSSL PROPERTY INTERFACE_LINK_DIRECTORIES "${openssl}/lib/${ANDROID_ABI}")
set_property(TARGET OpenSSL PROPERTY INTERFACE_LINK_LIBRARIES crypto ssl)

message("open-jpeg ${open_jpeg}")
# OpenCV
set(OpenCV_DIR ${opencv})
set(OpenJPEG_DIR ${open_jpeg})
find_package(OpenJPEG REQUIRED HINTS ${open_jpeg})
find_package(OpenCV REQUIRED HINTS ${opencv})

Als Nächstes geben wir die Pfade zu den Quellen unseres nativen Codes an:

set(FLUTTER_UMBRELLA
   ${umbrella}/OpenCV/OpenCvFFI.cpp
   ${umbrella}/FaceDetector/FaceDetector.cpp
   ${umbrella}/ShapeDetector/ShapeDetector.cpp
)
# Build a shared library with our umbrella classes
add_library(${CMAKE_PROJECT_NAME} SHARED
   ${FLUTTER_UMBRELLA}
)
target_include_directories(${CMAKE_PROJECT_NAME} PUBLIC
    ${umbrella}
)

Nun müssen wir OpenCV mit all seinen externen Abhängigkeiten (die bereits vorkompiliert sind) verknüpfen:

target_link_libraries(${CMAKE_PROJECT_NAME} PUBLIC
   ${OPENJPEG_LIBRARIES}
   ${OpenCV_LIBS}
    OpenSSL
   ${log-lib}
   ${GLES2_LIBRARY}
)

Jetzt haben wir ein CMake-Skript, das unsere Build-Beschreibung für die gemeinsam genutzte Bibliothek enthält. Schließlich verknüpfen wir es mit dem Android-Gradle , das die eigentliche Erstellung übernimmt. Hier ist der relevante Inhalt von android/build.gradle:

defaultConfig {
    minSdkVersion 21
    //for debug
    externalNativeBuild {
        cmake {
            arguments "-DANDROID_STL=c++_shared",
                    "-DANDROID_TOOLCHAIN=clang",
                    "-DCMAKE_BUILD_TYPE:=Release" //Use Debug for proper debugging of native code
        }
    }
}

externalNativeBuild {
    cmake {
        path file('../src/android/CMakeLists.txt')
        version '3.21.2'
    }
}
ndkVersion "23.0.7599858"

Einige Probleme, die hier auftreten können:

  • Wir müssen CMake 3.21+ verwenden, ansonsten schlägt die Kompilierung fehl. Ab Version 3.21 kann CMake einige zusätzliche Flags und Eigenschaften verarbeiten, die für einen korrekten Build unserer gemeinsam genutzten Bibliothek mit OpenCV erforderlich sind. Um dies sicherzustellen, haben wir die NDK- und CMake-Versionen in der Gradle-Konfiguration angegeben.
  • Wenn der Code nicht kompiliert, versuchen Sie, alle Android NDK-Versionen außer der in Gradle deklarierten zu löschen. Wir benötigen Version 23.0.75 oder höher.

Das ist alles, was wir brauchen, um unser Build-System für nativen Code einzurichten. Wir können es nun für die Entwicklung und das Debugging verwenden. 

Für den Release-Build müssen wir die nativen Bibliotheken manuell vorkompilieren, da wir unsere Quellen nicht mit dem Plugin ausliefern werden. In diesem Fall sollten die Bibliotheken im eigenen src/lib -Ordner des Android-Plugins gespeichert und wie unten gezeigt zu Gradle hinzugefügt werden. 

Da wir all diese Bibliotheken bereits vorkompiliert haben, sollte der vorherige Codeschnipsel ignoriert werden.

sourceSets {
    main.java.srcDirs += 'src/main/kotlin'
    main.jniLibs.srcDirs = ['src/lib'] //for release
}

Ein Hinweis zum Debuggen und Entwickeln

Als Entwickler:in wollen wir unseren Code in einer IDE debuggen, zum Beispiel in Android Studio. Für Flutter ist das Debuggen von Nicht-Flutter-Code ein wenig schwierig. Wir benötigen eine separate Instanz von Android Studio, die in der Lage ist, Kotlin- und C++-Code zu debuggen. 

Dazu müssen wir den Android-Teil der Flutter-App wie folgt öffnen:

  • Klicken Sie mit der rechten Maustaste auf android in unserer App.
  • Gehen Sie im Menü auf Flutter.
  • Wählen Sie Open Android module in Android Studio.

Nachdem wir nun mit dem Android-Teil fertig sind, kommen wir zu iOS.

iOS-Build-System und -Prozess

Um den Build-Prozess zu vereinheitlichen und ihn der Android-Konfiguration anzugleichen, werden wir auch hier CMake verwenden, um Xcode frameworks zu erstellen. 

Die iOS-CMake-Konfiguration ist im Allgemeinen die gleiche wie die für Android, aber es gibt ein paar wichtige Unterschiede. Hier ist die CMake-Konfiguration für die Erstellung der .framework - Datei aus den Quellen. Das vollständige Beispiel finden Sie hier: src/ios/CMakeLists.txt.

set_target_properties(${CMAKE_PROJECT_NAME} PROPERTIES
  FRAMEWORK TRUE
  MACOSX_FRAMEWORK_IDENTIFIER com.scanbot.flutterffi
  PUBLIC_HEADER "${FLUTTER_UMBRELLA_PUBLIC}"
  VERSION 1.0.0
  SOVERSION 1.0.0
#For release build
  #XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY ""
  #XCODE_ATTRIBUTE_DEVELOPMENT_TEAM ""
  #XCODE_ATTRIBUTE_CODE_SIGNING_REQUIRED "YES"
)

Das folgende Xcode-Flag muss für die bitcode - Optimierungen hinzugefügt werden:

## compile option necessary to build proper framework
target_compile_options(${CMAKE_PROJECT_NAME}
 PUBLIC
   "-fembed-bitcode"
 )

Außerdem müssen wir unterschiedliche Pfade für OpenSSL- und OpenCV-Builds auswählen. Details dazu finden Sie in src/ios/CMakeLists.txt.

Nachdem wir unsere CMakeLists.txt erstellt haben, müssen wir uns mit einem weiteren Hindernis befassen. Wir können unseren CMake-Build nicht direkt mit dem iOS-Modul verbinden, da dessen Build-System dies nicht unterstützt. Stattdessen werden wir die Framework-Datei vorkompilieren und sie an der richtigen Stelle ablegen. Dazu erstellen Sie den Ordner src/ios/build und führen darin den folgenden cmake -Befehl aus:

$ cd src/ios/build/
$ cmake ../ \
-DCMAKE_TOOLCHAIN_FILE=$HOME/projects/flutter_ffi_demo/src/ios/opencv-build/opencv-4.5.0/platforms/ios/cmake/Toolchains/Toolchain-iPhoneOS_Xcode.cmake \
-DIOS_ARCH=arm64 \
-DIPHONEOS_DEPLOYMENT_TARGET=11.0 \
-DCMAKE_OSX_SYSROOT=iphoneos \
-DCMAKE_CONFIGURATION_TYPES:=Debug \
-GXcode

Nachdem CMake erfolgreich beendet wurde, verwenden Sie den folgenden Xcode-Befehl zu Erstellung des Projekts: flutter_ffi.xcodeproj:

$ xcodebuild -project flutter_ffi.xcodeproj -target flutter_ffi

Jetzt können wir in den Debug-iphoneos gehen und die .framework -Datei von dort in den iOS-Plugin-Ordner kopieren.
Um diesen Prozess zu vereinfachen, stellen wir das Build-Skript build.sh im Repository-Ordner build.sh bereit. Nach erfolgreicher Ausführung sollten Sie die Datei src/ios. Nach erfolgreicher Ausführung sollten Sie die Datei flutter_ffi.framework im ios -Hauptordner finden. Dieses Framework wird für weitere iOS-Builds verwendet.

Schließlich müssen wir die generierte Framework-Datei für unsere Anwendung sichtbar machen. Da der iOS-Teil des Flutter-Plugins nur als pod bereitgestellt wird, müssen wir unser Framework als "vendor framework" registrieren. Nur so können wir erreichen, dass der Xcode-Pod ein vorgefertigtes Framework enthält. 

Dazu fügen wir einfach die folgende Zeile in die ios/flutter_ffi_demo.podspec -Datei hinzu:

 s.vendored_frameworks = 'flutter_ffi.framework'

Das war's! Jetzt können wir unsere Anwendung auf iOS und Android verwenden.

Zusammenfassung

In diesem ersten Teil des Artikels haben wir ein leeres Flutter-Plugin mit nativer Code-Unterstützung über dart::ffi implementiert. Es sind noch einige Dinge zu tun, um ein komplettes Kamera-Plugin mit allen grundlegenden Funktionen zu erstellen. Im zweiten Teil werden wir durchgehen, wie man mit der Kamera in Flutter umgeht, wie man die Bilder streamt, konvertiert und mit OpenCV verarbeitet und wie man die Ergebnisse anzeigt.

Weiter geht es mit dem Tutorial in Teil 2.

Sie möchten jetzt loslegen? Dann probieren Sie unsere Flutter Document oder Flutter Barcode Scanner SKDs aus.

Bereit zum Ausprobieren?

Das Hinzufügen unserer kostenlosen Testversion zu Ihrer App ist ganz einfach. Laden Sie das Scanbot SDK jetzt herunter und entdecken Sie die Möglichkeiten der mobilen Datenerfassung.