
Все вы знаете, что Flutter реализует несколько абстракций для передачи данных между Dart-кодом и кодом, связанным с оболочкой Flutter Engine на языке платформы (например, Kotlin для Android). Но в действительности у Dart есть еще один инструмент для взаимодействия с внешним миром и он может использоваться для добавления C/C++ библиотек и вызова функций из Dart-кода. Основную сложность представляет разные соглашения по кодированию типизованных числовых значений, строк и структур, но часть задач по преобразованию и работе с памятью выполняют библиотека dart:ffi и пакет package:ffi/ffi.dart, а некоторые из них могут быть выполнены самостоятельно. В статье мы рассмотрим общие принципы подключения внешних библиотек и кодогенерации для создания связываний dart-функции и классов и структур данных C.
Начнем с более простого примера и создадим небольшую библиотеку на C и рассмотрим последовательность действий для ее интеграции во Flutter-приложение.
Создадим новый проект и в нем новый каталог в android/app/src/main/cpp, добавим исходный текст, файл с заголовками и CMakeLists.txt для сборки библиотеки:
#include "string.h" #include "stdlib.h" int sum(int a, int b) { return a+b; } int len(char* str) { return strlen(str); } char* reversed(char* str) { char* newstr = malloc(strlen(str)+1); str += strlen(str)-1; while (*str) { *newstr = *str; str--; newstr++; } *newstr = 0; return newstr; }
#ifndef FFI1_SAMPLE_H #define FFI1_SAMPLE_H int sum(int a, int b); int len(char* str); char* reversed(char* str); #endif //FFI1_SAMPLE_H
И создадим CMakeLists.txt:
cmake_minimum_required(VERSION 3.4.1) project(sample) add_library(sample SHARED sample.c) find_library(log-lib log) target_link_libraries(sample)
И также добавим конфигурацию внешней сборки в app/build.gradle:
... android { ... externalNativeBuild { cmake { path "src/main/сpp/CMakeLists.txt" } } }
После этого при выполнении сборки проекта через flutter build apk в финальный архив будет добавляться so-файл библиотеки, собранный для соответствующих платформ (lib/). Для подключения к библиотеке мы будем использовать возможности dart:ffi (Foreign Function Interface).
dart:ffi определяет внешние (нативные) типы данных и позволяет связать определение dart-функции и внешней функции из импортированной библиотеки. Типы данных включают в себя:
Void - пустой тип (может использоваться как результат функции, которая ничего не возвращает);
Bool - логическое значение (в C будет представлен целым числом);
Int8, Int16, Int32, Int64 - целое число со знаком с соответствующей разрядностью;
Uint8 (Char), Uint16, Uint32, Uint64 - целое число без знака с соответствующей разрядностью;
Float, Double - 32-х и 64-х разрядное число с плавающей точкой;
Pointer - указатель на область памяти в C;
Array, Struct, Union - составные типы данных (соответственно массив однотипных элементов, структура разнотипных элементов, объединение элементов, занимающих одну область памяти).
Для привязки внешних функций необходимо загрузить библиотеку (DynamicLibrary.open) и выполнить обнаружение функции по сигнатуре и названию. Например, для поиска функции sum можно использовать следующий вызов (при условии, что в переменной library размещен результат вызова DynamicLibrary.open):
typedef DartSumFunction = int sum(int,int); typedef CSumFunction = ffi.Int64 ffi.Function(ffi.Int64,ffi.Int64)> DartSumFunction sum = library.lookup('sum').asFunction();
Для определения структур и объединений необходимо создать класс-расширение от ffi.Struct или ffi.Union и пометить все его свойства ключевым словом external (внешние типы данных задаются через ffi-типы, которые используются как аннотации), например для описания комплексного числа можно создать такую структуру:
class ComplexNumber extends ffi.Struct { @ffi.Double() external double x; @ffi.Double() external double y; }
Объединения создаются аналогично (наследованием от ffi.Union). Для управления памятью и определения указателей нужно использовать тип Pointer с уточнением типа значения под указателем (например, Pointer, Pointer или Uint8Pointer может быть использован как указатель на строку, поскольку аналогом в C является тип char*, указатель на целое 8-битное число без знака).
Для получения указателя из typed_data (бывает часто нужно для передачи двоичных данных, например изображения), можно использовать следующий фрагмент кода:
Uint8Pointer getBlob(Uint8List data) { final blob = calloc(data.length); final blobBytes = blob.asTypedList(data.length); blobBytes.setAll(0, data); return blob; }
Для определения массива (Array) также нужно использовать уточнение типа или одно из расширений (например, Int64Array).
Для выделения памяти можно использовать функцию calloc(N), которая возвращает указатель на тип T с выделением N элементов (суммарно N*sizeof(T) байт). Также для создания указателя на один элемент (например структуру) можно использовать malloc(). Важно не забывать освобождать выделенную память через malloc.free(ptr).
Для преобразования строк доступны расширения в String toNativeUtf8 (возвращает Pointer) и toDartString() для обратного преобразования. Для извлечения значения под указателем используется метод .value(). Указатели могут быть преобразованы к другому типу методом .cast. Все функции по управлению памятью и работе с указателями доступны в пакете package:ffi/ffi.dart.
Всегда необходимо помнить, что если внешняя функция возвращает указатель, нужно скопировать данные в другой объект, поскольку указатель будет нужно освободить. Поэтому предпочтительно для структур и объединений создавать отдельный Dart-класс и именованный конструктор, который будет заполнять данные Dart-объекта на основе значений, полученных из указателя на структуру.
Процесс создания интерфейса для вызова внешних функций может быть автоматизировать с помощью кодогенерации пакетом ffigen. Установим его как зависимость для разработки:
flutter pub add --dev ffigen
И добавим конфигурацию в pubspec.yaml:
ffigen: output: 'lib/generated_bindings.dart' name: SampleNative headers: entry-points: - 'android/app/src/main/сpp/sample.h'
После запуска flutter pub run ffigen в сгенерированном файле будет доступен класс SampleNative, который при создании получает объект загруженной динамической библиотеки и представляет интерфейс с методами, совпадающими с заголовочным файлом sample.h:
import 'package:path/path.dart' as path; String libraryFilename(String library) { if (Platform.isMacOs) return "lib${library}.dylib"; if (Platform.isWindows) return "${library}.dll"; return "lib${library}.so"; } void example() { ffi.DynamicLibrary library; if (Platform.isAndroid) { library = ffi.DynamicLibrary.open(libraryFilename('sample')); } else if (Platform.isIos) { library = ffi.DynamicLibrary.process(); } else { library = ffi.DynamicLibrary.open(path.join(Directory.current.path, libraryFilename('sample'))); } final nativeLibrary = NativeSample(library); print(nativeLibrary.sum(2,2)); }
Теперь, когда мы умеет подключать простые библиотеки, обсудим способы интеграции OpenCV.
Начнем с установки исходных текстов и настройки сборки для получения so-библиотек и необходимых заголовочных файлов:
git clone https://github.com/opencv/opencv.git git clone https://github.com/opencv/opencv_contrib.git python3 opencv/platforms/android/build_sdk.py --sdk_path $ANDROID_HOME --ndk_path $ANDROID_NDK_HOME --extra_modules_path opencv_contrib/modules/
Для импорта сгенерированных библиотек и header-файлов можно использовать готовый CMakeLists.txt отсюда. Для правильной сборки нужно указать диалект языка C++ в build.gradle:
externalNativeBuild { cmake { cppFlags "-frtti -fexceptions -std=c++17" abiFilters "armeabi-v7a", "arm64-v8a" } }
Обертки над функциями могут быть написаны самостоятельно или с использованием ffigen, например для функции cvMaxRect, которая принимает два указателя на прямоугольники и возвращает новый прямоугольник, определение может выглядеть следующим образом:
class CvRect extends Struct { @Int64() external int x; @Int64() external int y; @Int64(); external int width; @Int64(); external int height; } typedef CvRectPtr = Pointer<CvRect> typedef cv_max_rect_function = NativeFunction<CvRect Function(CvRectPtr, CvRectPtr)> typedef CvMaxRectFunction = CvRect Function<CvRectPtr, CvRectPtr> final cvMaxRect = library.lookup<cv_max_rect_function>('cvMaxRect').asFunction<CvMaxRectFunction>();
При вызовах ffi необходимо помнить, что обращение к функциям происходит синхронно и блокирует основной изолят, поэтому для длительных операций (преобразования изображение, детектирование объектов и др.) предпочтительно использовать изоляты (в простейшем варианте можно применить compute с передачей внешней функции).
Важно помнить, что dart FFI поддерживает только С API, поэтому для библиотек, которые созданы только для C++ потребуется создавать дополнительные функции-обертки для создания и удаления объектов (результатом может быть структура с указателем на объект или иным handle, который можно использовать для обнаружения созданного объекта в C-коде), а также функции-адаптеры для вызова соответствующих методов объекта (указатель или handle-объекта передается параметром функции). Для корректной компиляции обертки (при смешивании C и C++ исходных текстов) перед функциями необходимо добавить extern "C". Примеры создания таких связываний можно посмотреть в этом репозитории.
Во второй части статьи мы рассмотрим более подробно интеграцию нативных Android-библиотек на примере библиотеки обработки и синтеза звука AAudio.
И в заключение приглашаю всех желающих на бесплатный урок по теме: "Сферический Flutter в вакууме. Создаем свою систему координат для RenderObject".