На связи тимлид Mobile SDK в 2ГИС Александр Максимовский и Flutter-разработчик Михаил Новосельцев (@Sameri11). Наша команда разработала собственный продукт для генерации платформенного Dart-кода на базе публичного C++ API, и мы уже рассказали об основных принципах его работы.
Эта статья — про то, как на основе сырого сгенерированного кода реализовать SDK, готовый к внедрению в пользовательские Flutter-приложения.
Инициализация Flutter SDK
Основной способ для инициализации SDK для Flutter — это метод DGis.Initialize. Это Dart-метод, внутри которого вызывается C++ метод. Через DGis.Initialize можно настроить логирование, сетевой клиент, геопозиционирование и другие параметры.
Результат инициализации — DGis.Context. Это Dart-класс, сгенерированный на основе C++ класса. DGis.Context работает как DI-контейнер, с помощью которого можно обращаться ко всем сущностям поставляемого продукта.
Поэтому главная сложность при реализации SDK для Flutter заключалась в том, чтобы спроектировать архитектуру обращения к платформенному функционалу из C++ кода.

- Пользовательский код вызывает статичный Dart-метод - DGis.initialize.
- Через FFI вызывается C++ метод для инициализации DI-контейнера - Context.
- Внутри C++ создаётся реализация всех платформенных сущностей: абстрактный класс объявляется в C++ коде, а в DI-контейнере хранится указатель на платформенную реализацию. 
- Для Android обращения к Kotlin реализованы через JNI. 
- Для iOS платформенная часть реализована на Objective-C и напрямую интегрирована в C++. 
При такой архитектуре минимизируются обращения к платформенному функционалу, но при этом сохраняются все преимущества платформенного кода, такие как геопозиционирование, получение информации о батарее и сети, воспроизведение аудиосемплов и так далее.
Реализация MapWidget для рендеринга карты
Наш 3D-движок для отрисовки карты реализован на C++, но сама поверхность, на которой будет происходить рендеринг данных, должна быть получена с платформы в зависимости от типа рендерера (OpenGL, Vulkan, Metal).
- В Android Mobile SDK с платформы из Kotlin в C++ передаётся SurfaceTexture/SurfaceView для рендеринга, которая устанавливается в ANativeWindow из android/native_window. 
- В iOS Mobile SDK используется MTKView для получения CAMetalDrawable, которая передаётся в C++ для рендеринга через Metal. 
Для Flutter Mobile SDK также используется наш C++ 3D-движок, поэтому при реализации рендеринга нужно было перевести всё на те же самые «рельcы», что и Android/iOS Mobile SDK.
Чтобы инкапсулировать всю логику с получением поверхности для отрисовки и передачей её в 3D-движок, мы реализовали базовый StatefullWidget MapWidget для работы с картой.
Основа MapWidget:
- MapWidgetController — класс для настройки карты: добавление callback на tap и longTouch по объектам карты, задание FPS, настройка темы и т.д. 
- TextureController— внутренний класс для создания MethodChannel, чтобы можно было отправить канализированное сообщение в платформы для создания платформенной текстуры и в ответ получить идентификатор созданной текстуры. Именно с этим идентификатором работает- Texture.
- Texture — виджет для отображения текстуры, полученной с платформы. На этой текстуре 3D-движок рендерит данные карты. 
- Listener и - MapGestureController— виджет и контроллер для обработки жестов карты.- Listenerотслеживает касания на карте (tap, long touch) и передаёт информацию о них в- MapGestureController.- MapGestureControllerраспознаёт тип жеста и соответствующим образом изменяет отображение карты, например, перемещает её или изменяет масштаб
- Различные вспомогательные внутренние классы для определения размеров карты, системной темы и PPI устройства. 
MapWidget на Android
Flutter Texture widget прекрасно адаптирован для взаимодействия с Android SurfaceTexture. TextureController публикует в канал “flutter_map_surface_plugin” событие о создании новой текстуры. В Kotlin-коде в обработчике события создаётся SurfaceTexture и соответствующий ей Surface, которые передаются в C++ через JNI-прослойку. Идентификатор полученной текстуры отправляется обратно в Dart-код. 
Регистрируемый при запуске Android-плагин для подписки на канал и создания текстуры выглядит следующим образом:
class AndroidJniPlugin : FlutterPlugin, MethodCallHandler {
    private val renders = LongSparseArray<SurfaceTexture>()
    private lateinit var textures: TextureRegistry
    private lateinit var channel: MethodChannel
    private lateinit var context: Context
    override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
        context = flutterPluginBinding.applicationContext
        setup(context)
        textures = flutterPluginBinding.textureRegistry
        channel = MethodChannel(flutterPluginBinding.binaryMessenger, "flutter_map_surface_plugin")
        channel.setMethodCallHandler(this)
    }
    override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
        when (call.method) {
            "setSurface" -> {
                val arguments = call.arguments as? Map<String, Number> ?: return
                val entry = textures.createSurfaceTexture()
                val surfaceTexture = entry.surfaceTexture()
                val mapSurfaceId = arguments["mapSurfaceId"]?.toLong() ?: return
                val surface = Surface(surfaceTexture)
                setSurface(mapSurfaceId, surface, 0, 0)
                renders.put(entry.id(), surfaceTexture)
                result.success(entry.id())
                surface.release()
            }
            "updateSurface" -> {
                val arguments = call.arguments as? Map<String, Number> ?: return
                val textureId = arguments["textureId"]?.toLong() ?: return
                val width = arguments["width"]?.toInt() ?: return
                val height = arguments["height"]?.toInt() ?: return
                val surfaceTexture = renders.get(textureId)
                surfaceTexture?.setDefaultBufferSize(width, height)
            }
            "dispose" -> {
                val arguments = call.arguments as? Map<String, Number> ?: return
                val textureId = arguments["textureId"]?.toLong() ?: return
                renders.delete(textureId)
            }
            "getScreenFps" -> {
                result.success(getScreenFps())
            }
            else -> result.notImplemented()
        }
    }
    override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
        channel.setMethodCallHandler(null)
    }
    private fun getScreenFps(): Int {
        val wm = context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager
        val display = wm?.defaultDisplay
        return try {
            val fps = display?.mode?.refreshRate ?: display?.refreshRate ?: 60f
            fps.roundToInt()
        } catch (e: Exception) {
            val fps = display?.refreshRate ?: 60f
            fps.roundToInt()
        }
    }
    external fun initializeJni(
        context: Context,
        classLoader: ClassLoader,
        packageName: String,
        version: String
    )
    external fun setSurface(
        mapSurfaceId: Long,
        surface: Surface,
        width: Int,
        height: Int
    )
}Схематично это выглядит следующим образом:

MapWidget на iOS
Под iOS нет стандартных механизмов взаимодействия Flutter Texture widget и Metal для рендеринга данных. На текущий момент есть только протокол FlutterTexture в Objective-C/Swift для реализации класса, в котором буфер CVPixelBufferRef используется для передачи данных из Objective-C/Swift в Dart для отображения в Texture widget.
При реализации Flutter Texture нужно учесть два пункта:
- 3D-движок Mobile SDK работает с CAMetalDrawable и MTLTexture. 
- Flutter работает с CVPixelBufferRef, то есть с буфером данных текстуры. 
Исходя из этого, мы решили использовать IOSurface — буфер для расшаривания данных между разными механизмами хранения. На основе IOSurface создаётся CVPixelBufferRef и MTLTexture, чтобы передача данных между Metal-сущностями и буфером для передачи во Flutter происходила без лишних копирований:
CVPixelBufferRef newPixelBuf = NULL;
CVMetalTextureRef newSourceImageBuf = NULL;
id<MTLTexture> newMetalTexture = nil;
// Создаём свойства для IOSurface
NSDictionary *ioSurfaceProperties = @{
    (NSString *)kIOSurfaceWidth            : @(self.newWidth),
    (NSString *)kIOSurfaceHeight           : @(self.newHeight),
    (NSString *)kIOSurfaceBytesPerElement  : @4,
    (NSString *)kIOSurfacePixelFormat      : @(kCVPixelFormatType_32BGRA)
};
IOSurfaceRef ioSurface = IOSurfaceCreate((__bridge CFDictionaryRef)ioSurfaceProperties);
if (!ioSurface) {
    return;
}
// Атрибуты для PixelBuffer
NSDictionary *pixelBufferAttributes = @{
    (NSString *)kCVPixelBufferMetalCompatibilityKey : @YES
};
CVReturn pixelBufferCreateStatus = CVPixelBufferCreateWithIOSurface(
    kCFAllocatorDefault,
    ioSurface,
    (__bridge CFDictionaryRef)pixelBufferAttributes,
    &newPixelBuf
);
CFRelease(ioSurface);
if (pixelBufferCreateStatus != kCVReturnSuccess) {
    return;
}
CVReturn textureCreateStatus = CVMetalTextureCacheCreateTextureFromImage(
    kCFAllocatorDefault,
    _textureCache,
    newPixelBuf,
    nil,
    MTLPixelFormatBGRA8Unorm,
    self.newWidth,
    self.newHeight,
    0,
    &newSourceImageBuf
);
if (textureCreateStatus != kCVReturnSuccess) {
    return;
}
newMetalTexture = CVMetalTextureGetTexture(newSourceImageBuf);Чтобы 3D-движок рендерил данные в MTLTexture, нужен кастомный MetalDrawable, удовлетворяющий протоколу MTLDrawable. 
После окончания рендеринга следует вызвать метод FlutterTextureRegistry.textureFrameAvailable, чтобы сообщить Flutter-потоку о готовности CVPixelBufferRef для отрисовки через Texture widget.
- (instancetype)initWithTextureRegistry:(id<FlutterTextureRegistry>)flutterTextureRegistry {
    self = [super init];
    if (self) {
        _flutterTextureRegistry = flutterTextureRegistry;
    }
    return self;
}
- (void)setFlutterTextureId:(NSInteger)textureId {
    _flutterTextureId = textureId;
}
- (void)present {
    __strong id<FlutterTextureRegistry> registry = _flutterTextureRegistry;
    if (registry) {
        [registry textureFrameAvailable:_flutterTextureId];
    }
}
Как уже было написано выше, IOSurface позволяет передавать данные для рендеринга между Metal и Flutter без лишних копирований. Однако возникает проблема рассинхронизации, которая проявляется в одновременном доступе к буферу со стороны Flutter и 3D-движка. Во время копирования данных в буфер со стороны движка Flutter будет рендерить эти же данные, что приведёт к артефактам и нежелательным «миганиям». Чтобы этого избежать, мы реализовали механизм тройной буферизации. Смысл в том, что создаётся три буфера: один для использования Flutter-кодом, другой — для промежуточного хранения данных, а третий — для рендеринга со стороны 3D-движка. 
Алгоритм буферизации с тремя буферами выглядит так:
- 3D-движок рендерит данные в буфер 3. Промежуточный буфер 2 и буфер 3 меняются индексами — буфер 2 становится 3, то есть буфером для рендеринга со стороны 3D-движка, а буфер 3 становится 2, то есть буфером для промежуточного хранения данных. После этого - MetalDrawableсообщает Flutter-коду, что данные из буфера 2 готовы для рендеринга.
- Flutter-поток вызывает метод - copyPixelBufferдля получения буфера для отображения в Texture widget. При вызове метода буфер 1 и буфер 2 также меняются индексами.
- Пункты 1 и 2 повторяются каждый раз для рендеринга кадра. 

Widgets в составе FlutterSDK
Flutter SDK предоставляет полный функционал по работе с картой, справочником, построением маршрутов и навигатором. Для более удобной интеграции всего функционала в составе продукта есть набор базовых Widgets:
- Карты: перелёт к текущему местоположению, изменение масштаба карты, компас и т.д. 
- Справочника: поисковая строка для отображения результата поискового запроса. 
- Навигатора: отображение информации о пройденном и оставшемся маршруте, текущая скорость, маневры и т.д. 
Система базовых классов
Карта работает в реальном времени и представляет собой сложную подсистему, поэтому:
- Нужно дождаться инициализации нативных ресурсов, чтобы отобразить карту. 
- Приходится обрабатывать асинхронные вызовы и подписываться на события (например, смену темы, изменение размеров карты, поворот камеры). 
- Необходимо корректно освобождать ресурсы при удалении или скрытии виджета. 
Чтобы упростить выполнение этих задач, наши базовые классы скрывают асинхронную логику, связанную с инициализацией и управлением объектом карты. Разработчики могут сфокусироваться на функциональности, не отвлекаясь на вопросы о доступности карты, смене режима (светлого/тёмного) и другом «карточном» окружении.
Основой всей системы служит класс BaseMapWidgetState, который:
- подключается к объекту карты при построении виджета; 
- инициирует все необходимые подписки и модели, связанные с этим объектом карты; 
- освобождает ресурсы и снимает подписки при удалении виджета. 
Пример объявления класса:
abstract class BaseMapWidgetState<T extends StatefulWidget> extends State<T> {
  sdk.Map? _map;
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _map = mapOf(context);
    if (_map == null) {
      throw Exception('Any MapControl should be added as child of MapWidget');
    }
    onAttachedToMap(_map!);
  }
  @override
  void dispose() {
    onDetachedFromMap();
    _map = null;
    super.dispose();
  }
  void onAttachedToMap(sdk.Map map);
  void onDetachedFromMap();
}При наследовании от этого класса достаточно переопределить методы:
- onAttachedToMap— инициализация моделей, подписок и другого функционала.
- onDetachedFromMap— освобождение ресурсов.
Предположим, у нас есть виджет, который показывает текущее местоположение пользователя. Мы можем создать его класс, наследуя BaseMapWidgetState:
import 'package:dgis_mobile_sdk_full/dgis.dart' as sdk;
class LocationWidget extends StatefulWidget {
  const LocationWidget({super.key});
  @override
  LocationWidgetState createState() => LocationWidgetState();
}
class LocationWidgetState extends sdk.BaseMapWidgetState<LocationWidget> {
  late sdk.MyLocationControlModel _model;
  @override
  void onAttachedToMap(sdk.Map map) {
    _model = sdk.MyLocationControlModel(map);
    // Здесь можно подписаться на события параметров карты, если не используется StreamBuilder.
    // Например, если используется ValueNotifier, можно получать значения из стрима тут:
    //
    //   _model.isEnabledChannel.listen(...);
    //
    // Эту подписку позднее можно отменить в onDetachedFromMap
  }
  @override
  void onDetachedFromMap() {
    // Отписка от событий, освобождение ресурсов
  }
  @override
  Widget build(BuildContext context) {
    // Логика отображения виджета
    return StreamBuilder(
      stream: _model.isEnabledChannel,
      builder: (...),
    );
  }
}Таким образом, упрощено несколько аспектов:
- Nullability карты — виджет гарантирует, что карта будет готова к моменту использования. 
- Ограничения неправильного использования — разработчик получит выраженное исключение с понятным описанием на раннем этапе разработки, если будет использовать виджет вне контекста, в котором есть карта. 
Система тем
В контексте карт темы и механизм их изменения очень важны для создания удобного опыта использования и эстетического соответствия общему оформлению приложения и системы в целом. Карта 2ГИС поддерживает задание тем и их автоматическое переключение, но мы пошли дальше и добавили единый механизм, который позволяет виджетам следовать цветовой схеме карты и автоматически переключать её при определённых условиях.
Чтобы не дублировать логику переключения внутри каждого виджета, мы вынесли этот функционал в отдельный базовый класс ThemedMapControllingWidgetState. Он расширяет BaseMapWidgetState и дополнительно:
- отслеживает текущий режим карты (светлый или тёмный) через MapTheme; 
- применяет соответствующую цветовую схему к виджету. 
Пример объявления абстрактного виджета для управления темами:
abstract class ThemedMapControllingWidget<T extends MapWidgetColorScheme>
    extends StatefulWidget {
  final T light;
  final T dark;
  const ThemedMapControllingWidget({
    required this.light,
    required this.dark,
    super.key,
  });
}А для стейта, который следит за сменой темы, используется ThemedMapControllingWidgetState:
abstract class ThemedMapControllingWidgetState<
    T extends ThemedMapControllingWidget<S>,
    S extends MapWidgetColorScheme
> extends BaseMapWidgetState<T> {
  late S colorScheme;
  MapThemeColorMode? _colorMode;
  @override
  void didChangeDependencies() {
    final mapTheme = mapThemeOf(context);
    if (_colorMode == mapTheme?.colorMode) {
      return;
    }
    if (mapTheme != null) {
      _colorMode = mapTheme.colorMode;
    }
    switch (_colorMode) {
      case MapThemeColorMode.light:
        setState(() {
          colorScheme = widget.light;
        });
        break;
      case MapThemeColorMode.dark:
        setState(() {
          colorScheme = widget.dark;
        });
        break;
      default:
        setState(() {
          colorScheme = widget.light;
        });
    }
    super.didChangeDependencies();
  }
}Когда тема карты меняется (например, пользователь переключает её из светлой в тёмную), виджет автоматически получает новую схему. Вот пример простого виджета, который отображает текст и изменяет его цвет в зависимости от текущей темы карты:
class MyTextWidget
    extends ThemedMapControllingWidget<MyTextWidgetColorScheme> {
  const MyTextWidget({
    Key? key,
    required MyTextWidgetColorScheme light,
    required MyTextWidgetColorScheme dark,
  }) : super(light: light, dark: dark, key: key);
  @override
  ThemedMapControllingWidgetState<MyTextWidget, MyTextWidgetColorScheme>
      createState() => _MyTextWidgetState();
}
class _MyTextWidgetState
    extends ThemedMapControllingWidgetState<MyTextWidget, MyTextWidgetColorScheme> {
  @override
  void onAttachedToMap(sdk.Map map) {
    // Инициализация зависимостей
  }
  @override
  void onDetachedFromMap() {
    // Освобождение ресурсов
  }
  @override
  Widget build(BuildContext context) {
    // colorScheme всегда доступен внутри наследника ThemedMapControllingWidgetState
    return Text(
      'Пример текстового виджета',
      style: TextStyle(color: colorScheme.textColor),
    );
  }
}
class MyTextWidgetColorScheme extends MapWidgetColorScheme {
  final Color textColor;
  const MyTextWidgetColorScheme({required this.textColor});
  @override
  MyTextWidgetColorScheme copyWith({Color? textColor}) {
    return MyTextWidgetColorScheme(
      textColor: textColor ?? this.textColor,
    );
  }
}Виджеты навигации
Помимо базовых виджетов карты, часть нашего SDK посвящена навигационным функциям. В нашем решении реализована компонентная архитектура, которая:
- позволяет легко добавлять навигационные возможности, такие как индикация маршрута, отображение времени в пути, расстояния и других параметров. 
- сохраняет общий подход к работе с темами (дневная/ночная) и ресурсами карты, как это уже сделано в виджетах карты; 
- даёт возможность пользоваться готовыми решениями или создавать собственные компоненты и контроллеры. 
Для удобства мы сделали виджет NavigationLayoutWidget, который объединяет в себе всю логику управления навигацией и связанные элементы. Идея этого виджета — дать готовое решение с маршрутными подсказками, оверлеями (например, «завершение маршрута»), поддержкой дневной/ночной темы, соответствующей карте.
Для создания полностью стандартного экрана с навигацией нужно создать NavigationLayoutWidget с помощью стандартного конструктора defaultLayout:
class MyNavigationScreen extends StatelessWidget {
  final NavigationManager navigationManager;
  const MyNavigationScreen({Key? key, required this.navigationManager})
      : super(key: key);
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: NavigationLayoutWidget.defaultLayout(
        navigationManager: navigationManager,
      ),
    );
  }
}Для более гибкой настройки можно использовать стандартный конструктор. Для примера создадим экран навигации, в котором будет доступен только виджет спидометра, а все остальные элементы отсутствуют. При этом мы хотим взять стандартный спидометр, так как он полностью устраивает с точки зрения внешнего вида и функциональности:
class MyNavigationScreen extends StatelessWidget {
  final NavigationManager navigationManager;
  const MyNavigationScreen({Key? key, required this.navigationManager})
      : super(key: key);
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: NavigationLayoutWidget(
        navigationManager: navigationManager,
        speedLimitWidgetBuilder: SpeedLimitWidget.defaultBuilder,
      ),
    );
  }
}Принцип «Builder + Controller» в NavigationLayoutWidget
NavigationLayoutWidget сам создаёт объекты контроллеров для всех подключаемых (или переопределённых) виджетов навигации. В коде это выглядит так (упрощённый пример для DashboardWidget):
class _NavigationLayoutWidgetState extends BaseMapWidgetState<NavigationLayoutWidget> {
  late DashboardController dashboardController;
  @override
  void onAttachedToMap(sdk.Map map) {
    dashboardController = DashboardController(
      navigationManager: widget.navigationManager,
      map: map,
    );
  }
  @override
  Widget build(BuildContext context) {
    return widget._dashboardWidgetBuilder?.call(
      dashboardController,
      (offset) => /*...*/,
    ) ?? SizedBox.shrink();
  }
}Предположим, нужно полностью заменить DashboardWidget на MyCustomDashboardWidget. Тогда достаточно написать примерно такой код:
return NavigationLayoutWidget(
  navigationManager: navigationManager,
  dashboardWidgetBuilder: (controller, onHeaderChangeSize) {
    return MyCustomDashboardWidget(
      controller: controller,
      onHeaderChangeSize: onHeaderChangeSize,
      // Можно передать свою тему
      light: MyCustomDashboardTheme.light,
      dark: MyCustomDashboardTheme.dark,
      // Или оставить тему по умолчанию
    );
  },
);Основным элементом в этой схеме является контроллер, который передаётся в параметре controller. Этот объект предоставляет всю информацию, необходимую для данного типа виджета и его можно использовать так (на примере DashboardController):
class MyCustomDashboardWidget extends StatelessWidget {
  final DashboardController controller;
  final Function(Offset) onHeaderChangeSize;
  const MyCustomDashboardWidget({
    Key? key,
    required this.controller,
    required this.onHeaderChangeSize,
  }) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<DashboardModel>(
      valueListenable: controller.state,
      builder: (context, model, child) {
        // Создаём UI на основе данных из модели
        return Column(
          children: [
            Text('Distance left: ${model.distance}m'),
            Text('Time left: ${model.duration}s'),
            Switch(
              value: model.soundsEnabled,
              onChanged: (_) => controller.toggleSounds(),
            ),
            ElevatedButton(
              onPressed: () => controller.showRoute(),
              child: const Text('Show route'),
            ),
          ],
        );
      },
    );
  }
}Контроллеры через поле state предоставляют подмножество всей доступной навигационной информации, которую мы посчитали достаточным для данного типа виджета. Если кейс подразумевает, что виджет должен получать какую-то другую информацию о навигации, в контроллере также доступен NavigationManager, из которого можно получить любую информацию о текущем сеансе навигации, а также подписаться на какие-то необходимые события.
Мы создали инфраструктуру, в которой соблюдаются два ключевых принципа:
- Простота старта. Достаточно нескольких строк кода, чтобы подключить дефолтные виджеты через NavigationLayoutWidget.defaultLayout. При этом всё уже работает «из коробки»: корректная работа с ресурсами карты, автоматическая смена тем (дневная/ночная), индикация маршрута, управление звуком и т.д. Не нужно вручную писать код для подписок на - Mapили- NavigationManager.
- Глубокая кастомизация. Если стандартный интерфейс или набор функций не подходит, всегда можно заменить любой элемент на свою собственную реализацию. Важная особенность: базовая работа с картой и жизненным циклом при этом остаётся безопасной — NavigationLayoutWidget и BaseMapWidgetState гарантируют своевременную инициализацию, освобождение ресурсов и подписок. 
Сочетание готовых решений и возможности тонкой настройки делает систему полезной для широкого круга задач и типов проектов. Мы сохранили все преимущества стандартного подхода (быстрый запуск, типовые виджеты) и добавили гибкость для создания уникального пользовательского интерфейса, не опасаясь проблем с корректностью и стабильностью работы.
Что в итоге
У нас получился масштабируемый продукт, который пока только можно использовать на Android и iOS:
- Написали кодогенератор, который позволяет вызывать C++ код напрямую из Dart с помощью FFI. Благодаря этому кодогенерируемое API полностью аналогично iOS и Android Mobile SDK. 
- Реализовали - Flutter MapWidgetдля рендеринга карты во Flutter-приложении через- Texture Widget. При этом свели к минимуму использование нативной части (Swift/Kotlin) и полностью отказались от использования- PlatformView.
- Реализовали - Flutter Mapи- Navigation Widgetsдля отображения различных UI-элементов карты и навигатора.
В ближайших планах — интеграция FlutterSDK в другие ОС, в частности в мобильную Aurora OS. Об этом планируем написать отдельно.
Комментарии (18)
 - Tyiler26.05.2025 18:48- Приветствую. - Написали кодогенератор, который позволяет вызывать C++ код напрямую из Dart с помощью FFI. Благодаря этому кодогенерируемое API полностью аналогично iOS и Android Mobile SDK - Такой вопрос. А проще путь если: клиент-сервер, клиент на Dart, сервер C++, обмен данными не обязательно через сокет, если быстрее надо можно пайпы использовать или шару. Чем такой вариант хуже?  - AlexanderMaxal Автор26.05.2025 18:48- Привет. Я так понимаю, что идея заключается в локальном развертывании клиент-серверного приложения. Теперь по порядку. - При клиент-серверном взаимодействии данные передаются в виде, например, JSON. Сериализовать/десериализовать данные в JSON и обратно - явно будет медленнее, чем через FFI. Да, можно использовать protobuf, но тоже не уверен, что будет сильно производительнее JSON и точно хуже FFI. 
- Поддержка и использование API пользователями нашего SDK будет сложнее. Сейчас пользователь может подписаться на CancelableOperation и получить в callback готовый для использования тип. Или передать класс в наш API. А так придется еще и через JSON/protobuf все это прогонять. 
  - AlexanderMaxal Автор26.05.2025 18:48- И ты имеешь ввиду, что проще - это не нужен кодогенератор? Ну при подходе в клиент-серверном взаимодействии нужно реализовать сериализаторы/десериализаторы. Плюс, кодогенератор один раз написали и все, он на CI работает и генерирует сборки.  - Tyiler26.05.2025 18:48- Генератор бывает надо докручивать, и мало кто туда лезет это делать. В общем, сложно выглядит в поддержке с обоих сторон получается, и у вас и у юзера.  - AlexanderMaxal Автор26.05.2025 18:48- Генератор - это наш внутренний продукт, недоступный для внешнего пользователя. Генерируемый файл мы поставляем вместе с FlutterSDk. 
 Да, из за того, что политика flutter требует предоставление всех dart исходников, получается, что сгенерированный файл мы выставляем наружу. Но пользователь сам себе выстрелит в ногу, если что-то там сломает))
 
 
  - Tyiler26.05.2025 18:48- JSON только если для команд и метаданных, а обновленную карту из 3D движка прямо так и передавать в бинарном виде. Сериализация нужна будет, да, protobuf подойдет, но можно и самим написать не хитрый протокол, типа: в начале метаинфо в json, затем бинарные данные, если есть. 
- 
Мне вот кажется наоборот у вас сейчас АПИ для юзера довольно сложное получилось, вы его в свои классы загоняете, которые создавать надо с помощью генератора. Если клиент-сервер не хочется делать, то можно и либу оставить как сейчас, только интерфейс ей проще сделать - как выше написал, JSON для метаданных, и бинарник для карты. 
  - AlexanderMaxal Автор26.05.2025 18:48- Так у нас же не просто отображение карты. Наш продукт позволяет задавать перелеты и сложные алгоритмы для изменения параметров камеры во время слежения. Отслеживание разных параметров камеры. 
 У нас есть справочник со большим объемом данных и вложенными структурами.
 Навигатор позволяет в реальном времени получать данные об оставшемся маршруте, о текущей скорости, потери GPS и прочим.
 Для всех выше указанных и множества других сценариев постоянно гонять protobuf - накладно. - AlexanderMaxal Автор26.05.2025 18:48- >его в свои классы загоняете, которые создавать надо с помощью генератора. 
 Самому пользователю ничего создавать не нужно) Готовые Dart классы и методы уже доступны из коробки вместе с дефолтными UI виджетами.
  - Tyiler26.05.2025 18:48- Для пользователя protobuf мбыть не всем удобен, да. (но протобуф быстре парсить есст-но, чем json, но это мелочь все). 
 Вот что имею ввиду, если json-ом пусть пользоваться (на самом деле любым можно текст протоколом, без разницы):- имеем в "С" апи такую ф-ю: 
 BOOL setParam(int ptype, char* jsonVal);- и для валидации json пусть ф-ю, пригодится чтобы проверить апи на изменение в будущем - BOOL checkParamValid(int ptype, char* jsonVal); 
 < задавать перелеты и сложные алгоритмы для изменения параметров камеры во время слежения:- setParam(CAM_TANG, "{"deg": 2, "posA": 1234, "posB" : 567...}"); - Пользователю давать эти ф-ии в руки, а не сгенерирован классы, и он будет доволен.  - Tyiler26.05.2025 18:48- Еще BSON как вариант использовать, если у вас там прямо большие стр-ры данных передваться будут. - Плюс такого интр-са С-го простого, что можно его в любых языках использовать один и тот же, не надо ничего городить для каждого языка. Пусть юзер сам себе городит сверху что-то, если надо ему.  - AlexanderMaxal Автор26.05.2025 18:48- Ты сейчас рассматриваешь простой кейс - задать позицию камеры. 
 У нас есть метод Camera.move, который возвращает CancelableOperation с сигналом о том, что перелет камеры завершен. Или у нас есть канал positionChannel, который Stream и возвращает данные об изменении позиции камеры.
 С этим как быть? Слушать конкретные сокеты?
 Также у нас еще есть функционал с передачей платформенной реализации какого-то абстрактного класса - то же сложное задание слежения за позицией или углом наклона. - Tyiler26.05.2025 18:48- С этим как быть? - колбеки же никто не отменял, С-й же интр-с. Пользователь задаст вам свой колбек, вы дерните его когда там надо.. 
 
  - AlexanderMaxal Автор26.05.2025 18:48- >Пусть юзер сам себе городит сверху что-то, если надо ему. 
 Цель то наша как раз и создать максимально удобный API, чтобы пользователю не нужно ничего "городить", а у него было бы максимально доступный и удобный набор ручек.
 В поддержку нашего подхода скажу, что наши конкуренты также используют платформенное API над C++ кодом. - Tyiler26.05.2025 18:48- Вы и так могли бы на базе С-го интер-са создать (потом) вспом классы на конкретном языке, для удобства юзеру. И он бы сам выбирал, пользоваться ими, или спуститься пониже и свои написать обертки. - Сейчас у него один вариант только - использовать ваши ручки. 
 
 
  - AlexanderMaxal Автор26.05.2025 18:48- По твоему подходу следует, что ты предлагаешь заменишь наши типизированные классы на JSON/protobuf и тд. Но это лишь малая часть наших "проблем", которые решает кодогенератор. 
 Самая "мясная" часть кодогенератора связана с передачей CancelableOperation и Stream, с заданием реализации конкретного абстрактного класса.
 И у нас кодогенератор еще позволяет генерировать именно платформенные сущности конкретного языка. Dart разработчикам удобно работать с CancelableOperation и Stream, потому что это все часть Dart.
 В Swift и Kotlin у нас такой же подход - использовать по максимуму возможности платформенного языка. - Tyiler26.05.2025 18:48- Самая "мясная" часть - Сложно у вас все я и говорю об этом. - Ладно, пользователь если доволен, и у вас есть (пока) столько сил тащить все эти языки (Swift, Kotlin, Dart, потом еще другие надо будет), то.. ну ок. 
 
 
 
 
 
 
 
           
 
RussellPro
У вас же на Qt все, перестал устраивать?
AlexanderMaxal Автор
На Qt написано приложение 2ГИС. API часть SDK не использует Qt.