Files
gamegroup_fe/doc/design/05-移动端与跨平台方案.md
2026-01-28 14:35:43 +08:00

25 KiB
Raw Permalink Blame History

GameGroup 移动端与跨平台方案

项目名称: GameGroup 前端系统 文档版本: v1.0 更新时间: 2026-01-28


📋 目录


1. 需求分析

1.1 核心需求

  • 流畅运行: 移动端60fps流畅体验
  • 美观易用: 符合移动端设计规范
  • 简单移植: 最小化代码改动,实现多平台覆盖
  • 原生体验: 接近原生App的交互体验

1.2 目标平台

平台 优先级 覆盖方式
iOS App Capacitor / PWA
Android App Capacitor / PWA
微信小程序 uni-app / 原生小程序
Web 响应式Web应用

2. 技术方案选型

2.1 推荐方案Capacitor + PWA

方案对比

方案 优势 劣势 推荐度
Capacitor • 原生性能
• 可访问原生功能
• App Store分发
• 代码复用率高
• 需要打包发布
• 审核周期
PWA • 无需审核
• 即时更新
• 可安装到桌面
• 渐进式增强
• iOS支持有限
• 无法访问所有原生API
React Native • 原生组件
• 性能最优
• 需要重写
• 无法复用代码
uni-app • 一套代码多端运行
• 小程序友好
• Vue 2支持为主
• 性能损耗

最终方案:Capacitor + PWA

理由:

  1. 保持Vue 3 + TypeScript技术栈
  2. 代码复用率95%以上
  3. 可发布到App Store和Google Play
  4. 支持PWA无需审核即可使用
  5. 可访问摄像头、推送通知等原生功能
  6. 团队学习成本低

3. 移动端增强设计

3.1 导航模式

移动端专属导航

移动端布局(< 768px
┌─────────────────────────────┐
│  [≡] GameGroup    [🔔][👤]  │  ← 顶部栏
├─────────────────────────────┤
│                             │
│       内容区域               │
│      (可滚动)               │
│                             │
│                             │
├─────────────────────────────┤
│ [首页][小组][游戏][我的]    │  ← 底部Tab导航
└─────────────────────────────┘

实现代码

<!-- components/layout/MobileLayout.vue -->
<template>
  <div class="mobile-layout">
    <!-- 顶部栏 -->
    <div class="mobile-header">
      <Button
        icon="menu"
        type="text"
        @click="toggleSidebar"
      />
      <h1 class="logo">GameGroup</h1>
      <div class="header-actions">
        <Icon name="bell" :badge="unreadCount" />
        <Avatar :src="user.avatar" size="small" />
      </div>
    </div>

    <!-- 主内容区 -->
    <div class="mobile-content">
      <RouterView v-slot="{ Component }">
        <Transition name="slide" mode="out-in">
          <component :is="Component" />
        </Transition>
      </RouterView>
    </div>

    <!-- 底部Tab导航 -->
    <div class="mobile-tabs">
      <div
        v-for="tab in tabs"
        :key="tab.path"
        :class="['tab-item', { active: isActive(tab.path) }]"
        @click="navigate(tab.path)"
      >
        <Icon :name="tab.icon" />
        <span>{{ tab.label }}</span>
      </div>
    </div>

    <!-- 侧边栏抽屉 -->
    <Drawer v-model:visible="sidebarVisible" placement="left">
      <Sidebar />
    </Drawer>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'

const router = useRouter()
const route = useRoute()
const sidebarVisible = ref(false)

const tabs = [
  { path: '/', icon: 'home', label: '首页' },
  { path: '/groups', icon: 'users', label: '小组' },
  { path: '/games', icon: 'game', label: '游戏' },
  { path: '/user', icon: 'user', label: '我的' }
]

const isActive = (path: string) => route.path.startsWith(path)

const navigate = (path: string) => {
  router.push(path)
}

const toggleSidebar = () => {
  sidebarVisible.value = true
}

const unreadCount = computed(() => 0) // 从store获取
</script>

<style scoped lang="scss">
.mobile-layout {
  display: flex;
  flex-direction: column;
  height: 100vh;
  background: var(--color-bg-primary);
}

.mobile-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
  background: var(--color-bg-primary);
  border-bottom: 1px solid var(--color-border);
  position: sticky;
  top: 0;
  z-index: 100;
}

.logo {
  font-size: 18px;
  font-weight: 700;
  color: var(--color-primary-500);
}

.header-actions {
  display: flex;
  gap: 12px;
  align-items: center;
}

.mobile-content {
  flex: 1;
  overflow-y: auto;
  overflow-x: hidden;
  padding-bottom: env(safe-area-inset-bottom);
}

.mobile-tabs {
  display: flex;
  justify-content: space-around;
  padding: 8px 0 calc(8px + env(safe-area-inset-bottom));
  background: var(--color-bg-primary);
  border-top: 1px solid var(--color-border);
  position: sticky;
  bottom: 0;
}

.tab-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 8px 16px;
  color: var(--color-text-secondary);
  transition: color 0.2s;

  &.active {
    color: var(--color-primary-500);
  }

  svg {
    width: 24px;
    height: 24px;
  }

  span {
    font-size: 12px;
  }
}

// 页面切换动画
.slide-enter-active,
.slide-leave-active {
  transition: transform 0.3s, opacity 0.3s;
}

.slide-enter-from {
  transform: translateX(100%);
  opacity: 0;
}

.slide-leave-to {
  transform: translateX(-30%);
  opacity: 0;
}
</style>

3.2 触摸优化

按钮尺寸

移动端最小触摸目标:44px × 44pxiOS人机界面指南

/* 移动端按钮尺寸 */
@media (max-width: 768px) {
  .g-button {
    min-height: 44px;
    min-width: 44px;
    padding: 12px 20px;
    font-size: 16px; /* 防止iOS自动缩放 */
  }

  .g-button--small {
    min-height: 36px;
    padding: 8px 16px;
  }
}

手势支持

// composables/useSwipe.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useSwipe(
  elementRef: Ref<HTMLElement | undefined>,
  options: {
    onSwipeLeft?: () => void
    onSwipeRight?: () => void
    onSwipeUp?: () => void
    onSwipeDown?: () => void
    threshold?: number
  } = {}
) {
  let startX = 0
  let startY = 0
  const threshold = options.threshold || 50

  const onTouchStart = (e: TouchEvent) => {
    startX = e.touches[0].clientX
    startY = e.touches[0].clientY
  }

  const onTouchEnd = (e: TouchEvent) => {
    const endX = e.changedTouches[0].clientX
    const endY = e.changedTouches[0].clientY
    const diffX = endX - startX
    const diffY = endY - startY

    if (Math.abs(diffX) > Math.abs(diffY)) {
      // 水平滑动
      if (Math.abs(diffX) > threshold) {
        if (diffX > 0) {
          options.onSwipeRight?.()
        } else {
          options.onSwipeLeft?.()
        }
      }
    } else {
      // 垂直滑动
      if (Math.abs(diffY) > threshold) {
        if (diffY > 0) {
          options.onSwipeDown?.()
        } else {
          options.onSwipeUp?.()
        }
      }
    }
  }

  onMounted(() => {
    const el = elementRef.value
    if (el) {
      el.addEventListener('touchstart', onTouchStart)
      el.addEventListener('touchend', onTouchEnd)
    }
  })

  onUnmounted(() => {
    const el = elementRef.value
    if (el) {
      el.removeEventListener('touchstart', onTouchStart)
      el.removeEventListener('touchend', onTouchEnd)
    }
  })
}

// 使用示例
const cardRef = ref<HTMLElement>()

useSwipe(cardRef, {
  onSwipeLeft: () => {
    // 左滑删除
    deleteItem(item.id)
  },
  threshold: 80
})

下拉刷新

<template>
  <div
    ref="containerRef"
    class="pull-refresh"
    @touchstart="onTouchStart"
    @touchmove="onTouchMove"
    @touchend="onTouchEnd"
  >
    <div class="pull-indicator" :style="{ transform: `translateY(${status}px)` }">
      <Icon v-if="loading" name="loading" class="is-spin" />
      <span v-else>{{ pullText }}</span>
    </div>
    <slot />
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'

const startY = ref(0)
const currentY = ref(0)
const loading = ref(false)
const containerRef = ref<HTMLElement>()

const status = computed(() => {
  if (loading.value) return 50
  return Math.max(0, currentY.value - startY.value)
})

const pullText = computed(() => {
  const distance = status.value
  if (distance < 50) return '下拉刷新'
  if (loading.value) return '正在刷新...'
  return '释放立即刷新'
})

const onTouchStart = (e: TouchEvent) => {
  startY.value = e.touches[0].clientY
}

const onTouchMove = (e: TouchEvent) => {
  currentY.value = e.touches[0].clientY
}

const onTouchEnd = async () => {
  if (status.value >= 50 && !loading.value) {
    loading.value = true
    await emit('refresh')
    loading.value = false
  }
  startY.value = 0
  currentY.value = 0
}
</script>

3.3 移动端专属组件

底部动作面板ActionSheet

<template>
  <Transition name="slide-up">
    <div v-if="visible" class="action-sheet" @click="handleMaskClick">
      <div class="action-sheet__content" @click.stop>
        <div class="action-sheet__title">{{ title }}</div>
        <div class="action-sheet__actions">
          <div
            v-for="(action, index) in actions"
            :key="index"
            :class="['action-item', action.className]"
            @click="handleAction(action)"
          >
            {{ action.label }}
          </div>
        </div>
        <div class="action-item action-item--cancel" @click="handleCancel">
          取消
        </div>
      </div>
    </div>
  </Transition>
</template>

移动端卡片列表

<template>
  <div class="mobile-card-list">
    <div
      v-for="item in items"
      :key="item.id"
      class="mobile-card"
      @click="handleClick(item)"
    >
      <img :src="item.cover" class="card-cover" />
      <div class="card-content">
        <h4 class="card-title">{{ item.title }}</h4>
        <p class="card-desc">{{ item.description }}</p>
        <div class="card-meta">
          <Tag size="small">{{ item.tag }}</Tag>
          <span class="card-time">{{ formatTime(item.time) }}</span>
        </div>
      </div>
      <Icon name="chevron-right" class="card-arrow" />
    </div>
  </div>
</template>

<style scoped lang="scss">
.mobile-card {
  display: flex;
  gap: 12px;
  padding: 12px;
  background: white;
  border-radius: 12px;
  margin-bottom: 12px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);

  &:active {
    background: var(--color-bg-secondary);
  }
}

.card-cover {
  width: 80px;
  height: 80px;
  border-radius: 8px;
  object-fit: cover;
  flex-shrink: 0;
}

.card-content {
  flex: 1;
  min-width: 0;
}

.card-title {
  font-size: 16px;
  font-weight: 600;
  margin: 0 0 4px 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.card-desc {
  font-size: 14px;
  color: var(--color-text-secondary);
  margin: 0 0 8px 0;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.card-meta {
  display: flex;
  align-items: center;
  gap: 8px;
}

.card-arrow {
  flex-shrink: 0;
  color: var(--color-text-tertiary);
  align-self: center;
}
</style>

4. 跨平台移植方案

4.1 Capacitor集成

安装Capacitor

# 安装Capacitor
npm install @capacitor/core @capacitor/cli

# 初始化
npx cap init GameGroup com.gamegroup.app

# 安装平台
npm install @capacitor/android @capacitor/ios

# 构建
npm run build

# 同步代码
npx cap sync

# 打开Android Studio
npx cap open android

# 打开Xcode
npx cap open ios

配置文件

<!-- capacitor.config.xml -->
<capacitor-config>
  <appName>GameGroup</appName>
  <appId>com.gamegroup.app</appId>
  <webDir>dist</webDir>
  <server>
    <androidScheme>https</androidScheme>
    <iosScheme>https</iosScheme>
  </server>

  <!-- iOS配置 -->
  <ios>
    <minVersion>13.0</minVersion>
    <preferences>
      <preference name="WKWebViewDecelerationSpeed" value="fast" />
    </preferences>
  </ios>

  <!-- Android配置 -->
  <android>
    <minVersion>24</minVersion>
  </android>

  <!-- 插件 -->
  <plugins>
    <plugin id="Camera" />
    <plugin id="LocalNotifications" />
    <plugin id="PushNotifications" />
    <plugin id="Share" />
    <plugin id="Haptics" />
    <plugin id="StatusBar" />
    <plugin id="Keyboard" />
  </plugins>
</capacitor-config>

使用原生功能

// 相机
import { Camera, CameraResultType } from '@capacitor/camera'

async function takePicture() {
  const image = await Camera.getPhoto({
    quality: 90,
    allowEditing: true,
    resultType: CameraResultType.Uri
  })
  return image.webPath
}

// 推送通知
import { PushNotifications } from '@capacitor/push-notifications'

async function initPushNotifications() {
  const result = await PushNotifications.register()
  console.log('Registered:', result)
}

// 分享
import { Share } from '@capacitor/share'

async function shareContent(title: string, text: string, url: string) {
  await Share.share({
    title,
    text,
    url
  })
}

// 触觉反馈
import { Haptics, ImpactStyle } from '@capacitor/haptics'

async function hapticImpact() {
  await Haptics.impact({ style: ImpactStyle.Medium })
}

// 状态栏
import { StatusBar, Style } from '@capacitor/status-bar'

async function setStatusBar() {
  await StatusBar.setStyle({ style: Style.Light })
  await StatusBar.setBackgroundColor({ color: '#8b5cf6' })
}

4.2 PWA配置

Manifest文件

{
  "name": "GameGroup",
  "short_name": "GameGroup",
  "description": "游戏社群管理平台",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#8b5cf6",
  "orientation": "portrait",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/maskable-icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/mobile-home.png",
      "sizes": "390x844",
      "type": "image/png",
      "form_factor": "narrow"
    }
  ],
  "categories": ["social", "games"],
  "shortcuts": [
    {
      "name": "创建预约",
      "short_name": "预约",
      "description": "快速创建游戏预约",
      "url": "/appointments/create",
      "icons": [{ "src": "/icons/appointment.png", "sizes": "96x96" }]
    }
  ]
}

Service Worker

// src/sw.ts
import { registerRoute, NavigationRoute, CacheFirst, StaleWhileRevalidate } from 'workbox-routing'
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
import { ExpirationPlugin } from 'workbox-expiration'
import { precacheAndRoute } from 'workbox-precaching'

// 预缓存静态资源
precacheAndRoute(self.__WB_MANIFEST)

// 缓存图片
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200]
      }),
      new ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60 // 30天
      })
    ]
  })
)

// 缓存API响应
registerRoute(
  ({ url }) => url.pathname.startsWith('/api'),
  new StaleWhileRevalidate({
    cacheName: 'api-responses',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200]
      }),
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60 // 5分钟
      })
    ]
  })
)

// 离线页面
registerRoute(
  new NavigationRoute(
    new CacheFirst({
      cacheName: 'offline-page',
      plugins: [
        new CacheableResponsePlugin({
          statuses: [0, 200]
        })
      ]
    })
  )
)

注册Service Worker

// src/main.ts
import { registerSW } from 'virtual:pwa-register'

if (import.meta.env.PROD) {
  registerSW({
    onNeedRefresh() {
      // 显示更新提示
      showUpdateNotification()
    },
    onOfflineReady() {
      // 显示离线就绪提示
      showOfflineReadyNotification()
    }
  })
}

Vite PWA插件配置

// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg,webp}']
      },
      manifest: {
        name: 'GameGroup',
        short_name: 'GameGroup',
        theme_color: '#8b5cf6',
        icons: [
          {
            src: '/icons/icon-192x192.png',
            sizes: '192x192',
            type: 'image/png'
          },
          {
            src: '/icons/icon-512x512.png',
            sizes: '512x512',
            type: 'image/png'
          }
        ]
      }
    })
  ]
})

5. 性能优化

5.1 移动端性能优化

减少首屏加载

// 路由懒加载
const routes = [
  {
    path: '/',
    component: () => import('@/views/home/index.vue')
  },
  {
    path: '/groups',
    component: () => import('@/views/group/List.vue')
  }
]

// 组件懒加载
const HeavyChart = defineAsyncComponent(() =>
  import('@/components/HeavyChart.vue')
)

图片优化

<template>
  <!-- 响应式图片 -->
  <picture>
    <source
      media="(max-width: 640px)"
      :srcset="image.mobile"
    />
    <source
      media="(min-width: 641px)"
      :srcset="image.desktop"
    />
    <img
      :src="image.fallback"
      :alt="image.alt"
      loading="lazy"
    />
  </picture>

  <!-- 或使用Vue组件 -->
  <ResponsiveImage
    :mobile="image.mobile"
    :desktop="image.desktop"
    :alt="image.alt"
    lazy
  />
</template>

虚拟滚动

<template>
  <RecycleScroller
    :items="longList"
    :item-size="80"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="list-item">{{ item.name }}</div>
  </RecycleScroller>
</template>

<script setup lang="ts">
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
</script>

5.2 网络优化

离线优先策略

// composables/useOffline.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useOffline() {
  const isOnline = ref(navigator.onLine)

  const updateStatus = () => {
    isOnline.value = navigator.onLine
  }

  onMounted(() => {
    window.addEventListener('online', updateStatus)
    window.addEventListener('offline', updateStatus)
  })

  onUnmounted(() => {
    window.removeEventListener('online', updateStatus)
    window.removeEventListener('offline', updateStatus)
  })

  return { isOnline }
}

// 使用
const { isOnline } = useOffline()

watchEffect(() => {
  if (!isOnline.value) {
    showToast('当前处于离线模式')
  }
})

数据预加载

// router/guards.ts
router.beforeEach(async (to, from, next) => {
  // 预加载可能访问的页面数据
  if (from.path === '/groups' && to.path === '/groups/:id') {
    const groupStore = useGroupStore()
    await groupStore.fetchGroupDetail(to.params.id)
  }

  next()
})

6. 最佳实践

6.1 安全区域适配

/* 安全区域padding */
.safe-area {
  padding-top: env(safe-area-inset-top);
  padding-bottom: env(safe-area-inset-bottom);
  padding-left: env(safe-area-inset-left);
  padding-right: env(safe-area-inset-right);
}

/* 固定顶部栏 */
.fixed-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  padding-top: calc(12px + env(safe-area-inset-top));
}

/* 固定底部栏 */
.fixed-footer {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  padding-bottom: calc(12px + env(safe-area-inset-bottom));
}

6.2 视口配置

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta
    name="viewport"
    content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no"
  />
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
  <meta name="theme-color" content="#8b5cf6" />
</head>
</html>

6.3 检测移动端

// utils/device.ts
export const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
  navigator.userAgent
)

export const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)

export const isAndroid = /Android/.test(navigator.userAgent)

export const isStandalone = window.matchMedia('(display-mode: standalone)').matches

// 获取安全区域
export function getSafeAreaInset() {
  const style = getComputedStyle(document.documentElement)
  return {
    top: parseInt(style.getPropertyValue('safe-area-inset-top')),
    right: parseInt(style.getPropertyValue('safe-area-inset-right')),
    bottom: parseInt(style.getPropertyValue('safe-area-inset-bottom')),
    left: parseInt(style.getPropertyValue('safe-area-inset-left'))
  }
}

6.4 性能监控

// utils/performance.ts
export function measureRender(componentName: string) {
  const markStart = `${componentName}-start`
  const markEnd = `${componentName}-end`

  performance.mark(markStart)

  return () => {
    performance.mark(markEnd)
    performance.measure(
      componentName,
      markStart,
      markEnd
    )

    const measure = performance.getEntriesByName(componentName)[0]
    console.log(`${componentName} render time: ${measure.duration}ms`)
  }
}

// 使用
onMounted(() => {
  const endMeasure = measureRender('HomePage')
  // 组件渲染完成后
  nextTick(() => {
    endMeasure()
  })
})

7. 总结

7.1 方案优势

特性 Capacitor PWA Web
原生性能 ⚠️ ⚠️
App Store分发
即时更新 ⚠️
原生功能 ⚠️
代码复用 95% 100% 100%

7.2 实施建议

阶段1: Web端优先2-3周

  • 实现响应式布局
  • 优化移动端体验
  • 配置PWA

阶段2: Capacitor集成1-2周

  • 安装Capacitor
  • 配置iOS和Android
  • 集成原生功能(推送、分享等)

阶段3: 打包发布1周

  • 准备应用图标和截图
  • 配置应用签名
  • 提交App Store和Google Play

7.3 注意事项

  1. iOS限制

    • PWA在iOS上功能受限
    • 推荐使用Capacitor打包
    • 需要Apple Developer账号$99/年)
  2. Android限制

    • 需要应用签名
    • Google Play一次性费用$25
    • PWA可直接安装
  3. 性能建议

    • 使用虚拟滚动处理长列表
    • 图片懒加载和响应式图片
    • 代码分割和懒加载
    • 启用生产模式压缩

文档维护: 随移动端开发持续更新 最后更新: 2026-01-28 技术负责: Frontend Team