☰
微信小程序 Canvas 2D 翻书效果全解析:几何、交互与性能优化
2026/9/30 4:39:59 网站建设 项目流程

想让微信小程序里出现真实的翻书效果,大多数人第一次都会低估它。我刚接到这个需求的时候,以为无非就是给容器加个 CSS 3D 旋转,半小时收工。真正上手才发现,手指拖动时的纸张弯曲、背面的透光、折痕处的阴影、松手后的回弹判定,再加上微信小程序特有的双线程通信模型,这些东西叠在一起,足够让人熬夜改好几版。这篇内容就把我完整走过的路径摊开讲一遍:翻书效果在小程序里有哪些实现路线、每条路的成本与天花板在哪、Canvas 2D 版本的几何推导和绘制顺序怎么落地、拖拽手感怎么调、真机上最容易在哪些地方翻车。不管你是做电子绘本、校园服务手册、婚礼邀请函里的相册页,还是单纯想给自己的小程序加一个有点质感的展示页,都能直接拿去用。

1. 先想清楚:小程序里做翻书,到底难在哪

1.1 三条技术路线摆在一起比一比

在小程序里做翻书,市面上能走的路其实只有三条,我把它们放在一起对比,是因为选型没选好,后面写多少代码都是白费。

第一条是CSS 3D 方案。用transform: rotateY()配合perspective和preserve-3d,把每一页当成一个绝对定位的层,绕左边或者中缝旋转。这条路的最大好处是简单到离谱,几十行样式就能跑起来,而且渲染完全交给视图层合成器,性能开销极小,帧率基本稳在 60。代价是它的翻页是"硬纸板式"的——纸永远是平的,转过去就是转过去,没有弯曲、没有卷边、没有折痕阴影。对于婚礼邀请函、简单相册这类追求干净利落的效果,它完全够用;但如果你要做绘本那种纸张质感,它就差得远。

第二条是Canvas 2D 方案。把整个书本区域画在一张 canvas 上,用手写几何算法模拟纸张折叠,每帧重绘。效果上限最高,纸张可以弯曲、可以有折痕、可以有正反面内容、可以有光源投影。代价是开发成本高、需要自己处理触摸映射、需要自己算动画帧,而且在小程序里 canvas 属于原生组件,层级问题一堆。我最后选的就是这条路,具体的几何推导放在第 2、3 节。

第三条是混合方案。静止状态用普通 WXML 节点铺图,手指按下时把当前页的内容截图或者直接用 canvas 接管,翻页动画交给 canvas,松手结束后再切回静态节点。听上去很聪明,实际上是两头的好处和两头的麻烦都占全了——截图有延迟,切换到 canvas 时会有一次明显的闪烁,还要维护两套布局逻辑。我试过一版,最后放弃了,因为真机上那个闪烁藏不住。

方案效果上限开发成本真机性能风险适合场景
CSS 3D中等,平面翻转低低邀请函、简单相册、卡片展示
Canvas 2D高,可弯曲可投影高中高,需自己做帧调度电子绘本、画册、仿真书
静态 + Canvas 混合高极高高,切换有闪烁一般不建议

1.2 为什么我最后选了 Canvas 2D 打底

决定性的因素是"纸张必须是弯的"。真实的书页在翻动过程中,靠近折痕的位置会产生一个明显的弧面,纸面上还会因为受光角度变化出现一条从亮到暗的渐变带,折痕根部的阴影最深。这个视觉信息一旦缺失,大脑立刻就能判断出"这是假的"。CSS 3D 给不了这个,所以只能上 Canvas。

但我也不是一开始就全量用 Canvas。我给自己划了一条线:只有正在被翻动的那一页走 Canvas,其余所有静止页面继续用普通节点。这样既拿到了纸张弯曲的效果,又避免了整本书重绘带来的性能塌陷。具体做法是页面容器分三层——底层是前一页的静态图,中间层是后一页的静态图,最上面盖一张全屏 canvas,只在手指按下到动画结束这段时间可见。手指抬起、动画归位之后,立刻把 canvas 隐藏掉,把数据层的页码切换过去,重新渲染静态节点。

这个思路说穿了就是"用 Canvas 补一个短暂的过渡帧",而不是"用 Canvas 渲染整本书"。项目做完回头看,这个取舍是正确的,因为真正需要高质量渲染的只有过渡的那 300 到 600 毫秒。

1.3 那些被低估的隐性成本

选型之后还有一堆隐性成本,这些在动手前基本想不到。

第一是资源准备。翻书效果要好看,每一页的正反两面都得有素材。如果你只有一张单面图,翻过去之后背面就是空白或者镜像的同一张图,一眼假。我的处理方式是正面用原图,背面用原图加一层半透明的白色蒙版加模糊,模拟纸张背面的透印感,成本低但效果能接受。

第二是尺寸适配。手机屏幕比例五花八门,书本的宽高比定死了会变形,不定死又会有黑边。我的做法是设置一个基准宽高比,然后按contain方式在可用区域里居中,剩下两边用背景色填充。这个决定直接影响后面所有几何计算里的 W 和 H,所以一定要在做算法之前定下来。

第三是顶部导航栏高度。如果用了自定义导航栏,书本区域的顶部起点得手动算:状态栏高度加胶囊按钮区域高度。这个值不同机型不一样,用wx.getWindowInfo()拿statusBarHeight,再用wx.getMenuButtonBoundingClientRect()拿胶囊的位置反推,才能算准。算错了书本会被顶掉一截,视觉上非常明显。

2. 把翻页动作拆成几何题:纸张是怎么"折"起来的

2.1 折痕线的推导

翻页这件事,用几何语言描述就是一次平面反射。假设当前翻的是书本的右半页,右上角的角点为 A,手指按在页面上某点 P。纸张从 A 角被掀起来往左走,最终 A 点会落到 P 点所在的位置——这是最符合直觉的模型:折痕线就是线段 AP 的垂直平分线。你拿一张真实的纸在桌上试一下就明白了,把纸角折到某个位置,折出来的那一条折痕,恰好垂直于原来角点和落点之间的连线,并且过它们的中点。

这个模型的好处是它可以随着手指连续变化。手指往左推,P 点往左移,折痕线跟着往左扫,被折起的三角形面积变大;手指抬起来往右回,折痕线又扫回去,纸张就自动展平了。整个过程不需要任何关键帧,天然是连续的。

![](data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%221%22%20height%3D%221%22%3E%3C%2Fsvg%3E)

上面这个思路需要几个量:A 点坐标、P 点坐标、中点 M、以及折痕的法向量。写出来就是下面这段,很短,但它是整个效果的数学地基。

// W、H 为书本渲染区域的宽高(逻辑像素) // px、py 为手指在画布坐标系里的位置 function buildFold(W, H, px, py) { // 右半页的右上角,作为初始被掀起的角点 const A = { x: W, y: 0 }; const P = { x: px, y: py }; const dx = P.x - A.x; const dy = P.y - A.y; const len = Math.sqrt(dx * dx + dy * dy) || 1; // 单位法向量:折痕垂直于 AP,所以 AP 方向就是折痕的法向 const nx = dx / len; const ny = dy / len; // AP 的中点,折痕必然经过这里 const M = { x: (A.x + P.x) / 2, y: (A.y + P.y) / 2 }; return { A, P, M, nx, ny, len }; }

有一点必须提前说清楚:手指不能任意乱放。如果 P 点跑到中缝左边去了,或者几乎贴着 A 点,折痕线会切出一个非常奇怪的形状,绘制出来就是一团乱。我的处理是给 P 点做边界钳制——横向限制在[W * 0.5 + 40, W - 20]之间,纵向限制在[20, H - 20]之间,超出范围就取边界值。这样折痕永远和矩形的上边、右边相交,切出来的永远是一个稳定的三角形,绘制逻辑可以大幅简化。

2.2 正面、背面、投影三层绘制顺序

几何算完,接下来是绘制顺序,这一块顺序错了效果就完全不对。我用的是从下往上叠的顺序:

第一层,右半页的静止部分。也就是当前页还没有被掀起的那一大块。先把整张当前页图按正常方式画在右半区域,等会儿被折起的三角形区域会被后面的绘制覆盖掉,所以这里不用特意挖洞。

第二层,折起纸张的背面。把被折起的三角形区域做镜像变换,落到 P 点那一侧。现在要在这个镜像区域里绘制"纸张背面"的内容。按我前面说的资源策略,这里画的是下一页左半页的内容,再叠一层半透明白色模拟透印。

第三层,折起纸张的正面。等一下,不是应该先画正面再画背面吗?在视觉顺序上,当纸张被掀起来的时候,我们看到的确实是它的背面(纸的反面朝向观察者)。但为了让折起的部分看起来有厚度和立体感,我实际的做法是:在镜像区域里先绘制背面内容,再叠加一层从折痕处向外扩散的渐变阴影,最后才在最上层用globalCompositeOperation做一次柔化的边缘描边。这是从多次试错里磨出来的一点点经验,直接照搬"先正后反"的顺序,出来的纸片是没有立体感的。

第四层,折痕处的投影。这一层最容易被忽略,但它是决定"像不像"的关键。折痕根部应该有最深的阴影,向外逐渐变浅。实现方式是沿折痕线的法线方向创建一个线性渐变,起点在折痕处用rgba(0,0,0,0.45),终点在距离约 30 到 50 像素的位置用rgba(0,0,0,0),然后把这条渐变带裁剪到折起区域内部绘制。

下面这个表是我调试时反复对比出来的参数区间,可以直接当起点用:

参数建议取值说明
折痕阴影起始透明度0.35 ~ 0.50低于 0.35 几乎看不见,高于 0.5 发脏
折痕阴影扩散宽度页面宽度的 6% ~ 10%太窄像刀切,太宽像雾
背面透印蒙版透明度0.55 ~ 0.75越高越像薄纸,越低越像卡纸
纸张边缘描边宽度1 ~ 2 逻辑像素再粗就会显得像贴纸

2.3 翻页进度与页码的映射

翻页过程中有一个很容易被写错的地方:什么时候算翻过去了。如果手指只拖了一点点就松手,纸张应该回弹;拖过某个阈值松手,纸张应该继续走完剩下的动画。这个阈值不能简单用"横向位移超过一半"来判断,因为手指可能是在页面下方一个很小的范围内来回移动,横向位移很小,但视觉上已经能明显看出纸被折起来了。

我的判定用了两个条件做与运算:横向进度超过 0.45,或者手指位置已经越过中缝线 60 像素以上,满足任意一个就判定为"翻过去"。同时在松手瞬间记录手指的速度,如果速度足够快,即使没有达到阈值也直接判定翻页成功——这就是所谓的手势惯性,用户快速一划就能翻页,不需要拖到指定位置。

进度值本身用一个 0 到 1 的浮点数表示,0 是完整展平在右半页,1 是完整翻到左半页。这个值不是直接由手指横坐标除以宽度得来的,而是做了两段映射:手指从右边缘往左移动的第 70% 区间对应进度的 0 到 0.85,剩下的 30% 对应 0.85 到 1。这么做的原因是,翻页的视觉变化在末尾阶段非常剧烈,如果线性映射,前段会显得迟钝、后段会显得突然加速,体感很怪。改成非线性之后,整个动作就顺了。

3. Canvas 2D 落地实现

3.1 初始化:尺寸、DPR 与 rpx 换算

小程序里的 canvas 2D 有几个必须记住的细节,第一个就是DPR 处理。CSS 里的宽高是逻辑像素,canvas 的width和height属性是物理像素,两者差着一个设备像素比。如果不处理,在高端机上画出来的字和线会糊成一团。

const dpr = wx.getWindowInfo().pixelRatio; wx.createSelectorQuery() .select('#bookCanvas') .fields({ node: true, size: true }) .exec((res) => { const canvas = res[0].node; const ctx = canvas.getContext('2d'); // 关键:先把画布的物理尺寸放大到 dpr 倍 canvas.width = res[0].width * dpr; canvas.height = res[0].height * dpr; // 再用缩放把坐标系拉回逻辑像素,后面所有计算都按逻辑像素来 ctx.scale(dpr, dpr); this.canvas = canvas; this.ctx = ctx; this.W = res[0].width; this.H = res[0].height; });

ctx.scale(dpr, dpr)这一步非常关键,它让后面所有的绘制代码都能直接用逻辑像素思考,不用到处乘 dpr。代价是只要调用一次ctx.scale,整个上下文的坐标系就变了,如果后面还有ctx.setTransform之类的操作,一定要小心别把它覆盖掉。我踩过一次这个坑:在绘制折起纸张时用了ctx.setTransform来重置,结果把缩放也重置没了,画面直接放大一倍,排查了半天。

另外,type="2d"这个属性不能忘。不带这个属性的<canvas>是老版接口,用的是wx.createCanvasContext,那套 API 有自己的一堆问题,绘制性能也差得多,能不用就不用。

3.2 图片资源预处理与双面缓存

图片不要每次都从零加载再绘制,那样每帧都会触发一次图片解码,帧率直接掉到个位数。正确的做法是在小程序生命周期开始时就预加载所有页面素材,缓存成 canvas 可用的 Image 对象。

function loadImage(canvas, src) { return new Promise((resolve, reject) => { const img = canvas.createImage(); img.onload = () => resolve(img); img.onerror = reject; img.src = src; }); } // 页面初始化阶段 async function preload(canvas, urls) { const cache = {}; for (let i = 0; i < urls.length; i++) { try { cache[i] = await loadImage(canvas, urls[i]); } catch (e) { // 单张失败不能拖垮整本书,给个占位即可 cache[i] = null; } } return cache; }

注意这里必须用canvas.createImage(),而不是全局的new Image()。小程序环境里没有标准 DOM,全局 Image 构造出来的对象 canvas 认不出来,绘制时会静默失败——不报错,但画不出来,非常难排查。

还有一个容易忽略的点:不要一次性把整本书的图全加载进内存。一本 30 页的画册,每张图按 1080×1440 的 RGBA 算,一张就是 6MB 左右,30 张接近 200MB,iOS 上很容易触发内存告警直接闪退。我的做法是只保留当前页前后各 3 页的缓存,滑动超过这个范围就把远处的图片引用置空,让垃圾回收把内存释放掉。

3.3 裁剪与镜像:把一页画两遍

现在进入核心绘制环节。整个绘制函数大概长这样,我把注释写得详细一点,方便你对着改。

function render(progress, px, py) { const { ctx, W, H, cache, currentPage } = this; ctx.clearRect(0, 0, W, H); // 1. 右半页:后一页的左半内容(翻过去之后会露出来的那半张) drawHalfImage(ctx, cache[currentPage + 1], 'left', 0, 0, W, H); // 2. 右半页:当前页的右半内容 drawHalfImage(ctx, cache[currentPage], 'right', 0, 0, W, H); if (progress <= 0.001) return; const fold = buildFold(W, H, px, py); // 折痕与矩形上边的交点 const s1 = -fold.M.y / fold.nx; const c1 = { x: fold.M.x - s1 * fold.ny, y: fold.M.y + s1 * fold.nx }; // 折痕与矩形右边的交点 const s2 = (W - fold.M.x) / fold.ny; const c2 = { x: fold.M.x - s2 * fold.ny, y: fold.M.y + s2 * fold.nx }; // 3. 计算折起三角形的镜像落点 const r1 = reflect(c1, fold.M, fold.nx, fold.ny); const r2 = reflect(c2, fold.M, fold.nx, fold.ny); const rA = reflect(fold.A, fold.M, fold.nx, fold.ny); ctx.save(); // 裁剪到镜像后的三角形 ctx.beginPath(); ctx.moveTo(rA.x, rA.y); ctx.lineTo(r1.x, r1.y); ctx.lineTo(r2.x, r2.y); ctx.closePath(); ctx.clip(); // 用反射矩阵做变换:I - 2nn^T,平移项为 2(M·n)n const k = 2 * (fold.M.x * fold.nx + fold.M.y * fold.ny); ctx.transform( 1 - 2 * fold.nx * fold.nx, -2 * fold.nx * fold.ny, -2 * fold.nx * fold.ny, 1 - 2 * fold.ny * fold.ny, k * fold.nx, k * fold.ny ); // 绘制背面内容(下一页的左半页) const back = cache[currentPage + 1]; if (back) { ctx.globalAlpha = 0.75; ctx.drawImage(back, 0, 0, W / 2, H, 0, 0, W / 2, H); ctx.globalAlpha = 1; } // 叠一层白色,模拟纸张透印 ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillRect(0, 0, W, H); ctx.restore(); // 4. 折痕处的阴影带 drawCreaseShadow(ctx, fold, W, H); }

reflect函数就是前面推导的反射公式,写成独立函数会清爽很多:

function reflect(q, M, nx, ny) { const t = (q.x - M.x) * nx + (q.y - M.y) * ny; return { x: q.x - 2 * t * nx, y: q.y - 2 * t * ny }; }

这里有个细节值得单独说:ctx.transform传入的六个参数对应的是矩阵的[a, b, c, d, e, f],也就是[m11, m12, m21, m22, dx, dy]。反射矩阵的线性部分是对称的,所以b和c相等,这不是笔误。平移部分之所以是2(M·n)n,是因为反射公式展开之后,常数项正好是这个形式。如果不想推公式,也可以用"平移到原点、旋转、翻转 Y、旋转回来、平移回去"这五步组合实现,效果完全一样,但代码会长一些。

3.4 手指拖拽到翻页进度的映射

触摸事件的绑定要加在 canvas 上,注意 canvas 2D 支持bindtouchstart这类常规事件,不需要像老接口那样用wx.createCanvasContext配套的触摸事件。

onTouchStart(e) { const t = e.touches[0]; this.startX = t.x; this.startY = t.y; this.startTime = Date.now(); this.dragging = true; this.canvasNode.style.display = 'block'; } onTouchMove(e) { if (!this.dragging) return; const t = e.touches[0]; // 先判断是不是横向意图,避免和页面纵向滚动打架 const dx = Math.abs(t.x - this.startX); const dy = Math.abs(t.y - this.startY); if (!this.locked && dx < dy * 1.2 && dx < 12) return; this.locked = true; const px = clamp(t.x, this.W * 0.5 + 40, this.W - 20); const py = clamp(t.y, 20, this.H - 20); const progress = this.toProgress(t.x); this.renderFrame(progress, px, py); } onTouchEnd(e) { if (!this.dragging) return; this.dragging = false; const elapsed = Date.now() - this.startTime; const moved = this.lastX - this.startX; const velocity = elapsed > 0 ? moved / elapsed : 0; // 快速轻扫直接判定翻页 const quickFlip = Math.abs(velocity) > 0.6; if (this.progress > 0.45 || quickFlip) { this.animateTo(1); } else { this.animateTo(0); } }

那个lock判定的逻辑要多说两句。小程序的 canvas 触摸事件和页面滚动是两套东西,如果用户在 canvas 上纵向滑动想滚页面,而你的代码把所有 touchmove 都吃掉去做翻页,体验会非常糟糕:用户滚不动页面,还以为卡死了。加一个方向判定,横向位移明显大于纵向、或者横向已经超过 12 像素了,才锁定到翻页模式,其余情况直接放行给页面滚动。这个 12 像素的阈值是试出来的,太小会误判,太大用户会觉得迟钝。

4. 交互手感调优

4.1 为什么不要在 touchmove 里疯狂 setData

微信小程序默认是双线程模型,逻辑层跑在 JSCore 里,视图层跑在 WebView 里,中间的setData是一次跨线程的序列化和通信。这个开销在数据量小的时候感觉不到,但touchmove一秒能触发 60 次以上,每次 setData 传一个几百字节的对象,累计起来就非常可观了。表现就是手指拖动时画面明显滞后,眼睛能看出画布跟不上手指。

正确的做法有两条路。第一条是完全不 setData,直接在逻辑层拿到 canvas node 的引用,调用它的绘制方法。canvas 2D 的 node 是可以跨线程持有引用的,绘制指令不需要经过 setData 通道。上面代码里的this.canvasNode就是提前拿到的节点引用,用它调requestAnimationFrame和绘制方法,完全绕开 setData。

第二条是用WXS 响应事件。如果走的是 CSS 3D 那条路,样式变换必须经过视图层,这时候 WXS 就是救星——它运行在视图层,可以监听触摸事件并直接调用setStyle修改节点样式,完全没有跨线程通信。这是小程序的独门技巧,在别的平台根本用不上,但在这里能把翻页手感从"能接受"提升到"跟手"。

<wxs module="gesture"> var startX = 0; function onStart(e) { startX = e.touches[0].pageX; } function onMove(e, ownerInstance) { var x = e.touches[0].pageX; var delta = startX - x; // 换算成 0~1 的进度,限制在合法范围内 var p = delta / 260; p = p < 0 ? 0 : (p > 1 ? 1 : p); var deg = -180 * p; ownerInstance.selectComponent('.page-active').setStyle({ transform: 'rotateY(' + deg + 'deg)', transition: 'none' }); } function onEnd(e, ownerInstance) { var instance = ownerInstance.selectComponent('.page-active'); instance.setStyle({ transition: 'transform 300ms cubic-bezier(0.2, 0.8, 0.3, 1)', transform: 'rotateY(-180deg)' }); } module.exports = { onStart: onStart, onMove: onMove, onEnd: onEnd }; </wxs>

注意 WXS 里不能用 ES6 的箭头函数、模板字符串、let和const,语法规则接近 ES5,写的时候容易顺手写错。另外 WXS 里拿不到this,跨节点操作要通过ownerInstance或者getRegistInstance。

4.2 松手后的判定、回弹与惯性

松手之后的动画,关键是缓动曲线的选择。我试过好几种,最后定下来的是:

  • 回弹(没达到阈值,回到展平):cubic-bezier(0.2, 0.9, 0.3, 1),持续时间 260ms。这条曲线起步快、收尾慢,模拟的是纸张被松开后迅速弹回、最后轻轻落定的感觉。
  • 完成翻页:cubic-bezier(0.25, 0.75, 0.4, 1),持续时间 420ms。翻页的收尾比回弹慢一些,因为纸张翻过去还要"躺下",这个过程有视觉重量感,太快会显得像纸片被吸走。

Canvas 版的动画不能直接用 CSS 缓动函数,得自己写插值。我写了一个简单的时间驱动循环:

function animateTo(target) { const from = this.progress; const duration = target > from ? 420 : 260; const start = performance.now(); const ease = (t) => 1 - Math.pow(1 - t, 3); const step = (now) => { const t = Math.min(1, (now - start) / duration); const p = from + (target - from) * ease(t); this.progress = p; this.renderFrame(p, this.lastPx, this.lastPy); if (t < 1) { this.rafId = this.canvasNode.requestAnimationFrame(step); } else { this.progress = target; if (target === 1) { this.commitPage(); } else { this.hideCanvas(); } } }; this.rafId = this.canvasNode.requestAnimationFrame(step); }

canvasNode.requestAnimationFrame是小程序 canvas 2D 节点自带的方法,和浏览器的一致,但必须通过节点调用,用全局的requestAnimationFrame在小程序里不存在。另外动画结束时要记得把rafId存下来,页面隐藏或者组件卸载时调用cancelAnimationFrame,否则后台还在跑绘制循环,白白耗电。

4.3 松手瞬间的页面切换时机

有一个非常隐蔽的坑:翻页动画结束时,你不能立刻把 canvas 隐藏掉然后切换页码。因为动画的最后一帧和静态节点的第一帧之间,只要有一次渲染时序没对上,就会出现一帧的白屏闪烁。我在华为和 OPPO 的几款机器上都复现过,iOS 上反而不明显。

解决办法是延迟一帧再隐藏。用setTimeout加一个16ms的延迟,或者在commitPage里先切换数据、等nextTick之后再隐藏 canvas。多花这十几毫秒用户完全感知不到,但闪烁消失了。这个技巧在处理任何"canvas 与静态节点交接"的场景里都通用。

commitPage() { // 先更新数据,让静态节点渲染成新的一页 this.setData({ currentPage: this.data.currentPage + 1 }, () => { // 等静态节点渲染完,再隐藏 canvas wx.nextTick(() => { this.hideCanvas(); }); }); }

5. 性能与内存:真机上最容易翻车的地方

5.1 图片尺寸与内存账

这一块我要算一笔实在账。假设书本区域在屏幕上占 700×1000 逻辑像素,设备像素比是 3,那么实际渲染出来的画布是 2100×3000 物理像素,也就是 630 万像素。每个像素 4 字节 RGBA,一张全屏画布本身就占 25MB 左右。如果每页图片都用这个尺寸,前后各缓存 3 页,再加上 canvas 自己的缓冲区,内存轻轻松松突破 150MB。

所以图片绝对不能按屏幕尺寸准备。我的经验值是:书本区域宽度的 1.5 倍就足够了,再大纯属浪费。一张 1000×1400 的 JPEG,解码后大概 5.6MB,6 页也就 34MB,安全得多。而且绘制的时候按目标尺寸drawImage缩放,视觉上看不出差别。

还有一个更狠的省内存技巧:如果画面里的图片是静态的、不需要频繁重绘,可以把它预先绘制到一个离屏 canvas 里,然后用drawImage直接把整块贴过来。离屏 canvas 的绘制比逐次解码图片快得多,而且尺寸可控。缺点是它同样占内存,所以只对翻页过程中高频使用的那两三张做这个优化。

5.2 预加载与回收策略

预加载的节奏我调整过好几版,最后稳定下来的策略是:

  • 首屏必须同步就绪。第一页和第二页的图在页面onLoad里就加载完,加载完之前显示一个简单的骨架屏,不要用空白页硬扛。
  • 滑动窗口异步补充。当前页变化时,异步加载前后各 3 页,加载完成的存进缓存对象,加载失败的下次访问时重试一次,再失败就用底色兜底。
  • 超出窗口的引用置空。缓存对象里超出窗口的部分设为null,不要保留。小程序没有手动释放图片内存的 API,唯一能做的就是断开引用,等 GC 工作。
  • 页面onHide时清理。用户切走页面之后,如果还持有大图引用,内存不会释放。我在onHide里把整个缓存对象清空,onShow时重新按当前页加载。

5.3 不同机型的降级档位

真机性能差异比想象中大得多。五年前的安卓机跑满效果的翻书,帧率可能只有 20 出头,肉眼能看出明显卡顿。所以我加了一个自动降级机制:上线前用真机测了几款典型机型,根据wx.getDeviceInfo()拿到的benchmarkLevel分档。

性能档位判定条件效果策略
高档benchmarkLevel >= 50完整弯曲 + 折痕阴影 + 透印
中档20 <= benchmarkLevel < 50保留弯曲,阴影简化为单色半透明块
低档benchmarkLevel < 20 或未知直接退回 CSS 3D 平面翻转

这个降级不是"降级体验",而是"保证可用"。在低端机上跑一个帧率只有 20 的华丽翻书,用户只会觉得这个页面很卡;跑一个简洁的平面翻转,用户会觉得流畅顺手。选择哪个,答案很明显。

6. 踩坑排查实录与速查表

6.1 原生组件层级与真机差异

Canvas 在小程序里属于原生组件,原生组件的层级永远在所有普通 WXML 节点之上。这意味着如果你在 canvas 上盖了一个自定义弹窗、一个提示气泡、甚至一个普通的view,在真机上会被 canvas 盖住,而开发者工具里看起来是正常的。这个差异坑过我不止一次。

解决办法有两个:一是用cover-view和cover-image,这两个组件是专门为覆盖在原生组件之上设计的;二是在需要弹窗时把 canvas 隐藏掉,弹窗关闭后再显示回来。我倾向于第二个,因为cover-view支持的样式非常有限,稍微复杂一点的效果就做不出来。

还有一个真机和工具的差异:canvas.width的设置时机。开发者工具里可以在onLoad阶段直接设置,真机上必须等到createSelectorQuery的回调里拿到节点之后再设置,否则设置无效。这个差异导致我在工具里跑得好好的代码,一上真机就是一片空白。

6.2 苹果手机上的滑动手势冲突

小程序的页面滚动和 canvas 触摸事件之间有一层微妙的博弈。在 iOS 上,如果 canvas 铺满屏幕,用户在上面纵向滑动,页面有时候滚不动。原因是触摸事件先被 canvas 捕获,如果代码里没有明确放行,滚动就不会触发。

处理方式是在touchmove里做方向判定,前面已经写过。但还有一个补充手段:给页面容器加catchtouchmove的开关,在翻页拖拽激活时阻止冒泡,在非激活时放行。另外如果页面本身不需要滚动,整体用page样式加overflow: hidden更省事,从根上避免冲突。

顺便提一句,iOS 上还有一个表现是快速连续滑动时偶发丢帧。这个不是代码问题,是 WebView 合成器在某些机型上的调度策略导致的。我能做的优化是减少每帧的绘制指令数量——把渐变对象缓存起来复用,把save/restore的配对减少到最少,把路径的构建提前算好。做完这些之后,丢帧率明显下降,但不可能完全消除。

6.3 常见问题速查表

下面这张表是我在做这个效果的过程中攒下来的问题清单,遇到卡壳的时候可以直接对着排查。

现象可能原因排查方向
真机一片空白,工具正常canvas 节点获取时机不对检查是否在exec回调里设置 width/height
画面整体放大一倍DPR 缩放被覆盖检查是否用了setTransform冲掉了scale
拖动时画面严重滞后touchmove 里频繁 setData改为直接调用 canvas 节点绘制
折起区域内容错位反射矩阵参数顺序写反核对transform六参数的顺序
折痕像刀切一样生硬缺折痕阴影渐变补充沿法线的线性渐变带
翻页结束后闪一下白canvas 与静态节点交接时序用nextTick延迟隐藏 canvas
iOS 上页面滚不动canvas 吞掉了纵向手势加方向判定,非横向意图直接放行
翻五六页后闪退图片缓存占满内存收窄缓存窗口,onHide时清空引用
边缘出现一圈白边裁剪路径未对齐像素路径坐标做 0.5 像素偏移,或加 1px 描边覆盖
低端机帧率只有 20效果档位太高按 benchmarkLevel 自动降级

6.4 几个我自己踩出来的小技巧

最后分享几个从实际操作里摸出来的技巧,都属于文档里不会写、但用起来真香的那种。

第一,给折起区域的边缘加一条极细的高光。用rgba(255,255,255,0.6)描一条 1 像素宽的边,沿着折起三角形的斜边画。这条高光模拟的是纸张边缘的反光,加上之后纸片的立体感会明显提升一个档次。我一开始不信,加上去对比了一下,差异非常直观。

第二,回弹动画的终点不要设在进度 0。严格来说应该回到 0,但实测下来设在 0.02 再瞬间归零,回弹的收尾会更干脆。因为纸张展平的最后一点点过程在视觉上本来就"没东西可看",与其让它慢慢挪,不如快速收掉。

第三,用wx.nextTick而不是setTimeout处理交接。setTimeout(fn, 0)在小程序里最小实际延迟能到 4 到 20 毫秒不等,不稳定;wx.nextTick是框架提供的渲染后回调,时机更准确,能有效减少闪烁概率。

第四,开发阶段把帧耗时打到页面上。在 canvas 角落画一个半透明的60fps或者32ms的文字,比在工具里看性能面板直观得多,因为真机上的差异才是真差异。上线前把这个绘制去掉就行。

这套东西前后我折腾了大概三周,中间推翻过两次方案,最后稳定下来的版本在主流机型上帧率能稳在 50 到 60 之间,低端机自动降级后也没有明显卡顿。如果让我重新做一遍,我会在一开始就把性能档位和缓存窗口这两件事定死,而不是等到卡顿了再回头补,因为它们是架构级的决定,后期改动成本很高。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询