Flutter¶
Flutter - это фреймворк и комплект средств разработки с открытым исходным кодом для создания кроссплатформенных приложений из одной кодовой базы от корпорации Google. С помощью Flutter можно создавать: мобильные приложения(Android, iOS), десктопные приложения(Windows, macOS, Linux) и веб-приложения. Для разработки приложений на Flutter используется язык программирования Dart, созданный специально для этого фреймворка. Дизайн пользовательского интерфейса разрабатывается с помощью виджетов: любой элемент интерфейса(кнопка, текст, отступ, анимация) и логика его поведения - это виджет. Комбинацией простых виджетов можно получить более сложный, экран по сути является вложенной древовидной структурой виджетов.
Flutter очень удобен при разработке мобильных приложений - один и тот же код может запустить приложение и на Android, и на iOS, без переписывания под каждую платформу. EV Toolbox версии Advanced поддерживает разработку мобильных приложений с использованием Flutter, который подключен в качестве плагина. Вы можете проектировать с помощью него пользовательский интерфейс приложения, также имеется возможность взамодействия между lua-скриптами, отвечающими за логику работы AR-приложения и интерфейсом, разработанным с помощью Flutter.
Приложение на Flutter создается в качестве модуля, который будет интегрирован в основное приложение. Модуль создается следующей командой:
flutter create --template=module имя_модуля
Будет создана соответствующая структура модуля, в которой точкой входа является файл main.dart, располагающийся в директории lib.
В данном модуле может быть создано несколько точек входа с помощью @pragma("vm:entry-point"), основная точка входа - функция main
В Android для каждой точки входа с помощью FlutterFragment создается отдельный фрагмент - часть пользовательского интерфейса внутри Activity, у которой есть свой
собственный жизненный цикл и логика. Таким образом в приложение можно встроить несколько фрагментов, которые могут занимать как весь экран, так и его часть.
Таким образом для приложений с дополненной реальностью можно создавать интерфейс - кнопки, диалоги, окна и т.д.
В плагине предусмотрен следующий функционал: показ фрагмента, скрытие фрагмента, вызов Lua-кода из Dart, вызов из Lua зарегистрированных в Dart функций обратного вызова.
Разберем пример интеграции Flutter в приложение с помощью плагина. Полный код примера вы можете найти на нашем гитхабе. В получившемся приложении будет представлена модель нашего логотипа и пользовательский интерфейс, состоящий из трех элементов: иконка скрытия/показа, кнопка проигрывания аудио и текст, отображающий название узла, на котором произошло касание.
Для начала на стороне Flutter необходимо определить нативные функции плагина, которые будут вызываться в Dart.
late void Function(Pointer<Utf8>, Pointer<Utf8>) nativeHideHostView;
late void Function(Pointer<Utf8>, Pointer<Utf8>) nativeShowHostView;
late void Function(Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>) nativePushChunk;
late void Function(Pointer<Utf8>, Pointer<Utf8>, int, Pointer<Utf8>) nativeRegisterDartCb;
Эти функции будут вызываться из Dart через FFI. Первая функция скрывает фрагмент, вторая показывает его, третья вызывает lua-код и последняя регистрирует функции обратного вызова в Dart, которые можно будет вызвать затем из Lua-скриптов.
Примечание
FFI (Foreign Function Interface) - механизм, позволяющий вызвать код, написанный на других языках. В данном случае из Dart вызываются функции плагина, написанные на C++.
Далее эти функции необходимо проинициализировать для каждой точки входа (функция main или любая другая, помеченная @pragma("vm:entry-point")):
final dylib = Platform.isAndroid
? DynamicLibrary.open('libevi_plugin_flutter.so')
: DynamicLibrary.open("evi_plugin_flutter.framework/evi_plugin_flutter");
nativeHideHostView = dylib.lookupFunction<Void Function(Pointer<Utf8>, Pointer<Utf8>), void Function(Pointer<Utf8>, Pointer<Utf8>)>("evi_plugin_flutter_hostview_hide");
nativeShowHostView = dylib.lookupFunction<Void Function(Pointer<Utf8>, Pointer<Utf8>), void Function(Pointer<Utf8>, Pointer<Utf8>)>("evi_plugin_flutter_hostview_display");
nativeRegisterDartCb = dylib.lookupFunction<Void Function(Pointer<Utf8>, Pointer<Utf8>, IntPtr, Pointer<Utf8>),
void Function(Pointer<Utf8>, Pointer<Utf8>, int, Pointer<Utf8>)>("evi_plugin_flutter_register_callback");
nativePushChunk = dylib.lookupFunction<Void Function(Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>), void Function(Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>)>("evi_plugin_flutter_push_chunk");
В данном случае открывается динамическая библиотека и в ней идет поиск нативных функций по их названию и сигнатуре.
Функция nativeHideHostView используется для скрытия любого фрагмента и вызывается следующим образом:
final nLibUri = "".toNativeUtf8();
final nEntryPoint = "main".toNativeUtf8();
nativeHideHostView(nEntryPoint, nLibName);
calloc.free(nLibName);
calloc.free(nEntryPoint);
Нативная функцию nativeHideHostView имеет два аргумента - название точки входа(в примере выше это «main») и название библиотеки(в данном случае название библиотеки - пустая строка, т.к. библиотека одна), - которые необходимо конвертировать в нативную utf8-строку.
Так как строка создается в нативной области памяти, после вызова функции с аргументами, эту область памяти необходимо освободить с помощью calloc.free() или malloc.free().
Функция nativeShowHostView используется для показа любого фрагмента и вызывается таким же способом, как и nativeHideHostView, т.е. с двумя аргументами - название точки входа и название библиотеки.
Функция nativeRegisterDartCb нужна, чтобы зарегистрировать функции обратного вызова, т.е. Dart-функции, которые будут вызваны из исполняемого кода Lua. В данном случае создан вспомогательный
mixin, который можно использовать для любого Stateful виджета.
typedef NativeStrCallback = Void Function(Pointer<Void>);
mixin FfiCallbacks {
final Map<String, NativeCallable<NativeStrCallback>> _callbacks = {};
void addCallback(String name, void Function(Pointer<Void>) cb) {
_callbacks[name]?.close();
_callbacks[name] = NativeCallable<NativeStrCallback>.listener(cb);
}
void registerCallbacks(String entrypoint) {
_callbacks.forEach((name, callable) {
final entryPoint = entrypoint.toNativeUtf8();
final libUri = "".toNativeUtf8();
final funcName = name.toNativeUtf8();
nativeRegisterDartCb(entryPoint, libUri, callable.nativeFunction.address, funcName);
malloc.free(entryPoint);
malloc.free(libUri);
malloc.free(funcName);
});
}
void disposeCallbacks() {
for (final cb in _callbacks.values) {
cb.close();
}
_callbacks.clear();
}
String readUtf8(Array<Uint8> arr, int maxLen) {
final bytes = <int>[];
for (var i = 0; i < maxLen; i++) {
final byte = arr[i];
if (byte == 0) break;
bytes.add(byte);
}
return String.fromCharCodes(bytes);
}
}
Функциональность вышеприведенного mixin позволяет хранить функции обратного вызова, их регистрировать и закрывать. Использование данного mixin приведено ниже.
class _BottomWidgetsState extends State<BottomWidgets> with FfiCallbacks {
bool isPlaying = false;
String clickText = "Нажмите на модель";
@override
void initState() {
super.initState();
addCallback("onAudioFinished", (Pointer<Void> data) {
setState(() {
isPlaying = false;
});
});
addCallback("onNodeClick", (Pointer<Void> data) {
final struct = data.cast<LuaArgs>().ref;
setState(() {
clickText = readUtf8(struct.nodeName, 256);
});
});
registerCallbacks("bottomWidgets");
}
@override
void dispose() {
disposeCallbacks();
super.dispose();
}
}
Функциональность плагина позволяет передавать этим функциям аргументы в виде структуры void*, но для этого необходимо одинаково описать эту структуру как на стороне Lua, так и на стороне Dart.
Определение структуры на стороне Lua будет представлено далее, на стороне Dart это делается следующим образом:
final class LuaArgs extends Struct {
@Array(256)
external Array<Uint8> nodeName;
}
256 в данном случае - размер строки, которая будет передаваться. Он должен совпадать в Lua и Dart. Если в структуре есть еще какие-то поля, то их порядок и тип также должны совпадать.
Функция nativePushChunk необходима, чтобы выполнять Lua-код из Dart.
final nLibName = "".toNativeUtf8();
final nEntryPoint = "main".toNativeUtf8();
final nChunk = "globals.audio:play()".toNativeUtf8();
nativePushChunk(nEntryPoint, nLibName, nChunk);
calloc.free(nLibName);
calloc.free(nEntryPoint);
calloc.free(nChunk);
Примечание
Код, который будет выполняться (в данном примере globals.audio:play()) должен содержать глобальные переменные, код с локальными переменными и функциями приведет к ошибке.
Теперь расмотрим, что нужно сделать на стороне Lua. В скриптах сначала необходимо зарегистрировать плагин flutter и загрузить библиотеку через ffi:
local logger = set_lua_logger("flutter_example")
local pluginManager = evi.PluginManager.instance()
if pluginManager then
local result = pluginManager:registerPlugin("flutter")
if result >= 0 then
logger:info("'flutter' plugin registered!")
else
logger:error("Cannot register 'flutter' plugin!")
end
else
logger:error("Cannot create plugin manager!")
end
local ffi = require_sys("ffi")
local flutterLib = ffi.load(__evi_os == "ios" and "@executable_path/Frameworks/evi_plugin_flutter.framework/evi_plugin_flutter" or "evi_plugin_flutter")
Далее необходимо с помощью ffi объявить вызываемые функции плагина и определить структуру для передаваемых аргументов
ffi.cdef[[
typedef struct {
char nodeName[256];
} LuaArgs;
void evi_plugin_flutter_call_flutter(const char* aEntryPoint, const char* aLibraryURI, const char* aName, void* aData);
void evi_plugin_flutter_add_listener(const char* aEntryPoint, const char* aLibraryURI);
]]
Как мы видим объявлены две функции - evi_plugin_flutter_call_flutter и evi_plugin_flutter_add_listener. Первая функция позволяет вызвать заранее зарегистрированные
с помощью nativeRegisterDartCb Dart-функции. Она имеет 4 аргумента - название точки входа, название библиотеки, название функции и void* aData - указатель на передаваемые из Lua в Dart аргументы,
который может быть объектом произвольного класса. В данном случае это структура LuaArgs, единственным полем которой является массив символов размером 256.
Далее необходимо создать фрагменты
if flutter then
local function createTraits(affectLifecyle, transparent, h, w, x, y, id)
local traits = flutter.HostView.Traits()
traits:setAffectLifecycleEVI(affectLifecyle)
traits:setTransparent(transparent)
traits:setHeight(h)
traits:setWidth(w)
traits:setX(x)
traits:setY(y)
traits:setContainerId(id)
return traits
end
traits1 = createTraits(false, true, 0.1, 0.18, 1 - 0.18, 0.2, "flutter_container_main")
traits1:setLayer(10.0)
flutterHostViewMain = flutter.HostView("main", "", traits1)
traits2 = createTraits(false, true, 0.12, 0.5, 0.0, 0.8, "flutter_container_button")
traits2:setLayer(15.0)
flutterHostViewButton = flutter.HostView("bottomWidgets", "", traits2)
flutterLib.evi_plugin_flutter_add_listener("main", "")
lua2flutterData = ffi.new("LuaArgs")
end
Для инициализации фрагмента используется класс Traits и вспомогательная функция createTraits. Сначала устанавливается параметр, отвечающий за влияние на жизненный цикл основного окна (т.е.
будет ли при показе фрагмента оно поставлено на паузу). Второй параметр устанавливает, использовать ли прозрачность (так, подложка контейнера, если виджет не имеет фона, будет иметь черный
непрозрачный цвет в случае установки данного параметра в false). Следующие четыре числовых параметра устанавливают размер контейнера для iOS, для Android же размеры контейнера устанавливаются
в отдельном .xml файле, который будет рассмотрен позднее. Последний параметр - идентификатор контейнера(для Android должен совпадать с идентификатором контейнера, указанном в .xml файле).
Далее с помощью класса HostView создается фрагмент, в конструкторе которого указываются три параметра - название точки входа, название библиотеки и объект Traits, хранящий параметры фрагмента.
Функция evi_plugin_flutter_add_listener должна быть вызвана один раз для точки входа, если на стороне Flutter планируется вызывать Lua-код с помощью nativePushChunk. Также с помощью ffi
создается lua2flutterData - объект, хранящий передаваемые во Flutter данные.
Далее в скриптах возможен вызов Dart-функций обратного вызова, ранее зарегистрированных
flutterLib.evi_plugin_flutter_call_flutter("bottomWidgets", "", "onAudioFinished", lua2flutterData)
ffi.copy(lua2flutterData.nodeName, nodeName)
flutterLib.evi_plugin_flutter_call_flutter("bottomWidgets", "", "onNodeClick", lua2flutterData)
Также в нашем примере есть один момент, который стоит пояснить: фрагменты в скриптах показываются по таймеру, а не сразу после создания. Делается это, потому что нужно какое-то время для инициализации и «прогрева» фрагмента. Если показывать фрагмент сразу после создания, то возможны артефакты(например, промигивание темного фона у фрагмента на старте приложения).
local timer_ui = reactorController:getReactorByName("timer/ui")
timer_ui:subscribeEvent("onAlarm", function()
flutterHostViewMain:display()
flutterHostViewButton:display()
observer:setOnDownCb(onDownCb)
end)
timer_ui:start(0.5, TimerReactor.Mode.ONCE)
Для Android размеры и расположение контейнера описываются в файле main_activity.xml, который должен быть создан по следующему пути: папка-с-ресурсами-проекта/android/res/layout/main_activity.xml
Содержание файла в нашем примере следующее
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/evi_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"/>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/flutter_container_main"
android:translationZ="5dp"
android:layout_width="80dp"
android:layout_height="80dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.1"/>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/flutter_container_button"
android:translationZ="10dp"
android:layout_width="240dp"
android:layout_height="120dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.8"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
Теперь можно приступить к сборке приложения под Android. Перейдите в папку с созданным модулем Flutter и выполните в консоли следующую команду
flutter build aar --no-debug --no-profile --output="путь_до_модуля/install"
Затем скопируйте файл build.gradle.in, который находится по пути место-установки-конструктора/studio_data/android/flutter в любую папку, например в папку с проектом.
Замените в нем
maven {
// Change it with your flutter module
url = '$AAR_DIR/flutter/host/outputs/repo'
}
На следующее
maven {
url = 'путь_до_модуля/install/host/outputs/repo'
}
Также в самом конце файла замените зависимость
implementation 'ru.eligovision.evi.flutter_template_module:flutter_release:1.0'
На 3-ий пункт вывода команды flutter build aar в консоли. В нашем примере это
implementation 'ru.eligovision.flutter_example:flutter_release:1.0'
Затем вы можете провести экспорт. В настройках экспорта выберите «Файлы конфигурации» - «User Preset». Для файла AndroidManifest.xml укажите значение - ${ANDROID_DATA_DIR}/flutter/AndroidManifest.xml.in,
для файла build.gradle укажите путь к файлу build.gradle.in, ранее скопированному и измененному. Оставшиеся два файла конфигурации менять не надо. После этого проведите экспорт в формат apk и
готово - приложение можно устанавливать на устройство.