ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

微信小程序 Canvas 2D 拟真卷页翻书效果实现与性能优化

微信小程序 Canvas 2D 拟真卷页翻书效果实现与性能优化 做过电子画册、产品手册或者婚礼邀请函这类需求的人大概率都被问过同一句话能不能做成真书那样手指捏着纸角翻起来有折痕、有阴影、有厚度感。这个需求翻译成技术语言就是在微信小程序里实现翻书效果。它和普通的左右滑动切图完全是两回事——滑动只是图片轮播而翻书效果要处理的是纸张的翻折几何、折痕位置的实时计算、纸张背面的镜像绘制以及松手之后到底是翻过去还是弹回来。这套东西在小程序里没有现成组件官方文档也不会教你全靠自己算和画。这篇文章面向的是已经写过几个小程序页面、熟悉 WXML/WXSS 和基础手势事件的同学也适合正在做电子绘本、展览画册、菜单、邀请函一类内容展示型页面的开发者参考。我会把三条可选技术路线的边界讲清楚把 Canvas 2D 卷页的几何推导讲透把小程序环境里那些和 H5 不一样的特殊处理点逐条列出来最后把我在真机上踩过的坑和性能账一并交底。读完之后你应该可以独立搭出一个跟手的、不掉帧的卷页翻书效果。1. 先分清你要的是翻页还是卷页这决定了你的技术选型很多人一上来就搜翻书效果代码拿到一段 CSS 的 rotateY 就直接往项目里塞结果发现甲方要的是捏起来的手感而不是一块硬纸板绕书脊转圈。所以第一步必须把需求拆成两种完全不同的形态它们的实现成本差了一个数量级。翻页刚性翻折整张纸作为刚体绕书脊旋转页面本身不发生形变翻转过程中能看到纸张正反两面。视觉上接近翻日历、翻卡牌或是那种硬壳绘本的内页。这种效果的数学只有旋转和透视用 CSS 3D transform 就能做得不错。卷页柔性翻折纸张被手指捏起一个角折痕是一条随手指位置实时变化的斜线纸张边缘出现卷曲弧度折痕两侧有渐变阴影翻起来的过程中纸张的余下部分还贴在原地。iBooks 那种真书感就是卷页。它必须每帧重算几何并重绘CSS 做不到得上 Canvas。我见过最常见的翻车方式是用 CSS 做卷页做出一堆用 clip-path 切三角形的歪门邪道最后在安卓机上全是锯齿和穿透。所以选型这步不能省。1.1 三条技术路线的能力对照方案核心 API能做到的效果主要代价适合场景CSS 3D transformperspective、rotateY、preserve-3d、backface-visibility刚性翻页、卡牌翻转、有限度的跟手拖动距离映射角度安卓内核兼容性不稳、难以做卷曲、层叠上下文容易失控翻页动画、卡片翻转、轻量电子画册Canvas 2Dcanvas type2d、drawImage、仿射变换、渐变完整卷页、折痕实时跟随、可加卷曲弧度与厚度阴影几何要自己推、绘制要自己写、帧率要自己保拟真翻书、绘本、产品手册、邀请函movable-view 组合movable-area、movable-view、bindchange拖拽跟手、松手回弹、松手触发切换只能做拖到阈值触发翻转过程本身还是静态的翻页 CSS 动画的混合方案这张表里最值得说的一句是movable-view 解决的是跟手拖拽这个交互问题不是翻书这个渲染问题。它是一个省力但不省心的中间态适合那些动画差不多就行、主要是要有拖拽手感的项目。1.2 为什么我把拟真需求都交给 Canvas 2D理由有三个层次。第一是可控性Canvas 里我拿到的是每一帧的完整绘制权折痕位置、阴影角度、卷曲幅度都是变量甲方说阴影再重点我改一个数就行而 CSS 里这些东西要么做不了要么得靠伪元素硬凑。第二是一致性小程序的 CSS 3D 在不同安卓内核上的表现差异相当大同一份代码在 iOS 上正常、在某台两千块的安卓机上就穿帮而 Canvas 的绘制结果和像素绑定跨端差异小得多最多是抗锯齿的细微区别。第三是可扩展性一旦上手了 Canvas 的仿射变换后面想加翻到边缘时的弧形鼓包纸张轻微弯曲翻页时的高光扫过都是顺手的事不用重构。代价也要说清楚Canvas 方案下你要自己处理坐标系、DPR、图片加载、触摸事件节流代码量大概是 CSS 方案的三到五倍。所以小项目、动画要求不高的话我依然会建议先用 CSS 路线快速验证确认要拟真效果再迁移。2. CSS 3D 那条路十分钟跑起来也可能卡住半个月即便最后会走到 Canvas我也建议先把 CSS 方案写一遍。原因很实在它能帮你在半天之内把产品要的节奏、时长、透视强度这些体感参数定下来这些参数迁移到 Canvas 时依然有参考价值。2.1 用 rotateY 搭一个能合得上的双面纸页结构上就是一个容器加一张纸纸上贴正反两面。关键在于三个属性同时到位容器给透视纸页给 3D 保留两面都给背面隐藏。view classbook view classpage {{turned ? turned : }} bindtaponFlip view classface front image src{{frontSrc}} modeaspectFill / /view view classface back image src{{backSrc}} modeaspectFill / /view /view /view.book { position: relative; width: 640rpx; height: 920rpx; perspective: 1400px; /* 用 px 而不是 rpx透视强度才不会随屏幕宽度漂移 */ perspective-origin: 0% 50%; /* 透视点放在书脊一侧观感更像书 */ } .page { position: absolute; left: 0; top: 0; width: 100%; height: 100%; transform-origin: left center; /* 书脊在左边向右翻 */ transform-style: preserve-3d; transition: transform 0.7s cubic-bezier(0.4, 0, 0.2, 1); backface-visibility: hidden; -webkit-backface-visibility: hidden; } .page.turned { transform: rotateY(-180deg); } .face { position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden; } .back { transform: rotateY(180deg); /* 背面先自转 180 度翻过去之后才是正的 */ }这段代码里有几个容易被忽略的细节。perspective我用 px 而不是 rpx因为 rpx 会按屏幕宽度动态换算结果就是同一份代码在大屏手机上透视感更弱、小屏手机上更强视觉不一致。perspective-origin默认在中心把它挪到左侧书脊处翻转时的收束感更接近真实书本。至于.back的那次rotateY(180deg)它是很多翻过去之后内容是镜像的问题的根源——背面内容天然需要一个 180 度预旋转来抵消漏了这一步就会出现整页倒着显示。2.2 安卓上的穿透重影和半页失踪从哪来CSS 方案的坑几乎都集中在渲染层面不是逻辑层面。我整理了几类高频问题。第一类是背面穿透。backface-visibility: hidden在部分安卓内核上支持不完整翻转超过 90 度之后正面内容会隐约透出来和背面内容叠在一起看起来像重影。绕过办法有两个一是在翻转进度过半时用 JS 切换两面的opacity或visibility用逻辑补偿渲染的不确定性二是干脆不做双面翻转时用一张纯色底加阴影代替背面把视觉重点转移到折痕上。后者在内容本身比较简洁的画册里观感并不差。第二类是半页消失。父容器的overflow: hidden加上子元素的 3D 变换某些内核会把超出部分裁掉旋转到一定角度时页面就被切了一刀。处理办法是给外层的滚动容器保留足够的 padding或者把overflow交给更外层负责。第三类是层叠上下文错乱。给某个祖先元素加了transform或opacity小于 1它会创建新的层叠上下文导致内部的z-index完全失效翻页过程中的前后遮挡关系就乱了。所以perspective要加在容器上而不是用transform: perspective()写在页面元素自己身上两者看起来等价实际层叠行为不同。第四类是图片白闪。翻转过程中第一次显示背面图片时如果图片还没解码完会闪一下白。规避方式是在页面加载时就用image把正反两面都渲染出来哪怕用visibility: hidden占位或者提前用wx.getImageInfo把图片拉进本地缓存让后续渲染直接命中。注意CSS 方案里所有涉及翻转进度的计算都应该用transitionend或动画帧回调来做阶段切换不要用setTimeout估时间。动画时长和浏览器实际执行的时长在低端机上可能差出一大截定时器会对不上。3. 卷页的几何模型把一个捏起的纸角翻译成坐标到了 Canvas 这条线核心问题就一个手指按在屏幕上某一点、往某个方向拖纸张应该沿着哪条线折起来。这个问题听起来很玄但结论非常简洁——折痕就是触摸点和被抓住的那个角点之间的垂直平分线。3.1 折痕位置的推导以及为什么是垂直平分线先建坐标系。假设画布逻辑宽 W、高 H单页模式下我们从右向左翻被手指抓住的是页面右下角 C(W, H)触摸点是 P(px, py)。纸张被折叠时纸角 C 沿折痕翻过去之后落点正好是手指所在的 P。折叠是刚性变换本质上是一次镜面反射。而镜面反射有一个基本性质反射点与被反射点的连线被镜面垂直平分。反过来说要让 C 反射到 P镜面必须过线段 CP 的中点 M并且垂直于 CP。这就是折痕的方程来源不需要解任何复杂的方程。有了这条直线接下来要算的是它与页面矩形边界的交点。因为折痕的斜率可以是任意值它通常会和矩形的两条边相交把被翻起的区域切出一个三角形或者多边形。// 折痕过点 M(mx, my)方向向量 (dx, dy)求与矩形 [0,W] x [0,H] 的交点 function intersectRect(mx, my, dx, dy, W, H) { const eps 1e-6 const pts [] const add (t) { const x mx dx * t const y my dy * t if (x -0.5 x W 0.5 y -0.5 y H 0.5) { // 去重避免两条边相交处的重复点 if (!pts.some((p) Math.abs(p.x - x) 1 Math.abs(p.y - y) 1)) { pts.push({ x, y }) } } } if (Math.abs(dx) eps) { add((0 - mx) / dx) add((W - mx) / dx) } if (Math.abs(dy) eps) { add((0 - my) / dy) add((H - my) / dy) } return pts }拿到交点之后被翻起的区域就是角点 C 两个边界交点围成的多边形。这里有个边界情况必须处理当手指拖到离书脊很近的位置时垂直平分线可能不再与矩形的两条邻边相交而是与对边相交翻起的区域会从三角形变成梯形甚至接近整个页面。工程上我一般直接给折痕到书脊的距离设一个下限比如不小于页面宽度的 3%超过就直接判定为整页翻过避免退化形状带来的绘制异常。3.2 镜像绘制三段变换搞定居中反射几何算完了接下来的问题是怎么把背面内容画到翻起的区域里。直接算每个像素的坐标显然不现实正确做法是构造一个反射矩阵让 Canvas 自己帮我们算。前面说了反射的本质是绕折痕做镜面翻转。而关于一条过 M 点、倾角为 θ 的直线做镜面反射这个变换可以拆成三步先平移到原点附近的 M旋转 2θ 让折痕与 x 轴重合再沿 x 轴上下翻转也就是scale(1, -1)最后平移回去。数学上可以验证这个组合的结果正是标准的反射矩阵。// 在已裁剪出翻起区域的前提下绘制纸张背面 function drawBackFace(ctx, mx, my, theta, img, W, H) { ctx.save() ctx.translate(mx, my) // 平移到折痕中点 ctx.rotate(2 * theta) // 旋转两倍折痕倾角 ctx.scale(1, -1) // 沿折痕方向做镜面翻转 ctx.translate(-mx, -my) // 平移回原位 ctx.drawImage(img, 0, 0, W, H) ctx.restore() }这段代码看起来只有五行但它是整个卷页效果的支点。理解它的关键在于意识到drawImage传进去的还是原页面的坐标是变换矩阵在把图像折过去。所以背面图片用的是下一页的位图画完之后自然就落在翻起的区域里并且呈现出从背面看的镜像姿态与真实纸张的表现一致。3.3 折痕两侧的阴影决定了纸有没有厚度只做镜面反射翻起来的纸看起来像一张贴纸因为它没有厚度也没有受光变化。补上阴影之后观感会立刻上一个台阶。阴影要分两处画。一处是未翻起部分靠近折痕的位置随着折痕远去逐渐变浅模拟纸张被抬起后透下来的暗部另一处是翻起的纸背面靠近折痕的位置稍微亮一点模拟背面受环境光。两处都用线性渐变方向垂直于折痕。// 折痕一侧的渐变阴影以竖直方向的折痕为例 function drawCreaseShadow(ctx, mx, my, W, H, dir) { const len dir 0 ? W - mx : mx const grad ctx.createLinearGradient(mx, my, mx dir * len, my) grad.addColorStop(0, rgba(0, 0, 0, 0.28)) grad.addColorStop(0.35, rgba(0, 0, 0, 0.10)) grad.addColorStop(1, rgba(0, 0, 0, 0)) ctx.save() ctx.fillStyle grad ctx.fillRect(dir 0 ? mx : mx - len, 0, len, H) ctx.restore() }这里我特意用createLinearGradient而不是shadowBlur。shadowBlur写起来更省事但它在小程序的 Canvas 2D 里开销非常大一旦面积铺开帧率会掉得很明显属于典型的能跑但跑不顺的写法。3.4 想要真正的卷曲弧度就在折痕附近做条带变形上面的方案做出来的是刚性折叠折痕是一条笔直的硬边。真实纸张折起来的时候靠近折痕的那一小段是有弧度的纸面会鼓起来一点边缘看起来是圆的。实现思路是切片沿垂直于折痕的方向把翻起区域切成 N 条窄带然后对每一条带单独做drawImage的缩放绘制缩放系数按条带到折痕的距离用一条平滑曲线比如正弦的前四分之一段控制越贴折痕拉伸越明显越往外越接近 1。条带数量我一般取 24 到 32太少会出现明显的台阶太多则每帧的绘制调用暴涨。条带变形的代价是绘制次数成倍增加。所以我会提前把当前页和下一页的位图渲染到离屏 canvas 上缓存起来翻页过程中只做从离屏画布往主画布上贴这一件事避免每帧重复解码图片或者重复渲染文字。4. 把 Canvas 接进小程序几个和 H5 完全不同的地方Canvas 的绘制逻辑在网页和小程序里大同小异但工程侧的接入方式差异很大。这几处如果没处理好你会得到一张全黑的画布或者一个点了没反应的组件。4.1 节点查询与 DPR 缩放的正确顺序小程序里拿不到document.getElementById必须走createSelectorQuery。而且顺序很讲究先查节点和尺寸再设置画布像素尺寸最后才是缩放坐标系。Page({ onReady() { wx.createSelectorQuery() .select(#book) .fields({ node: true, size: true }) .exec((res) { const info res[0] if (!info || !info.node) return const canvas info.node const ctx canvas.getContext(2d) const dpr wx.getWindowInfo().pixelRatio // 基础库 2.20.1旧版本用 getSystemInfoSync canvas.width info.width * dpr canvas.height info.height * dpr ctx.scale(dpr, dpr) this.canvas canvas this.ctx ctx this.W info.width this.H info.height }) } })两个坑在这里。第一如果这段代码写在自定义组件里必须用this.createSelectorQuery()用全局的wx.createSelectorQuery()是查不到组件内部节点的症状就是回调里res[0]恒为 null。第二canvas.width设置的是物理像素ctx.scale(dpr, dpr)之后所有绘制坐标就变成了逻辑像素这一步不做画出来的东西会糊成一片。另外canvas type2d从基础库 2.9.0 开始支持属于同层渲染不会再像老的canvas canvas-id那样盖在所有元素上面。如果你还在用canvas-id的老写法层级遮挡的问题会一直缠着你建议直接升级。4.2 图片必须先 createImage不能直接给 src 字符串H5 里new Image()然后drawImage是常识小程序里没有Image构造函数得从 canvas 节点上创建。function loadImage(canvas, src) { return new Promise((resolve, reject) { const img canvas.createImage() img.onload () resolve(img) img.onerror () reject(new Error(图片加载失败: src)) img.src src }) }这里有两个必须提前知道的限制。一是网络图片的域名要配置在后台的合法域名里否则onerror会被触发而且错误信息不一定明确容易让人怀疑是代码问题。二是本地图片路径要用/pages/xxx/xxx.png这种绝对路径或者从wx.getImageInfo、wx.downloadFile拿到的临时文件路径直接把相对路径塞进去同样会加载失败。我一般会在页面onLoad阶段把所有页面的图片一次性加载完放进一个数组翻页时直接取。加载过程中显示一个简单的骨架占位比翻到一半发现图片没加载出来要体面得多。4.3 触摸事件的 x/y 已经是逻辑像素别再乘 DPR这是一个我自己踩过的坑值得单独拿出来说。Canvas 的触摸事件里e.touches[0].x和e.touches[0].y是相对于 canvas 左上角的逻辑像素坐标而我们在ctx.scale(dpr, dpr)之后绘制坐标系也是逻辑像素。两者单位一致直接拿来用就行。但我当时想的是物理像素才对应实际渲染触摸坐标应该也乘 dpr于是多乘了一次结果手指按在页面中部翻起的折痕却出现在页面四分之一处整整错位一倍。排查了半天才发现是坐标系理解错了。判断标准很简单如果你的折痕位置偏移量恰好等于 DPR 倍数关系在 iPhone 上大约是 2 倍或 3 倍那基本就是这个原因。顺便提醒普通view上的触摸事件用的是clientX/clientY或者pageX/pageY和 canvas 上的x/y不是一套字段。做混合布局的时候别搞混。canvas type2d idbook disable-scroll{{true}} bindtouchstartonTouchStart bindtouchmoveonTouchMove bindtouchendonTouchEnd /disable-scroll是个容易被忽略的属性不加它的话手指在 canvas 上滑动时页面会跟着一起滚卷页和滚动打架体验直接崩掉。如果外层还有滚动容器可以考虑把bindtouchmove换成catchtouchmove用事件拦截的方式阻断冒泡。4.4 画布尺寸不是越大越好Canvas 的像素尺寸在 iOS 上有实际的上限约束一旦宽或高超过几千像素可能出现绘制失败、局部空白甚至内存告警被系统回收。手机屏幕尺寸摆在那里普通页面其实不太会撞到这个限制但如果你为了让翻书更清晰把 canvas 按两倍尺寸放大再按 DPR 乘一次就很容易超标。我的做法是canvas 的逻辑尺寸严格等于它在页面里占的布局尺寸DPR 最多取到 3 就不再往上加了。清晰度不够时优先保证图片资源本身的分辨率够高而不是靠放大画布来换。5. 手势判定、回弹与帧循环怎么组织交互层面的代码如果组织得不好翻书会显得黏——手指在动纸在慢半拍地追。这块的核心是把触摸采样、状态判定、动画补间三条线拆开各管各的。5.1 触摸三件套记录起点、跟手重算、松手结算onTouchStart(e) { const t e.touches[0] this.startPoint { x: t.x, y: t.y } this.lastSamples [{ x: t.x, y: t.y, t: e.timeStamp }] this.dragging true this.cancelAnimation() // 拖动时打断正在进行的补间 } onTouchMove(e) { if (!this.dragging) return const t e.touches[0] this.lastSamples.push({ x: t.x, y: t.y, t: e.timeStamp }) if (this.lastSamples.length 5) this.lastSamples.shift() this.curlPoint { x: t.x, y: t.y } this.requestRender() // 只标记需要重绘不直接绘制 } onTouchEnd(e) { this.dragging false const speed this.calcSpeed() // 像素/毫秒 const progress this.curlProgress // 0 到 1 的翻起进度 if (progress 0.35 || speed 0.5) { this.animateTo(1) } else { this.animateTo(0) } }有个细节值得强调requestRender不要直接调绘制函数。因为安卓上的touchmove触发频率比屏幕刷新率高一帧之内可能来两三次直接绘制就是白干活。正确做法是设一个脏标记在下一帧的绘制循环里统一处理。5.2 松手之后是翻页还是回弹靠两个指标联合判断只用一个阈值是不够的。如果只看拖动距离快速轻扫时手指位移很小但明显想翻页会被误判成回弹如果只看速度慢速拖到页面大半又松手时会被误判成翻页用户觉得我明明没松手翻啊。判定指标计算方式建议阈值覆盖的场景翻起进度折痕到书脊的距离 / 页面宽度大于 0.35 判翻页慢慢拖到过半后松手滑动速度最近 3 到 5 个采样点的位移 / 时间差大于 0.5 px/ms 判翻页快速轻扫方向反转最近采样的位移方向与初始方向相反直接判回弹拖了一点又往回拉速度计算建议用最近几个采样点做平均而不是取最后两个点因为安卓的触摸坐标本身有轻微抖动两个点的样本量太小速度值会跳。同时要注意e.timeStamp的单位是毫秒位移单位是逻辑像素算出来的速度单位就是像素每毫秒阈值别写成像素每秒的数量级。还有一种情况要单独处理拖到书脊之后还继续往左拖。这时页面已经完全翻开再拖下去没有意义需要在计算折痕位置时做钳制让进度封顶在 1同时给一点阻尼反馈让用户感觉到到头了。5.3 用 canvas.requestAnimationFrame 组织补间动画小程序的 2D canvas 节点自带requestAnimationFrame注意它挂在 canvas 实例上不是全局 API。animateTo(target) { const from this.curlProgress const duration 260 const startTime Date.now() const tick () { const elapsed Date.now() - startTime const t Math.min(elapsed / duration, 1) const eased 1 - Math.pow(1 - t, 3) // easeOutCubic this.curlProgress from (target - from) * eased this.renderFrame() if (t 1) { this.rafId this.canvas.requestAnimationFrame(tick) } else { this.rafId null if (target 1) this.commitTurn() } } this.rafId this.canvas.requestAnimationFrame(tick) }缓动函数的选择对体感影响很大。回弹我一般用easeOutCubic起步快、收尾稳很像纸张本身的弹性翻页完成用easeInOutCubic中段加速看起来有惯性。时长上回弹 200 到 260 毫秒足够翻页完成可以给到 320 毫秒左右太短会显得生硬太长会显得拖沓。5.4 翻页完成后的重绘以及 setData 的调用时机这一点是性能上的分水岭翻页动画的整个过程中一次 setData 都不要调。翻书的每一帧都是 Canvas 内部重绘跟视图层没关系。只有在翻页真正完成、需要切换页码或更新状态栏的时候才调一次 setData。commitTurn() { this.currentIndex 1 this.curlProgress 0 this.curlPoint null // 只在翻页结束后同步一次视图层状态 this.setData({ currentIndex: this.currentIndex }) this.renderFrame() }我在早期版本里犯过一个错每帧都把当前翻页进度同步到setData里想让页面顶部显示一个进度百分比。结果是翻页过程中视图层和逻辑层的通信开销直接吃掉了大半帧率翻起来肉眼可见地卡。后来把进度显示改成 Canvas 内部绘制问题立刻消失。这个教训值得记住——Canvas 场景下凡是能用 Canvas 画的东西就别往回传给视图层。6. 真机上踩过的坑与性能账代码在开发者工具里跑得顺不代表真机没问题。下面几条是我在几台不同价位机型上实测出来的经验按踩坑的严重程度排序。6.1 那些看起来像玄学的真机问题触摸坐标的轻微抖动。部分安卓机型在手指静止时也会持续触发touchmove坐标在 ±1 像素内来回跳。如果不做过滤折痕会跟着高频颤动视觉上像在抽搐。处理方式是在onTouchMove里加一个位移阈值只有相对上一个有效点的距离超过 1.5 像素才更新状态。首次触摸的坐标不可靠。极少数机型首次按下的坐标会滞后一帧导致第一下拖动时折痕突然跳一下。我的做法是在onTouchStart里只记录起点、不立刻重绘等第一次有效的touchmove再开始绘制代价是有十几毫秒的空白几乎感知不到。长按选中文本。如果 canvas 上层还叠了普通文本节点长按可能触发选择或者系统菜单。canvas 本身不涉及这个问题但叠加的说明文字建议加user-select: none。开发者工具和真机的绘制差异。开发者工具用的是桌面内核Canvas 的抗锯齿和图片缩放算法跟真机不一样。工具里看着边缘干净的图像在真机上可能有轻微毛边。所以任何翻书效果都必须用真机预览验收工具里过了不算过。6.2 掉帧的三个元凶以及我最终的优化组合我把翻页过程从 20 多帧掉到稳定 55 帧以上靠的是三项改动效果按贡献排序如下。优化项具体做法效果提升去掉 shadowBlur全部改用 createLinearGradient 画阴影提升最明显低端机从个位数帧率回到 40位图离屏缓存用 createOffscreenCanvas 预渲染每一页的静态内容中端机帧率再提 10 到 15 帧降低条带数量从 48 条降到 28 条视觉损失很小高负载场景下明显更稳wx.createOffscreenCanvas({ type: 2d, width, height })从基础库 2.16.1 开始支持用法和普通 canvas 节点基本一致拿到之后同样要设置物理尺寸并scale。它的价值在于把页面内容的渲染和翻页过程的绘制彻底分开页面里的图片、文字、装饰元素在离屏画布上渲染一次翻页时每帧只做一次drawImage省掉了重复的图像缩放和文字排版开销。另外还有一个容易被忽视的点绘制区域裁剪。每帧不要整块画布重绘而是根据折痕位置只重绘受影响的区域。翻页时受影响的其实只有页面的一小部分用ctx.clearRect和ctx.rect配合把区域收窄绘制成本会明显下降。这个优化在页面内容复杂比如有大量文字时收益最大。6.3 双页对开模式下页码和折角方向都要重算单页模式跑通之后如果要升级成左右对开像一本摊开的书有几处必须调整。坐标系的原点变了。对开模式下右页的坐标系原点在屏幕中线右下角是(W, H)而左页的原点在屏幕最左被抓住的角是左下角(0, H)翻页方向相反。折痕的角点选错翻过去的内容就会跑到屏幕外面。页码分配要提前算好。对开模式里左页永远是偶数页、右页永远是奇数页这是装订线决定的。翻到第 N 页时左页显示哪一页、右页显示哪一页需要一个映射函数别在渲染时临时判断否则首页和末页必然出现空白页错位。左页回翻的触摸区要单独划。用户从右往左翻是看后面的内容从左往右拖是回看前面的内容两个方向的折痕角点、镜像方向、阴影方向全都要对称实现。我一般是写一个curlPage(pageIndex, corner, point)函数把角点和方向都参数化两个方向共用同一套几何代码避免复制粘贴出两套不一致的逻辑。中缝阴影别忘加。对开模式对观感贡献最大的一笔是书脊处那道固定的暗部。它是静态的一次渲染就行但没有它两页看起来就是并排的两张图而不是一本书。我个人的经验是对开模式虽然看起来只是单页模式的两倍工作量实际调起来大概是三倍因为对称性带来的边界情况会翻倍。如果项目排期紧先做单页模式是完全可行的视觉上也不吃亏。最后分享一个我在实际项目里用了很久的小技巧给翻页加上一点回弹过冲。具体做法是让翻页完成后页面的最终角度稍微超过目标位置一点点再收回来幅度大概 3 到 5 度。真实纸张翻过去之后确实会有一点点回弹加上这个细节整个效果的手感会从电子变成物理。实现上只需要把缓动函数从easeOutCubic换成带过冲的easeOutBack代码改动极小但试用过的同事几乎都留了下来。
返回列表