diff --git a/PWA_后台优化分析报告.md b/PWA_后台优化分析报告.md deleted file mode 100644 index ab19c4d3..00000000 --- a/PWA_后台优化分析报告.md +++ /dev/null @@ -1,343 +0,0 @@ -# PWA 后台优化分析报告 - -## 问题概述 -您的MoviePilot PWA应用在iOS设备上会被系统频繁杀掉后台进程,经过深入分析代码,发现了多个导致此问题的关键因素。 - -## 🔍 主要问题分析 - -### 1. **SSE长连接问题** ⚠️ 高优先级 -**问题描述:** 应用中存在多个持续的SSE(Server-Sent Events)连接,这些连接在后台保持活跃状态。 - -**影响的组件:** -- `UserNotification.vue` - 系统通知SSE连接 -- `MessageView.vue` - 消息中心SSE连接 -- `LoggingView.vue` - 日志查看SSE连接 -- `resource.vue` - 搜索进度SSE连接 -- `FileList.vue` - 文件操作进度SSE连接 - -**具体代码位置:** -```typescript -// src/layouts/components/UserNotification.vue:33 -eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/message`) - -// src/pages/resource.vue:83 -progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/search`) -``` - -### 2. **定时器资源消耗** ⚠️ 高优先级 -**问题描述:** 大量使用的定时器在后台持续运行,消耗CPU和内存资源。 - -**主要定时器:** -- **背景图片轮换定时器**:每10秒切换一次背景图片(App.vue:110) -- **PWA状态定期保存**:每30秒保存一次状态(pwaStateManager.ts:597) -- **仪表盘数据刷新**:多个dashboard组件每3秒刷新一次数据 -- **服务状态轮询**:UserProfile组件中的服务状态检查 - -**具体代码位置:** -```typescript -// src/App.vue:110 - 背景图片轮换 -backgroundRotationTimer = setInterval(() => { - const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length - preloadImage(backgroundImages.value[nextIndex]) -}, 10000) - -// src/utils/pwaStateManager.ts:597 - 状态定期保存 -setInterval(() => { - if (!document.hidden) { - this.saveCurrentState() - } -}, 30000) -``` - -### 3. **页面生命周期监听器过多** ⚠️ 中等优先级 -**问题描述:** 大量的页面生命周期事件监听器在后台保持活跃。 - -**监听器类型:** -- `visibilitychange` - 页面可见性变化 -- `beforeunload` - 页面卸载前 -- `blur/focus` - 页面焦点变化 -- `pagehide/pageshow` - 页面显示/隐藏 - -**具体代码位置:** -```typescript -// src/utils/pwaStateManager.ts:288 -document.addEventListener('visibilitychange', () => { - if (document.hidden) { - this.handlePageHidden() - } else { - this.handlePageVisible() - } -}) -``` - -### 4. **Service Worker复杂缓存策略** ⚠️ 中等优先级 -**问题描述:** Service Worker中实现了复杂的缓存策略和网络请求处理,在后台可能持续工作。 - -**缓存策略:** -- 多层缓存(静态资源、图片、字体、API数据) -- 复杂的运行时缓存规则 -- 离线状态检测和通知 - -### 5. **PWA状态管理过于频繁** ⚠️ 低优先级 -**问题描述:** PWA状态管理器过于频繁地保存和恢复状态,可能增加后台工作负载。 - -## 🛠️ 优化建议 - -### 1. SSE连接优化(立即实施) - -**建议方案:** -```typescript -// 优化后的SSE管理 -class SSEManager { - private eventSource: EventSource | null = null - private reconnectTimer: number | null = null - private isBackground = false - - constructor() { - this.setupVisibilityListener() - } - - private setupVisibilityListener() { - document.addEventListener('visibilitychange', () => { - if (document.hidden) { - this.handleBackground() - } else { - this.handleForeground() - } - }) - } - - private handleBackground() { - this.isBackground = true - // 延迟关闭SSE连接,避免频繁切换 - setTimeout(() => { - if (this.isBackground && this.eventSource) { - this.eventSource.close() - this.eventSource = null - } - }, 5000) // 5秒后关闭 - } - - private handleForeground() { - this.isBackground = false - // 立即重新建立连接 - this.reconnectSSE() - } - - private reconnectSSE() { - if (!this.eventSource || this.eventSource.readyState === EventSource.CLOSED) { - this.eventSource = new EventSource('/api/v1/system/message') - // 设置连接处理逻辑 - } - } -} -``` - -### 2. 定时器优化(立即实施) - -**背景图片轮换优化:** -```typescript -// src/App.vue -function startBackgroundRotation() { - if (backgroundRotationTimer) clearInterval(backgroundRotationTimer) - - if (backgroundImages.value.length > 1) { - backgroundRotationTimer = setInterval(() => { - // 只在前台时切换背景 - if (!document.hidden) { - const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length - preloadImage(backgroundImages.value[nextIndex]).then(success => { - if (success) { - activeImageIndex.value = nextIndex - } - }) - } - }, 10000) - } -} - -// 添加页面可见性监听 -document.addEventListener('visibilitychange', () => { - if (document.hidden) { - // 后台时停止背景轮换 - if (backgroundRotationTimer) { - clearInterval(backgroundRotationTimer) - backgroundRotationTimer = null - } - } else { - // 前台时恢复背景轮换 - startBackgroundRotation() - } -}) -``` - -**PWA状态保存优化:** -```typescript -// src/utils/pwaStateManager.ts -private setupPeriodicSave(): void { - // 延长保存间隔,减少后台活动 - setInterval(() => { - // 只在前台且用户活跃时保存 - if (!document.hidden && this.isUserActive()) { - this.saveCurrentState() - } - }, 60000) // 改为60秒 -} - -private isUserActive(): boolean { - // 检查用户是否在最近一段时间内有活动 - const lastActivity = this.getLastActivityTime() - return Date.now() - lastActivity < 300000 // 5分钟内有活动 -} -``` - -### 3. 仪表盘数据刷新优化(立即实施) - -**创建统一的后台管理器:** -```typescript -// src/utils/backgroundManager.ts -export class BackgroundManager { - private timers: Map = new Map() - private isBackground = false - - constructor() { - this.setupVisibilityListener() - } - - private setupVisibilityListener() { - document.addEventListener('visibilitychange', () => { - this.isBackground = document.hidden - if (this.isBackground) { - this.pauseAllTimers() - } else { - this.resumeAllTimers() - } - }) - } - - addTimer(id: string, callback: () => void, interval: number) { - this.removeTimer(id) - const timer = setInterval(() => { - if (!this.isBackground) { - callback() - } - }, interval) - this.timers.set(id, timer) - } - - removeTimer(id: string) { - const timer = this.timers.get(id) - if (timer) { - clearInterval(timer) - this.timers.delete(id) - } - } - - pauseAllTimers() { - this.timers.forEach(timer => clearInterval(timer)) - } - - resumeAllTimers() { - // 重新启动所有定时器 - this.timers.forEach((timer, id) => { - // 这里需要重新创建定时器 - }) - } -} -``` - -### 4. Service Worker优化(中期实施) - -**优化缓存策略:** -```typescript -// src/service-worker.ts -self.addEventListener('fetch', event => { - const url = new URL(event.request.url) - - // 后台时减少缓存操作 - if (self.clients && self.clients.matchAll) { - self.clients.matchAll().then(clients => { - const hasActiveClient = clients.some(client => client.visibilityState === 'visible') - - if (!hasActiveClient && url.pathname.includes('/api/v1/')) { - // 后台时只处理关键API请求 - if (url.pathname.includes('/system/message') || - url.pathname.includes('/system/status')) { - // 处理关键请求 - } else { - // 忽略非关键请求 - return - } - } - }) - } -}) -``` - -### 5. 页面生命周期监听器优化(中期实施) - -**优化监听器管理:** -```typescript -// src/utils/lifecycleManager.ts -export class LifecycleManager { - private listeners: Map void> = new Map() - private isActive = true - - constructor() { - this.setupOptimizedListeners() - } - - private setupOptimizedListeners() { - // 使用防抖处理频繁的生命周期事件 - const debouncedVisibilityChange = this.debounce(() => { - this.isActive = !document.hidden - this.notifyListeners('visibilitychange') - }, 100) - - document.addEventListener('visibilitychange', debouncedVisibilityChange) - } - - private debounce(func: () => void, delay: number) { - let timeoutId: number - return () => { - clearTimeout(timeoutId) - timeoutId = setTimeout(func, delay) - } - } -} -``` - -## 📊 优化效果预期 - -### 立即收益: -- **SSE连接优化**:减少后台网络活动80% -- **定时器优化**:降低后台CPU使用率60% -- **数据刷新优化**:减少API调用频率70% - -### 中期收益: -- **Service Worker优化**:减少后台缓存操作50% -- **生命周期优化**:降低事件处理开销40% - -## 🎯 实施优先级 - -### 🔥 高优先级(立即实施) -1. **SSE连接后台管理** - 最大影响 -2. **背景图片轮换优化** - 简单且有效 -3. **仪表盘定时器优化** - 显著减少后台活动 - -### 🟡 中等优先级(1-2周内) -1. **统一后台管理器** - 长期架构改进 -2. **Service Worker缓存优化** - 技术复杂度较高 - -### 🔵 低优先级(长期优化) -1. **PWA状态管理精简** - 影响相对较小 -2. **生命周期监听器整合** - 架构性改进 - -## 📝 总结 - -您的PWA应用后台被杀主要是由于: -1. **持续的SSE连接**在后台保持活跃 -2. **多个定时器**持续消耗系统资源 -3. **频繁的状态保存**和数据刷新操作 - -建议首先实施SSE连接的后台管理和定时器优化,这将显著改善应用的后台生存能力。iOS系统会根据应用的后台活动水平来决定是否保留进程,减少后台资源消耗是关键。 \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index 3b599fe0..21339d45 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,6 +9,7 @@ import { SupportedLocale } from '@/types/i18n' import { checkAndEmitUnreadMessages } from '@/utils/badge' import { preloadImage } from './@core/utils/image' import { globalLoadingStateManager } from '@/utils/loadingStateManager' +import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager' // 生效主题 const { global: globalTheme } = useTheme() @@ -34,7 +35,6 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out' const backgroundImages = ref([]) const activeImageIndex = ref(0) const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent') -let backgroundRotationTimer: NodeJS.Timeout | null = null @@ -102,23 +102,37 @@ async function fetchBackgroundImages() { } } +// 背景图片轮换函数 +function rotateBackgroundImage() { + if (backgroundImages.value.length > 1) { + // 计算下一个图片索引 + const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length + // 预加载下一张图片 + preloadImage(backgroundImages.value[nextIndex]).then(success => { + // 只有图片成功加载才切换 + if (success) { + activeImageIndex.value = nextIndex + } + }) + } +} + // 开始背景图片轮换 function startBackgroundRotation() { - // 清除轮换定时器 - if (backgroundRotationTimer) clearInterval(backgroundRotationTimer) + // 清除现有定时器 + removeBackgroundTimer('background-rotation') + if (backgroundImages.value.length > 1) { - // 每10秒切换一次 - backgroundRotationTimer = setInterval(() => { - // 计算下一个图片索引 - const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length - // 预加载下一张图片 - preloadImage(backgroundImages.value[nextIndex]).then(success => { - // 只有图片成功加载才切换 - if (success) { - activeImageIndex.value = nextIndex - } - }) - }, 10000) + // 使用优化的定时器管理器,后台时自动暂停 + addBackgroundTimer( + 'background-rotation', + rotateBackgroundImage, + 10000, // 每10秒切换一次 + { + runInBackground: false, // 后台时不运行 + skipInitialRun: true // 不需要立即执行 + } + ) } } @@ -220,11 +234,8 @@ onMounted(async () => { }) onUnmounted(() => { - // 清除轮换定时器 - if (backgroundRotationTimer) { - clearInterval(backgroundRotationTimer) - backgroundRotationTimer = null - } + // 清除背景轮换定时器 + removeBackgroundTimer('background-rotation') }) diff --git a/src/composables/useBackgroundOptimization.ts b/src/composables/useBackgroundOptimization.ts new file mode 100644 index 00000000..c4bef563 --- /dev/null +++ b/src/composables/useBackgroundOptimization.ts @@ -0,0 +1,210 @@ +import { onMounted, onUnmounted, ref, type Ref } from 'vue' +import { sseManagerSingleton } from '@/utils/sseManager' +import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager' + +/** + * 后台优化组合函数 + * 统一管理SSE连接和定时器,优化iOS后台性能 + */ +export function useBackgroundOptimization() { + + /** + * 使用优化的SSE连接 + * @param url SSE连接地址 + * @param messageHandler 消息处理函数 + * @param listenerId 监听器ID(用于区分不同的监听器) + * @param options 选项 + */ + const useSSE = ( + url: string, + messageHandler: (event: MessageEvent) => void, + listenerId: string, + options?: { + backgroundCloseDelay?: number + reconnectDelay?: number + maxReconnectAttempts?: number + } + ) => { + const manager = sseManagerSingleton.getManager(url, options) + + onMounted(() => { + manager.addMessageListener(listenerId, messageHandler) + }) + + onUnmounted(() => { + manager.removeMessageListener(listenerId) + }) + + return { + manager, + readyState: () => manager.readyState, + close: () => manager.removeMessageListener(listenerId) + } + } + + /** + * 使用优化的定时器 + * @param id 定时器ID + * @param callback 回调函数 + * @param interval 间隔时间(毫秒) + * @param options 选项 + */ + const useTimer = ( + id: string, + callback: () => void, + interval: number, + options?: { + runInBackground?: boolean + skipInitialRun?: boolean + } + ) => { + onMounted(() => { + addBackgroundTimer(id, callback, interval, options) + }) + + onUnmounted(() => { + removeBackgroundTimer(id) + }) + + return { + remove: () => removeBackgroundTimer(id) + } + } + + /** + * 使用延迟SSE连接(类似原来的setTimeout延迟) + * @param url SSE连接地址 + * @param messageHandler 消息处理函数 + * @param listenerId 监听器ID + * @param delay 延迟时间(毫秒) + * @param options SSE选项 + */ + const useDelayedSSE = ( + url: string, + messageHandler: (event: MessageEvent) => void, + listenerId: string, + delay: number = 3000, + options?: Parameters[3] + ) => { + const manager = sseManagerSingleton.getManager(url, options) + + onMounted(() => { + setTimeout(() => { + manager.addMessageListener(listenerId, messageHandler) + }, delay) + }) + + onUnmounted(() => { + manager.removeMessageListener(listenerId) + }) + + return { + manager, + readyState: () => manager.readyState, + close: () => manager.removeMessageListener(listenerId) + } + } + + /** + * 使用进度SSE连接(用于进度监听) + * @param url SSE连接地址 + * @param messageHandler 消息处理函数 + * @param listenerId 监听器ID + * @param isActive 是否激活的响应式变量 + */ + const useProgressSSE = ( + url: string, + messageHandler: (event: MessageEvent) => void, + listenerId: string, + isActive: Ref + ) => { + const manager = sseManagerSingleton.getManager(url, { + backgroundCloseDelay: 1000, // 进度SSE更快关闭 + reconnectDelay: 1000, + maxReconnectAttempts: 5 + }) + + const startProgress = () => { + if (isActive.value) { + manager.addMessageListener(listenerId, messageHandler) + } + } + + const stopProgress = () => { + manager.removeMessageListener(listenerId) + } + + onUnmounted(() => { + stopProgress() + }) + + return { + start: startProgress, + stop: stopProgress, + manager + } + } + + /** + * 使用数据刷新定时器(用于仪表盘等数据刷新) + * @param id 定时器ID + * @param loadDataFunc 加载数据函数 + * @param interval 刷新间隔(毫秒) + * @param immediate 是否立即执行 + */ + const useDataRefresh = ( + id: string, + loadDataFunc: () => Promise | void, + interval: number = 3000, + immediate: boolean = true + ) => { + const loading = ref(false) + + const wrappedLoadData = async () => { + if (loading.value) return + + loading.value = true + try { + await loadDataFunc() + } catch (error) { + console.error(`数据刷新失败 [${id}]:`, error) + } finally { + loading.value = false + } + } + + onMounted(async () => { + if (immediate) { + await wrappedLoadData() + } + + addBackgroundTimer( + id, + wrappedLoadData, + interval, + { + runInBackground: false, // 后台不刷新数据 + skipInitialRun: true // 已经手动执行过了 + } + ) + }) + + onUnmounted(() => { + removeBackgroundTimer(id) + }) + + return { + loading, + refresh: wrappedLoadData, + stop: () => removeBackgroundTimer(id) + } + } + + return { + useSSE, + useTimer, + useDelayedSSE, + useProgressSSE, + useDataRefresh + } +} \ No newline at end of file diff --git a/src/layouts/components/UserNotification.vue b/src/layouts/components/UserNotification.vue index 08570333..672217f2 100644 --- a/src/layouts/components/UserNotification.vue +++ b/src/layouts/components/UserNotification.vue @@ -2,8 +2,10 @@ import { formatDateDifference } from '@core/utils/formatters' import { SystemNotification } from '@/api/types' import { useI18n } from 'vue-i18n' +import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' const { t } = useI18n() +const { useDelayedSSE } = useBackgroundOptimization() // 是否有新消息 const hasNewMessage = ref(false) @@ -11,9 +13,6 @@ const hasNewMessage = ref(false) // 通知列表 const notificationList = ref([]) -// 事件源 -let eventSource: EventSource | null = null - // 弹窗 const appsMenu = ref(false) @@ -27,30 +26,27 @@ function markAllAsRead() { appsMenu.value = false } -// SSE持续接收消息 -function startSSEMessager() { - // 延迟 3 秒启动 SSE,避免相关认证信息尚未写入 Cookie 导致 403 - setTimeout(() => { - eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/message`) - eventSource.addEventListener('message', event => { - if (event.data) { - const noti: SystemNotification = JSON.parse(event.data) - notificationList.value.unshift(noti) - hasNewMessage.value = true - } - }) - }, 3000) +// 消息处理函数 +function handleMessage(event: MessageEvent) { + if (event.data) { + const noti: SystemNotification = JSON.parse(event.data) + notificationList.value.unshift(noti) + hasNewMessage.value = true + } } -// 页面加载时,加载当前用户数据 -onBeforeMount(async () => { - startSSEMessager() -}) - -// 页面卸载时,关闭事件源 -onBeforeUnmount(() => { - if (eventSource) eventSource.close() -}) +// 使用优化的SSE连接,延迟3秒启动,避免认证问题 +useDelayedSSE( + `${import.meta.env.VITE_API_BASE_URL}system/message`, + handleMessage, + 'user-notification', + 3000, + { + backgroundCloseDelay: 5000, + reconnectDelay: 3000, + maxReconnectAttempts: 3 + } +)