Наводим мосты между Flutter и нативными библиотеками
Все вы знаете, что 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".