1162 lines
25 KiB
Markdown
1162 lines
25 KiB
Markdown
|
|
# 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
|