mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 00:36:41 +08:00
feat: 实现PWA状态管理防止iOS后台被杀
- 添加多层存储策略(localStorage + sessionStorage + IndexedDB + Service Worker缓存) - 实现智能状态恢复决策机制 - 自动监听页面生命周期事件进行状态保存和恢复 - 支持表单数据、滚动位置、UI状态的自动保存 - 专为iOS设备PWA优化,解决后台被杀导致状态丢失的问题 - 版本号更新至 2.6.3
This commit is contained in:
@@ -1,270 +0,0 @@
|
|||||||
# PWA状态管理功能实现说明
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本次实现为您的MoviePilot项目添加了完整的PWA状态管理功能,专门解决iOS设备上PWA后台被杀导致状态丢失的问题。
|
|
||||||
|
|
||||||
## 已实现的功能
|
|
||||||
|
|
||||||
### 1. 核心状态管理器 (`src/utils/pwaStateManager.ts`)
|
|
||||||
|
|
||||||
- ✅ **多层存储策略**:localStorage + sessionStorage + IndexedDB + Service Worker缓存
|
|
||||||
- ✅ **智能状态恢复**:基于时间、URL、设备方向的智能决策
|
|
||||||
- ✅ **生命周期监听**:自动监听页面可见性、焦点变化、卸载事件
|
|
||||||
- ✅ **表单状态保存**:自动保存和恢复表单数据
|
|
||||||
- ✅ **滚动位置恢复**:精确恢复页面滚动位置
|
|
||||||
- ✅ **UI状态管理**:保存侧边栏、主题等界面状态
|
|
||||||
|
|
||||||
### 2. Service Worker增强 (`src/service-worker.ts`)
|
|
||||||
|
|
||||||
- ✅ **状态缓存端点**:虚拟的`/api/pwa-state`端点用于状态存储
|
|
||||||
- ✅ **消息通信**:支持与主应用的双向状态同步
|
|
||||||
- ✅ **缓存管理**:专用的状态缓存空间
|
|
||||||
- ✅ **错误处理**:完善的错误处理和降级策略
|
|
||||||
|
|
||||||
### 3. Vue集成 (`src/main.ts`)
|
|
||||||
|
|
||||||
- ✅ **自动初始化**:PWA模式下自动启动状态管理
|
|
||||||
- ✅ **环境检测**:智能检测PWA运行环境
|
|
||||||
- ✅ **全局可用**:状态管理器绑定到全局对象
|
|
||||||
- ✅ **事件监听**:监听状态恢复事件并处理
|
|
||||||
|
|
||||||
### 4. Vue组合式API (`src/composables/usePWAState.ts`)
|
|
||||||
|
|
||||||
- ✅ **响应式状态**:提供响应式的状态管理接口
|
|
||||||
- ✅ **便捷方法**:封装常用的状态操作方法
|
|
||||||
- ✅ **类型安全**:完整的TypeScript类型支持
|
|
||||||
- ✅ **组件友好**:易于在Vue组件中使用
|
|
||||||
|
|
||||||
### 5. 类型声明 (`src/types/pwa.d.ts`)
|
|
||||||
|
|
||||||
- ✅ **类型扩展**:扩展Window和Navigator接口
|
|
||||||
- ✅ **自定义事件**:定义状态恢复事件类型
|
|
||||||
- ✅ **TypeScript支持**:完整的类型安全保障
|
|
||||||
|
|
||||||
### 6. 演示组件 (`src/components/PWAStateDemo.vue`)
|
|
||||||
|
|
||||||
- ✅ **功能演示**:展示所有状态管理功能
|
|
||||||
- ✅ **测试界面**:提供测试表单和操作按钮
|
|
||||||
- ✅ **状态监控**:实时显示状态管理器状态
|
|
||||||
|
|
||||||
## 使用方法
|
|
||||||
|
|
||||||
### 在Vue组件中使用
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { usePWAState } from '@/composables/usePWAState'
|
|
||||||
|
|
||||||
const {
|
|
||||||
isPWAMode,
|
|
||||||
isStateRestored,
|
|
||||||
saveCurrentState,
|
|
||||||
resetStateRestored
|
|
||||||
} = usePWAState()
|
|
||||||
|
|
||||||
// 手动保存状态
|
|
||||||
const handleImportantAction = async () => {
|
|
||||||
await saveCurrentState()
|
|
||||||
// 执行重要操作
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<VAlert v-if="isStateRestored" type="success">
|
|
||||||
状态已恢复!
|
|
||||||
</VAlert>
|
|
||||||
|
|
||||||
<VBtn @click="handleImportantAction">
|
|
||||||
执行重要操作
|
|
||||||
</VBtn>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 手动操作状态
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 在浏览器控制台中
|
|
||||||
window.pwaStateController.saveCurrentState() // 保存当前状态
|
|
||||||
```
|
|
||||||
|
|
||||||
## 工作原理
|
|
||||||
|
|
||||||
### 1. 状态保存时机
|
|
||||||
|
|
||||||
- 🔄 **页面隐藏时**:`visibilitychange`事件触发
|
|
||||||
- 🔄 **失去焦点时**:`blur`事件延迟1秒触发
|
|
||||||
- 🔄 **页面卸载时**:`beforeunload`事件触发
|
|
||||||
- 🔄 **定期保存**:每30秒自动保存一次
|
|
||||||
- 🔄 **手动触发**:调用API手动保存
|
|
||||||
|
|
||||||
### 2. 状态恢复时机
|
|
||||||
|
|
||||||
- 🔄 **应用启动时**:自动检查并恢复状态
|
|
||||||
- 🔄 **页面显示时**:`visibilitychange`事件触发
|
|
||||||
- 🔄 **获得焦点时**:清除延迟保存定时器
|
|
||||||
|
|
||||||
### 3. 存储策略
|
|
||||||
|
|
||||||
```
|
|
||||||
localStorage (主要状态)
|
|
||||||
↓
|
|
||||||
sessionStorage (临时状态)
|
|
||||||
↓
|
|
||||||
IndexedDB (大量数据)
|
|
||||||
↓
|
|
||||||
Service Worker缓存 (跨页面共享)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 恢复决策
|
|
||||||
|
|
||||||
状态恢复需要同时满足:
|
|
||||||
- ✅ 状态未过期(默认30分钟内)
|
|
||||||
- ✅ URL路径匹配
|
|
||||||
- ✅ 设备方向未显著变化
|
|
||||||
|
|
||||||
## 配置选项
|
|
||||||
|
|
||||||
### 修改状态保存间隔
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 在 PWAStateController 中修改
|
|
||||||
private setupPeriodicSave(): void {
|
|
||||||
setInterval(() => {
|
|
||||||
if (!document.hidden) {
|
|
||||||
this.saveCurrentState()
|
|
||||||
}
|
|
||||||
}, 60000) // 改为60秒保存一次
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 修改状态过期时间
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 在 StateRestoreDecision 中修改
|
|
||||||
export class StateRestoreDecision {
|
|
||||||
private maxStateAge = 60 * 60 * 1000 // 改为60分钟
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 自定义状态内容
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 在 PWAStateController 中添加自定义状态
|
|
||||||
private getAppSpecificState(): any {
|
|
||||||
return {
|
|
||||||
// 现有状态...
|
|
||||||
|
|
||||||
// 添加自定义状态
|
|
||||||
customData: {
|
|
||||||
userPreferences: this.getUserPreferences(),
|
|
||||||
currentMedia: this.getCurrentMedia(),
|
|
||||||
searchHistory: this.getSearchHistory()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 调试和监控
|
|
||||||
|
|
||||||
### 控制台调试
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 检查状态管理器是否可用
|
|
||||||
console.log('状态管理器:', window.pwaStateController)
|
|
||||||
|
|
||||||
// 查看当前保存的状态
|
|
||||||
console.log('本地状态:', localStorage.getItem('mp-pwa-app-state'))
|
|
||||||
|
|
||||||
// 手动保存状态
|
|
||||||
window.pwaStateController?.saveCurrentState()
|
|
||||||
|
|
||||||
// 清除所有状态
|
|
||||||
localStorage.removeItem('mp-pwa-app-state')
|
|
||||||
sessionStorage.removeItem('mp-pwa-session-state')
|
|
||||||
```
|
|
||||||
|
|
||||||
### 监听状态事件
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 监听状态恢复事件
|
|
||||||
window.addEventListener('pwaStateRestored', (event) => {
|
|
||||||
console.log('状态已恢复:', event.detail.state)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
### iOS特殊性
|
|
||||||
- PWA不与Safari共享存储空间
|
|
||||||
- 后台执行时间有限(约30秒-5分钟)
|
|
||||||
- 内存压力时会被强制清理
|
|
||||||
- Service Worker可能会被暂停
|
|
||||||
|
|
||||||
### 存储限制
|
|
||||||
- **localStorage**: 约5-10MB
|
|
||||||
- **sessionStorage**: 约5-10MB
|
|
||||||
- **IndexedDB**: 较大但可能被清理
|
|
||||||
- **Service Worker缓存**: 约50MB
|
|
||||||
|
|
||||||
### 性能考虑
|
|
||||||
- 避免保存过大的状态对象
|
|
||||||
- 使用防抖技术避免频繁保存
|
|
||||||
- 异步处理状态操作
|
|
||||||
- 定期清理过期状态
|
|
||||||
|
|
||||||
## 测试方法
|
|
||||||
|
|
||||||
### 1. 基本功能测试
|
|
||||||
1. 将应用添加到iOS桌面
|
|
||||||
2. 打开PWA,填写测试表单
|
|
||||||
3. 切换到其他应用
|
|
||||||
4. 等待几分钟后重新打开PWA
|
|
||||||
5. 检查表单数据和滚动位置是否恢复
|
|
||||||
|
|
||||||
### 2. 状态管理测试
|
|
||||||
1. 在PWA中访问演示页面:`/pwa-state-demo`
|
|
||||||
2. 观察状态管理器状态
|
|
||||||
3. 测试手动保存和恢复功能
|
|
||||||
4. 验证状态恢复通知
|
|
||||||
|
|
||||||
### 3. 长时间测试
|
|
||||||
1. 保持PWA在后台运行几小时
|
|
||||||
2. 使用其他应用增加内存压力
|
|
||||||
3. 重新打开PWA检查状态恢复
|
|
||||||
4. 重启设备后测试状态持久性
|
|
||||||
|
|
||||||
## 故障排除
|
|
||||||
|
|
||||||
### 状态未恢复
|
|
||||||
1. 检查是否在PWA模式运行
|
|
||||||
2. 确认状态管理器已初始化
|
|
||||||
3. 检查控制台错误信息
|
|
||||||
4. 验证存储权限和配额
|
|
||||||
|
|
||||||
### 性能问题
|
|
||||||
1. 减少状态保存频率
|
|
||||||
2. 优化状态对象大小
|
|
||||||
3. 检查内存使用情况
|
|
||||||
4. 考虑延迟初始化
|
|
||||||
|
|
||||||
### 兼容性问题
|
|
||||||
1. 检查iOS版本支持
|
|
||||||
2. 验证Service Worker注册
|
|
||||||
3. 测试不同设备和浏览器
|
|
||||||
4. 检查网络连接状态
|
|
||||||
|
|
||||||
## 后续优化建议
|
|
||||||
|
|
||||||
1. **智能压缩**:对大型状态对象进行压缩
|
|
||||||
2. **增量保存**:只保存变化的状态部分
|
|
||||||
3. **云端同步**:结合服务器实现跨设备状态同步
|
|
||||||
4. **用户偏好**:允许用户自定义状态保存策略
|
|
||||||
5. **性能监控**:添加状态管理性能指标
|
|
||||||
6. **A/B测试**:测试不同的状态管理策略效果
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
通过这套完整的解决方案,您的MoviePilot PWA应该能够在iOS设备上提供更好的用户体验,显著减少因后台被杀而导致的状态丢失问题。
|
|
||||||
@@ -1,645 +0,0 @@
|
|||||||
# PWA在iOS上防止后台被杀及状态恢复解决方案
|
|
||||||
|
|
||||||
## 问题概述
|
|
||||||
|
|
||||||
PWA添加到iOS桌面后,经常遇到以下问题:
|
|
||||||
- iOS系统积极清理后台应用内存
|
|
||||||
- 重新打开PWA时页面刷新,丢失之前状态
|
|
||||||
- 用户体验不佳,类似于"冷启动"
|
|
||||||
|
|
||||||
## 核心解决策略
|
|
||||||
|
|
||||||
### 1. 实现状态持久化
|
|
||||||
|
|
||||||
#### 使用多层存储策略
|
|
||||||
```javascript
|
|
||||||
// 状态管理类
|
|
||||||
class PWAStateManager {
|
|
||||||
constructor() {
|
|
||||||
this.storageKey = 'pwa-app-state';
|
|
||||||
this.sessionKey = 'pwa-session-state';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存应用状态
|
|
||||||
saveState(state) {
|
|
||||||
try {
|
|
||||||
// 主要状态存储到localStorage
|
|
||||||
localStorage.setItem(this.storageKey, JSON.stringify({
|
|
||||||
...state,
|
|
||||||
timestamp: Date.now()
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 临时状态存储到sessionStorage
|
|
||||||
sessionStorage.setItem(this.sessionKey, JSON.stringify({
|
|
||||||
scrollPosition: window.scrollY,
|
|
||||||
activeTab: state.activeTab,
|
|
||||||
formData: state.formData
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('状态保存失败:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 恢复应用状态
|
|
||||||
restoreState() {
|
|
||||||
try {
|
|
||||||
const savedState = localStorage.getItem(this.storageKey);
|
|
||||||
const sessionState = sessionStorage.getItem(this.sessionKey);
|
|
||||||
|
|
||||||
if (savedState) {
|
|
||||||
const state = JSON.parse(savedState);
|
|
||||||
const sessionData = sessionState ? JSON.parse(sessionState) : {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
...sessionData,
|
|
||||||
isRestored: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('状态恢复失败:', error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除过期状态
|
|
||||||
clearExpiredState(maxAge = 24 * 60 * 60 * 1000) { // 24小时
|
|
||||||
try {
|
|
||||||
const savedState = localStorage.getItem(this.storageKey);
|
|
||||||
if (savedState) {
|
|
||||||
const state = JSON.parse(savedState);
|
|
||||||
if (Date.now() - state.timestamp > maxAge) {
|
|
||||||
localStorage.removeItem(this.storageKey);
|
|
||||||
sessionStorage.removeItem(this.sessionKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('清除过期状态失败:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 使用IndexedDB存储大量数据
|
|
||||||
```javascript
|
|
||||||
// IndexedDB状态管理
|
|
||||||
class PWAIndexedDBManager {
|
|
||||||
constructor() {
|
|
||||||
this.dbName = 'PWAStateDB';
|
|
||||||
this.dbVersion = 1;
|
|
||||||
this.storeName = 'appState';
|
|
||||||
}
|
|
||||||
|
|
||||||
async initDB() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
|
||||||
|
|
||||||
request.onerror = () => reject(request.error);
|
|
||||||
request.onsuccess = () => resolve(request.result);
|
|
||||||
|
|
||||||
request.onupgradeneeded = (event) => {
|
|
||||||
const db = event.target.result;
|
|
||||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
||||||
db.createObjectStore(this.storeName, { keyPath: 'id' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveState(state) {
|
|
||||||
try {
|
|
||||||
const db = await this.initDB();
|
|
||||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
|
||||||
const store = transaction.objectStore(this.storeName);
|
|
||||||
|
|
||||||
await store.put({
|
|
||||||
id: 'appState',
|
|
||||||
data: state,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('IndexedDB保存失败:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async restoreState() {
|
|
||||||
try {
|
|
||||||
const db = await this.initDB();
|
|
||||||
const transaction = db.transaction([this.storeName], 'readonly');
|
|
||||||
const store = transaction.objectStore(this.storeName);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const request = store.get('appState');
|
|
||||||
request.onsuccess = () => {
|
|
||||||
const result = request.result;
|
|
||||||
resolve(result ? result.data : null);
|
|
||||||
};
|
|
||||||
request.onerror = () => reject(request.error);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('IndexedDB恢复失败:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 使用Service Worker实现状态共享
|
|
||||||
|
|
||||||
#### Service Worker状态管理
|
|
||||||
```javascript
|
|
||||||
// sw.js - Service Worker中的状态管理
|
|
||||||
const STATE_CACHE_NAME = 'pwa-state-cache';
|
|
||||||
const STATE_ENDPOINT = '/api/state';
|
|
||||||
|
|
||||||
// 激活时立即接管页面
|
|
||||||
self.addEventListener('activate', event => {
|
|
||||||
event.waitUntil(clients.claim());
|
|
||||||
});
|
|
||||||
|
|
||||||
// 拦截状态相关请求
|
|
||||||
self.addEventListener('fetch', event => {
|
|
||||||
const { request } = event;
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
if (url.pathname === STATE_ENDPOINT) {
|
|
||||||
if (request.method === 'POST') {
|
|
||||||
event.respondWith(saveStateToCache(request));
|
|
||||||
} else if (request.method === 'GET') {
|
|
||||||
event.respondWith(getStateFromCache());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 保存状态到缓存
|
|
||||||
async function saveStateToCache(request) {
|
|
||||||
try {
|
|
||||||
const state = await request.json();
|
|
||||||
const cache = await caches.open(STATE_CACHE_NAME);
|
|
||||||
|
|
||||||
await cache.put(STATE_ENDPOINT, new Response(JSON.stringify({
|
|
||||||
...state,
|
|
||||||
timestamp: Date.now()
|
|
||||||
})));
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ success: true }));
|
|
||||||
} catch (error) {
|
|
||||||
return new Response(JSON.stringify({ error: error.message }), {
|
|
||||||
status: 500
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从缓存获取状态
|
|
||||||
async function getStateFromCache() {
|
|
||||||
try {
|
|
||||||
const cache = await caches.open(STATE_CACHE_NAME);
|
|
||||||
const response = await cache.match(STATE_ENDPOINT);
|
|
||||||
|
|
||||||
if (response) {
|
|
||||||
const state = await response.json();
|
|
||||||
return new Response(JSON.stringify(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({}));
|
|
||||||
} catch (error) {
|
|
||||||
return new Response(JSON.stringify({ error: error.message }), {
|
|
||||||
status: 500
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 客户端状态同步
|
|
||||||
```javascript
|
|
||||||
// 客户端状态同步
|
|
||||||
class ServiceWorkerStateSync {
|
|
||||||
constructor() {
|
|
||||||
this.stateEndpoint = '/api/state';
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveState(state) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.stateEndpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(state)
|
|
||||||
});
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Service Worker状态保存失败:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadState() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.stateEndpoint);
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Service Worker状态加载失败:', error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 监听应用生命周期
|
|
||||||
|
|
||||||
#### 页面可见性监听
|
|
||||||
```javascript
|
|
||||||
// 页面可见性状态管理
|
|
||||||
class VisibilityStateManager {
|
|
||||||
constructor(stateManager) {
|
|
||||||
this.stateManager = stateManager;
|
|
||||||
this.setupVisibilityListener();
|
|
||||||
}
|
|
||||||
|
|
||||||
setupVisibilityListener() {
|
|
||||||
// 监听页面可见性变化
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
|
||||||
if (document.hidden) {
|
|
||||||
// 页面被隐藏时保存状态
|
|
||||||
this.handlePageHidden();
|
|
||||||
} else {
|
|
||||||
// 页面显示时恢复状态
|
|
||||||
this.handlePageVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听页面卸载
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
this.handlePageUnload();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听页面焦点变化
|
|
||||||
window.addEventListener('blur', () => {
|
|
||||||
this.handlePageBlur();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('focus', () => {
|
|
||||||
this.handlePageFocus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePageHidden() {
|
|
||||||
// 页面被隐藏时保存当前状态
|
|
||||||
const currentState = this.getCurrentAppState();
|
|
||||||
this.stateManager.saveState(currentState);
|
|
||||||
|
|
||||||
console.log('页面被隐藏,已保存状态');
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePageVisible() {
|
|
||||||
// 页面显示时检查是否需要恢复状态
|
|
||||||
const restoredState = this.stateManager.restoreState();
|
|
||||||
if (restoredState) {
|
|
||||||
this.restoreAppState(restoredState);
|
|
||||||
console.log('页面显示,已恢复状态');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePageUnload() {
|
|
||||||
// 页面卸载时最后保存状态
|
|
||||||
const currentState = this.getCurrentAppState();
|
|
||||||
this.stateManager.saveState(currentState);
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePageBlur() {
|
|
||||||
// 失去焦点时保存状态(定时器避免频繁保存)
|
|
||||||
if (this.blurTimer) clearTimeout(this.blurTimer);
|
|
||||||
this.blurTimer = setTimeout(() => {
|
|
||||||
const currentState = this.getCurrentAppState();
|
|
||||||
this.stateManager.saveState(currentState);
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePageFocus() {
|
|
||||||
// 获得焦点时清除定时器
|
|
||||||
if (this.blurTimer) {
|
|
||||||
clearTimeout(this.blurTimer);
|
|
||||||
this.blurTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentAppState() {
|
|
||||||
// 获取当前应用状态(需要根据具体应用实现)
|
|
||||||
return {
|
|
||||||
url: window.location.href,
|
|
||||||
scrollPosition: window.scrollY,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
// 添加其他应用特定状态
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreAppState(state) {
|
|
||||||
// 恢复应用状态(需要根据具体应用实现)
|
|
||||||
if (state.scrollPosition) {
|
|
||||||
window.scrollTo(0, state.scrollPosition);
|
|
||||||
}
|
|
||||||
// 恢复其他应用特定状态
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 实现智能状态恢复
|
|
||||||
|
|
||||||
#### 状态恢复决策器
|
|
||||||
```javascript
|
|
||||||
class StateRestoreDecision {
|
|
||||||
constructor() {
|
|
||||||
this.maxStateAge = 30 * 60 * 1000; // 30分钟
|
|
||||||
this.urlChangeThreshold = 5; // URL变化阈值
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldRestoreState(savedState, currentContext) {
|
|
||||||
if (!savedState) return false;
|
|
||||||
|
|
||||||
// 检查状态年龄
|
|
||||||
if (this.isStateExpired(savedState)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查URL匹配
|
|
||||||
if (!this.isUrlCompatible(savedState.url, currentContext.url)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查设备方向
|
|
||||||
if (this.isOrientationChanged(savedState, currentContext)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
isStateExpired(savedState) {
|
|
||||||
return Date.now() - savedState.timestamp > this.maxStateAge;
|
|
||||||
}
|
|
||||||
|
|
||||||
isUrlCompatible(savedUrl, currentUrl) {
|
|
||||||
if (!savedUrl || !currentUrl) return false;
|
|
||||||
|
|
||||||
const savedPath = new URL(savedUrl).pathname;
|
|
||||||
const currentPath = new URL(currentUrl).pathname;
|
|
||||||
|
|
||||||
return savedPath === currentPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
isOrientationChanged(savedState, currentContext) {
|
|
||||||
return savedState.orientation !== currentContext.orientation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 完整的应用状态管理器
|
|
||||||
|
|
||||||
#### 统一状态管理
|
|
||||||
```javascript
|
|
||||||
// 完整的PWA状态管理器
|
|
||||||
class PWAStateController {
|
|
||||||
constructor() {
|
|
||||||
this.stateManager = new PWAStateManager();
|
|
||||||
this.indexedDBManager = new PWAIndexedDBManager();
|
|
||||||
this.swStateSync = new ServiceWorkerStateSync();
|
|
||||||
this.visibilityManager = new VisibilityStateManager(this.stateManager);
|
|
||||||
this.restoreDecision = new StateRestoreDecision();
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
async init() {
|
|
||||||
// 清理过期状态
|
|
||||||
this.stateManager.clearExpiredState();
|
|
||||||
|
|
||||||
// 检查是否需要恢复状态
|
|
||||||
await this.checkAndRestoreState();
|
|
||||||
|
|
||||||
// 设置定期保存
|
|
||||||
this.setupPeriodicSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkAndRestoreState() {
|
|
||||||
const currentContext = {
|
|
||||||
url: window.location.href,
|
|
||||||
orientation: window.orientation || 0,
|
|
||||||
timestamp: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
// 尝试从多个来源恢复状态
|
|
||||||
const sources = [
|
|
||||||
() => this.stateManager.restoreState(),
|
|
||||||
() => this.indexedDBManager.restoreState(),
|
|
||||||
() => this.swStateSync.loadState()
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const source of sources) {
|
|
||||||
try {
|
|
||||||
const savedState = await source();
|
|
||||||
if (this.restoreDecision.shouldRestoreState(savedState, currentContext)) {
|
|
||||||
await this.restoreState(savedState);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('状态恢复失败:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveCurrentState() {
|
|
||||||
const state = {
|
|
||||||
url: window.location.href,
|
|
||||||
scrollPosition: window.scrollY,
|
|
||||||
orientation: window.orientation || 0,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
// 添加应用特定状态
|
|
||||||
appData: this.getAppSpecificState()
|
|
||||||
};
|
|
||||||
|
|
||||||
// 多重保存策略
|
|
||||||
await Promise.allSettled([
|
|
||||||
this.stateManager.saveState(state),
|
|
||||||
this.indexedDBManager.saveState(state),
|
|
||||||
this.swStateSync.saveState(state)
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async restoreState(state) {
|
|
||||||
// 恢复滚动位置
|
|
||||||
if (state.scrollPosition) {
|
|
||||||
window.scrollTo(0, state.scrollPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 恢复应用特定状态
|
|
||||||
if (state.appData) {
|
|
||||||
this.restoreAppSpecificState(state.appData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 触发状态恢复事件
|
|
||||||
this.dispatchStateRestoreEvent(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
setupPeriodicSave() {
|
|
||||||
// 每30秒保存一次状态
|
|
||||||
setInterval(() => {
|
|
||||||
if (!document.hidden) {
|
|
||||||
this.saveCurrentState();
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAppSpecificState() {
|
|
||||||
// 根据具体应用实现
|
|
||||||
return {
|
|
||||||
// 表单数据
|
|
||||||
formData: this.getFormData(),
|
|
||||||
// 用户选择
|
|
||||||
userSelections: this.getUserSelections(),
|
|
||||||
// 其他应用状态
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreAppSpecificState(appData) {
|
|
||||||
// 根据具体应用实现状态恢复
|
|
||||||
if (appData.formData) {
|
|
||||||
this.restoreFormData(appData.formData);
|
|
||||||
}
|
|
||||||
if (appData.userSelections) {
|
|
||||||
this.restoreUserSelections(appData.userSelections);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchStateRestoreEvent(state) {
|
|
||||||
const event = new CustomEvent('stateRestored', {
|
|
||||||
detail: { state }
|
|
||||||
});
|
|
||||||
window.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
getFormData() {
|
|
||||||
// 获取表单数据
|
|
||||||
const forms = document.querySelectorAll('form');
|
|
||||||
const formData = {};
|
|
||||||
|
|
||||||
forms.forEach((form, index) => {
|
|
||||||
const data = new FormData(form);
|
|
||||||
formData[`form-${index}`] = Object.fromEntries(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
return formData;
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreFormData(formData) {
|
|
||||||
// 恢复表单数据
|
|
||||||
Object.entries(formData).forEach(([formId, data]) => {
|
|
||||||
const formIndex = parseInt(formId.split('-')[1]);
|
|
||||||
const form = document.querySelectorAll('form')[formIndex];
|
|
||||||
|
|
||||||
if (form) {
|
|
||||||
Object.entries(data).forEach(([name, value]) => {
|
|
||||||
const input = form.querySelector(`[name="${name}"]`);
|
|
||||||
if (input) {
|
|
||||||
input.value = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getUserSelections() {
|
|
||||||
// 获取用户选择状态
|
|
||||||
return {
|
|
||||||
selectedItems: Array.from(document.querySelectorAll('.selected')).map(el => el.id),
|
|
||||||
activeTab: document.querySelector('.tab.active')?.id
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreUserSelections(selections) {
|
|
||||||
// 恢复用户选择
|
|
||||||
if (selections.selectedItems) {
|
|
||||||
selections.selectedItems.forEach(id => {
|
|
||||||
const element = document.getElementById(id);
|
|
||||||
if (element) {
|
|
||||||
element.classList.add('selected');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selections.activeTab) {
|
|
||||||
const tab = document.getElementById(selections.activeTab);
|
|
||||||
if (tab) {
|
|
||||||
tab.classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. 使用方法
|
|
||||||
|
|
||||||
#### 在应用中集成
|
|
||||||
```javascript
|
|
||||||
// 在应用启动时初始化状态管理
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const stateController = new PWAStateController();
|
|
||||||
|
|
||||||
// 监听状态恢复事件
|
|
||||||
window.addEventListener('stateRestored', (event) => {
|
|
||||||
console.log('状态已恢复:', event.detail.state);
|
|
||||||
// 执行状态恢复后的处理
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 在关键操作后手动保存状态
|
|
||||||
function onImportantUserAction() {
|
|
||||||
if (window.stateController) {
|
|
||||||
window.stateController.saveCurrentState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. 最佳实践
|
|
||||||
|
|
||||||
1. **分层存储策略**:
|
|
||||||
- localStorage:持久性强,用于核心状态
|
|
||||||
- sessionStorage:会话级别,用于临时状态
|
|
||||||
- IndexedDB:大量数据存储
|
|
||||||
- Service Worker缓存:跨页面共享状态
|
|
||||||
|
|
||||||
2. **智能状态恢复**:
|
|
||||||
- 检查状态年龄
|
|
||||||
- 验证URL匹配
|
|
||||||
- 考虑设备方向变化
|
|
||||||
- 处理异常情况
|
|
||||||
|
|
||||||
3. **性能优化**:
|
|
||||||
- 避免频繁保存状态
|
|
||||||
- 使用防抖技术
|
|
||||||
- 异步处理状态操作
|
|
||||||
- 清理过期状态
|
|
||||||
|
|
||||||
4. **错误处理**:
|
|
||||||
- 多重保存策略
|
|
||||||
- 降级处理
|
|
||||||
- 日志记录
|
|
||||||
- 用户通知
|
|
||||||
|
|
||||||
### 8. 注意事项
|
|
||||||
|
|
||||||
1. **存储限制**:
|
|
||||||
- localStorage: 约5-10MB
|
|
||||||
- sessionStorage: 约5-10MB
|
|
||||||
- IndexedDB: 较大容量但可能被清理
|
|
||||||
- Service Worker缓存: 约50MB
|
|
||||||
|
|
||||||
2. **iOS特殊性**:
|
|
||||||
- PWA不与Safari共享存储
|
|
||||||
- 后台执行时间有限
|
|
||||||
- 内存压力时会被清理
|
|
||||||
|
|
||||||
3. **用户体验**:
|
|
||||||
- 提供状态恢复指示
|
|
||||||
- 处理恢复失败情况
|
|
||||||
- 保持操作流畅性
|
|
||||||
|
|
||||||
这个完整的解决方案应该能够显著改善PWA在iOS上的状态恢复体验,减少用户因为后台被杀而丢失状态的问题。
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "moviepilot",
|
"name": "moviepilot",
|
||||||
"version": "2.6.2",
|
"version": "2.6.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": "dist/service.js",
|
"bin": "dist/service.js",
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
<template>
|
|
||||||
<VCard class="ma-4" title="PWA状态管理演示">
|
|
||||||
<VCardText>
|
|
||||||
<VAlert
|
|
||||||
v-if="isPWAMode"
|
|
||||||
type="success"
|
|
||||||
class="mb-4"
|
|
||||||
>
|
|
||||||
<VIcon icon="mdi-check-circle" class="me-2" />
|
|
||||||
检测到PWA模式,状态管理功能已启用
|
|
||||||
</VAlert>
|
|
||||||
|
|
||||||
<VAlert
|
|
||||||
v-else
|
|
||||||
type="info"
|
|
||||||
class="mb-4"
|
|
||||||
>
|
|
||||||
<VIcon icon="mdi-information" class="me-2" />
|
|
||||||
当前在浏览器模式,请添加到桌面后体验状态管理功能
|
|
||||||
</VAlert>
|
|
||||||
|
|
||||||
<VRow>
|
|
||||||
<VCol cols="12" md="6">
|
|
||||||
<VCard variant="outlined">
|
|
||||||
<VCardTitle>状态信息</VCardTitle>
|
|
||||||
<VCardText>
|
|
||||||
<VList density="compact">
|
|
||||||
<VListItem>
|
|
||||||
<VListItemTitle>PWA模式</VListItemTitle>
|
|
||||||
<VListItemSubtitle>{{ isPWAMode ? '是' : '否' }}</VListItemSubtitle>
|
|
||||||
</VListItem>
|
|
||||||
<VListItem>
|
|
||||||
<VListItemTitle>状态管理器可用</VListItemTitle>
|
|
||||||
<VListItemSubtitle>{{ isStateManagerAvailable() ? '是' : '否' }}</VListItemSubtitle>
|
|
||||||
</VListItem>
|
|
||||||
<VListItem>
|
|
||||||
<VListItemTitle>状态恢复次数</VListItemTitle>
|
|
||||||
<VListItemSubtitle>{{ stateRestoreCount }}</VListItemSubtitle>
|
|
||||||
</VListItem>
|
|
||||||
<VListItem v-if="isStateRestored">
|
|
||||||
<VListItemTitle>最后恢复时间</VListItemTitle>
|
|
||||||
<VListItemSubtitle>{{ lastRestoredState?.timestamp ? new Date(lastRestoredState.timestamp).toLocaleString() : '无' }}</VListItemSubtitle>
|
|
||||||
</VListItem>
|
|
||||||
</VList>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VCol cols="12" md="6">
|
|
||||||
<VCard variant="outlined">
|
|
||||||
<VCardTitle>操作面板</VCardTitle>
|
|
||||||
<VCardText class="d-flex flex-column ga-3">
|
|
||||||
<VBtn
|
|
||||||
@click="saveCurrentState"
|
|
||||||
:disabled="!isStateManagerAvailable()"
|
|
||||||
color="primary"
|
|
||||||
prepend-icon="mdi-content-save"
|
|
||||||
>
|
|
||||||
手动保存状态
|
|
||||||
</VBtn>
|
|
||||||
|
|
||||||
<VBtn
|
|
||||||
@click="checkStateRestore"
|
|
||||||
:disabled="!isStateManagerAvailable()"
|
|
||||||
color="secondary"
|
|
||||||
prepend-icon="mdi-restore"
|
|
||||||
>
|
|
||||||
检查状态恢复
|
|
||||||
</VBtn>
|
|
||||||
|
|
||||||
<VBtn
|
|
||||||
@click="clearStoredState"
|
|
||||||
color="warning"
|
|
||||||
prepend-icon="mdi-delete"
|
|
||||||
>
|
|
||||||
清除存储状态
|
|
||||||
</VBtn>
|
|
||||||
|
|
||||||
<VBtn
|
|
||||||
@click="resetStateRestored"
|
|
||||||
v-if="isStateRestored"
|
|
||||||
color="info"
|
|
||||||
prepend-icon="mdi-refresh"
|
|
||||||
>
|
|
||||||
重置恢复标志
|
|
||||||
</VBtn>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</VCol>
|
|
||||||
</VRow>
|
|
||||||
|
|
||||||
<!-- 测试表单 -->
|
|
||||||
<VCard variant="outlined" class="mt-4">
|
|
||||||
<VCardTitle>测试表单(用于验证状态恢复)</VCardTitle>
|
|
||||||
<VCardText>
|
|
||||||
<VForm>
|
|
||||||
<VRow>
|
|
||||||
<VCol cols="12" md="6">
|
|
||||||
<VTextField
|
|
||||||
v-model="testForm.name"
|
|
||||||
label="姓名"
|
|
||||||
name="test-name"
|
|
||||||
persistent-hint
|
|
||||||
hint="切换应用后再回来,这个值应该被恢复"
|
|
||||||
/>
|
|
||||||
</VCol>
|
|
||||||
<VCol cols="12" md="6">
|
|
||||||
<VTextField
|
|
||||||
v-model="testForm.email"
|
|
||||||
label="邮箱"
|
|
||||||
name="test-email"
|
|
||||||
type="email"
|
|
||||||
/>
|
|
||||||
</VCol>
|
|
||||||
<VCol cols="12">
|
|
||||||
<VTextarea
|
|
||||||
v-model="testForm.message"
|
|
||||||
label="消息"
|
|
||||||
name="test-message"
|
|
||||||
rows="3"
|
|
||||||
/>
|
|
||||||
</VCol>
|
|
||||||
</VRow>
|
|
||||||
</VForm>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
|
|
||||||
<!-- 状态恢复提示 -->
|
|
||||||
<VAlert
|
|
||||||
v-if="isStateRestored"
|
|
||||||
type="success"
|
|
||||||
class="mt-4"
|
|
||||||
closable
|
|
||||||
@click:close="resetStateRestored"
|
|
||||||
>
|
|
||||||
<VIcon icon="mdi-check-circle" class="me-2" />
|
|
||||||
状态已成功恢复!滚动位置和表单数据应该已经恢复到之前的状态。
|
|
||||||
</VAlert>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { usePWAState, useGlobalPWAState } from '@/composables/usePWAState'
|
|
||||||
|
|
||||||
// 使用PWA状态管理
|
|
||||||
const {
|
|
||||||
isPWAMode,
|
|
||||||
isStateRestored,
|
|
||||||
stateRestoreCount,
|
|
||||||
lastRestoredState,
|
|
||||||
saveCurrentState,
|
|
||||||
checkStateRestore,
|
|
||||||
resetStateRestored,
|
|
||||||
isStateManagerAvailable
|
|
||||||
} = usePWAState()
|
|
||||||
|
|
||||||
// 使用全局PWA状态管理
|
|
||||||
const { clearStoredState } = useGlobalPWAState()
|
|
||||||
|
|
||||||
// 测试表单数据
|
|
||||||
const testForm = ref({
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
message: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听状态恢复事件,恢复表单数据
|
|
||||||
watch(isStateRestored, (restored) => {
|
|
||||||
if (restored && lastRestoredState.value?.appData?.formState) {
|
|
||||||
console.log('检测到状态恢复,尝试恢复表单数据')
|
|
||||||
// 这里可以添加更复杂的表单数据恢复逻辑
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
console.log('PWA状态演示组件已挂载')
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.v-card {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Reference in New Issue
Block a user