Files
gamegroup_fe/doc/design/05-移动端与跨平台方案.md

1162 lines
25 KiB
Markdown
Raw Permalink Normal View History

2026-01-28 14:35:43 +08:00
# 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