На 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, с помощью которого можно выделять память:

  1. Создаём объект для выделения памяти
    Arena arena = Arena();

  2. Создаём нужные указатели, например, таким образом:
    Pointer<Int> townsPriority = arena.allocate(length * sizeInt);

  3. После того, как созданные указатели перестали быть нам нужны, очищаем память:
    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/ - гайд по структурам

Комментарии (7)


  1. gudvinr
    20.06.2023 19:08
    +2

    Utf8 не поддерживает кириллицу

    Это как?


    1. equeim
      20.06.2023 19:08
      +1

      Возможно имелось в виду что винда не может выводить в консоль utf-8 текст без плясок с бубном (до этого автор упоминал проблемы с кодировкой при выводе на консоль). Но в случае передачи строки в C# можно использовать UTF-8, C# умеет с ней работать.


      1. iamkisly
        20.06.2023 19:08

        А разве utf-8 не кодировка по умолчанию в . net?


        1. NN1
          20.06.2023 19:08

          char в .NET 16-бит.

          UTF-8 на сегодня представлен как Span<byte>.


        1. equeim
          20.06.2023 19:08

          Смотря что значит "по умолчанию")
          Строки изнутри в UTF-16 кодировке (но понятное дело что получить строку можно из данных в любой кодировке которая может конвертироваться в UTF-16).
          На консоль выводятся байты в системной "легаси" консольной кодировке (Console.OutputEncoding в .NET). По умолчанию это не UTF-8 а до-юникодная DOS кодировка (зависит от языка, на русском это CP866). Можно самому заменить на UTF-8 но тогда другая сторона (например IDE) тоже должна ожидать именно UTF-8, как-то об этом договориться "на лету" нельзя. Многие программы могут работать только с системной кодировкой, другие только с UTF-8.


    1. iamgirya Автор
      20.06.2023 19:08

      Решил перепроверить и не зря. Связка .toNativeUtf8 и Marshal.PtrToStringUTF8 тоже работает.
      Видимо, я всё-таки запутался в кодировках в первый раз.


  1. PacmanGamePlay
    20.06.2023 19:08

    Круто, что на флаттер приложеньках можно юзать либы из C#, например. Но подойдёт ли такой подход для реальных дексктоп приложений с запуском в прод?