Add-to-app встраивает Flutter-экраны в существующее Android- или iOS-приложение вместо переписывания. Модуль представляет собой Flutter-проект, собираемый как библиотека для хоста: своей оболочки приложения у него нет, а .android и .ios лишь сгенерированные обёртки, которые не редактируют.
Зачем это командам: переводить большое нативное приложение на Flutter экран за экраном или делить одну фичу (checkout, онбординг) между двумя нативными приложениями
**module: в pubspec.yaml**: содержит androidPackage и iosBundleIdentifier; именно этот блок делает проект модулем
Сгенерированные хосты: flutter pub get пересоздаёт .android и .ios; они нужны только чтобы запускать модуль отдельно и в git не попадают, поэтому нужный нативный код живёт в хост-приложении или в локальном плагине
Dart-часть обычная: виджеты, пакеты и плагины работают как всегда; меняется то, что engine запускает хост и навигацией владеет он же
FlutterSenior
Add-to-app и нативная интеграция
FlutterEngine, несколько engine, встраивание в существующее iOS/Android-приложение, Pigeon, плагин или FFI, размер и запуск
Закрыто вопросов
0 / 16
- 0Повторить
- 1Часто спрашивают
Вопросы
FlutterEngine выполняет ваш Dart-код: изолят, каналы, плагины и рендерер. FlutterActivity, FlutterFragment и FlutterViewController служат лишь нативными контейнерами, которые дают engine поверхность для отрисовки и передают ему ввод и события жизненного цикла.
Разные времена жизни: engine может стартовать раньше любого Flutter-экрана (прогрев) и пережить его; Dart-состояние, таймеры и сокеты продолжают жить, пока Flutter-экран не виден
Один view на engine: на мобильных engine рисует в один контейнер за раз, поэтому двум одновременно живым Flutter-экранам нужны два экземпляра engine
Поведение по умолчанию дорогое: контейнер, созданный без engine (FlutterActivity.withNewEngine(), FlutterViewController(project:nibName:bundle:)), запускает собственный и уничтожает его при закрытии, так что каждое открытие платит полный старт
Сегодня документированный путь ведёт через Swift Package Manager: flutter build swift-package --platform ios создаёт локальный пакет, который вы подключаете в Xcode. CocoaPods (podhelper.rb) и xcframework из flutter build ios-framework ещё работают, но считаются устаревшими.
Swift Package Manager
Build settings: задайте FLUTTER_SWIFT_PACKAGE_OUTPUT на созданную папку (build/ios/SwiftPackages/), чтобы Xcode нашёл пакет
Пересборка Dart из Xcode: по желанию задайте FLUTTER_APPLICATION_PATH и ENABLE_USER_SCRIPT_SANDBOXING=NO, чтобы каждая сборка в Xcode перекомпилировала правки в Dart
Хуки сборки: добавьте pre-action схемы flutter_integration.sh prebuild и Run Script phase assemble
Новая зависимость в модуле: снова запустите flutter build swift-package
Устаревшее: CocoaPods и фреймворки
CocoaPods: podhelper.rb собирает модуль из исходников и требует Flutter SDK на машине каждого iOS-разработчика и на CI; CocoaPods в режиме поддержки, а его trunk становится read-only 2 декабря 2026
**flutter build ios-framework**: создаёт xcframework в папках Debug, Profile и Release, которые встраивают под каждую конфигурацию Xcode; Flutter SDK хосту не нужен, но изменения в Dart требуют пересборки
Либо подключите Gradle-проект модуля в сборку хоста (include_flutter.groovy, затем implementation(project(":flutter"))), либо опубликуйте AAR командой flutter build aar в локальный Maven-репозиторий и зависьте от них, как от любой другой библиотеки.
Зависимость на исходники: Gradle компилирует Dart-код при каждой сборке хоста, поэтому изменения видны сразу; Flutter SDK нужен каждому Android-разработчику и CI-агенту, а версии AGP и Kotlin в хосте должны подходить Flutter Gradle-плагину
AAR: хосту не нужен Flutter SDK, и он собирается быстрее; изменения в Dart требуют снова запустить flutter build aar и выпустить новую версию (--build-number)
Три варианта: flutter_debug, flutter_profile и flutter_release, плюс по AAR на каждый плагин в том же репозитории; чтобы подключить profile-вариант, хосту нужен build type profile
Два репозитория: локальный, который пишет сборка, и https://storage.googleapis.com/download.flutter.io, где лежат артефакты engine
ABI: Flutter поставляет нативный код только для arm64-v8a, armeabi-v7a и x86_64; хосту, который собирает ещё и x86, нужны abiFilters, иначе Flutter-экран упадёт там без libflutter.so
FlutterActivity берут для полноэкранного Flutter-флоу, потому что кода нужно меньше всего; FlutterFragment подходит, когда Flutter является частью нативного экрана, например одной вкладкой; голый FlutterView нужен, только когда не подходит ни то ни другое, потому что жизненный цикл тогда подключаете вы сами.
**FlutterActivity**: объявите её в AndroidManifest.xml с темой и configChanges и запускайте через withCachedEngine, withNewEngine или withNewEngineInGroup; backgroundMode делает её прозрачной
**FlutterFragment**: Activity-хост должна пробрасывать onPostResume, onNewIntent, onUserLeaveHint, onTrimMemory и результаты запроса разрешений, иначе плагины и клавиатура ведут себя странно; shouldAutomaticallyHandleOnBackPressed(true) позволяет фрагменту самому обрабатывать Back
**RenderMode**: surface (по умолчанию, SurfaceView) самый быстрый, но живёт в отдельном слое; texture смешивается с анимированными или перекрывающими нативными view ценой некоторой нагрузки на GPU
Смена конфигурации: переиспользуйте фрагмент, который FragmentManager восстановил по тегу, а не добавляйте второй
**FlutterView**: вы сами вызываете attachToFlutterEngine, сообщаете resume и pause в lifecycleChannel engine и привязываете плагины к Activity; легко ошибиться в мелочах
На Android новый FlutterEngine регистрирует все плагины автоматически, через GeneratedPluginRegistrant; на iOS автоматически не происходит ничего, поэтому GeneratedPluginRegistrant.register(with:) вызывают на каждом запущенном engine. Ломаются плагины, считающие хост Flutter-приложением.
На каждый engine: каждый engine получает свои экземпляры плагинов; плагин, который хранит нативное состояние в статических полях или синглтонах, видит два engine и путает их
Activity ещё нет: на прогретом Android-engine ActivityAware-плагины (пикеры, разрешения, вход через аккаунт) не имеют Activity, пока не подключится FlutterActivity или FlutterFragment; вызывайте их только с видимого Flutter-экрана
События приложения и сцены: iOS-плагины получают события приложения, только если app delegate наследует FlutterAppDelegate (или реализует FlutterAppLifeCycleProvider), а события сцены (URL, universal links) получают, только если scene delegate наследует FlutterSceneDelegate (или реализует FlutterSceneLifeCycleProvider и пробрасывает их в FlutterPluginSceneLifeCycleDelegate)
Конфликты зависимостей: нативный SDK плагина (Firebase, карты, OkHttp) резолвится вместе с копией хоста; Gradle молча выберет одну версию, а CocoaPods может отказаться, так что выравнивайте версии или оставьте SDK хосту и откройте его для Dart
Отключение: FlutterEngine(context, null, false) выключает автоматическую регистрацию на Android, когда ею нужно управлять самим
До запуска engine задайте ему initial route или выберите Dart entrypoint и передайте аргументы. Когда engine уже работает, они уже израсходованы, поэтому при следующих заходах пушат маршрут или вызывают типизированный API через messenger этого engine.
Initial route: navigationChannel.setInitialRoute до executeDartEntrypoint на Android, run(withEntrypoint:initialRoute:) на iOS; в Dart он виден как PlatformDispatcher.defaultRouteName, и MaterialApp подхватывает его сам
Глубокие initial route складываются в стек: MaterialApp разбивает /card/42 на /, /card и /card/42 и пушит каждый, который резолвит onGenerateRoute; задайте onGenerateInitialRoutes, если нужен один экран
Entrypoint: отдельная top-level функция на фичу с пометкой @pragma('vm:entry-point'), иначе AOT-компилятор выкинет её при tree shaking; аргументы приходят как List<String>
Последующие заходы: navigationChannel.pushRoute или FlutterViewController.pushRoute доходят до Navigator или Router приложения; если нужно передать данные, понятнее метод Pigeon @FlutterApi, например openOrder(id)
Передавайте id, а не объекты: остальное Dart загрузит у хоста или из своего репозитория; токены и персональные данные в маршрут не кладут никогда
В add-to-app AppLifecycleState описывает Flutter view, а не приложение. Когда пользователь уходит с Flutter-экрана на нативный, engine сообщает paused, а после ухода view сообщает detached, хотя приложение из foreground не уходило.
Что сообщает engine: Android шлёт paused из onStop у FlutterActivity и detached, когда она отключается от engine; iOS шлёт paused, когда FlutterViewController исчезает с экрана
Обратный баг: пока сверху нативный экран, настоящий уход в фон не даёт в Dart нового события, потому что состояние уже paused
Dart продолжает работать: закэшированный engine без view по-прежнему крутит таймеры, стримы и сокеты, останавливаются только кадры; ставьте polling на паузу сами или уничтожайте engine
Исправление: политика уровня приложения (блокировка, таймауты сессии, сессии аналитики) принадлежит хосту, либо хост пересылает настоящее состояние по каналу из ProcessLifecycleOwner или UIApplication.didEnterBackgroundNotification
**AppLifecycleListener**: с той же семантикой, так что переход на него ничего не меняет
showProfile на каждое нажатие создаёт и запускает новый FlutterEngine, поэтому каждый заход платит полный старт engine до первого кадра и начинается с пустой Dart-кучи. Создайте один engine заранее, запустите его один раз и переиспользуйте на каждом заходе.
Почему медленно: run создаёт изолят, загружает скомпилированный Dart-код, выполняет main, затем регистрируются плагины; до первого кадра view controller ничего не показывает
Почему теряется состояние: каждый engine запускает отдельный изолят; предыдущий ушёл вместе со своим view controller и унёс позицию скролла, загруженные данные и открытые соединения
Почему так написали: initial route читается только при старте engine, поэтому казалось, что маршрут на каждый заход требует нового engine; на работающий engine экран и данные отправляют по каналу или через pushRoute
Память: engine, созданный через init(name:), продолжает работать без view до destroyContext(); если его что-то удерживает, например обработчик канала, который его захватил, каждое нажатие добавляет ещё один изолят и набор плагинов
Один engine на один view: переиспользование работает, потому что одновременно виден один экран профиля; двум Flutter-экранам сразу нужны engine из FlutterEngineGroup
Запустите хост-приложение в debug из Xcode или Android Studio, затем выполните flutter attach в папке модуля: он найдёт Dart VM service работающего engine и даст hot reload, hot restart, DevTools и точки останова в Dart.
Порядок не важен: flutter attach ждёт появления engine; с прогретым engine подключится при запуске, иначе подключится, когда откроется первый Flutter-экран
Выбор цели: -d выбирает устройство, --app-id выбирает приложение, если VM service объявляют несколько, а --debug-url подключается к известному адресу, когда автопоиск не срабатывает
Поиск на iOS: tool ищет VM service через mDNS, поэтому debug-сборкам нужны NSLocalNetworkUsageDescription и NSBonjourServices с _dartVmService._tcp, лежащие в отдельном debug-only plist (например, Info-Debug.plist), чтобы не попасть в Release-сборку, а пользователь должен разрешить доступ к локальной сети
Hot restart перезапускает только Dart: экраны хоста, нативное состояние и engine остаются; изменения нативного кода и новые плагины по-прежнему требуют полной нативной пересборки
Режимы сборки: у release-сборок нет VM service, поэтому для работы с производительностью подключайтесь к --profile-сборкам; debug-сборку Flutter на реальном iPhone нужно запускать из Xcode или Flutter tool
Оба отладчика сразу: Xcode или Android Studio останавливаются в нативном коде, а flutter attach или Flutter Attach в IDE обрабатывает точки останова в Dart
Engine одной FlutterEngineGroup разделяют AOT-код, шрифты и GPU-контекст, поэтому лишний engine дешевле по памяти и стартует быстрее независимого. Dart-состояние не разделяется: у каждого свой изолят, куча, статика и плагины.
Почему одного engine мало
На мобильных engine рисует в один view за раз, поэтому экранам, видимым одновременно, или стеку native → Flutter → native → Flutter нужен каждому свой engine
Независимый engine загружает всё заново; документация Flutter называет около 180 кБ статической памяти на дополнительный engine из группы плюс то, до чего вырастет его Dart-куча
Общее состояние между engine
Источником правды служит нативная сторона: сессией, корзиной или настройками владеет хост, открывает их через Pigeon на messenger каждого engine и рассылает изменения каждому
Не Dart-синглтоны: static или хранилище состояния (BLoC/Riverpod) в одном engine невидим для остальных; две копии одного репозитория расходятся
Между engine: поддерживаемый путь только один, через хост (Pigeon или каналы), потому что у каждого экземпляра свой стек навигации, UI и состояние
Цена и подводные камни
Каждый engine всё равно выполняет свой entrypoint и регистрирует плагины, поэтому прогревайте только те, которые вероятно откроются
Плагины, которые держат нативную статику или рассчитывают на единственный engine, работают неправильно
Уничтожайте ненужные engine: destroy() на Android, destroyContext() и освобождение ссылки на iOS
Стеком между экранами владеет хост, а стеком внутри своего view владеет Flutter. Каждый Flutter-экран живёт в нативном контейнере в стеке хоста, и Back сначала снимает внутренние маршруты Flutter, а потом закрывает контейнер.
Двум Flutter-контейнерам нужны два engine: один engine обслуживает один видимый контейнер, поэтому если переиспользовать один закэшированный engine для обоих, первый экран при возврате останется без содержимого; используйте FlutterEngineGroup или проектируйте флоу так, чтобы в стеке был только один Flutter-контейнер
Back на Android: FlutterActivity сначала отдаёт Back во Flutter; когда Navigator снимать нечего, SystemNavigator.pop() завершает Activity; с predictive back PopScope(canPop: ...) решает, можно ли запускать системную анимацию
Свайп от края на iOS: FlutterViewController, запушенный в UINavigationController, конкурирует с собственным свайпом назад во Flutter; отключайте interactivePopGestureRecognizer, пока у Flutter есть внутренние маршруты, переключая его из NavigatorObserver через канал
Закрытие из Dart: на iOS SystemNavigator.pop() снимает view controller с его navigation controller или закрывает модально показанный; явный close(result) в API хоста понятнее и тестируется
Открыть нативный экран из Flutter: Dart просит хост (openNative('kyc')), и хост пушит свой экран; Dart никогда не считает, что знает стек хоста
Диплинки: хост разбирает ссылку и решает, какой контейнер и какой маршрут открыть
Относитесь к нему как к версионированному контракту: опишите его один раз в Pigeon-файле, сгенерируйте из него Kotlin, Swift и Dart, держите его небольшим, с немногими укрупнёнными вызовами, а общим состоянием вроде сессии пусть владеет хост.
Контракт
Pigeon вместо сырых каналов: переименованный метод ломает сборку с обеих сторон, а не бросает MissingPluginException в продакшене; сгенерированный код хоста живёт в хост-приложении или локальном плагине, но никогда в сгенерированных .android или .ios
Мало крупных вызовов: getSession(), openNative(screen), close(result) вместо десятков геттеров; каждый вызов означает асинхронный переход с сериализацией
Выпускаются вместе: хост и модуль идут в одном бинарнике, поэтому расхождения версий в рантайме нет, но артефакт модуля должен совпадать со сгенерированным кодом хоста; CI публикует их одной версией
Направление и момент вызова
@HostApi отвечает за Dart → native, @FlutterApi отвечает за native → Dart; вызывайте второй только после того, как Dart сообщил о готовности, или пусть Dart сам забирает состояние при старте
Несколько engine: вызывайте setUp на messenger каждого engine; messageChannelSuffix даёт каждому экземпляру свои имена каналов, когда одному messenger нужно несколько
Владение
Авторизацией владеет хост: Dart запрашивает свежий токен на каждый запрос, а не копирует его в своё хранилище, чтобы logout и refresh происходили в одном месте
Ошибки входят в контракт: переводите нативные сбои в коды ошибок, которые обрабатывает Dart; не кодируйте «не залогинен» как null
Ядро на C++ вызывайте через dart:ffi: это один и тот же код на обеих платформах, и сериализация не нужна. Платформенные SDK по умолчанию вызывайте через Pigeon, а биндинги jnigen или ffigen рассматривайте только для широких синхронных API, потоками которых вы управляете.
Ядро на C++ через FFI
dart:ffi видит только C ABI: откройте ядро через заголовок с extern "C", простыми функциями и непрозрачными дескрипторами, и направьте ffigen на этот заголовок
ffigen генерирует биндинги из заголовков; библиотека уже есть в хосте, поэтому открывайте её через DynamicLibrary.open('libcore.so') на Android и DynamicLibrary.process() на iOS, где она слинкована статически
Release-сборки могут вырезать символы, на которые ничто в бинарнике не ссылается; выставьте Strip Style в Non-Global Symbols и держите функции экспортированными, иначе поиск символа упадёт только в release
Хост и Dart используют одну копию библиотеки, поэтому её глобальное состояние общее и уже должно быть потокобезопасным
Платформенные SDK
Pigeon: асинхронно, потоки явные, работает с любым SDK; вариант по умолчанию
**jnigen: Dart-биндинги, которые вызывают Java или Kotlin напрямую через JNI; хорош для множества мелких вызовов, но JNI-ссылками и потоками управляете вы, а инструмент ещё молодой
ffigen для Objective-C**: прямые вызовы Objective-C API; Swift SDK сначала нужно открыть через @objc
Потоки начиная с Flutter 3.29
Корневой изолят Dart теперь выполняется на главном потоке платформы на Android и iOS, поэтому медленный синхронный FFI-вызов из UI-кода замораживает и нативный UI; долгую работу выносите в Isolate.run
Плюс: API, доступные только с главного потока, можно вызывать синхронно из корневого изолята
Размер: engine плюс ваш AOT-скомпилированный Dart, ассеты и плагины занимают несколько мегабайт на каждую архитектуру процессора. Время: ничего, пока engine не стартовал, а затем обычно несколько сотен миллисекунд на его загрузку и выполнение main, при открытии экрана или раньше, если engine прогрет.
Размер
Измеряйте разницу: сравнивайте сборки хоста с модулем и без: по ABI на Android, где App Bundle разделяет ABI за вас, и по отчёту о размере в App Store Connect на iOS
Урежьте Dart-часть: --split-debug-info выносит символы из бинарника; tree shaking иконок включён по умолчанию; уберите неиспользуемые пакеты и тяжёлые ассеты
Плагины тянут нативные SDK: хост часто уже содержит тот же SDK карт или аналитики; если открыть копию хоста через Pigeon, второй не понадобится
Старт
**Не прогревайте в Application.onCreate по привычке: это замедляет холодный старт самого хоста, а именно по этой метрике оценивают нативную команду
Разделите работу**: FlutterLoader.startInitialization в самом начале грузит libflutter.so и ресурсы вне главного потока; engine создавайте и запускайте позже, когда главный поток простаивает или пользователь движется к Flutter-экрану
iOS: если Flutter не на первом экране, запускайте engine после работы на старте, а не внутри didFinishLaunching
Измеряйте оба конца: старт хоста (adb shell am start -W, Macrobenchmark, Xcode Organizer) и время от нажатия до первого Flutter-кадра (FlutterUiDisplayListener, setFlutterViewDidRenderCallback)
Начните с самодостаточного флоу, мало связанного с остальным приложением, сначала наладьте сборку, тонкий API хоста и стратегию engine, оставьте хосту владение авторизацией, навигацией и состоянием уровня приложения и наращивайте долю Flutter, пока не станет дешевле перевернуть схему встраивания.
Первые шаги
Выберите первую фичу: изолированный флоу на краю приложения (настройки, онбординг, новая фича), а не главный экран; он проверит путь сборки, релиза и аналитики с малым риском
Инфраструктура один раз: интеграция модуля в CI обоих хостов как версионированные AAR и SwiftPM-пакет, контракт на Pigeon и одна стратегия engine: сначала один закэшированный engine, а FlutterEngineGroup подключают, когда Flutter появится в нескольких местах
Согласованность
Один источник правды: сессия, фича-флаги, remote config и настройки пользователя живут в хосте и читаются через API; Dart не кэширует того, что не может инвалидировать
Без видимого шва: тема Flutter строится из нативных дизайн-токенов, а аналитика использует те же имена событий и экранов
Понятное владение: жизненный цикл приложения, пуши, диплинки и запросы разрешений остаются за хостом
Точка перелома
Когда большинство экранов на Flutter, переворачивайте схему: Flutter становится приложением, а оставшиеся нативные экраны открываются как отдельные Activity или view controller через каналы либо как platform view там, где они должны быть внутри Flutter-экрана
Отслеживайте crash-free rate, время старта, размер и память на каждом шаге, чтобы решение опиралось на цифры