// 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,超分后的清晰动图)。 // 组件可能在 attached 之后才拿到 src(父页面接口异步返回),用 observer 兜底重新加载。 src: { type: String, value: '', observer(value) { if (value) this._load(); } } }, data: { failed: false, loading: true }, lifetimes: { attached() { this._aborted = false; this._timer = null; this._reader = null; this._ctx = null; this._rgba = null; this._frame = 0; this._loadedSrc = ''; if (this.data.src) this._load(); }, detached() { // 仅 detached 时彻底停止;不要把 _aborted 留给异步回调误判 this._aborted = true; if (this._timer) { clearTimeout(this._timer); this._timer = null; } } }, methods: { // 清除上一次可能的循环定时器(不动 _aborted 标志,否则会误杀正在进行的异步请求) _clearTimer() { if (this._timer) { clearTimeout(this._timer); this._timer = null; } }, _load() { const raw = this.data.src; if (!raw || this._aborted) return; // 击穿微信 GET 缓存:服务器静态文件被覆盖新版本(如重做超分动图)后, // 旧图仍可能被微信请求缓存导致客户端显示旧图。加固定 bust 参数强制重新下载。 const sep = raw.indexOf('?') >= 0 ? '&' : '?'; const src = raw + sep + 'cb=' + config.MEDIA_CACHE_BUST; // 同一 src(含 bust)且已解码完成则跳过,避免 attached 与 observer 重复触发 if (this._loadedSrc === src && this._reader) return; this._loadedSrc = src; this._clearTimer(); this.setData({ loading: true, failed: 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 }); } }); }, // 等待 Canvas 2D 节点就绪(自定义组件 attached 时可能还没布局好,最多重试 15 次) _start(attempt = 0) { if (this._aborted || !this._reader) return; const q = wx.createSelectorQuery().in(this); q.select('#gifCanvas').fields({ node: true, size: true }).exec((rs) => { if (this._aborted) return; if (!rs || !rs[0] || !rs[0].node) { if (attempt < 15) setTimeout(() => this._start(attempt + 1), 60); return; } const canvas = rs[0].node; const ctx = canvas.getContext('2d'); const w = this._reader.width; const h = this._reader.height; // 画布内部分辨率 = GIF 原始分辨率(已超分到 720);CSS 负责缩放到容器 canvas.width = w; canvas.height = h; this._ctx = ctx; this._rgba = new Uint8ClampedArray(w * h * 4); this._frame = 0; this._render(); }); }, _render() { if (this._aborted || !this._reader || !this._ctx) return; const r = this._reader; 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); img.data.set(this._rgba); this._ctx.putImageData(img, 0, 0); } catch (e) { console.error('[gif-player] frame fail', e); this.setData({ failed: true, loading: false }); return; } // 首帧渲染完成,关闭加载提示 if (this.data.loading) this.setData({ loading: false }); const info = r.frameInfo(this._frame); // omggif 的 delay 单位是 1/100 秒,需 ×10 转毫秒;缺失时给 100ms const delay = (info && info.delay) ? info.delay * 10 : 100; this._frame = (this._frame + 1) % n; // 单帧 GIF 只画一次;多帧无限循环(健身演示需要持续播放)。最小 40ms 避免极端快帧 if (n > 1) this._timer = setTimeout(() => this._render(), Math.max(40, delay)); } } });