mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 15:10:56 +08:00
perf(app): add activity lifecycle management (#579)
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
APP_ACTIVITY_IDLE_DELAY_MS,
|
||||
APP_ACTIVITY_SUSPEND_DELAY_MS,
|
||||
AppActivityLifecycle,
|
||||
type AppActivityState,
|
||||
} from '@/utils/appActivityLifecycle'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('AppActivityLifecycle', () => {
|
||||
let lifecycle: AppActivityLifecycle
|
||||
let release: (() => void) | null
|
||||
let states: AppActivityState[]
|
||||
let focused: boolean
|
||||
let visibility: DocumentVisibilityState
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
focused = true
|
||||
visibility = 'visible'
|
||||
vi.spyOn(document, 'hasFocus').mockImplementation(() => focused)
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibility)
|
||||
lifecycle = new AppActivityLifecycle()
|
||||
states = []
|
||||
lifecycle.subscribe(state => states.push(state))
|
||||
release = lifecycle.acquire()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
release?.()
|
||||
release = null
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('enters idle after focused inactivity and wakes on interaction', () => {
|
||||
vi.advanceTimersByTime(APP_ACTIVITY_IDLE_DELAY_MS)
|
||||
|
||||
expect(lifecycle.getState()).toBe('idle')
|
||||
|
||||
document.dispatchEvent(new Event('pointerdown'))
|
||||
|
||||
expect(lifecycle.getState()).toBe('active')
|
||||
expect(states).toContain('idle')
|
||||
})
|
||||
|
||||
it('keeps the idle deadline relative to the latest high-frequency activity', () => {
|
||||
vi.advanceTimersByTime(APP_ACTIVITY_IDLE_DELAY_MS - 1_000)
|
||||
document.dispatchEvent(new Event('pointermove'))
|
||||
vi.advanceTimersByTime(1_000)
|
||||
|
||||
expect(lifecycle.getState()).toBe('active')
|
||||
|
||||
vi.advanceTimersByTime(APP_ACTIVITY_IDLE_DELAY_MS - 1_000)
|
||||
|
||||
expect(lifecycle.getState()).toBe('idle')
|
||||
})
|
||||
|
||||
it('moves through passive and suspended while the visible window is unfocused', () => {
|
||||
focused = false
|
||||
window.dispatchEvent(new Event('blur'))
|
||||
|
||||
expect(lifecycle.getState()).toBe('passive')
|
||||
|
||||
vi.advanceTimersByTime(APP_ACTIVITY_SUSPEND_DELAY_MS)
|
||||
|
||||
expect(lifecycle.getState()).toBe('suspended')
|
||||
|
||||
focused = true
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
|
||||
expect(lifecycle.getState()).toBe('active')
|
||||
})
|
||||
|
||||
it('suspends immediately while hidden and restores according to focus', () => {
|
||||
visibility = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
|
||||
expect(lifecycle.getState()).toBe('suspended')
|
||||
|
||||
visibility = 'visible'
|
||||
focused = false
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
|
||||
expect(lifecycle.getState()).toBe('passive')
|
||||
})
|
||||
|
||||
it('removes global listeners after the final consumer releases', () => {
|
||||
const removeDocumentListener = vi.spyOn(document, 'removeEventListener')
|
||||
const removeWindowListener = vi.spyOn(window, 'removeEventListener')
|
||||
|
||||
release?.()
|
||||
release = null
|
||||
|
||||
expect(removeDocumentListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function))
|
||||
expect(removeWindowListener).toHaveBeenCalledWith('blur', expect.any(Function))
|
||||
expect(removeWindowListener).toHaveBeenCalledWith('focus', expect.any(Function))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { commitPreloadedBackgroundRotation } from '@/utils/backgroundRotation'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(promiseResolve => {
|
||||
resolve = promiseResolve
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('commitPreloadedBackgroundRotation', () => {
|
||||
it('drops a successful preload when the rotation becomes inactive before completion', async () => {
|
||||
const preload = deferred<boolean>()
|
||||
const commit = vi.fn()
|
||||
let active = true
|
||||
const result = commitPreloadedBackgroundRotation({
|
||||
canCommit: () => active,
|
||||
commit,
|
||||
preload: () => preload.promise,
|
||||
})
|
||||
|
||||
active = false
|
||||
preload.resolve(true)
|
||||
|
||||
await expect(result).resolves.toBe(false)
|
||||
expect(commit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('commits a successful preload while the request remains current', async () => {
|
||||
const commit = vi.fn()
|
||||
|
||||
await expect(
|
||||
commitPreloadedBackgroundRotation({
|
||||
canCommit: () => true,
|
||||
commit,
|
||||
preload: async () => true,
|
||||
}),
|
||||
).resolves.toBe(true)
|
||||
expect(commit).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('drops an obsolete preload even when decorative motion becomes active again', async () => {
|
||||
const preload = deferred<boolean>()
|
||||
const commit = vi.fn()
|
||||
const requestVersion = 1
|
||||
let currentVersion = requestVersion
|
||||
const result = commitPreloadedBackgroundRotation({
|
||||
canCommit: () => requestVersion === currentVersion,
|
||||
commit,
|
||||
preload: () => preload.promise,
|
||||
})
|
||||
|
||||
currentVersion += 1
|
||||
preload.resolve(true)
|
||||
|
||||
await expect(result).resolves.toBe(false)
|
||||
expect(commit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 应用活动状态:active 允许交互与装饰动效;idle 表示前台静置;passive 表示短时失焦;
|
||||
* suspended 表示隐藏或持续失焦,需要释放高成本渲染资源。
|
||||
*/
|
||||
export type AppActivityState = 'active' | 'idle' | 'passive' | 'suspended'
|
||||
|
||||
export const APP_ACTIVITY_IDLE_DELAY_MS = 3 * 60_000
|
||||
export const APP_ACTIVITY_SUSPEND_DELAY_MS = 60_000
|
||||
|
||||
type AppActivityStateListener = (state: AppActivityState) => void
|
||||
|
||||
const activityEvents = ['keydown', 'pointerdown', 'pointermove', 'scroll', 'touchstart', 'wheel'] as const
|
||||
|
||||
/**
|
||||
* 统一管理页面活动状态,确保装饰动效、定时器和高成本渲染共享相同的前后台语义。
|
||||
*/
|
||||
export class AppActivityLifecycle {
|
||||
private state: AppActivityState = 'active'
|
||||
private listeners = new Set<AppActivityStateListener>()
|
||||
private idleTimer: number | null = null
|
||||
private suspendTimer: number | null = null
|
||||
private lastActivityAt = Date.now()
|
||||
private acquireCount = 0
|
||||
private started = false
|
||||
|
||||
private readonly handleActivity = () => {
|
||||
if (document.visibilityState !== 'visible' || !document.hasFocus()) return
|
||||
|
||||
this.lastActivityAt = Date.now()
|
||||
this.setState('active')
|
||||
this.scheduleIdle()
|
||||
}
|
||||
|
||||
private readonly handleBlur = () => {
|
||||
if (document.visibilityState === 'hidden') return
|
||||
|
||||
this.clearIdleTimer()
|
||||
this.setState('passive')
|
||||
this.scheduleSuspend()
|
||||
}
|
||||
|
||||
private readonly handleFocus = () => {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
|
||||
this.clearSuspendTimer()
|
||||
this.lastActivityAt = Date.now()
|
||||
this.setState('active')
|
||||
this.scheduleIdle()
|
||||
}
|
||||
|
||||
private readonly handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this.clearTimers()
|
||||
this.setState('suspended')
|
||||
return
|
||||
}
|
||||
|
||||
if (document.hasFocus()) {
|
||||
this.handleFocus()
|
||||
} else {
|
||||
this.handleBlur()
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前生命周期状态。 */
|
||||
getState() {
|
||||
return this.state
|
||||
}
|
||||
|
||||
/** 获取最近一次有效前台交互时间。 */
|
||||
getLastActivityAt() {
|
||||
return this.lastActivityAt
|
||||
}
|
||||
|
||||
/** 订阅状态变化;订阅时立即回放当前状态。 */
|
||||
subscribe(listener: AppActivityStateListener) {
|
||||
this.listeners.add(listener)
|
||||
listener(this.state)
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取生命周期使用权;首个消费者注册全局监听,最后一个消费者释放全部资源。
|
||||
*/
|
||||
acquire() {
|
||||
this.acquireCount += 1
|
||||
if (!this.started) this.start()
|
||||
|
||||
let released = false
|
||||
|
||||
return () => {
|
||||
if (released) return
|
||||
|
||||
released = true
|
||||
this.acquireCount = Math.max(0, this.acquireCount - 1)
|
||||
if (this.acquireCount === 0) this.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/** 供路由或测试路径显式登记一次有效前台活动。 */
|
||||
markActivity() {
|
||||
this.handleActivity()
|
||||
}
|
||||
|
||||
private start() {
|
||||
if (this.started) return
|
||||
|
||||
this.started = true
|
||||
activityEvents.forEach(event => document.addEventListener(event, this.handleActivity, { passive: true }))
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
window.addEventListener('blur', this.handleBlur)
|
||||
window.addEventListener('focus', this.handleFocus)
|
||||
|
||||
this.handleVisibilityChange()
|
||||
}
|
||||
|
||||
private stop() {
|
||||
if (!this.started) return
|
||||
|
||||
this.clearTimers()
|
||||
activityEvents.forEach(event => document.removeEventListener(event, this.handleActivity))
|
||||
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
window.removeEventListener('blur', this.handleBlur)
|
||||
window.removeEventListener('focus', this.handleFocus)
|
||||
this.started = false
|
||||
}
|
||||
|
||||
private setState(state: AppActivityState) {
|
||||
if (this.state === state) return
|
||||
|
||||
this.state = state
|
||||
this.listeners.forEach(listener => listener(state))
|
||||
}
|
||||
|
||||
private scheduleIdle() {
|
||||
if (this.idleTimer !== null) return
|
||||
|
||||
const elapsed = Date.now() - this.lastActivityAt
|
||||
const delay = Math.max(0, APP_ACTIVITY_IDLE_DELAY_MS - elapsed)
|
||||
|
||||
this.idleTimer = window.setTimeout(() => {
|
||||
this.idleTimer = null
|
||||
if (document.visibilityState !== 'visible' || !document.hasFocus()) return
|
||||
|
||||
const remaining = APP_ACTIVITY_IDLE_DELAY_MS - (Date.now() - this.lastActivityAt)
|
||||
if (remaining > 0) {
|
||||
this.scheduleIdle()
|
||||
return
|
||||
}
|
||||
|
||||
this.setState('idle')
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private scheduleSuspend() {
|
||||
this.clearSuspendTimer()
|
||||
this.suspendTimer = window.setTimeout(() => {
|
||||
this.suspendTimer = null
|
||||
if (document.visibilityState === 'visible' && !document.hasFocus()) this.setState('suspended')
|
||||
}, APP_ACTIVITY_SUSPEND_DELAY_MS)
|
||||
}
|
||||
|
||||
private clearIdleTimer() {
|
||||
if (this.idleTimer === null) return
|
||||
|
||||
window.clearTimeout(this.idleTimer)
|
||||
this.idleTimer = null
|
||||
}
|
||||
|
||||
private clearSuspendTimer() {
|
||||
if (this.suspendTimer === null) return
|
||||
|
||||
window.clearTimeout(this.suspendTimer)
|
||||
this.suspendTimer = null
|
||||
}
|
||||
|
||||
private clearTimers() {
|
||||
this.clearIdleTimer()
|
||||
this.clearSuspendTimer()
|
||||
}
|
||||
}
|
||||
|
||||
export const appActivityLifecycle = new AppActivityLifecycle()
|
||||
@@ -1,20 +1,24 @@
|
||||
import { appActivityLifecycle, type AppActivityState } from '@/utils/appActivityLifecycle'
|
||||
|
||||
/**
|
||||
* 后台管理器
|
||||
* 统一管理定时器和后台活动,减少iOS系统杀掉应用的概率
|
||||
*/
|
||||
export class BackgroundManager {
|
||||
private timers: Map<string, {
|
||||
callback: () => void
|
||||
interval: number
|
||||
timer: ReturnType<typeof setInterval> | null
|
||||
pausedAt?: number
|
||||
runInBackground?: boolean
|
||||
}> = new Map()
|
||||
|
||||
private readonly activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart', 'click']
|
||||
private readonly handleVisibilityChange = () => {
|
||||
private timers: Map<
|
||||
string,
|
||||
{
|
||||
callback: () => void
|
||||
interval: number
|
||||
timer: ReturnType<typeof setInterval> | null
|
||||
pausedAt?: number
|
||||
runInBackground?: boolean
|
||||
}
|
||||
> = new Map()
|
||||
|
||||
private readonly handleActivityStateChange = (state: AppActivityState) => {
|
||||
const wasBackground = this.isBackground
|
||||
this.isBackground = document.hidden
|
||||
this.isBackground = state === 'suspended'
|
||||
|
||||
if (this.isBackground && !wasBackground) {
|
||||
console.log('Background: 进入后台,暂停定时器')
|
||||
@@ -27,44 +31,30 @@ export class BackgroundManager {
|
||||
private readonly handleBeforeUnload = () => {
|
||||
this.destroy()
|
||||
}
|
||||
private readonly updateActivity = () => {
|
||||
this.lastActivityTime = Date.now()
|
||||
}
|
||||
|
||||
private isBackground = false
|
||||
private isDestroyed = false
|
||||
private lastActivityTime = Date.now()
|
||||
private isInitialized = false
|
||||
private releaseActivityLifecycle: (() => void) | null = null
|
||||
private stopActivityStateSubscription: (() => void) | null = null
|
||||
|
||||
private ensureInitialized() {
|
||||
if (this.isInitialized || this.isDestroyed) return
|
||||
|
||||
this.isInitialized = true
|
||||
this.isBackground = document.hidden
|
||||
this.setupVisibilityListener()
|
||||
this.setupActivityTracking()
|
||||
}
|
||||
|
||||
private setupVisibilityListener() {
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
this.releaseActivityLifecycle = appActivityLifecycle.acquire()
|
||||
this.stopActivityStateSubscription = appActivityLifecycle.subscribe(this.handleActivityStateChange)
|
||||
window.addEventListener('beforeunload', this.handleBeforeUnload)
|
||||
}
|
||||
|
||||
private setupActivityTracking() {
|
||||
// 按需跟踪用户活动,避免应用启动时就注册一批全局监听。
|
||||
this.activityEvents.forEach(event => {
|
||||
document.addEventListener(event, this.updateActivity, { passive: true })
|
||||
})
|
||||
}
|
||||
|
||||
private removeLifecycleListeners() {
|
||||
if (!this.isInitialized) return
|
||||
|
||||
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
window.removeEventListener('beforeunload', this.handleBeforeUnload)
|
||||
this.activityEvents.forEach(event => {
|
||||
document.removeEventListener(event, this.updateActivity)
|
||||
})
|
||||
this.stopActivityStateSubscription?.()
|
||||
this.stopActivityStateSubscription = null
|
||||
this.releaseActivityLifecycle?.()
|
||||
this.releaseActivityLifecycle = null
|
||||
this.isInitialized = false
|
||||
}
|
||||
|
||||
@@ -72,32 +62,32 @@ export class BackgroundManager {
|
||||
* 添加定时器
|
||||
*/
|
||||
addTimer(
|
||||
id: string,
|
||||
callback: () => void,
|
||||
interval: number,
|
||||
id: string,
|
||||
callback: () => void,
|
||||
interval: number,
|
||||
options: {
|
||||
runInBackground?: boolean
|
||||
skipInitialRun?: boolean
|
||||
} = {}
|
||||
} = {},
|
||||
) {
|
||||
const { runInBackground = false, skipInitialRun = false } = options
|
||||
|
||||
if (this.isDestroyed) return
|
||||
this.ensureInitialized()
|
||||
|
||||
|
||||
this.removeTimer(id)
|
||||
|
||||
|
||||
const timerConfig = {
|
||||
callback,
|
||||
interval,
|
||||
timer: null as ReturnType<typeof setInterval> | null,
|
||||
runInBackground
|
||||
runInBackground,
|
||||
}
|
||||
|
||||
// 创建定时器
|
||||
const wrappedCallback = () => {
|
||||
if (this.isDestroyed) return
|
||||
|
||||
|
||||
// 只有在前台运行,或者明确允许后台运行时才执行
|
||||
if (!this.isBackground || runInBackground) {
|
||||
try {
|
||||
@@ -159,7 +149,7 @@ export class BackgroundManager {
|
||||
if (!timerConfig.timer) {
|
||||
const wrappedCallback = () => {
|
||||
if (this.isDestroyed) return
|
||||
|
||||
|
||||
if (!this.isBackground || timerConfig.runInBackground) {
|
||||
try {
|
||||
timerConfig.callback()
|
||||
@@ -199,7 +189,7 @@ export class BackgroundManager {
|
||||
interval: config.interval,
|
||||
status: config.timer ? 'running' : 'paused',
|
||||
runInBackground: config.runInBackground || false,
|
||||
pausedAt: config.pausedAt
|
||||
pausedAt: config.pausedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -207,14 +197,14 @@ export class BackgroundManager {
|
||||
* 检查用户是否活跃
|
||||
*/
|
||||
isUserActive(maxInactiveTime = 5 * 60 * 1000): boolean {
|
||||
return Date.now() - this.lastActivityTime < maxInactiveTime
|
||||
return Date.now() - appActivityLifecycle.getLastActivityAt() < maxInactiveTime
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后活动时间
|
||||
*/
|
||||
getLastActivityTime(): number {
|
||||
return this.lastActivityTime
|
||||
return appActivityLifecycle.getLastActivityAt()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,8 +221,8 @@ export class BackgroundManager {
|
||||
isBackground: this.isBackground,
|
||||
isDestroyed: this.isDestroyed,
|
||||
timerCount: this.timers.size,
|
||||
lastActivityTime: this.lastActivityTime,
|
||||
isUserActive: this.isUserActive()
|
||||
lastActivityTime: appActivityLifecycle.getLastActivityAt(),
|
||||
isUserActive: this.isUserActive(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +231,7 @@ export class BackgroundManager {
|
||||
*/
|
||||
destroy() {
|
||||
this.isDestroyed = true
|
||||
|
||||
|
||||
// 清理所有定时器
|
||||
this.timers.forEach((timerConfig, id) => {
|
||||
if (timerConfig.timer) {
|
||||
@@ -266,13 +256,13 @@ export const backgroundManager = new BackgroundManager()
|
||||
* 便捷的定时器管理函数
|
||||
*/
|
||||
export function addBackgroundTimer(
|
||||
id: string,
|
||||
callback: () => void,
|
||||
interval: number,
|
||||
id: string,
|
||||
callback: () => void,
|
||||
interval: number,
|
||||
options?: {
|
||||
runInBackground?: boolean
|
||||
skipInitialRun?: boolean
|
||||
}
|
||||
},
|
||||
) {
|
||||
backgroundManager.addTimer(id, callback, interval, options)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
interface PreloadedBackgroundRotationOptions {
|
||||
/** 提交前重新判断当前生命周期和请求版本是否仍允许切换。 */
|
||||
canCommit: () => boolean
|
||||
/** 将已经完成预加载的壁纸切换为活动背景。 */
|
||||
commit: () => void
|
||||
/** 预加载目标壁纸,并以布尔值表示是否可安全显示。 */
|
||||
preload: () => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* 将壁纸预加载与最终提交分离,确保异步加载期间失效的轮换请求不会改变可见背景。
|
||||
*/
|
||||
export async function commitPreloadedBackgroundRotation(options: PreloadedBackgroundRotationOptions) {
|
||||
const succeeded = await options.preload()
|
||||
|
||||
if (!succeeded || !options.canCommit()) return false
|
||||
|
||||
options.commit()
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user