Архитектура
Capacitor для мобильных приложений
Использование Capacitor для сборки нативных мобильных приложений из веб-кодовой базы.
Статус: на рассмотрении. Детали уточняются.
Контекст
Dashboard-приложения используются сотрудниками в полевых условиях — курьерами, менеджерами, операторами. Мобильный доступ критичен для бизнес-процессов.
Варианты:
- Отдельные нативные приложения (Swift/Kotlin)
- React Native / Flutter
- PWA (Progressive Web App)
- Capacitor (веб в нативной обёртке)
Решение
Capacitor — нативная обёртка для веб-приложений от Ionic.
┌─────────────────────────────────────┐
│ Nuxt 4 SPA │
│ (один код для всех платформ) │
├─────────────────────────────────────┤
│ Capacitor │
│ (мост между веб и нативным API) │
├─────────────┬───────────────────────┤
│ iOS App │ Android App │
│ (TestFlight)│ (Internal Testing) │
└─────────────┴───────────────────────┘
Подход: Обёртка без упаковки билда
Ключевое решение: Веб-приложение не упаковывается в нативный бандл. Capacitor загружает SPA с сервера.
┌─────────────────────────────────────────────────────┐
│ Мобильное приложение │
│ ┌─────────────────────────────────────────────┐ │
│ │ Capacitor Shell │ │
│ │ (нативная обёртка, плагины, permissions) │ │
│ └─────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ WebView → https://crm.example.com │ │
│ │ (загружается с сервера) │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
Почему не упаковываем билд
| Причина | Преимущество |
|---|---|
| Мгновенные обновления | Изменения бизнес-логики без релиза в сторы |
| Единый деплой | Веб и мобайл обновляются одновременно |
| Нет review сторов | Фиксы багов попадают к пользователям сразу |
| Меньше размер APK/IPA | Только нативная обёртка, без веб-ассетов |
Что в бандле приложения
APK/IPA содержит:
├── Capacitor runtime
├── Нативные плагины (камера, геолокация, push)
├── Splash screen и иконки
├── Конфигурация (server.url)
└── НЕ содержит веб-код (HTML/JS/CSS)
Когда нужен новый релиз
| Изменение | Требуется релиз? |
|---|---|
| Баг в бизнес-логике | Нет — деплой на сервер |
| Новая страница/фича | Нет — деплой на сервер |
| Изменение стилей | Нет — деплой на сервер |
| Новый Capacitor плагин | Да — новый билд |
| Изменение permissions | Да — новый билд |
| Обновление splash/иконок | Да — новый билд |
Конфигурация
// capacitor.config.ts
const config: CapacitorConfig = {
appId: 'com.example.crm',
appName: 'App CRM',
// Production: загружаем с сервера
server: {
url: 'https://crm.example.com',
cleartext: false,
},
// Для локальной разработки:
// server: {
// url: 'http://192.168.1.100:3000',
// cleartext: true,
// },
}
CI/CD для мобильных билдов
CI/CD настраивается для сборки нативной обёртки:
# .gitlab-ci.yml
build:android:
stage: build
script:
- npx cap sync android
- cd android && ./gradlew assembleRelease
artifacts:
paths:
- android/app/build/outputs/apk/release/
only:
- tags
- /^mobile-release-.*/
Релизы редкие — только при изменении нативной части.
Структура проекта
crm-s2/
├── app/ # Nuxt приложение
├── android/ # Android проект (генерируется)
│ ├── app/
│ │ └── src/main/
│ │ ├── AndroidManifest.xml
│ │ └── res/
│ └── capacitor.settings.gradle
├── ios/ # iOS проект (генерируется)
│ └── App/
│ ├── App/
│ │ ├── Info.plist
│ │ └── AppDelegate.swift
│ └── App.xcworkspace
├── capacitor.config.ts # Конфигурация Capacitor
├── nuxt.config.ts
└── package.json
Конфигурация
capacitor.config.ts
import type { CapacitorConfig } from '@capacitor/cli'
const config: CapacitorConfig = {
appId: 'com.example.crm',
appName: 'App CRM',
webDir: '.output/public', // Nuxt generate output
server: {
// Dev server для hot reload
url: process.env.NODE_ENV === 'development'
? 'http://192.168.1.100:3000'
: undefined,
cleartext: true,
},
plugins: {
SplashScreen: {
launchShowDuration: 2000,
backgroundColor: '#ffffff',
showSpinner: false,
},
StatusBar: {
style: 'dark',
backgroundColor: '#ffffff',
},
Keyboard: {
resize: 'body',
resizeOnFullScreen: true,
},
},
ios: {
scheme: 'App CRM',
},
android: {
buildOptions: {
keystorePath: 'release.keystore',
keystoreAlias: 'app',
},
},
}
export default config
package.json scripts
{
"scripts": {
"dev": "nuxt dev",
"build": "nuxt generate",
"build:mobile": "nuxt generate && npx cap sync",
"cap:android": "npx cap open android",
"cap:ios": "npx cap open ios",
"cap:sync": "npx cap sync",
"cap:run:android": "npx cap run android",
"cap:run:ios": "npx cap run ios"
}
}
Установка
# Capacitor core
pnpm add @capacitor/core
pnpm add -D @capacitor/cli
# Платформы
pnpm add @capacitor/android @capacitor/ios
# Инициализация
npx cap init "App CRM" "com.example.crm" --web-dir=.output/public
# Добавление платформ
npx cap add android
npx cap add ios
Нативные возможности
Плагины Capacitor
| Плагин | Назначение | Пример использования |
|---|---|---|
@capacitor/camera | Камера | Фото документов, товаров |
@capacitor/geolocation | Геолокация | Отслеживание курьеров |
@capacitor/push-notifications | Push-уведомления | Новые заказы |
@capacitor/filesystem | Файловая система | Кэш, офлайн-данные |
@capacitor/network | Статус сети | Офлайн-режим |
@capacitor/app | Жизненный цикл | Background/foreground |
@capacitor/haptics | Вибрация | Тактильная обратная связь |
@capacitor/keyboard | Клавиатура | Управление виртуальной клавиатурой |
Использование в коде
// composables/useCamera.ts
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'
import { Capacitor } from '@capacitor/core'
export function useCamera() {
const isNative = Capacitor.isNativePlatform()
async function takePhoto() {
if (!isNative) {
// Fallback для веба — file input
return openFileInput()
}
const photo = await Camera.getPhoto({
quality: 90,
allowEditing: false,
resultType: CameraResultType.Base64,
source: CameraSource.Camera,
})
return photo.base64String
}
return { takePhoto, isNative }
}
// composables/useGeolocation.ts
import { Geolocation } from '@capacitor/geolocation'
export function useGeolocation() {
const position = ref<{ lat: number; lng: number } | null>(null)
const error = ref<string | null>(null)
async function getCurrentPosition() {
try {
const coords = await Geolocation.getCurrentPosition({
enableHighAccuracy: true,
})
position.value = {
lat: coords.coords.latitude,
lng: coords.coords.longitude,
}
} catch (e) {
error.value = e.message
}
}
function watchPosition(callback: (pos: GeolocationPosition) => void) {
return Geolocation.watchPosition(
{ enableHighAccuracy: true },
callback
)
}
return { position, error, getCurrentPosition, watchPosition }
}
Определение платформы
// composables/usePlatform.ts
import { Capacitor } from '@capacitor/core'
export function usePlatform() {
const platform = Capacitor.getPlatform() // 'web' | 'ios' | 'android'
const isNative = Capacitor.isNativePlatform()
const isIOS = platform === 'ios'
const isAndroid = platform === 'android'
const isWeb = platform === 'web'
return { platform, isNative, isIOS, isAndroid, isWeb }
}
<template>
<div>
<!-- Нативная кнопка "Назад" на Android -->
<UButton v-if="isAndroid" icon="i-lucide-arrow-left" @click="goBack" />
<!-- iOS-style свайп для возврата обрабатывается нативно -->
</div>
</template>
<script setup>
const { isAndroid } = usePlatform()
</script>
Workflow разработки
Hot Reload на устройстве
# 1. Запустить dev server с внешним IP
pnpm dev --host
# 2. Обновить capacitor.config.ts
server: {
url: 'http://192.168.1.100:3000',
cleartext: true,
}
# 3. Синхронизировать и запустить
npx cap sync
npx cap run android --target=<device-id>
Обоснование
Положительные последствия
| Преимущество | Описание |
|---|---|
| Один код | 95% кода переиспользуется между веб и мобайл |
| Веб-технологии | Vue, TypeScript, Tailwind — знакомый стек |
| Нативный доступ | Камера, GPS, push, файлы через плагины |
| Быстрый старт | Мобильное приложение за дни, не месяцы |
| Мгновенные обновления | Веб-изменения деплоятся без релиза в сторы |
| Простота | Нет CI/CD для сторов, ручные редкие релизы |
Отрицательные последствия
| Недостаток | Митигация |
|---|---|
| Производительность ниже нативного | Оптимизация, виртуализация списков |
| Размер приложения больше | ~15-20MB — приемлемо |
| Зависимость от WebView | Минимальные версии: iOS 13+, Android 5.1+ |
Альтернативы
| Вариант | Почему не подходит |
|---|---|
| Native (Swift/Kotlin) | Дублирование кода, отдельная команда |
| React Native | Другой фреймворк, нет переиспользования |
| Flutter | Dart, нет переиспользования веб-кода |
| PWA | Нет App Store, ограниченный нативный API |
Safe Areas и Notch
<template>
<!-- Учёт safe areas на iOS -->
<div class="pt-safe pb-safe px-safe">
<header class="pt-[env(safe-area-inset-top)]">
<!-- Header content -->
</header>
<main>
<!-- Content -->
</main>
<footer class="pb-[env(safe-area-inset-bottom)]">
<!-- Footer/TabBar -->
</footer>
</div>
</template>
/* app/assets/css/safe-areas.css */
.pt-safe {
padding-top: env(safe-area-inset-top);
}
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
.px-safe {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}