Архитектура

Capacitor для мобильных приложений

Использование Capacitor для сборки нативных мобильных приложений из веб-кодовой базы.
Статус: на рассмотрении. Детали уточняются.

Контекст

Dashboard-приложения используются сотрудниками в полевых условиях — курьерами, менеджерами, операторами. Мобильный доступ критичен для бизнес-процессов.

Варианты:

  1. Отдельные нативные приложения (Swift/Kotlin)
  2. React Native / Flutter
  3. PWA (Progressive Web App)
  4. 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-notificationsPush-уведомленияНовые заказы
@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Другой фреймворк, нет переиспользования
FlutterDart, нет переиспользования веб-кода
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);
}

Связанные документы