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

1162 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# GameGroup 移动端与跨平台方案
**项目名称**: GameGroup 前端系统
**文档版本**: v1.0
**更新时间**: 2026-01-28
---
## 📋 目录
- [1. 需求分析](#1-需求分析)
- [2. 技术方案选型](#2-技术方案选型)
- [3. 移动端增强设计](#3-移动端增强设计)
- [4. 跨平台移植方案](#4-跨平台移植方案)
- [5. 性能优化](#5-性能优化)
- [6. 最佳实践](#6-最佳实践)
---
## 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** | • 原生性能<br>• 可访问原生功能<br>• App Store分发<br>• 代码复用率高 | • 需要打包发布<br>• 审核周期 | ⭐⭐⭐⭐⭐ |
| **PWA** | • 无需审核<br>• 即时更新<br>• 可安装到桌面<br>• 渐进式增强 | • iOS支持有限<br>• 无法访问所有原生API | ⭐⭐⭐⭐ |
| **React Native** | • 原生组件<br>• 性能最优 | • 需要重写<br>• 无法复用代码 | ⭐⭐ |
| **uni-app** | • 一套代码多端运行<br>• 小程序友好 | • Vue 2支持为主<br>• 性能损耗 | ⭐⭐⭐ |
#### 最终方案:**Capacitor + PWA**
**理由**:
1. ✅ 保持Vue 3 + TypeScript技术栈
2. ✅ 代码复用率95%以上
3. ✅ 可发布到App Store和Google Play
4. ✅ 支持PWA无需审核即可使用
5. ✅ 可访问摄像头、推送通知等原生功能
6. ✅ 团队学习成本低
---
## 3. 移动端增强设计
### 3.1 导航模式
#### 移动端专属导航
```
移动端布局(< 768px
┌─────────────────────────────┐
│ [≡] GameGroup [🔔][👤] │ ← 顶部栏
├─────────────────────────────┤
│ │
│ 内容区域 │
│ (可滚动) │
│ │
│ │
├─────────────────────────────┤
│ [首页][小组][游戏][我的] │ ← 底部Tab导航
└─────────────────────────────┘
```
#### 实现代码
```vue
<!-- 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 × 44px**iOS人机界面指南
```css
/* 移动端按钮尺寸 */
@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;
}
}
```
#### 手势支持
```typescript
// 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
})
```
#### 下拉刷新
```vue
<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
```vue
<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>
```
#### 移动端卡片列表
```vue
<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
```bash
# 安装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
```
#### 配置文件
```xml
<!-- 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>
```
#### 使用原生功能
```typescript
// 相机
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文件
```json
{
"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
```typescript
// 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
```typescript
// src/main.ts
import { registerSW } from 'virtual:pwa-register'
if (import.meta.env.PROD) {
registerSW({
onNeedRefresh() {
// 显示更新提示
showUpdateNotification()
},
onOfflineReady() {
// 显示离线就绪提示
showOfflineReadyNotification()
}
})
}
```
#### Vite PWA插件配置
```typescript
// 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 移动端性能优化
#### 减少首屏加载
```typescript
// 路由懒加载
const routes = [
{
path: '/',
component: () => import('@/views/home/index.vue')
},
{
path: '/groups',
component: () => import('@/views/group/List.vue')
}
]
// 组件懒加载
const HeavyChart = defineAsyncComponent(() =>
import('@/components/HeavyChart.vue')
)
```
#### 图片优化
```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>
```
#### 虚拟滚动
```vue
<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 网络优化
#### 离线优先策略
```typescript
// 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('当前处于离线模式')
}
})
```
#### 数据预加载
```typescript
// 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 安全区域适配
```css
/* 安全区域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 视口配置
```html
<!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 检测移动端
```typescript
// 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 性能监控
```typescript
// 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