Все вы знаете, что 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".