Нашей команде поставили задачу разработать мобильное приложение для iOS и Android. Нативных разработчиков на Swift и Kotlin в команде нет, зато половина хорошо знает Vue. Поэтому я как лид выбрал Vue и Capacitor — фреймворк, который упаковывает обычное веб‑приложение в нативную оболочку и даёт доступ к функциям телефона через JavaScript‑плагины.

Через приложение клиенты заказывают охрану и транспорт с водителем. В нём есть:

  • конфигуратор и оформление заказа;

  • отслеживание исполнителя на карте;

  • вход по PIN‑коду или Face ID.

Стек: Vue 3, TypeScript, Vite и Capacitor 8.

По итогам тестирования приложение в целом ведёт себя как нативное. Для этого команде пришлось освоить то, с чем фронтендер обычно не работает:

  • нативные инструменты сборки на компьютерах с Windows, macOS и Linux;

  • вёрстку под вырез экрана и клавиатуру;

  • настройку нативных проектов;

  • раздачу тестовых сборок через TestFlight.

Что мы получили от Capacitor

Один стек на всё

Vue 3, Vite, Tailwind, Vitest и Storybook работают так же, как в вебе. Что это даёт на практике:

  • Вёрстку и роутинг почти целиком проверяем в Chrome DevTools в режиме устройства.

  • Эмулятор нужен только для того, что зависит от платформы.

  • Горячая замена модулей (HMR) в Vite работает и на телефоне: достаточно указать адрес dev‑сервера в server.url.

  • Приложение на устройстве отлаживается как обычная страница: через Safari Web Inspector на iOS и chrome://inspect на Android.

  • Нативный разработчик на Swift или Kotlin команде не понадобился.

Нативные функции через легко подключаемые плагины

Для работы с возможностями смартфона у Capacitor есть большой набор плагинов: камера, геолокация, уведомления, файловая система, вибрация и другие. Официальные плагины поддерживает команда Capacitor, их список есть в документации. Остальное, например биометрию, закрывают плагины сообщества из отдельного каталога.

Мы используем пять плагинов:

  • @capacitor/app — события жизненного цикла приложения и системные кнопки;

  • @capacitor/device — сведения об устройстве, например язык системы;

  • @capacitor/keyboard — управление экранной клавиатурой;

  • @aparajita/capacitor-biometric-auth — вход по Face ID и отпечатку пальца с имитацией биометрии в браузере;

  • capacitor-secure-storage-plugin — защищённое хранение данных в Keychain и Keystore.

Разберём подключение на примере @capacitor/keyboard.

Шаг 1. Установить пакет и обновить нативные проекты:

npm install @capacitor/keyboard
npx cap sync

Шаг 2. Настроить плагин в capacitor.config.ts. Параметр resize: None отключает сжатие WebView при открытии клавиатуры:

import type { CapacitorConfig } from "@capacitor/cli";
import { KeyboardResize } from "@capacitor/keyboard";

const config: CapacitorConfig = {
  appId: "com.example.app",
  appName: "Example",
  webDir: "dist",
  plugins: {
    Keyboard: {
      resize: KeyboardResize.None,
    },
  },
};

export default config;

Шаг 3. Вызвать API из кода приложения. Здесь панель над клавиатурой iOS скрывается, а высота клавиатуры передаётся в CSS‑переменную:

import { Capacitor } from "@capacitor/core";
import { Keyboard } from "@capacitor/keyboard";

if (Capacitor.getPlatform() === "ios") {
  await Keyboard.setAccessoryBarVisible({ isVisible: false });
}

await Keyboard.addListener("keyboardWillShow", ({ keyboardHeight }) => {
  document.documentElement.style.setProperty("--keyboard-height", `${keyboardHeight}px`);
});

Нативный проект под каждую платформу

Для каждой платформы Capacitor создаёт обычный нативный проект: Xcode для iOS и Android Studio для Android. Внутри него в WebView работает веб‑приложение, а собирается он стандартными инструментами платформы. В WebView доступно всё, что есть в браузере, поэтому мы использовали знакомые библиотеки и код, наработанный в других проектах.

По задумке Capacitor нативные проекты хранятся в репозитории и правятся вручную. Можно пойти другим путём и пересоздавать их из конфигурации при каждой сборке. Мы выбрали этот вариант:

  • Плюс: проект всегда чистый, конфликтов при слиянии нет.

  • Минус: ручные правки теряются, поэтому нативные настройки приходится вносить скриптом.

Сложность 1: придётся освоить нативный тулчейн

Писать нативный код не нужно, но собирать нативные проекты всё равно придётся, а для этого нужны незнакомые фронтендеру инструменты: JDK, Android SDK, эмуляторы, Xcode. Все их нам пришлось изучить и настроить, а у каждого свои требования к версиям. Часть инструментов ставит менеджер версий, остальное — вручную:

  • Ставит менеджер версий mise:

    • JDK (Temurin 21), на котором работает Gradle;

    • Android SDK: Platform 36 и Build‑Tools 35.0.0;

    • образы эмулятора Android API 36 и API 34;

    • Xcode 26+ и раннер iOS‑симулятора, только на macOS.

  • Ставим вручную:

    • Android Studio: из неё нужны Device Manager и Logcat;

    • виртуальные устройства в Device Manager;

    • аппаратное ускорение эмулятора: в Windows — компонент Windows Hypervisor Platform, в Linux — KVM (Kernel‑based Virtual Machine).

Ручная настройка

Если ставить всё вручную, легко пропустить один из шагов, и тогда Gradle не найдёт SDK или не запустится эмулятор. К тому же никто не гарантирует, что у двух разработчиков окажутся одинаковые версии. Где легко ошибиться:

  • JAVA_HOME должен указывать на корень JDK, без /bin и без слэша в конце.

  • В SDK Manager нужно включить «Show Package Details», иначе установятся не те версии. Флажок «Command‑line Tools» по умолчанию снят, а без него не будет sdkmanager.

  • Gradle JDK в настройках Android Studio задаётся отдельно: там тоже нужен Temurin 21.

  • Путь в ANDROID_HOME в Windows, macOS и Linux разный. В PATH добавляются три каталога, после чего придётся открыть новый терминал и полностью перезапустить VS Code.

mise: ещё один инструмент, который пришлось освоить

Чтобы не повторять эту настройку на каждой машине, мы освоили менеджер версий mise. Привычные фронтендеру nvm и Volta фиксируют только версию Node, а SDKMAN — только JVM‑инструменты. У mise возможностей больше:

  • закрепляет версии Node, JDK, Android cmdline‑tools и xcodes;

  • задаёт переменные окружения;

  • описывает задачи установки;

  • работает одинаково на Windows, macOS и Linux.

Всё это хранится в одном файле mise.toml. Упрощённый пример:

mise.toml
# Версии инструментов: mise скачает их и включит при входе в папку проекта
[tools]
java = "temurin-21"   # JDK для Gradle, который собирает Android-проект
node = "24"
android-sdk = "23.0"  # утилиты командной строки Android SDK
xcodes = { version = "latest", os = ["macos"] }  # установщик Xcode, только на macOS

# Переменные окружения, которые mise выставляет в папке проекта
[env]
# SDK лежит в стандартном для ОС каталоге, а не в каталоге mise:
# так он общий с Android Studio и образы не скачиваются дважды
ANDROID_HOME = "{% if os() == 'windows' %}{{ env.LOCALAPPDATA }}\\Android\\Sdk{% elif os() == 'macos' %}{{ env.HOME }}/Library/Android/sdk{% else %}{{ env.HOME }}/Android/Sdk{% endif %}"
# adb и emulator становятся доступны в терминале
_.path = ["{{ env.ANDROID_HOME }}/platform-tools", "{{ env.ANDROID_HOME }}/emulator"]

# Переменные для задач
[vars]
# ABI образа эмулятора: arm64 на Apple silicon, x86_64 на Intel и AMD
abi = "{% if arch() == 'arm64' or arch() == 'aarch64' %}arm64-v8a{% else %}x86_64{% endif %}"

# Задачи запускаются командой mise run <имя задачи>
[tasks."android:sdk"]
description = "Пакеты Android SDK для сборки"
run = "android sdk install platform-tools platforms/android-36 build-tools/35.0.0"

[tasks."android:emulator"]
description = "Эмулятор и образы Android API 36 и API 34"
depends = ["android:sdk"]  # сначала выполнится android:sdk
run = "android sdk install emulator system-images/android-36/google_apis/{{ vars.abi }} system-images/android-34/google_apis/{{ vars.abi }}"

[tasks."ios:simulator"]
description = "Раннер iOS-симулятора, только macOS"
run = "xcodebuild -downloadPlatform iOS"

Новому разработчику достаточно запустить mise install и задачу установки. Если чего‑то не хватает, задача проверки окружения покажет, чего именно, и разбираться в ошибках xcodebuild придётся реже. Подробнее о секциях и задачах написано в документации mise.

Грабли, на которые наступили по дороге

В автоматизации Android SDK через mise нашлись три подвоха:

  • cmd в Windows разбивает строку по ; даже внутри кавычек, поэтому пакет "platforms;android-36" превращался в два несуществующих. Пришлось перейти с sdkmanager на новый Android CLI, где имена пакетов пишутся через слэш.

  • Android CLI возвращает код 9 даже при успешной установке. Этот код приходится игнорировать, а установку проверять по наличию каталогов пакетов.

  • avdmanager не видит образы, установленные новым CLI, а android emulator create не позволяет задать имя и уровень API. В итоге виртуальные устройства по‑прежнему приходится создавать вручную в Device Manager.

Сложность 2: экран телефона отличается от окна браузера

Больше всего правок потребовали вещи, которых нет в десктопном браузере: вырез и полоска «домой», экранная клавиатура, сенсорные жесты и особенности WebView. Примерно каждый четвёртый баг на тестировании касался отрисовки на телефоне, причём половина таких багов проявлялась только на iPhone.

Зоны экрана iPhone, где ломалась вёрстка

Два бага связаны с безопасными зонами экрана. На iPhone шапка заезжала под часы и индикаторы: без viewport-fit=cover переменные env(safe-area-inset-*) не работают. Теперь viewport-fit=cover добавляется при запуске на iOS, а шапке задан отступ env(safe-area-inset-top). Низ шторки уходил под полоску «домой» из‑за фиксированного нижнего отступа, его заменил отступ calc(5.125rem + env(safe-area-inset-bottom)).

Три бага связаны с касаниями. Двойной тап на iPhone увеличивал масштаб, как на сайте, потому что масштабирование не было запрещено в viewport: помогли user-scalable=no и touch-manipulation на body. На iPhone и Android после касания оставалась синяя рамка фокуса, так как браузерный outline не был отключён для сенсорного ввода. Класс pointer-coarse:outline-none убрал её для касаний и сохранил для мыши и клавиатуры. На экране шириной 360 px не прокручивался ряд иконок: touch-none на ручке шторки перехватывал все касания. С touch-pan-x ряд прокручивается, а шторка двигается только вертикальным жестом.

Ещё три бага касаются прокрутки и анимаций. Шторку на iPhone не удавалось докрутить до конца, потому что сдвиг параллакса не учитывался в высоте прокрутки, и его компенсирует невидимый отступ на ту же величину. На вытянутых экранах вокруг интро‑анимации появлялись белые полосы: Lottie вписывает кадр 1179×2556 целиком, а параметр preserveAspectRatio: "xMidYMid slice" заполняет им экран. Стекло шапки на iPhone и Android теряло размытие во время перехода между экранами, так как backdrop-filter в отдельном слое View Transitions рисуется прямоугольником. Теперь в отдельный слой выносится только ряд кнопок, а стекло остаётся в снимке страницы.

Остаются баги с клавиатурой. На iPhone над ней появлялась лишняя панель, а саму клавиатуру нельзя было закрыть: так WebView ведёт себя по умолчанию. Это исправили плагин @capacitor/keyboard, атрибут enterkeyhint="done" и закрытие клавиатуры при прокрутке. На Android фон «сжимается» при открытии клавиатуры, потому что градиент растянут по высоте экрана, а видимая высота WebView уменьшается. Этот баг пока не исправлен.

Почему эти ошибки не видны в браузере

Режим устройства в Chrome DevTools не показывает реальную версию WebView, системные отступы (вырез, строка состояния), масштаб системного шрифта и нативные диалоги вроде Face ID. Приложение работает внутри системного WebView, а его версия и поведение зависят от устройства. На iOS движок привязан к версии системы, поэтому на старых iPhone часть веб‑API недоступна.

На Android WebView обновляется отдельно, через Play Store, но поведение самой системы меняется от версии к версии: начиная с Android 15 приложение принудительно отображается под системными панелями (edge‑to‑edge). Поэтому в проекте два образа эмулятора: API 36 (целевая версия) и API 34 (поведение до edge‑to‑edge). На практике сложную вёрстку лучше проверять на реальном iPhone или в симуляторе и отлаживать через Safari → Develop или chrome://inspect.

Сложность 3: нативную часть иногда нужно донастроить

Нативные проекты, которые создаёт Capacitor, и готовые плагины закрывают большую часть задач. Для остального понадобилось несколько нативных настроек и пара обработчиков поведения платформы.

Патчи вместо правок в Xcode

Если нативные проекты пересоздаются, ручные правки в Xcode и Android Studio теряются. Поэтому все нативные настройки мы вносим отдельным скриптом: он запускается при каждой сборке, а повторный запуск ничего не ломает. Что дописывает скрипт и что будет без этого:

  • NSFaceIDUsageDescription в Info.plist. Без него вход по Face ID падает при первом же запросе.

  • UIApplicationSceneManifest и SceneDelegate. Без них приложение, собранное в Xcode 27, закрывается сразу после запуска.

  • ITSAppUsesNonExemptEncryption = false. Без этого App Store Connect для каждой сборки задаёт вопрос о шифровании, и, пока ответа нет, сборка недоступна тестировщикам.

У скрипта есть слабое место: его нужно запускать везде, где собирается приложение, включая CI. Иначе сборка уйдёт без нужных настроек, например без разрешения на Face ID.

Поведение платформы не совпадает с вебом

  • Кнопка «Назад» на Android. По умолчанию она закрывала приложение, а не возвращала на предыдущий экран. Понадобился обработчик, связанный с vue‑router. Мы пока не проверяли, закрывает ли он открытую шторку, а не уводит со страницы.

  • Видео после сворачивания. iOS ставит его на паузу, поэтому слайдер теперь отслеживает видимость страницы через useDocumentVisibility.

Раздача iOS‑сборок

Аналога APK на iOS нет, поэтому просто отправить файл тестировщику нельзя. С бесплатным Apple ID сборка работает семь дней и только на телефоне разработчика, а для TestFlight нужно участие в Apple Developer Program за $99 в год. Собрать iOS‑приложение можно только на macOS, поэтому раздачу удобно вынести в облачный CI с Mac, например Codemagic: он подписывает сборку и сам загружает её в TestFlight.

Скрипты обвязки тоже надо поддерживать

Помимо самого приложения нужно поддерживать набор скриптов для удобной работы с ним. Эти скрипты:

  • запускают dev‑сервер и приложение на эмуляторе или симуляторе с live‑reload: находят уже запущенное устройство или поднимают новое и настраивают adb reverse;

  • проверяют окружение и показывают, чего не хватает на машине;

  • создают нативные проекты для Android и iOS;

  • вносят нативные настройки в сгенерированные проекты;

  • генерируют иконки и заставку приложения из исходных изображений;

  • собирают iOS‑приложение и загружают его в TestFlight.

Минусы

Для iOS нужны Mac и iPhone

Собрать iOS‑приложение можно только в Xcode, а Xcode работает только на macOS. Поэтому хотя бы у одного разработчика в команде должны быть Mac и iPhone: симулятор не заменяет проверку на реальном устройстве. Подойдёт не любой Mac:

  • Capacitor 8 требует Xcode 26 или новее (документация Capacitor).

  • Xcode 26–26.3 запускается на macOS Sequoia 15.6 и новее.

  • Xcode 26.4 и новее требует macOS Tahoe 26.2, а Xcode 27 — Tahoe 26.6 (системные требования Xcode).

  • macOS Tahoe ставится на любой Mac с Apple silicon, а из компьютеров на Intel — только на несколько моделей 2019–2020 годов. На остальных Intel‑Mac потолок — Xcode 26.3.

Ограничения задаёт Apple, а не Capacitor: с 28 апреля 2026 года App Store Connect принимает только сборки из Xcode 26 и новее, поэтому Flutter и React Native сталкиваются с теми же ограничениями. На практике для iOS‑разработки стоит брать Mac на Apple silicon.

Клавиатура обошлась дороже всего

По умолчанию открытая клавиатура уменьшает высоту WebView, и вместе с ним перерисовываются фон, градиент и шапка. На iOS мы отключили это поведение (Keyboard.resize: None в capacitor.config.ts), но тогда поле в фокусе перестало само подниматься над клавиатурой. Пришлось написать собственный модуль подъёма полей. Он:

  • находит контейнер, внутри которого прокручивается поле;

  • плавно сдвигает содержимое с учётом параллакса;

  • добавляет временный отступ, если места не хватает;

  • выставляет CSS‑переменную с высотой клавиатуры, по которой поднимаются шторки и экран входа.

Почти сразу понадобилась поправка: анимация подъёма конфликтовала с касаниями, и теперь она останавливается, как только пользователь дотрагивается до экрана. В нативном приложении эту работу делает система. В Capacitor есть два варианта: либо WebView сжимается и фон перерисовывается вместе с ним, либо подъём полей нужно написать самим.

В Capacitor пока нет поддержки Xcode 27

Приложение, собранное в Xcode 27, закрывается сразу после запуска: в шаблоне Capacitor 8.4 нет сценового жизненного цикла, а стабильного релиза с исправлением пока нет. Ключ UIApplicationSceneManifest в Info.plist и класс SceneDelegate пришлось добавлять в нативный проект самим.

На iPhone возможности WebView зависят от версии iOS

Движок WebView на iPhone обновляется только вместе с системой, поэтому новые веб‑API получают лишь пользователи свежих версий iOS. View Transitions, например, работают начиная с iOS 18, и на более старых телефонах страницы сменяются без анимации.

Итог: когда Capacitor стоит брать

Capacitor себя оправдал: веб‑команда без нативных разработчиков собрала приложение для двух платформ из одной кодовой базы, и почти все проблемы решались веб‑средствами или короткими скриптами. Принцип «написал один раз — работает везде» в целом сработал, но без доработок не обошлось: примерно каждый четвёртый баг на тестировании касался экрана телефона, а сборка держится на наборе скриптов и настроенном mise.

Подходит, если интерфейс состоит из форм, карточек, списков и карт, команда сильна в вебе, а функции устройства нужны стандартные и на них есть готовые плагины.

Стоит заложить больше времени, если продукт держится на сложных жестах, шторках и анимациях, которые должны ощущаться как нативные: их пришлось дорабатывать дольше всего. То же относится к нативным компонентам поверх веба. А для iOS‑версии понадобятся Mac и аккаунт разработчика Apple.

Что сделать в новом проекте с первого дня

  1. Закрепить версии Node, JDK и Android SDK в mise.toml и использовать те же версии в CI.

  2. Учесть безопасные зоны ещё до начала вёрстки: viewport-fit=cover, env(safe-area-inset-*) в шапке и нижних панелях, запрет масштабирования.

  3. Выбрать стратегию работы с клавиатурой до первой формы: режим resize, панель над клавиатурой, enterkeyhint.

  4. Хранить нативные настройки в скрипте и запускать его и локально, и в CI.

  5. Сразу обработать кнопку «Назад» на Android и возврат приложения из фона.

  6. Проверять вёрстку на реальном iPhone и на двух уровнях Android API, а не только в DevTools.

  7. Оборачивать плагины сообщества в собственный драйвер с тестами, как мы сделали с защищённым хранилищем.

Приложение пока на тестировании, в App Store и Google Play оно ещё не вышло. После релиза расскажем, как оно показало себя у реальных пользователей.

Полезные материалы

Capacitor

Окружение

Вёрстка и отладка на телефоне

Сборка и раздача

  • TestFlight — раздача iOS‑сборок тестировщикам.

  • Codemagic для Capacitor — облачная сборка на Mac и загрузка в TestFlight.