425 lines
12 KiB
JavaScript
425 lines
12 KiB
JavaScript
/**
|
||
* 进度数据管理模块
|
||
* 处理进度数据的获取、缓存和轮询
|
||
*
|
||
* 数据降级策略:
|
||
* 1. 优先从API获取最新数据
|
||
* 2. API失败时使用缓存数据(5分钟内有效)
|
||
* 3. 缓存数据超过30秒标记为"可能过时"
|
||
* 4. 无缓存时返回默认数据
|
||
* 5. 通过UI指示器而非toast显示数据状态
|
||
*/
|
||
|
||
import { POLLING_CONFIG, CACHE_CONFIG } from './performance-config'
|
||
import performanceMonitor from './performance-monitor'
|
||
import { getActivityProgressApi } from './api.js'
|
||
|
||
// 开发配置
|
||
const DEV_CONFIG = {
|
||
USE_MOCK_DATA: false,
|
||
MOCK_INITIAL_PROGRESS: 100,
|
||
MOCK_INCREMENT_MIN: 50,
|
||
MOCK_INCREMENT_MAX: 100,
|
||
MOCK_TARGET: 1000,
|
||
MOCK_NETWORK_DELAY: 200
|
||
}
|
||
|
||
// 存储当前模拟进度值
|
||
let currentMockProgress = DEV_CONFIG.MOCK_INITIAL_PROGRESS
|
||
|
||
// 缓存配置
|
||
const CACHE_EXPIRY_TIME = CACHE_CONFIG.PROGRESS_DATA_EXPIRY
|
||
const POLLING_INTERVAL_MIN = POLLING_CONFIG.MIN_INTERVAL
|
||
const POLLING_INTERVAL_MAX = POLLING_CONFIG.MAX_INTERVAL
|
||
const MAX_RETRY_COUNT = 3
|
||
|
||
/**
|
||
* 计算下次轮询间隔,使用标准指数退避 + 均匀抖动
|
||
*
|
||
* 正常情况(attempt=0):[MIN, MAX] 之间随机
|
||
* 每次失败 attempt+1,间隔翻倍,上限 MAX_RETRY_INTERVAL(60s)
|
||
* 均匀抖动避免多客户端同时重试的惊群效应
|
||
*
|
||
* @param {number} attempt - 连续失败次数
|
||
* @returns {number} 轮询间隔(毫秒)
|
||
*/
|
||
function getRandomPollingInterval(attempt = 0) {
|
||
const backoff = POLLING_INTERVAL_MIN * Math.pow(2, attempt)
|
||
const capped = Math.min(backoff, POLLING_CONFIG.MAX_RETRY_INTERVAL)
|
||
// 均匀抖动:在 [capped * 0.75, capped * 1.25] 范围内随机,最小不低于 MIN_INTERVAL
|
||
const jitter = capped * 0.75 + Math.random() * capped * 0.5
|
||
return Math.max(Math.floor(jitter), POLLING_INTERVAL_MIN)
|
||
}
|
||
|
||
/**
|
||
* 生成客户端唯一标识(uni-app 兼容版本)
|
||
* @returns {number} 客户端偏移量(0-5000ms)
|
||
*/
|
||
function getClientOffset() {
|
||
const globalData = getApp().globalData
|
||
|
||
if (!globalData.__clientOffset) {
|
||
const systemInfo = uni.getSystemInfoSync()
|
||
const factors = [
|
||
systemInfo.platform?.length || 0,
|
||
systemInfo.screenWidth || 0,
|
||
systemInfo.screenHeight || 0,
|
||
systemInfo.model?.length || 0,
|
||
systemInfo.system?.length || 0,
|
||
new Date().getTimezoneOffset()
|
||
]
|
||
const hash = factors.reduce((acc, val) => ((acc << 5) - acc) + val, 0)
|
||
globalData.__clientOffset = Math.abs(hash % 5000)
|
||
}
|
||
|
||
return globalData.__clientOffset
|
||
}
|
||
|
||
/**
|
||
* 进度数据管理器类
|
||
*/
|
||
export class ProgressManager {
|
||
constructor(activityId) {
|
||
this.activityId = activityId
|
||
this.pollingTimer = null
|
||
this._offsetTimer = null
|
||
this.isPolling = false
|
||
this.retryCount = 0
|
||
this.listeners = new Set()
|
||
this.isPageVisible = true
|
||
this.visibilityListener = null
|
||
}
|
||
|
||
addListener(listener) {
|
||
this.listeners.add(listener)
|
||
}
|
||
|
||
removeListener(listener) {
|
||
this.listeners.delete(listener)
|
||
}
|
||
|
||
notifyListeners(data) {
|
||
this.listeners.forEach(listener => {
|
||
try {
|
||
listener(data)
|
||
} catch (error) {
|
||
console.error('监听器执行错误:', error)
|
||
}
|
||
})
|
||
}
|
||
|
||
getCacheKey() {
|
||
return `progress_${this.activityId}`
|
||
}
|
||
|
||
getCachedData() {
|
||
try {
|
||
const cached = uni.getStorageSync(this.getCacheKey())
|
||
if (!cached || !cached.timestamp) return null
|
||
|
||
const cacheAge = Date.now() - cached.timestamp
|
||
if (cacheAge > CACHE_EXPIRY_TIME) {
|
||
uni.removeStorageSync(this.getCacheKey())
|
||
return null
|
||
}
|
||
|
||
return {
|
||
current: cached.current || 0,
|
||
target: cached.target || 1000,
|
||
isFromCache: true,
|
||
isStale: cacheAge > CACHE_CONFIG.STALE_DATA_THRESHOLD,
|
||
cacheAge,
|
||
lastUpdated: cached.timestamp
|
||
}
|
||
} catch (error) {
|
||
console.error('获取缓存数据失败:', error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
cacheData(data) {
|
||
try {
|
||
uni.setStorageSync(this.getCacheKey(), {
|
||
current: data.current || 0,
|
||
target: data.target || 1000,
|
||
timestamp: Date.now()
|
||
})
|
||
} catch (error) {
|
||
console.error('缓存数据失败:', error)
|
||
}
|
||
}
|
||
|
||
async fetchFromAPI() {
|
||
const startTime = Date.now()
|
||
|
||
try {
|
||
if (DEV_CONFIG.USE_MOCK_DATA) {
|
||
await new Promise(resolve => setTimeout(resolve, DEV_CONFIG.MOCK_NETWORK_DELAY))
|
||
|
||
const increment = Math.floor(
|
||
Math.random() * (DEV_CONFIG.MOCK_INCREMENT_MAX - DEV_CONFIG.MOCK_INCREMENT_MIN + 1)
|
||
) + DEV_CONFIG.MOCK_INCREMENT_MIN
|
||
|
||
currentMockProgress = Math.min(currentMockProgress + increment, DEV_CONFIG.MOCK_TARGET)
|
||
|
||
const progressData = {
|
||
current: currentMockProgress,
|
||
target: DEV_CONFIG.MOCK_TARGET,
|
||
isFromCache: false,
|
||
isStale: false
|
||
}
|
||
|
||
this.cacheData(progressData)
|
||
this.retryCount = 0
|
||
performanceMonitor.recordPollingRequest(true, Date.now() - startTime)
|
||
return progressData
|
||
}
|
||
|
||
const response = await getActivityProgressApi(this.activityId)
|
||
|
||
if (response.code !== 200 || !response.data) {
|
||
throw new Error(response.message || 'API返回数据异常')
|
||
}
|
||
|
||
const progressData = {
|
||
current: response.data.current_progress || 0,
|
||
target: response.data.target_progress || 1000,
|
||
isFromCache: false,
|
||
isStale: false
|
||
}
|
||
|
||
this.cacheData(progressData)
|
||
this.retryCount = 0
|
||
performanceMonitor.recordPollingRequest(true, Date.now() - startTime)
|
||
return progressData
|
||
|
||
} catch (error) {
|
||
console.error('API获取进度数据失败:', error)
|
||
performanceMonitor.recordPollingRequest(false, Date.now() - startTime)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
async getProgressData() {
|
||
try {
|
||
const data = await this.fetchFromAPI()
|
||
this.notifyListeners(data)
|
||
return data
|
||
} catch (error) {
|
||
console.warn('API获取失败,尝试使用缓存数据:', error.message)
|
||
|
||
const cachedData = this.getCachedData()
|
||
if (cachedData) {
|
||
console.log('使用缓存数据,缓存年龄:', cachedData.cacheAge, 'ms')
|
||
this.notifyListeners(cachedData)
|
||
return cachedData
|
||
}
|
||
|
||
const defaultData = {
|
||
current: 0,
|
||
target: 1000,
|
||
isFromCache: false,
|
||
isStale: false,
|
||
isDefault: true
|
||
}
|
||
this.notifyListeners(defaultData)
|
||
return defaultData
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 调度下一次轮询(统一入口,消除重复逻辑)
|
||
*/
|
||
_scheduleNextPoll() {
|
||
const interval = Math.max(getRandomPollingInterval(this.retryCount), POLLING_INTERVAL_MIN)
|
||
console.log(`[轮询] 下次轮询间隔: ${(interval / 1000).toFixed(1)}秒 (重试次数: ${this.retryCount})`)
|
||
|
||
this.pollingTimer = setTimeout(async () => {
|
||
if (!this.isPolling || !this.isPageVisible) {
|
||
console.log('[轮询] 状态变化,停止轮询')
|
||
return
|
||
}
|
||
|
||
try {
|
||
await this.getProgressData()
|
||
this.retryCount = 0
|
||
} catch (error) {
|
||
console.error('[轮询] 轮询失败:', error)
|
||
this.retryCount = Math.min(this.retryCount + 1, MAX_RETRY_COUNT)
|
||
}
|
||
|
||
if (this.isPolling && this.isPageVisible) {
|
||
this._scheduleNextPoll()
|
||
} else {
|
||
console.log('[轮询] 页面状态变化,停止调度下一次轮询')
|
||
}
|
||
}, interval)
|
||
}
|
||
|
||
startPolling() {
|
||
if (this.isPolling) {
|
||
console.log('[轮询] 轮询已在运行中,跳过启动')
|
||
return
|
||
}
|
||
|
||
this.isPolling = true
|
||
this.isPageVisible = true
|
||
|
||
const clientOffset = getClientOffset()
|
||
console.log(`[轮询] 准备启动,客户端偏移: ${(clientOffset / 1000).toFixed(1)}秒`)
|
||
|
||
this._offsetTimer = setTimeout(() => {
|
||
this._offsetTimer = null
|
||
if (!this.isPolling || !this.isPageVisible) {
|
||
console.log('[轮询] 页面已隐藏或轮询已停止,取消首次请求')
|
||
return
|
||
}
|
||
|
||
this.getProgressData().catch(err => {
|
||
console.error('[轮询] 首次轮询失败:', err)
|
||
this.retryCount++
|
||
})
|
||
|
||
this._scheduleNextPoll()
|
||
}, clientOffset)
|
||
|
||
console.log(`[轮询] 已启动,最小间隔: ${POLLING_INTERVAL_MIN / 1000}秒`)
|
||
}
|
||
|
||
stopPolling() {
|
||
if (this._offsetTimer) {
|
||
clearTimeout(this._offsetTimer)
|
||
this._offsetTimer = null
|
||
}
|
||
if (this.pollingTimer) {
|
||
clearTimeout(this.pollingTimer)
|
||
this.pollingTimer = null
|
||
}
|
||
this.isPolling = false
|
||
this.isPageVisible = false
|
||
console.log('[轮询] 进度数据轮询已停止')
|
||
}
|
||
|
||
pausePolling() {
|
||
console.log('[轮询] 页面隐藏,暂停轮询')
|
||
this.isPageVisible = false
|
||
if (this._offsetTimer) {
|
||
clearTimeout(this._offsetTimer)
|
||
this._offsetTimer = null
|
||
}
|
||
if (this.pollingTimer) {
|
||
clearTimeout(this.pollingTimer)
|
||
this.pollingTimer = null
|
||
console.log('[轮询] 已清除轮询定时器')
|
||
}
|
||
}
|
||
|
||
resumePolling() {
|
||
console.log('[轮询] 页面显示,恢复轮询')
|
||
this.isPageVisible = true
|
||
|
||
// 仅在轮询标志为真且没有任何定时器在运行时才恢复,防止竞态
|
||
if (this.isPolling && !this.pollingTimer && !this._offsetTimer) {
|
||
console.log('[轮询] 重新启动轮询调度')
|
||
|
||
this.getProgressData().catch(err => {
|
||
console.error('[轮询] 恢复后首次轮询失败:', err)
|
||
this.retryCount++
|
||
})
|
||
|
||
this._scheduleNextPoll()
|
||
}
|
||
}
|
||
|
||
async refresh() {
|
||
console.log('手动刷新进度数据')
|
||
// 暂停当前轮询循环,拉取最新数据后恢复,避免 stop+start 引入 clientOffset 延迟
|
||
const wasPolling = this.isPolling
|
||
if (wasPolling) {
|
||
this.pausePolling()
|
||
}
|
||
const data = await this.getProgressData()
|
||
if (wasPolling) {
|
||
this.isPolling = true
|
||
this.isPageVisible = true
|
||
this._scheduleNextPoll()
|
||
}
|
||
return data
|
||
}
|
||
|
||
destroy() {
|
||
console.log('[轮询] 销毁进度管理器,清理所有资源')
|
||
this.stopPolling()
|
||
this.listeners.clear()
|
||
console.log('[轮询] 进度管理器已销毁')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建进度管理器实例
|
||
* @param {string} activityId - 活动ID
|
||
* @returns {ProgressManager}
|
||
*/
|
||
export function createProgressManager(activityId) {
|
||
return new ProgressManager(activityId)
|
||
}
|
||
|
||
// { activityId -> { manager: ProgressManager, refCount: number } }
|
||
const managerCache = new Map()
|
||
|
||
/**
|
||
* 获取或创建进度管理器实例,内部维护引用计数。
|
||
* 每次调用 getProgressManager 必须对应一次 releaseProgressManager,
|
||
* 引用计数归零时自动销毁实例并从缓存中移除。
|
||
* @param {string} activityId - 活动ID
|
||
* @returns {ProgressManager}
|
||
*/
|
||
export function getProgressManager(activityId) {
|
||
if (!managerCache.has(activityId)) {
|
||
managerCache.set(activityId, {
|
||
manager: new ProgressManager(activityId),
|
||
refCount: 0
|
||
})
|
||
}
|
||
const entry = managerCache.get(activityId)
|
||
entry.refCount++
|
||
return entry.manager
|
||
}
|
||
|
||
/**
|
||
* 释放对进度管理器的引用。
|
||
* 引用计数归零时自动销毁实例并清理缓存,防止内存泄漏。
|
||
* @param {string} activityId - 活动ID
|
||
*/
|
||
export function releaseProgressManager(activityId) {
|
||
const entry = managerCache.get(activityId)
|
||
if (!entry) return
|
||
|
||
entry.refCount--
|
||
if (entry.refCount <= 0) {
|
||
entry.manager.destroy()
|
||
managerCache.delete(activityId)
|
||
console.log(`[缓存] 进度管理器已释放: ${activityId}`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 强制清理指定活动的进度管理器(忽略引用计数)
|
||
* @param {string} activityId - 活动ID
|
||
*/
|
||
export function cleanupProgressManager(activityId) {
|
||
const entry = managerCache.get(activityId)
|
||
if (entry) {
|
||
entry.manager.destroy()
|
||
managerCache.delete(activityId)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理所有进度管理器
|
||
*/
|
||
export function cleanupAllProgressManagers() {
|
||
managerCache.forEach(entry => entry.manager.destroy())
|
||
managerCache.clear()
|
||
}
|