# Capacitor: от веба к мобильным приложениям. Часть 6. Встраиваем Capacitor в native-приложение

Давно не виделись Хабровчане! Сегодняшняя тема для статьи мне показалась достаточно интересной и не такой уж очевидной. Сегодня мы будем пытаться встроить Capacitor-приложение в нативное приложение. Эту идею мне подкинул мой друг @solyonyvlad, за что ему спасибо.
Давайте представим такую гипотетическую ситуацию когда у нас есть основное мобильное приложение полностью написанное нативно(iOS,Android) и другая команда у которой есть определенный функционал в веб приложении который срочно нужно добавить в нативку. Тогда логичный но крайне затратный по ресурсам шаг это реализовать этот функционал на 2 нативках.
Кто-то сейчас может возразить и предложить открыть сайт в WebView, но тогда быстро появляется самодельный мост через postMessage, отдельная история с разрешениями и непонятный владелец навигации. В этой статье мы рассмотрим третий вариант: встроить Capacitor-приложение, реализующее нужный функционал, в экран существующего native-приложения.
Дисклеймер 1. Не обязательно в экран, это спокойно может быть и обособленная часть приложения
В этой статье мы не будем пытаться «заменить нативную разработку» или расписывать универсальный рецепт пушей, роутинга и авторизации. Эти вещи слишком зависят от конкретного приложения. Вместо этого соберём минимальный пример, так сказать proof of concept.
Дисклеймер 2. Вся статья опирается только на Android платформу. Пример собран на Capacitor 8.5.2 и проверен на Pixel 5a с Android 13. Подход рассчитан на обе платформы; iOS-сноски будут по ходу статьи, но без пошаговой инструкции для Xcode.
Репозиторий демо проекта: sudondie/capacitor-embedded-demo.
Оглавление:
Чтобы дальше не утонуть в терминах:
Основное приложение / host — уже существующее Android- или iOS-приложение. Оно открывает Capacitor-приложение, выделяет ему место на экране и остаётся владельцем.
Встраиваемое Capacitor-приложение — Приложение с
@capacitor/core, своимcapacitor.configи web bundle. В продуктовой речи ниже я иногда называю его web-фичей: это профиль, корзина или даже экран, который оно реализует.Bridge — Проводник между JavaScript и возможностями телефона. Web вызывает метод, а bridge передаёт запрос нативному коду и возвращает ответ.
Plugin — Готовый или собственный плагин для конкретной возможности: камеры, файлов, уведомлений, данных пользователя.
Контракт — небольшой набор разрешённых методов и событий между сторонами. Например,
getCurrentUser(),takeDocumentPhoto()и событиеlogout.Android shell — нативный Android/iOS-проект, который Capacitor CLI генерирует вокруг web-приложения в обычном сценарии. В embedded-сценарии эту роль уже играет существующий host, поэтому shell не генерируется, а подключается вручную.
Native-адаптер — тонкий Android/iOS-класс (в демо —
Fragment, на iOS —UIViewController), который создаёт Bridge и передаёт ему lifecycle host-приложения: результаты разрешений,Intent, паузу/возобновление.
Что мы вообще тут делаем?
За основу native-хоста я взял Jetsnack из Android Compose Samples. Это не пустой шаблон: в нём есть Compose-навигация, таб бар, тема и существующие экраны. В таб Profile я встроил отдельное Capacitor-приложение(React 19, Vite, Tailwind и shadcn/ui) которое собственно реализует профиль.
apps/
├── android-host/ # существующее Compose-приложение
└── web-feature/ # встраиваемое Capacitor-приложение на React
В обычном Capacitor-проекте Android shell генерируется вокруг web-приложения. Здесь Android shell уже существует и это само нативное приложение.
В результате пользователь видит Capacitor-приложение профиля внутри native-навигации Jetsnack: нижний бар, верхнеуровневая навигация и системные возможности остаются нативными, а сам профиль живёт в web-слое.


А зачем это нужно с продуктовой точки зрения?
Самое главное преимущество это конечно же сроки поставки нового функционала. Также такой подход частично меняет границу ответственности.
Веб-команда может поставлять самостоятельную фичу, не ожидая полной реализации её UI на двух платформах.
Часть UI, валидации и бизнес-логики можно разделить с сайтом.
Нативная команда сохраняет контроль над приложением: навигацией между крупными областями, авторизационной границей, разрешениями, пушами, аналитикой и жизненным циклом.
У приложения появляется ещё один инструмент.
Подход хорош для самостоятельного флоу: профиля, анкеты, кабинета, каталога, оформления услуги. Но самостоятельность фичи определяется не тем, занимает ли она весь экран, а собственной логикой, состоянием, API и владельцем.
Почему не Backend-Driven UI?

Backend-Driven UI — хороший соседний подход, но он решает другую задачу. BDUI позволяет серверу собрать экран из заранее реализованных native-блоков: карточек, форм, секций, списков. Это отлично для конфигурации, экспериментов и контентных экранов. Но каждый новый блок, его поведение, состояния, accessibility и аналитика всё равно должны появиться на Android и iOS.
Вопрос | Backend-Driven UI | Встроенная web-фича |
|---|---|---|
Кто реализует новый UI-компонент | Нативная команда заранее | Web-команда в пределах фичи |
Общий код с сайтом | Обычно небольшой | Может быть значительным |
Граница изменений | Набор готовых нативных блоков | Самостоятельный web-flow |
Нативные API | нативный слой | Через Capacitor plugin |
Лучший сценарий | Динамическая композиция native UI | Цельная фича, уже близкая к веб-продукту |
BDUI хорош для своей задачи. Capacitor даёт больше свободы самостоятельной web-фиче, но вместе с ней приносит ответственность за интеграцию.
Это не выбор «BDUI или Capacitor». В одном приложении главная лента может быть BDUI, а кабинет пользователя — встроенным Capacitor-приложением. Важнее понять границу фичи и то, какая команда ею владеет.
Правильный размер встраиваемого Capacitor-приложения
Приложение не обязано быть отдельным tab, экраном и т.д. Его можно ограничить обычным Compose Modifier и поместить в часть уже готового нативного экрана:
Native screen
├─ native app bar
├─ native адрес доставки
├─ Capacitor cart module
│ ├─ товары и изменение количества
│ ├─ промокод
│ └─ итоговая сумма
└─ native bottom navigation
В таком сценарии хост владеет оболочкой и переходами между крупными частями приложения, а Capacitor-приложение — состоянием и UI, скажем, корзины. Но до начала работы нужно договориться о нескольких вещах:
кто владеет прокруткой — родительский native-экран или сама корзина;
может ли Capacitor-приложение менять высоту;
как передаются пользователь, адрес, авторизация и т.д;
сохраняется ли WebView при уходе с экрана.
Плохим сценарием были бы десятки независимых Capacitor-приложений на странице. Если в LazyColumn каждая карточка создаёт свой bridge, быстро появятся проблемы с памятью, вложенной прокруткой, жизненным циклом.
Обычные повторяющиеся карточки лучше оставить native UI или, например, BDUI; ограниченная область с полноценной корзиной — совсем другой масштаб задачи.
Сначала договоримся о границах
Самая опасная интеграция — та, где встраиваемое приложение получает доступ ко всему подряд, а владелец переходов, токенов и разрешений не определён. У рабочей схемы граница выглядит так:
Native host
├─ navigation, lifecycle, permissions, push token
├─ auth boundary, analytics и release process
└─ Capacitor bridge и native adapters
↕ явный контракт
Web feature
├─ UI и flow своей фичи
├─ общая с сайтом логика, где это уместно
└─ вызовы разрешённых нативных возможностей
Capacitor-приложение не читает нативное хранилище и не решает самостоятельно, когда показывать системный диалог. Вместо этого она вызывает небольшой контракт: например, getCurrentUser, requestNotifications, openNativeRoute или closeFeature. Смысл контракта одинаков на Android и iOS, а реализация остаётся делом хоста.
iOS. Тот же принцип: host — приложение и его
AppDelegate/SceneDelegate, web-контейнер —UIViewControllerсWKWebView. Web-фича не должна становиться владельцем application lifecycle.
Что должно оставаться нативным
Встраивание Capacitor — не передача приложения вебу. Я бы оставил в host как минимум:
авторизационную границу и чувствительные токены;
push token и обработку уведомлений;
системные разрешения;
app links/deep links;
верхнеуровневую навигацию;
аналитику, crash reporting, feature flags и release process;
всё, что требует безупречного native UX или платформенных SDK.
Что происходит в демо проекте при сборке и запуске
Да, ассеты в итоге оказываются в APK, а Capacitor bridge инициализируется из native-кода. Но это не ручное копирование dist перед релизом.
npm run build
→ React/Vite собирает dist/
Gradle Sync
→ кладёт dist в генерируемые assets/public
→ кладёт capacitor.config.json рядом
assembleDebug / assembleRelease
→ упаковывает assets в APK
В Android-приложение добавляется библиотека Capacitor той же версии, что и в web-проекте. Для frontend-разработчика это примерно аналогично добавлению npm-пакета, только зависимость подключает Gradle:
dependencies {
implementation("com.capacitorjs:core:8.5.2")
implementation(libs.androidx.appcompat)
}
При открытии экрана Android создаёт контейнер для встраиваемого приложения, в нём — Capacitor WebView, а затем запускается bridge:
Profile Compose screen
→ FragmentContainerView
→ EmbeddedCapacitorFragment
→ CapacitorWebView
→ Bridge.Builder(fragment).create()
При create() Capacitor находит WebView, читает настройки и отдаёт ему public/index.html с остальными файлами из APK. Можно представлять это как локальный мини-сервер внутри приложения: он открывает собранную React-фичу без перехода в браузер. Capacitor здесь — runtime одного экрана, а не оболочка всего приложения.
Есть два важных следствия. Capacitor 8 требует minSdk 24, поэтому старый host с minSdk 23 нельзя «починить» через overrideLibrary: это продуктовое решение о поддерживаемых Android-версиях. И BridgeActivity Capacitor рассчитан на сценарий, где он сам владеет Activity; у существующего приложения Activity уже занята.
iOS. Framework Capacitor и ассеты добавляются в существующий target. Это не создание второго Xcode-проекта. Конкретный build phase нужно оформить и проверить на Mac отдельно.
Собираем ассеты автоматически
Ниже сокращённая версия Gradle-задач из demo. Она запускает Vite build, затем синхронизирует результат в генерируемые Android assets. В исходники Android ничего копировать не нужно.
val buildEmbeddedWebFeature by tasks.registering(Exec::class) {
workingDir(webFeatureDir)
commandLine("cmd", "/c", "npm", "run", "build")
outputs.dir(webFeatureDistDir)
}
val syncEmbeddedWebFeature by tasks.registering(Sync::class) {
dependsOn(buildEmbeddedWebFeature)
from(webFeatureDistDir) { into("public") }
from("src/main/capacitor/capacitor.config.json")
into(generatedCapacitorAssets)
}
tasks.named("preBuild").configure {
dependsOn(syncEmbeddedWebFeature)
}
public — стандартное имя каталога с ассетами, которое ждёт Capacitor. capacitor.config.json тоже должен попасть в assets: без него bridge не загрузит конфигурацию.
Обычный npx cap sync здесь не поможет. Он рассчитан на приложение, в котором Capacitor сам создал Android shell. В нашем случае shell уже есть, поэтому команда явно контролирует: какие библиотеки войдут в APK, какие файлы попадут в сборку и какие возможности будут разрешены встраиваемому приложению.
Нативная деталь: как Capacitor получает место на экране
Этот раздел можно спокойно просмотреть, если вы пишете только фронтенд. Он отвечает на практический вопрос Android-разработчика: «в каком именно месте приложения живёт Capacitor?»
В старых примерах можно встретить BridgeFragment. В Capacitor 8 готового BridgeFragment больше нет: есть BridgeActivity, а Bridge.Builder умеет работать с переданным Fragment. Fragment — это стандартный Android-контейнер для части экрана.
Поэтому нужен тонкий native-адаптер. Он содержит CapacitorWebView с id webview — этот id ищет Bridge.Builder:
Layout и Fragment native-адаптера
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.getcapacitor.CapacitorWebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
А сам Fragment создаёт bridge и освобождает его вместе с View:
public final class EmbeddedCapacitorFragment extends Fragment {
private Bridge bridge;
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
Bridge.Builder builder = new Bridge.Builder(this)
.setInstanceState(savedInstanceState);
builder.addPlugins(
new PluginManager(requireContext().getAssets()).loadPluginClasses()
);
bridge = builder.create();
bridge.onNewIntent(requireActivity().getIntent());
}
@Override
public void onDestroyView() {
if (bridge != null) bridge.onDestroy();
bridge = null;
super.onDestroyView();
}
}
Как Capacitor-приложение попадает в Compose-приложение
Compose — современный способ описывать Android-интерфейсы кодом. Он не мешает SDK, которому нужен старый Android-контейнер Fragment: Profile создаёт область для WebView и помещает туда adapter Capacitor. Для React-разработчика это похоже на компонент-обёртку, который монтирует чужой runtime и очищает его при размонтировании.
@Composable
fun EmbeddedCapacitorScreen(modifier: Modifier = Modifier) {
val activity = LocalContext.current as AppCompatActivity
val fragmentManager = activity.supportFragmentManager
AndroidView(
factory = { context ->
FragmentContainerView(context).apply {
id = R.id.embedded_capacitor_container
}
},
modifier = modifier,
)
DisposableEffect(fragmentManager) {
if (fragmentManager.findFragmentByTag(TAG) == null && !fragmentManager.isStateSaved) {
fragmentManager.beginTransaction()
.add(R.id.embedded_capacitor_container, EmbeddedCapacitorFragment(), TAG)
.commitNow()
}
onDispose {
fragmentManager.findFragmentByTag(TAG)?.let {
fragmentManager.beginTransaction().remove(it).commitNow()
}
}
}
}
В demo Capacitor-приложение живёт ровно столько, сколько открыт экран. На проде стоит отдельно решить, нужно ли сохранять WebView при переключении tab’ов: это влияет на память, историю, авторизацию и пользовательское ожидание.
iOS. В SwiftUI аналогом будет
UIViewControllerRepresentable, а в UIKit — контейнерныйUIViewController. Вопрос времени жизниWKWebViewостаётся тем же.
Что происходит, когда телефон совершает системные действия
Статичный экран заработает и без этого раздела. Но камера, разрешение, системный выбор файла или открытие ссылки могут временно увести пользователя из WebView. Когда он возвращается, Capacitor должен получить ответ от операционной системы и вернуть Promise в JavaScript.
Основное Android-приложение остаётся координатором и передаёт результат embedded-фрагменту:
Forwarding permissions и intent в MainActivity
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
) {
val handled = embeddedCapacitorFragment()?.forwardPermissionResult(
requestCode, permissions, grantResults,
) == true
if (!handled) super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
embeddedCapacitorFragment()?.onNewIntent(intent)
}
По тому же принципу передаются другие события: возврат с системного экрана, поворот устройства, уход приложения в фон и возврат обратно. Так телефонные события доходят до Capacitor-плагинов, а затем — до ожидающего их await в Capacitor-приложении.
iOS. Там нет буквального
onActivityResult, но правило идентично: URL, уведомления и события приложения нельзя потерять междуAppDelegate/SceneDelegate, native navigation и web-контейнером.
Контракт в деталях: роутинг, разрешения и пуши
Та же граница «явный контракт» с диаграммы выше, но по трём конкретным областям:
Область | Владеет native host | Владеет Capacitor-приложение |
|---|---|---|
Роутинг | переходы между крупными экранами, deep link | маршруты внутри своей фичи |
Разрешения | момент диалога, объясняющий экран, политика отказа | intent: «нужна камера» |
Пуши | device token, payload в foreground/background | что показать по payload |
Роутинг: заранее решите кто главный
В этой статье не будем строить «универсальный роутер». Он всё равно не переживёт различия между NavController, Compose multi-back-stack, авторизацией и web router читателя. Полезнее заранее зафиксировать правила:
native router владеет переходами между крупными областями приложения и открытием web-фичи;
web router владеет маршрутами внутри своей фичи;
переход через границу оформляется явным контрактом:
openNativeRoute,openWebPath,closeFeature;системный Back не должен случайно закрывать приложение, если у пользователя есть web history;
deep link: хост решает, открыть нативный экран или передать в Capacitor-приложение.
iOS. Аналогичный договор нужен для Universal Links: iOS-host принимает ссылку, определяет native или web destination и передаёт в web router только путь, за который фича отвечает.
Разрешения: WebView не должен диктовать политику приложения
Разрешения — ответственность хоста. Capacitor-приложение может выразить intent: «пользователь включил уведомления» или «для этого шага нужна камера». Но момент показа системного диалога, объясняющий экран и обработка отказа принадлежат хосту.
Для связи есть два нормальных пути:
подходящий Capacitor plugin, если его API совпадает с вашим продуктовым контрактом;
маленький собственный plugin или adapter с методом вроде
requestNotifications().
iOS. Запрос выполняется через системный API, например
UNUserNotificationCenterдля уведомлений. Момент запроса и продуктовая политика остаются за iOS-host; web-фича получает результат действия.
Пуши: плагин — только часть цепочки
Фраза «поставим push-плагин» скрывает слишком много архитектуры. Для embedded-фичи полезно видеть весь путь:
APNs / FCM → native application → token и обработка уведомления
→ Capacitor contract → web feature или native router
Нужно определить заранее:
кто регистрирует и обновляет device token(хост);
кто обрабатывает payload в foreground и background — тоже host;
как payload превращается в маршрут — host выбирает native destination либо передаёт web path по контракту;
что будет, если Capacitor-приложение ещё не инициализировано — событие нельзя отправить в несуществующий WebView, его надо сохранить до открытия destination.
В чем демо проект не поможет
Работающий экран в приложении доказывает, что подход технически возможен. Но это всего лишь минимальный пример перед еще большей работой над продакшен релизом. Как только встраеваемых приложений становится несколько или их начинают развивать независимо, появляется отдельная инженерная задача — их доставка.
Самая заметная часть этой задачи — CI/CD, то есть автоматическая сборка, проверка и доставка версий. Пайплайн должен как минимум:
воспроизводимо собрать каждый web бандл и положить ровно нужную версию в native release;
фиксировать совместимые версии Capacitor core, Android/iOS runtime и подключённых plugins;
отделять web-изменения от изменений native-кода, manifest,
Info.plist, разрешений и plugin-зависимостей — последние требуют нового релиза в сторах;проверять контракт host ↔ встраиваемое приложение: типы сообщений, минимальные версии bridge, deep links и feature flags;
уметь откатывать bundle, не оставляя host с несовместимым Capacitor-приложением;
собирать source maps, ошибки web-слоя и native crash reports так, чтобы их можно было связать с версией конкретной фичи.
Для одного встроенного приложения можно начать с простого Gradle build step, как в демо проекте.
Если фича одна и меняется редко, сложная платформа доставки может быть избыточна. Если встраиваемых приложений много и у них разные команды, CI/CD и управление совместимостью становятся частью продукта.
Ionic Portals: готовое решение уже существует
Часть этой цены за масштаб можно не платить самостоятельно. У Ionic, авторов Capacitor, есть отдельный коммерческий продукт под этот сценарий — Portals.
Portals — это не «CLI, которая магически подключает любой Capacitor plugin к нативке». Его CLI автоматизирует синхронизацию и доставку веб бандлов в нативку и поддерживает workflow нескольких portal-приложений, включая Appflow Live Updates — обновление бандлов без прохождения ревью в сторе. Но нативные зависимости и регистрация плагинов всё равно должны быть корректно подключены в Android Gradle или iOS Pods/проекте — эту часть Portals не берёт на себя.
Если у вас одна фича — простого Gradle-таска, как в демо, скорее всего достаточно. Если таких фич и команд несколько — разумнее сразу присмотреться к Portals, а не собирать его аналог самостоятельно.
Полезные источники:
Итог
По результатам демо проекта мы имеем готовое нативное приложение с встроенным Capacitor-приложением. Capacitor можно использовать не только как оболочку для целого web-приложения. Он может быть мостом для одной самостоятельной web-фичи внутри зрелого native-продукта.
Смысл решения не в том, чтобы избежать нативной разработки. Смысл в том, чтобы не дублировать код там, где фича естественно принадлежит веб-команде. Если заранее договориться о роутинге, разрешениях и пушах, получится отличная и вполне управляемая архитектура.
На этом у меня все. Пишите любые интересующие вас вопросы в комментарии и в личку.
Ссылки:
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.