// ============================================================ // 启动屏单例状态 // // 设计要点: // 1. 状态 + 事件双通道: 组件既可 watch visible,也可监听 'splash:hide' 事件 // 解决 uni.$emit 在组件挂载前发出就丢失的问题 (memory lesson) // 2. 软隐藏 (softHide): 标记应该 hide, 等组件 mount 后再真正 hide // 解决 onLaunch 跑完 + hide() 早于 page mount 的时序问题 // 3. 防重入: show()/hide() 多次调用安全,内部用 guard // 4. 兜底超时: 默认 8s 未关闭则强制 hide,防止卡死 // 5. 平台隔离: APP-PLUS 有原生 splash,这里直接返回 false // 6. 跨平台兼容: 同步状态可被 Vue 组件直接 watch // ============================================================ // APP-PLUS 直接返回 false (有原生启动屏) function needSplash() { // #ifdef APP-PLUS return false; // #endif // #ifndef APP-PLUS return true; // #endif } // H5 平台特殊处理: Lang.boot() 在 main.js 中 await, 此时 Vue 还没挂载, // 组件无法渲染, 内部降级为 uni.showLoading 做兜底 (虽然样式较丑但至少有遮罩) function useNativeLoading() { // #ifdef H5 return true; // #endif // #ifndef H5 return false; // #endif } const DEFAULT_TIMEOUT = 8000; // 总兜底超时 const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间 const PAGE_READY_GRACE_MS = 2000; // 软隐藏后等页面就绪的宽限时间 // 启动期阶段定义 (id → { percent, label }) // percent: 阶段完成的百分比 (0-100) // label: 阶段显示文案 (i18n key 由 splash 组件消费, 也可直接用最终文案) const STAGES = { boot: { percent: 5, label: 'Loading...' }, lang: { percent: 30, label: 'Loading language...' }, theme: { percent: 50, label: 'Applying theme...' }, override: { percent: 65, label: 'Loading theme tokens...' }, config: { percent: 80, label: 'Fetching configuration...' }, privacy: { percent: 95, label: 'Preparing privacy dialog...' }, done: { percent: 100, label: 'Ready' }, }; class SplashState { constructor() { this._visible = false; this._timeoutId = null; this._listeners = []; this._shownAt = 0; // 软隐藏相关 this._pendingHide = null; // 软隐藏请求的 opts, 等待组件 mount this._hasMounted = false; // 是否有 splash 组件 mount 过 this._mountGraceId = null; // 软隐藏兜底定时器 (等 mount) // 页面就绪等待 this._pageReady = false; // 页面内容是否已就绪 (由首页通知) this._pageReadyGraceId = null; // 页面就绪兜底定时器 // 进度上报 this._stage = 'boot'; this._percent = 0; this._stageSet = new Set(['boot']); // 已报告阶段 // 进度节流: 16ms 内的多次 reportProgress 合并成一次 emit // 避免多通道并发时, splash 组件 re-render 风暴 this._pendingProgress = null; // 待 emit 的最新进度 this._flushTimer = null; // 节流定时器 } show(opts = {}) { if (!needSplash()) return; if (this._visible) return; // 防重入 this._visible = true; this._shownAt = Date.now(); this._pendingHide = null; this._hasMounted = false; this._pageReady = false; this._stage = 'boot'; // 初始化时直接显示 'boot' 阶段的 5%, 而不是 0% // 避免首帧出现"进度条 0%"的视觉突兀 this._percent = (STAGES.boot && STAGES.boot.percent) || 5; this._stageSet = new Set(['boot']); this._pendingProgress = null; if (this._flushTimer) { clearTimeout(this._flushTimer); this._flushTimer = null; } if (this._mountGraceId) { clearTimeout(this._mountGraceId); this._mountGraceId = null; } if (this._pageReadyGraceId) { clearTimeout(this._pageReadyGraceId); this._pageReadyGraceId = null; } this._emit('splash:show'); // H5 降级: 走系统 loading (Vue 还没挂载,组件层无法渲染) // 注意: mask 设为 false。原 mask:true 会在 H5 注入 .uni-mask 全屏透明蒙层, // 配合 z-index:99999 的 Vue splash 一起把首页触摸全部拦截,导致 H5 // 首屏在 boot 期间无法滚动。Vue splash 组件本身已带白色背景提供视觉遮罩, // 这里只要 loading 文字提示,不需要拦截触摸。 if (useNativeLoading()) { try { uni.showLoading({ title: opts.title || '加载中...', mask: false }); } catch (e) {} } const timeout = opts.timeout || DEFAULT_TIMEOUT; if (this._timeoutId) { clearTimeout(this._timeoutId); this._timeoutId = null; } this._timeoutId = setTimeout(() => { // 兜底: 超时强制关闭,防止卡死 (走硬隐藏路径, 不等组件) if (this._visible) { console.warn('[splash-state] auto-hide after timeout', timeout); this._doHide({ reason: 'timeout' }); } }, timeout); } /** * 隐藏 splash * @param {Object} opts * @param {string} opts.reason - 隐藏原因 * - 'boot-complete': 软隐藏, 等组件 mount 后再真关 (默认) * - 'timeout': 硬隐藏, 立即关 (兜底用) * - 'manual': 硬隐藏, 立即关 * @param {boolean} [opts.soft] - 强制走软隐藏 * @param {string} [opts.mode] - 离开动画模式 * - 'default': 默认 480ms 动画 * - 'fast': 80ms 短动画, 用于隐私弹窗等需要立即接管视觉的场景, 避免"双遮罩"感 */ hide(opts = {}) { const reason = opts.reason || 'manual'; // boot-complete 默认走软隐藏, 避免时序问题 const useSoft = opts.soft === true || (opts.soft !== false && reason === 'boot-complete'); const mode = opts.mode === 'fast' ? 'fast' : 'default'; if (useSoft) { this._softHide({ reason, mode }); } else { this._doHide({ reason, mode }); } } /** * 软隐藏: 标记应该 hide, 等组件 mount + 页面就绪 * 如果组件 1s 内没 mount, 兜底自动 hide * 如果页面 2s 内没就绪, 兜底自动 hide */ _softHide(opts) { if (!this._visible) return; // 已经隐藏了 this._pendingHide = opts; // 如果组件已经 mount 且页面已就绪, 立即隐藏 if (this._hasMounted && this._pageReady) { this._flushPendingHide(); return; } // 兜底: 1s 内没 mount, 强制隐藏 if (this._mountGraceId) clearTimeout(this._mountGraceId); this._mountGraceId = setTimeout(() => { if (this._pendingHide) { this._flushPendingHide(); } }, MOUNT_GRACE_MS); } /** * 真正触发软隐藏 * 如果页面还未就绪, 等待页面就绪后再 hide */ _flushPendingHide() { if (this._mountGraceId) { clearTimeout(this._mountGraceId); this._mountGraceId = null; } // 如果页面还未就绪, 等待页面就绪 (保留 _pendingHide, 等页面就绪后再处理) if (!this._pageReady) { // 兜底: 2s 内没就绪, 强制隐藏 if (this._pageReadyGraceId) clearTimeout(this._pageReadyGraceId); this._pageReadyGraceId = setTimeout(() => { if (this._visible && this._pendingHide) { console.warn('[splash-state] auto-hide after page-ready timeout'); this._doHide(this._pendingHide || { reason: 'page-ready-timeout' }); } }, PAGE_READY_GRACE_MS); return; } const opts = this._pendingHide; this._pendingHide = null; this._doHide(opts || { reason: 'mount-completed' }); } /** * 实际隐藏 (硬路径) */ _doHide(opts) { if (!this._visible) return; // 防重入 if (this._timeoutId) { clearTimeout(this._timeoutId); this._timeoutId = null; } if (this._mountGraceId) { clearTimeout(this._mountGraceId); this._mountGraceId = null; } if (this._pageReadyGraceId) { clearTimeout(this._pageReadyGraceId); this._pageReadyGraceId = null; } this._pendingHide = null; this._visible = false; // H5 降级对应的关闭 if (useNativeLoading()) { try { uni.hideLoading(); } catch (e) {} } this._emit('splash:hide', { reason: opts.reason || 'manual', mode: opts.mode || 'default', }); } /** * 组件 mount 后通知 state 模块 * - 标记 _hasMounted * - 如果有 _pendingHide, 触发真关 * - H5 降级场景: 不再立即关掉原生 loading, 等待页面就绪后再关 */ notifyMounted() { if (this._hasMounted) return; this._hasMounted = true; if (this._pendingHide) { setTimeout(() => this._flushPendingHide(), 50); } } /** * 页面内容就绪后通知 state 模块 * - 标记 _pageReady * - 如果有 _pendingHide, 触发真关 * 调用时机: 首页 diy-group 渲染完成后调用 */ notifyPageReady() { if (this._pageReady) return; this._pageReady = true; if (this._pageReadyGraceId) { clearTimeout(this._pageReadyGraceId); this._pageReadyGraceId = null; } if (this._pendingHide) { // 给页面一帧时间完成渲染, 再隐藏 setTimeout(() => this._flushPendingHide(), 50); } } /** * 组件 unmount 时通知 (用于 _hasMounted 状态管理) * 目前保留 _hasMounted=true, 因为一般只有 tab 页有 splash 组件, 不会动态增减 */ notifyUnmounted() { // 不重置 _hasMounted, 因为软隐藏的 _pendingHide 处理不依赖 unmount } isShow() { return this._visible; } hasPendingHide() { return this._pendingHide != null; } elapsed() { if (!this._visible) return 0; return Date.now() - this._shownAt; } on(event, fn) { if (!this._listeners[event]) this._listeners[event] = []; this._listeners[event].push(fn); } off(event, fn) { if (!this._listeners[event]) return; this._listeners[event] = this._listeners[event].filter(f => f !== fn); } _emit(event, payload) { const arr = this._listeners[event]; if (!arr || !arr.length) return; for (let i = 0; i < arr.length; i++) { try { arr[i](payload); } catch (e) { console.warn('[splash-state] listener err', e); } } } /** * 上报启动期进度 * 节流: 16ms 内的多次 reportProgress 合并成 1 次 emit, 避免 splash re-render 风暴 * 取较大值, 不允许后退 (即使 throttle 合并也不会让进度条后退) * @param {string} stageId - 阶段 id (STAGES 中定义) * 'boot' | 'lang' | 'theme' | 'override' | 'config' | 'privacy' | 'done' * 或自定义阶段 id (传 percent + label) * @param {Object} [meta] - { percent?, label?, source?: 'channel'|'timeout'|'error' } * 阶段完成时 percent 应该是该阶段的"完成态"百分比 */ reportProgress(stageId, meta) { const def = STAGES[stageId] let percent, label if (def) { percent = (meta && typeof meta.percent === 'number') ? meta.percent : def.percent label = (meta && meta.label) || def.label } else { // 自定义阶段 percent = (meta && typeof meta.percent === 'number') ? meta.percent : this._percent label = (meta && meta.label) || stageId } // 取较大值, 不允许后退 if (percent < this._percent) percent = this._percent this._percent = percent this._stage = stageId if (!this._stageSet.has(stageId)) this._stageSet.add(stageId) // 节流 emit: 16ms 内的多次调用合并为 1 次 // 内部状态立即更新 (不延迟), 但 emit 推迟 // 注意: percent 不允许后退的逻辑已在上方处理, 所以 throttle 合并不会丢进度 const payload = { stage: stageId, label, percent, source: (meta && meta.source) || 'channel', at: Date.now(), } this._pendingProgress = payload if (this._flushTimer) return // 已有 pending, 等 timer flush this._flushTimer = setTimeout(() => { this._flushTimer = null if (this._pendingProgress) { const p = this._pendingProgress this._pendingProgress = null this._emit('splash:progress', p) } }, 16) } /** * 监听进度上报 * callback({ stage, label, percent, source, at }) * * 注意: 与 on() 不同, onProgress 是单例 listener (多个组件时只最后一个生效) * 设计意图: splash 进度条 UI 在系统中只有一个渲染者, 不需要多播 */ onProgress(callback) { this._listeners['splash:progress'] = [callback] } /** * 解绑进度上报 (清空 listener) */ offProgress() { this._listeners['splash:progress'] = [] } /** * 读取当前进度 (供组件初始化) * 同时返回已解析的 label, 避免组件再维护一份 STAGE_LABEL */ getProgress() { const def = STAGES[this._stage] return { stage: this._stage, percent: this._percent, label: def ? def.label : this._stage, } } /** * 暴露 STAGES 表 (供 splash 组件直接消费, 避免重复维护一份) */ getStageDef(stageId) { return STAGES[stageId] } /** * 立即刷新挂起的进度更新 * - reportProgress 默认 16ms throttle (1 帧), 避免连续触发 splash 组件多次 re-render * - 业务侧若要"立即"刷新 (例如 splash 即将隐藏), 可调本方法 * - 不存在 pending 时 noop */ flushProgress() { if (this._pendingProgress) { const p = this._pendingProgress this._pendingProgress = null if (this._flushTimer) { clearTimeout(this._flushTimer) this._flushTimer = null } this._emit('splash:progress', p) } } } const splashState = new SplashState(); export default splashState; export { splashState };