На связи тимлид 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. Об этом планируем написать отдельно.