Иногда перед разработчиками появляются задачи, где требуется максимальное быстродействие исполняемого кода или необходимо воспользоваться уже готовым решением.

В подобных случаях можно не ограничиваться лишь одним языком Dart благодаря поддержке технологии FFI (Foreign Function Interface, интерфейс внешних функций). С её помощью мы можем запускать код языков, компилируемых в динамические библиотеки с C-вызовами, — таких, как сам С, С++, С#, Rust или Go .

В этом параграфе мы разберём простой пример, как добавить в своё приложение FFI-плагин, а также подробно рассмотрим линковку на стороне Dart и настройку сборки приложения и самого плагина.

Первое погружение

В качестве примера работы с FFI добавим в обычное Flutter-приложение самостоятельно написанный плагин, который на стороне C будет вычислять факториал числа. Убедимся в работоспособности технологии, а после начнём погружаться в детали.

Создадим приложение:

Код приложения
1import 'package:flutter/material.dart';
2
3void main() {
4  runApp(const MaterialApp(home: MainApp()));
5}
6
7class MainApp extends StatefulWidget {
8  const MainApp({super.key});
9
10  @override
11  State<MainApp> createState() => _MainAppState();
12}
13
14class _MainAppState extends State<MainApp> {
15  // Вычисляем ли мы в данный момент факториал
16  bool isCalculating = false;
17  // Вычисленное значение факториала
18  int? calculatedNumber;
19
20  TextEditingController controller = TextEditingController();
21
22  @override
23  Widget build(BuildContext context) {
24    return Scaffold(
25      appBar: AppBar(
26        title: const Text('Вычисление факториала числа'),
27      ),
28      body: Center(
29        child: Column(
30          mainAxisAlignment: MainAxisAlignment.center,
31          children: [
32			// TextField для ввода числа, факториал которого необходимо вычислить
33            SizedBox(
34              width: 200,
35              child: TextField(
36                controller: controller,
37                textAlign: TextAlign.center,
38                decoration: const InputDecoration(
39                  border: OutlineInputBorder(),
40                  hintText: 'Введите число',
41                ),
42                onChanged: (value) {
43                  calculatedNumber = null;
44                  setState(() {});
45                },
46              ),
47            ),
48            const SizedBox(height: 10),
49			// Кнопка для вычисления факториала, на которой будет отображаться вычисленное значение
50            SizedBox(
51              width: 150,
52              height: 35,
53              child: OutlinedButton(
54                onPressed: () async {
55                  if (!isCalculating) {
56                    isCalculating = true;
57                    setState(() {});
58
59                    final number = int.tryParse(controller.text);
60                    if (number == null) {
61                      controller.clear();
62                    } else {
63                      // TODO здесь необходимо заменить на реальное вычисление в плагине
64                      calculatedNumber = number;
65                    }
66                    isCalculating = false;
67                    setState(() {});
68                  }
69                },
70                child: isCalculating
71                    ? const CircularProgressIndicator()
72                    : Text(
73                        calculatedNumber == null
74                            ? 'Вычислить!'
75                            : calculatedNumber.toString(),
76                      ),
77              ),
78            )
79          ],
80        ),
81      ),
82    );
83  }
84}
85

Теперь добавим свой FFI-плагин. Для этого создадим папку packages и внутри выполним команду создания шаблона FFI-плагина:

1flutter create ffi_factorial_plugin --template=plugin_ffi --platforms=android,ios,linux,macos,windows

Этот шаблон вычисляет сумму двух чисел с помощью C-кода. Код с использованием FFI на стороне Dart хранится в lib-директории, а часть для сборки библиотеки — в директории src. Немного подкорректируем код в обеих частях, чтобы он вычислял только факториал.

Вначале зайдём в папку src, которая отвечает за сборку FFI-библиотеки. Внутри имеются заголовочный файл ffi_factorial_plugin.h, файл с C-кодом ffi_factorial_plugin.c и сборочный файл СMakeList.txt с инструкциями компилятору. Вначале откроем файл ffi_factorial_plugin.c и заменим имеющиеся здесь реализации функций на:

1FFI_PLUGIN_EXPORT intptr_t factorial(intptr_t n) { 
2  intptr_t i = 2, num = 1;
3  while (i <= n) {
4    num *= i;
5    i++;
6  }
7
8  return num;
9}

Откроем заголовочный файл ffi_factorial_plugin.h и оставим объявление только функции факториала:

1FFI_PLUGIN_EXPORT intptr_t factorial(intptr_t n);

Таким образом собираемая из этого C-кода библиотека будет иметь только нужный набор функций. Названия файлов или их состав не изменялись, так что сборочный файл CMakeList.txt, в котором описан скрипт для сборки библиотеки, остаётся таким же.

Теперь необходимо актуализировать Dart-часть в lib-директории. Заменим класс FfiFactorialPluginBindings в файле ffi_factorial_plugin_bindings_generated.dart на:

1class FfiFactorialPluginBindings {
2  /// Функция поиска символов
3  final ffi.Pointer<T> Function<T extends ffi.NativeType>(String symbolName)
4      _lookup;
5
6  /// Символы просматриваются в [dynamicLibrary].
7  FfiFactorialPluginBindings(ffi.DynamicLibrary dynamicLibrary)
8      : _lookup = dynamicLibrary.lookup;
9
10  /// Символы просматриваются с помощью [lookup].
11  FfiFactorialPluginBindings.fromLookup(
12      ffi.Pointer<T> Function<T extends ffi.NativeType>(String symbolName)
13          lookup)
14      : _lookup = lookup;
15
16  int factorial(
17    int n,
18  ) {
19    return _factorial(
20      n,
21    );
22  }
23
24  late final _factorialPtr =
25      _lookup<ffi.NativeFunction<ffi.IntPtr Function(ffi.IntPtr)>>('factorial');
26  late final _factorial = _factorialPtr.asFunction<int Function(int)>();
27}

Файл bindings в шаблоне плагина сгенерирован библиотекой ffigen. Можно было обновить файл с её помощью, но об этом будет рассказано ниже. Для простоты заменим всё руками.

В одноимённом с плагином ffi_factorial_plugin.dart файле заменим sum на factorial, а также удалим весь код, связанный с асинхронным вызовом sumAsync, который будет рассмотрен позже.

1int  factorial(int a) => _bindings.factorial(a);

Ошибки в примере плагина, вызванные переименовыванием функций, можно проигнорировать. Вернёмся в приложение, добавим плагин в pubspec по пути и воспользуемся им в коде. Не забудем запустить pub get, который поменяет некоторые generated_plugins-файлы.

Итого была написана функция на стороне C, к ней протянуты ручки на стороне Dart, после подключили плагин в приложение — всё готово к запуску.

Вуаля, мы прекрасны! Однако, чтобы написать что-то более сложное, чем одна синхронная функция, необходимо разобраться в деталях того, как это всё работает.

Подключаем библиотеку на Dart-стороне

Любой FFI-плагин состоит из двух частей:

  • Dart-интерфейса, который вызывает C-функции, изменяя отправляемые и получаемые данные в необходимый для обмена формат;

  • нативной части, которая поставляет в приложение либо уже готовый файл динамической библиотеки, либо компилируемый из собственного кода во время сборки.

Вся Dart-часть плагина находится в папке lib и состоит в основном из bindings. Это код, что соединяет dart и C-вызовы и реализует преобразование данных к нужным типам.

В случае из примера это синглтон-класс FfiFactorialPluginBindings, используемый через интерфейс. А точнее, через верхнеуровневые функции в одноимённом с плагином файле. Он принимает объект DynamicLibrary — Dart-обёртку вокруг реального файла динамической библиотеки, который использует для получения функции _lookup.

1final ffi.Pointer<T> Function<T extends ffi.NativeType>(String symbolName)
2   _lookup;
3
4FfiFactorialPluginBindings(ffi.DynamicLibrary dynamicLibrary)
5   : _lookup = dynamicLibrary.lookup;
6

Она необходима для того, чтобы по названию функции, определённой в одном из заголовочных файлов C, получить указатель на саму функцию внутри библиотеки. Указатель — это переменная, которая хранит адрес памяти, указывающий на определённые данные.

Пакет dart:ffi предоставляет необходимые классы для передачи простых типов данных, ссылок или структур. Функция, возвращаемая из функции _lookup, имеет тип NativeFunction и может использовать только FFI-типы.

Её метод asFunction вернёт обычную Dart-функцию, которая может использовать и обычные типы:

1late final _factorialPtr =
2      _lookup<ffi.NativeFunction<ffi.IntPtr Function(ffi.IntPtr)>>('factorial');
3
4late final _factorial = _factorialPtr.asFunction<int Function(int)>();
5

Если же требуется передавать на сторону C-тип Object?, можно воспользоваться более низкоуровневым типом Handle и его C-аналогом Dart_Handle, однако для простоты обработки передачи параметров стоит ограничиться простыми типами и структурами.

Конечно, можно передавать модели данных с помощью JSON, но тогда пострадает быстродействие. Для передачи сложных данных, в том числе массивов, среди классов FFI существуют структуры. Чтобы их использовать, в Dart необходимо создать класс, наследованный от Struct, а в C-коде сделать его аналог. Обязательно нужно сохранять порядок полей структур в обоих языках.

Пример создания структуры массива в Dart:

1final class IntArray extends Struct {
2  external Pointer<Int32> data;
3  
4  @Int32
5  external int length;
6}
7

Пример этой же структуры в заголовочном C-файле:

1struct {
2  int *data;
3  int length;
4} IntArray;
5

Другие структуры создаются аналогично. Стоит упомянуть, что поддержка дженерик-данных отсутствует, то есть не получится создать структуру массива произвольного типа.

Существует запрет на создание наследников Struct в коде Dart. Чтобы обойти это ограничение, например, для передачи массивов или для любой другой работы с ссылками, необходимо воспользоваться Pointer — это аналог указателя в Dart.

Он создаётся более сложно, нежели в С++. Для создания необходимо подключить уже не только библиотеку dart:ffi, но и пакет package:ffi/ffi.dart. Он обладает классом Arena, с помощью которого можно выделять память:

1// Создаём объект для выделения памяти
2Arena arena = Arena();
3
4// Создаём нужные указатели:
5Pointer<IntArray> array = arena.call<IntArray>();
6array.ref.length = length;
7array.ref.data = arena.call<Int>(length);
8
9// use(array);
10
11// После вызова FFI-функции, то есть после того, как созданные указатели перестали быть нужны, очищаем память:
12arena.releaseAll();
13

Стоит оборачивать работу с указателями в try/catch и в блоке finally вызывать arena.releaseAll(), чтобы не допускать утечек памяти.

Как можно заметить, функции, вызываемые через FFI, выполняются синхронно. Это одновременно и удобно, и может оказаться проблемой. Так как код Dart по умолчанию выполняется в главном изоляте, то при вызове длительной функции на стороне C приложение на Flutter зависнет.

Чтобы этого не происходило, необходимо настроить выполнение таких функций в изоляте. Особенности их работы были описаны в другом уроке. Рассмотрим их применение, которое было в шаблоне плагина:

Пример вызова функции через FFI асинхронно через изолят
1/// Асинхронный вызов функции факториала в отдельном изоляте
2Future<int> factorialAsync(int a) async {
3  // Ожидаем инициализацию изолята и получаем его SendPort
4  final SendPort helperIsolateSendPort = await _helperIsolateSendPort;
5  // Создаём запрос, присваиваем ему id
6  final int requestId = _nextRequestId++;
7  final _Request request = _Request(requestId, a);
8  // Создаём новый Completer, в котором будет содержаться future с результатом вычислений
9  final Completer<int> completer = Completer<int>();
10  _requests[requestId] = completer;
11
12  // Отправляем запрос в изолят и ждём, пока он выполнит Completer
13  helperIsolateSendPort.send(request);
14  return completer.future;
15}
16
17/// Модель запроса
18class _Request {
19  final int id;
20  final int a;
21
22  const _Request(this.id, this.a);
23}
24
25/// Модель ответа
26class _Response {
27  final int id;
28  final int result;
29
30  const _Response(this.id, this.result);
31}
32
33/// Счётчик запросов
34int _nextRequestId = 0;
35
36/// Словарь, в котором по id запроса находится Completer, в котором ожидается результат вычислений
37final _requests = <int, Completer<int>>{};
38
39/// Инициализация дополнительного изолята
40/// Чтобы создать изолят и реализовать обмен сообщений между ним и главным изолятом,
41/// требуется обменяться портами. После этого можно начать обмениваться сообщениями.
42Future<SendPort> _helperIsolateSendPort = () async {
43  // Создаём Completer, в который дополнительный изолят внесёт свой SendPort
44  final Completer<SendPort> completer = Completer<SendPort>();
45
46  // Создаём ReceivePort главного изолята для двух видов сообщений от дополнительного изолята:
47  final ReceivePort receivePort = ReceivePort()
48    ..listen((dynamic data) {
49      if (data is SendPort) {
50        // 1. Получаем SendPort после инициализации, чтобы выполнить Completer и передать его вовне
51        completer.complete(data);
52        return;
53      }
54      if (data is _Response) {
55        // 2. Получаем результат вычислений и выполняем Completer
56        final Completer<int> completer = _requests[data.id]!;
57        _requests.remove(data.id);
58        completer.complete(data.result);
59        return;
60      }
61      throw UnsupportedError('Unsupported message type: ${data.runtimeType}');
62    });
63
64  // Создание дополнительного изолята
65  await Isolate.spawn((SendPort sendPort) async {
66    // Создаём ReceivePort дополнительного изолята для сообщений от главного изолята:
67    final ReceivePort helperReceivePort = ReceivePort()
68      ..listen((dynamic data) {
69        // При получении запроса вычисляем функцию, создаём ответ и отправляем главному изоляту
70        if (data is _Request) {
71          final int result = _bindings.factorial(data.a);
72          final _Response response = _Response(data.id, result);
73          sendPort.send(response);
74          return;
75        }
76        throw UnsupportedError('Unsupported message type: ${data.runtimeType}');
77      });
78
79    // После инициализации отправляем SendPort дополнительного изолята в SendPort главного
80    sendPort.send(helperReceivePort.sendPort);
81  }, receivePort.sendPort);
82
83  // Дожидаемся завершения обмена SendPort между изолятами
84  return completer.future;
85}();
86

Также Dart предоставляет возможность взять значение параметра nativePort класса SendPort для передачи на C-сторону. Тогда нативный код сможет с любого потока отправлять сообщения с данными на этот порт при помощи Dart_PostCObject.

Написание правильных объявлений для функций, классов-структур и прочая работа в файле bindings достаточно утомляющая и однообразная. Именно потому и существует пакет ffigen, позволяющий кодогенерировать Dart-сторону взаимодействия с библиотекой, основываясь на заголовочных файлах с C-объявлениями функций. Учтите, ffigen работает только с C-заголовками, не С++ или иными другими.

Чтобы установить ffigen, необходимо добавить его в dev-зависимости плагина, создать файл с настройками проекта ffigen.yaml ( как в созданном нами приложении), а также скачать LLVM.

Это достаточно большой и низкоуровневый проект, предоставляющий инструменты компиляции, в который для пользования ffigen совсем не обязательно погружаться. Достаточно его установить и прописать в PATH, после чего убедиться в работоспособности ffigen с помощью команды в директории плагина:

dart run ffigen --config ffigen.yaml

Кодогенерация позволит сэкономить много времени на написании объявлений функций, структур, перечислений и остального, что можно найти в заголовочных C-файлах. Кроме того, файл ffigen.yaml позволяет достаточно тонко настроить генерацию. Основные ограничения здесь — это сложность погружения в работу генератора, а также ограничение работы только с C-заголовками. В экспериментальном формате поддержаны ObjC- и Swift-заголовки.


Примерно так происходит «прокладывание мостов» между C-кодом и Dart. Создаём модели-аналоги с использованием Struct, Pointer и FFI-типов, объявляем функции, оборачиваем в изоляты сложные вычисления, инициализируем DynamicLibrary, вызываем оттуда функцию по имени, обрабатываем результат — и всё готово. Но только со стороны Dart.

Остаётся побольше узнать про сами библиотеки и то, как они собираются.

Собираем библиотеку

Ранее мы сказали, что FFI работает с динамической библиотекой. Это файл, содержащий скомпилированный код, который могут использовать другие программы. Обычно используется расширение dll для Windows, so для Linux/Android и dylib для iOS/macOS.

Обратите внимание

Для iOS часто используется статическая линковка зависимостей, вследствие чего необходимо пользоваться DynamicLibrary.process() для поиска символов из самого бинарного файла.

Чтобы плагин с FFI работал, необходимо два шага:

  1. Собрать или получить файл библиотеки.

  2. Добавить его в сборку приложения, то есть зарегистрировать как плагин. Как видите, после команды pub get некоторые файлы в платформенных директориях изменились — это и была регистрация нового плагина.

В создаваемом шаблоне плагина этап регистрации библиотеки описан в платформенных директориях — Android/iOS/Windows и т. д. Иногда для FFI-плагинов вместо iOS и macOS используют единую папку darwin. Шаги сборки самой библиотеки описаны в файле src/CMakeList.txt.

В платформенных директориях плагина используются сборочные файлы:

  • CMakeList.txt (Windows/Linux);

  • build.gradle (Android);

  • podspec (iOS, macOS).

В шаблонном плагине файлы генерируются с комментариями, описывающими каждый шаг. Текст для каждой платформы разный, но основная логика, что в них содержится, — это включение в процесс сборки src/CMakeList.txt для Windows/Linux/Android или включение напрямую файлов с кодом для iOS/macOS.

При расширении плагина в этих сборочных файлах может быть описана любая другая работа с нативом, например получение разрешений, постобработка скомпилированной библиотеки или даже загрузка уже готового скомпилированного файла, а не сборка собственного. Пример последнего можно увидеть для плагина sqlite3_flutter_libs или pdfrx.

В том случае, когда требуется использовать в плагине собственную библиотеку, необходимо обратить внимание на src/CMakeList.txt, в котором описаны шаги сборки. Чтобы система сборки CMake стала понятнее, рассмотрим такой файл подробнее:

1# Устанавливаем минимальную версию cmake
2cmake_minimum_required(VERSION 3.10)
3
4# Создаём проект библиотеки. Указываем язык С
5project(ffi_factorial_plugin_library VERSION 0.0.1 LANGUAGES C)
6
7# Добавляем в проект файл с реализацией
8add_library(ffi_factorial_plugin SHARED
9  "ffi_factorial_plugin.c"
10)
11
12# Добавляем в проект заголовочный файл и указываем имя выходного файла
13set_target_properties(ffi_factorial_plugin PROPERTIES
14  PUBLIC_HEADER ffi_factorial_plugin.h
15  OUTPUT_NAME "ffi_factorial_plugin"
16)
17
18# Указываем компилятору аргумент DART_SHARED_LIB
19target_compile_definitions(ffi_factorial_plugin PUBLIC DART_SHARED_LIB)
20

Сами же файлы внутри директории src представляют собой обычный C-проект за исключением специального флага, который помечает статические функции, которые должны стать внешними в результате компиляции.

Для C-кода используется FFI_PLUGIN_EXPORT, который определяется как:

1#if _WIN32
2#define FFI_PLUGIN_EXPORT __declspec(dllexport)
3#else
4#define FFI_PLUGIN_EXPORT
5#endif
6

Для С++ кода требуется все используемые объявления внешних функций обернуть в extern "C" {}. С помощью этой обёртки мы сводим компиляцию С++ заголовков к C, т. к. FFI умеет работать только с ними. Также в CMake-файле необходимо заменить язык компиляции на CPP.

Аналогично и для любого другого языка, поддерживающего компиляцию в библиотеки с поддержкой C-вызовов, требуется пометить внешние функции и особым образом скомпилировать файлы в библиотеку.

Что в итоге

Механизм FFI способен значительно ускорить разработку или повысить быстродействие кода, ведь с его помощью можно сделать то, что буквально невозможно сделать только с использованием Dart.

Однако необходимо понимать, что с большой силой приходит большая ответственность. Увеличится вес приложения за счёт дополнительных файлов, станет сложнее отлаживать код сразу для двух языков. Поэтому перед принятием решения об использовании FFI в реальном проекте необходимо подумать, принесёт ли он пользу.

Не стоит забывать и о том, что на создание промежуточных моделей, запуск функций и прочего тоже уходит время, так что использование FFI может, наоборот, замедлить выполнение кода. Используйте его на реально дорогостоящих операциях.

Хочется ещё упомянуть библиотеки, позволяющие более удобно подключать другие языки. А именно пакет flutter_rust_bridge для подключения библиотек, написанных на Rust. А также jnigen для работы с JAR-библиотеками.

Отмечайте параграфы как прочитанные чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф6.2. PlatformViews
Следующий параграф6.4. Add to App