Иногда перед разработчиками появляются задачи, где требуется максимальное быстродействие исполняемого кода или необходимо воспользоваться уже готовым решением.
В подобных случаях можно не ограничиваться лишь одним языком 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 работал, необходимо два шага:
-
Собрать или получить файл библиотеки.
-
Добавить его в сборку приложения, то есть зарегистрировать как плагин. Как видите, после команды
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-библиотеками.