Дружим Flutter с С# и С++
На Flutter`е очень удобно и хорошо пишутся интерфейсы для пользователей. Но использовать Dart для решения алгоритмических задач тяжко и неэффективно. Семейство Си справляется гораздо лучше и позволяет легко распараллеливать вычисления. Кроме того, за многолетнюю историю С++ и С# обзавелись множеством полезных библиотек, не все из которых имеют аналоги во Flutter.
Зная про существование библиотеки FFI для Flutter, что позволяет даже синхронно запускать Си-шный код, я решил закопаться в эту тему и попробовать объединить наследие Си и их эффективность с удобным фреймворком. Учитывая то, что в интернете маловато информации про использование FFI, особенно с C#, я решил поделиться своим опытом "построения мостов" на примере двух приложений в этой статье.
Немного теории
FFI - это foreign function interface, или же интерфейс внешних функций. Это механизм, позволяющий программе на одном языке вызывать функции, написанные на другом. В случае с Flutter-ом, это возможность вызывать из Dart функции Си из скомпилированных библиотек. Функции, что можно вызвать из вне обязательно перед компиляцией должны быть помечены как внешние и являться статическими.
Библиотека FFI позволяет работать с внешними функциями на всех популярных платформах. Но в этой статье я проверял работу библиотеки только для Windows, однако, не думаю, что на других платформах должны быть из ряда вон выходящие осложнения.
Работа с симуляцией на С++
Моя первая курсовая работа была написана на плюсах и выводила результаты в виде bmp файлов. Когда же подошло время второй курсовой, я понял, что явно нужно сделать хоть какой-то интерфейс, отличный от проводника Windows. Переписывать тысячи строк кода такое себе удовольствие. Кроме того, Dart - всё ещё однопоточный язык, а распараллелить вычисления так же легко, как с openMP, не получится. Так что было решено создать программу по такому образу: С++ выполняет шаги симуляции, хранит в себе все данные и состояние, а Flutter поддерживает симуляцию и получает состояние симуляции, отображая его на интерфейсе.
Благо, есть документация о том, как создать ffi плагин. В ней можно легко найти команду:
flutter create --template=plugin_ffi --platforms=android,ios,linux,macos,windows ffigen_app
В созданном плагине даже сразу есть example, который можно изучить, чтобы понять, как с точки зрения кода работает вызов функции.
Однако здесь же меня ждала первая проблема - код Dart-а в этом шаблоне написан с использование ffigen. Кодогенерация всегда хорошо, но было болезненно осознавать, что она работает только для чистого Си. Так что пришлось самостоятельно записывать каждый вызов функций, а также править сгенерированный CMake файл.
С последним достаточно легко. Необходимо вписать все необходимые файлы с кодом (например, main.cpp можно не включать), указать заголовочный файл, а также подключить необходимые пакеты (например, openMP, чтобы работали его директивы). Вот пример из моего проекта.
Перед тем, как начинать работу с функциями, необходимо настроить само подключение к библиотеке. Для этого достаточно написать путь к ней и динамически открыть её с помощью DynamicLibrary.open(pathToLib/libName.dll)
. Именно из этого объекта с помощью методаlookup
мы будем вызывать функции при помощи их названия на стороне С++.
Но и этого недостаточно, ибо только простые типы данных можно передавать между языками. То есть аргументы функции и возвращаемый тип в обоих языках обязан быть простым. Для передачи структурированных данных, необходимо воспользоваться указателями и структурами. В Dart необходимо создать класс, наследованный от Struct
, а также делать его аналог в коде С++, после чего работать с этим классом через Dart класс Pointer
. Причём, обязательно нужно сохранять порядок полей структур в обоих языках:
Пример структуры в Dart:
final class IntArray extends Struct{
external Pointer<Int32> data;
@Int32()
external int length;
}
Пример этой же структуры в С++:
typedef struct {
int *data;
int length;
} IntArray;
Здесь показан пример создания массива. Другие структуры создаются аналогично. Как пример, структура модели локации, что используется в симуляции. Так как я не хотел бы в бизнес-логике приложения работать с классом Pointer, то для каждой структуры пишем класс без указателей.
Стоит упомянуть, что указатель в Dart создаётся намного более проблематично, нежели в С++. Для создания необходимо подключить уже не только библиотеку dart:ffi
, но и пакет package:ffi/ffi.dart
, для того, чтобы воспользоваться классом Arena
, с помощью которого можно выделять память:
Создаём объект для выделения памяти
Arena arena = Arena();
Создаём нужные указатели, например, таким образом:
Pointer<Int> townsPriority = arena.allocate(length * sizeInt);
После того, как созданные указатели перестали быть нам нужны, очищаем память:
arena.releaseAll();
Теперь, когда есть все необходимые структуры данных и подключение к библиотеке, настало время писать глобальные внешние функции и функции, что вызывают внешние функции.
На стороне С++ я создал отдельный файлик FlutterAdapter.cpp, который хранит всё для работы с внешними функциями. Из необычного в нём толькоFFI_PLUGIN_EXPORT
перед теми функциями, которые являются внешними. Аналогично FFI_PLUGIN_EXPORT
нужно написать и в заголовочном файле перед объявлением функций. Кроме того, в нём ещё необходимо сделать два действия. Во-первых, добавить в начало:
#if _WIN32
#define FFI_PLUGIN_EXPORT __declspec(dllexport)
#else
#define FFI_PLUGIN_EXPORT
#endif
А во-вторых, обернуть все объявления в extern "C" {}
. Это ключевое слово необходимо для сохранности кода при переводе С++ и Си. Однако, его добавление может вызвать проблемы из-за того, например, отсутствия поддержки template
у функций.
Согласен с мыслью, что легче один раз увидеть, чем пытаться прочитать теорию, так что для стороны Dart-а я просто покажу примеры: вот пример кода с получением указателя, а вот пример кода с передачей аргументов в С++.
Но и это ещё не всё! Так как Dart однопоточный, то при вызове длительной функции на стороне С++ интерфейс на Flutter-е "прикажет долго жить" и зависнет. Для того, чтобы этого не происходило, необходимо настроить работу с отдельным изолятом. Это достаточно много сложного кода, который необходимо было повторять для каждой такой функции (причём эту работу должен был делать ffigen), но у меня получилось при помощи парочки костылей настроить вызов изолята для любой функции. Подобные вызовы функции выглядят таким образом:
Future<void> makeStep(int stepCount) async {
return await executeInIsolate<void>(
_makeStepForIsolate, {'stepCount': stepCount});
}
void _makeStepForIsolate(Map<String, dynamic> args) {
final execute = lookup<NativeFunction<Void Function(Int)>>('execute')
.asFunction<void Function(int)>();
return execute(args['stepCount']);
}
Всё выглядит так костыльно из-за того, что в SendPort
можно передавать только верхнеуровневые функции. Для этого отдельно и объявляется _makeStepForIsolate.
Аргументы записываются в Map для того, чтобы сохранить возможность передать абсолютно любую функцию внутрь executeInIsolate
. Ждём макросы в Dart.
Для тех, кто хочет разобраться, каким же образом создаётся изолят и выполняются функции в отдельном потоке, вот в этом файле я оставил достаточно много комментариев.
Перед подведением итогов хочу ещё сказать о том, что было достаточно проблематично писать этот проект из-за проблем с дебагом. Писал код Flutter-а в VScode и иногда через него же делал правки в код плюсов. Во-первых, при компиляции и запуске программы ошибки C++ выводятся в debug console, но на другой кодировке, из-за чего большинство ошибок мне приходилось гуглить по её номеру, ибо текст ошибки представлял собой кучу вопросов. Скорее всего это из-за русского языка. Настройки CMake в vs-code не помогли. Во-вторых, я заметил проблему, что при создании функции с 20-ю и более аргументами программа завершается без вывода ошибки.
Подведём итог. Для построения мостов между языками необходимо: настроить CMake, на стороне С++ пометить внешние функции и скомпилировать код в dll, на Dart подключиться к этой библиотеке и вызвать функцию по названию, при необходимости создать на двух языках одинаковые структуры и передавать указатели на них, и при тяжеловесности функции создаём отдельный изолят для вызова.
Документооборот на C#
Была задача для десктопа: оптимизировать заполнение определённых типов документов, чтобы их можно было делать не через Office, а через удобную специальную программу. Конечно же, я сразу вспомнил C#, ибо пару раз подобное уже писал на нём. Но желание экспериментировать с FFI заставило меня пересмотреть мой выбор в сторону сразу двух языков. Да и, кроме того, на момент создания программы для Flutter не было нормальной библиотеки для работы с Word, а заказчик хотел иметь возможность редактировать и Word и Excel.
Настрадавшись с прошлым проектом, было решено в этом минимизировать точки соприкосновения двух языков. Как следствие этого - программа имеет лишь две внешние функции, аргументы которой являются json-ами. При вызове метода makeFile
в одном большом json передаётся информация о всех необходимых структурах. Очень удобно, хоть, конечно, нужно понимать, что это чуточку мешает производительности. Кроме того, чтобы не было проблем с форматом обязательно нужно воспользоватьсяjson.encode
на стороне Dart-а и не запутаться в кодировках. Напомню, что Utf8 не поддерживает кириллицу. Так что подготовка аргументов на стороне Dart выглядит вот так:
final json = json.encode(data.toJson());
final pointer = json.toNativeUtf16();
final result = dartFunc(pointer);
Так что на сей раз абсолютно вся работа с FFI на стороне Dart поместилась в один файлик.
Если в прошлый раз сразу же создавался простой CMake файл, в котором нужно было заполнить парочку строчек, чтобы всё работало по нажатию одной кнопки, то на сей раз у меня была только минимальная информация из интернета. К сожалению, мои поиски ни к чему не привели. Однако, это и не особо нужно, ведь всю работу по компиляции можно выполнить при помощи обычного скрипта.
Касательно создания самого dll файла. .NET Core поддерживает AOT компиляцию, как и Dart, но только с помощью определённого компилятора и парочки команд dotnet. Все шаги были проделаны на .net 6.0. Не могу точно сказать, работают ли команды на более высокой версии. Буду рад, если в комментариях кто-нибудь напишет, получилось ли на более высокой.
Для начала я создал обычное консольное приложение C# через VS. Далее через консоль добавляем необходимый компилятор командой:
dotnet add package Microsoft.DotNet.ILCompiler -v 7.0.0-*
Теперь проект готов к тому, чтобы его наполнили кодом и библиотеками. В этой программе для работы с excel использовал epplus, что подключил через nuget. Внешние функции также выделяем в отдельный файлик, делаем статическими и помечаем атрибутом, который хранит в себе имя, которое мы будем искать через lookup
:
[UnmanagedCallersOnly(EntryPoint = "makeFile")]
Пользуемся Marshal.PtrToStringUni
, помня о кодировке и используя встроенную десериализацию, чтобы получить готовые к работе модели. Как раз из-за json формата передачи появилась проблема с неймингом полей передаваемых моделей, так как стили в Dart и C# разные. Я решил, что пусть уж C# потерпит это нарушение, так что названия полей сделал точно как в Dart, то есть с маленькой буквы:
public string name { get; set; }
Тестируем программу в режиме консольного приложения, но для компиляции обязательно меняем тип проекта на библиотеку классов. Чтобы получить dll файл билдим проект и пишем в консоль:
dotnet publish /p:NativeLib=Shared /p:SelfContained=true -r win-x64 -c release(или debug)
Спустя достаточно долгое время работы компилятора в папке bin/Release/net.6.0/win-x64/publish появился нужны dll файл. Внутри него сразу включены все нужные ему библиотеки, так что достаточно воспользоваться только им.
Подведём итог. На сей раз достаточно лишь: создать проект и подключить компилятор, пометить внешние функции, скомпилить dll и перенести в нужное место, в Dart подключиться к библиотеке и передать в вызов функции json.
Заключение
По итогу у меня получились два приложения, с разными алгоритмическими языками и с разными подходами к построению связи с Flutter-ом. В целом, полученных на практике знаний вполне достаточно для того, чтобы без страха в следующий раз подключать код семейста Си в проект.
Плюсами такого подхода считаю увеличение производительности и расширение пула библиотек, ибо теперь помимо pub у нас есть все библиотеки С++ и C#.
К минусам я могу отнести повышенный вес приложения, увеличение времени компиляции проекта и сложность отладки.
Буду рад, если мой опыт окажется кому-то полезным полезным.
Прикрепляю ссылки на проекты, которые я использовал в качестве примеров:
Flutter&C++: https://github.com/iamgirya/Physarum-building-an-optimal-road-network
Flutter&С#: https://github.com/NullExp-Team/builders_act_maker
И несколько источников, которые мне помогали по мере разработки приложения
https://medium.com/@stevehamblett/using-c-libraries-in-dart-ec630848d52c - пример с C#
https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-publish - про dotnet publish
https://www.programmersought.com/article/589910582570/ - гайд по структурам