diff --git a/miniprogram/components/gif-player/gif-player.js b/miniprogram/components/gif-player/gif-player.js index 9b23726..7abe780 100644 --- a/miniprogram/components/gif-player/gif-player.js +++ b/miniprogram/components/gif-player/gif-player.js @@ -1,8 +1,105 @@ // gif-player:微信 组件不播放 GIF 动画(只渲染首帧), // 这里用 Canvas 2D + 纯 JS 解码器(omggif)逐帧绘制,实现真正的动图播放。 +// +// 两个关键修复: +// 1) 循环:循环回到首帧时重置累积缓冲区,避免上一轮 disposal 残留导致画面冻结/错乱。 +// 2) 缓存:同一 GIF 下载/解码一次后,同会话用内存缓存秒开,跨会话用本地文件缓存, +// 不再每次进入详情页都重新下载。 const { GifReader } = require('../../utils/vendor/omggif.js'); const config = require('../../config.js'); +// ---------- 模块级 GIF 缓存 ---------- +// 内存缓存(同会话秒开) + 本地文件缓存(跨会话持久) 双保险。 +const _mem = new Map(); // url -> { buf, reader } 按插入顺序充当 LRU +const _inflight = new Map(); // url -> Promise,去重并发请求,防止同一 GIF 重复下载 +const _MAX_MEM = 30; // 内存最多保留 30 个解码结果 +const _MAX_LOCAL = 12; // 本地文件最多保留 12 个(约数十 MB,避免超配额) +const _localIndex = []; // 本地文件访问顺序(用于 LRU 淘汰) + +function _hash(s) { + let h = 0; + for (let i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) >>> 0; + return h.toString(36); +} +function _localPath(url) { + // 含 cb 参数天然区分媒体版本,换超分动图后旧缓存不会误命中 + return `${wx.env.USER_DATA_PATH}/gifcache_${_hash(url)}.bin`; +} +function _readLocal(url) { + return new Promise((resolve) => { + wx.getFileSystemManager().readFile({ + filePath: _localPath(url), + success: (r) => resolve(r.data), + fail: () => resolve(null) + }); + }); +} +function _writeLocal(url) { + const entry = _mem.get(url); + if (!entry) return; + try { + wx.getFileSystemManager().writeFileSync(_localPath(url), entry.buf); + const p = _localPath(url); + const i = _localIndex.indexOf(p); + if (i >= 0) _localIndex.splice(i, 1); + _localIndex.push(p); + while (_localIndex.length > _MAX_LOCAL) { + const old = _localIndex.shift(); + try { wx.getFileSystemManager().unlinkSync(old); } catch (e) { /* ignore */ } + } + } catch (e) { /* 配额不足等写入失败,忽略:内存缓存仍生效 */ } +} +function _touchMem(url) { + const v = _mem.get(url); + if (v) { _mem.delete(url); _mem.set(url, v); } // 移到末尾 = 最近使用 +} +function _evictMem() { + while (_mem.size > _MAX_MEM) { + const k = _mem.keys().next().value; + _mem.delete(k); + } +} +function loadGif(url) { + if (_mem.has(url)) { + _touchMem(url); + return Promise.resolve(_mem.get(url)); + } + if (_inflight.has(url)) return _inflight.get(url); + const p = (async () => { + // 先试本地文件缓存(跨会话) + const local = await _readLocal(url); + if (local) { + const reader = new GifReader(new Uint8Array(local)); + const entry = { buf: local, reader }; + _mem.set(url, entry); + _touchMem(url); + _evictMem(); + return entry; + } + // 未命中则下载 + const buf = await new Promise((resolve, reject) => { + wx.request({ + url, + responseType: 'arraybuffer', + success: (res) => (res.statusCode >= 200 && res.statusCode < 300 + ? resolve(res.data) + : reject(new Error('HTTP ' + res.statusCode))), + fail: reject + }); + }); + const reader = new GifReader(new Uint8Array(buf)); + const entry = { buf, reader }; + _mem.set(url, entry); + _touchMem(url); + _evictMem(); + _writeLocal(url); // 落本地,下次跨会话可直接读 + return entry; + })(); + _inflight.set(url, p); + p.finally(() => _inflight.delete(url)); + return p; +} + Component({ properties: { // GIF 地址(通常是 ex.gifUrl,超分后的清晰动图)。 @@ -53,22 +150,16 @@ Component({ this._loadedSrc = src; this._clearTimer(); this.setData({ loading: true, failed: false }); - wx.request({ - url: src, - responseType: 'arraybuffer', - success: (res) => { - // detached 之后才允许放弃 - if (this._aborted) return; - try { - this._reader = new GifReader(new Uint8Array(res.data)); - this._start(); - } catch (e) { - console.error('[gif-player] decode fail', e); - this.setData({ failed: true, loading: false }); - } - }, - fail: () => { - if (!this._aborted) this.setData({ failed: true, loading: false }); + loadGif(src).then((entry) => { + // detached 或 src 已变化则放弃 + if (this._aborted || this._loadedSrc !== src) return; + this._reader = entry.reader; + this._frame = 0; + this._start(); + }).catch((e) => { + if (!this._aborted) { + console.error('[gif-player] load fail', e); + this.setData({ failed: true, loading: false }); } }); }, @@ -101,6 +192,11 @@ Component({ const n = r.numFrames(); if (!n) { this.setData({ failed: true, loading: false }); return; } try { + // 循环回到首帧时重置累积缓冲区,清除上一轮 disposal 残留, + // 否则画面会在播放一轮后冻结/错乱。 + if (this._frame === 0) { + this._rgba = new Uint8ClampedArray(r.width * r.height * 4); + } // decodeAndBlitFrameRGBA 按 GIF 的 disposal 规则在 rgba 上累积绘制 r.decodeAndBlitFrameRGBA(this._frame, this._rgba); const img = this._ctx.createImageData(r.width, r.height); @@ -117,7 +213,7 @@ Component({ // omggif 的 delay 单位是 1/100 秒,需 ×10 转毫秒;缺失时给 100ms const delay = (info && info.delay) ? info.delay * 10 : 100; this._frame = (this._frame + 1) % n; - // 单帧 GIF 只画一次;多帧才循环。最小 40ms 避免极端快帧 + // 单帧 GIF 只画一次;多帧无限循环(健身演示需要持续播放)。最小 40ms 避免极端快帧 if (n > 1) this._timer = setTimeout(() => this._render(), Math.max(40, delay)); } } diff --git a/miniprogram/config.js b/miniprogram/config.js index 1f250f0..758bf61 100644 --- a/miniprogram/config.js +++ b/miniprogram/config.js @@ -6,5 +6,7 @@ module.exports = { BASE_URL: 'http://192.227.237.8:3001', // 媒体资源缓存击穿:服务器静态文件(动图/图片)被覆盖新版本后,旧版可能被微信请求缓存, // 导致客户端仍显示旧图。换媒体(如重做超分动图)后修改此值即可强制全量客户端重新下载。 - MEDIA_CACHE_BUST: '20260724' + // 2026-08-02:服务器端 1324 个动图经 gifsicle -O3 --lossy=80 压缩(1.8G→856M,分辨率/帧数不变), + // 改此值让客户端重新拉取压缩后的小体积版本。 + MEDIA_CACHE_BUST: '20260802' };