简介:这是一份基于uni-app与uni-ui实现的小程序转盘组件源码,采用纯canvas绘制,面向需要快速集成抽奖、轮盘或做选择功能的小程序开发者,支持微信、支付宝、抖音等多端部署。资源共13个文件,压缩包仅19KB,代码量精简,主体包含3个vue页面文件、3个json配置、2个js逻辑文件,另有scss样式、png图标、readme说明等,目录结构清晰,便于直接阅读和二次修改。功能涵盖自定义转盘选项、设置选项权重进行按概率抽奖、转速减速模拟真实转动以及选项颜色配置,可直接通过HbuilderX导入使用。无论是抽奖、抓阄,还是纠结今天吃什么、周末去哪玩,只要修改选项数据即可快速复用;同时也很适合学习canvas动画与uni-app跨端开发思路。目前已有318人学习下载,作为轻量实用的canvas转盘示例,具备较高的参考价值,尤其适合需要快速上线抽奖功能的中小型项目。
1. 为什么我要用纯 Canvas 在 uni-app 小程序里写转盘:先把选型理由说透
做了几个小程序营销组件之后,我越来越倾向把这类交互从图片方案里摘出来。uni-app 加小程序加纯 Canvas 做转盘,听起来比直接贴一张底图复杂度高,但换来的是尺寸自适应、奖品可动态配置、抽奖动画完全可控。之前用 gif 或序列帧做转盘,改一个奖品名要重新出图,转起来还偶发卡顿,后来换成 Canvas 手绘之后,这些问题基本消失。这篇笔记会把完整的绘制、旋转、判定和跨端适配过程拆开,新手能照着抄,熟手可以对比参数和边界条件。如果你正被“转盘在真机上模糊”“抽奖结果对不上指针”这类问题困扰,直接跳到对应章节。
2. 纯 Canvas 绘制转盘:坐标系、扇形与文字样式的可靠写法
2.1 画布初始化与坐标系理解
先不碰动画,把静态转盘画出来。Canvas 的坐标系原点是左上角,x 轴向右,y 轴向下,角度从三点钟方向开始,顺时针递增。转盘要绕圆心旋转,所以第一步是把圆心移到画布中心,换算坐标后再画每一个扇形。
在 uni-app 里初始化一张画布,常见做法是给 canvas 绑定一个 id,然后在 onReady 里创建上下文。旧版写法是 uni.createCanvasContext,新版小程序基础库推荐 type="2d" 的方式,直接拿标准 CanvasRenderingContext2D。下面这段代码同时兼容两种方式,先探测是否支持 node 接口,拿不到就退回旧版上下文。
// 模板里: // <canvas type="2d" id="wheelCanvas" class="wheel-canvas"></canvas> // <canvas canvas-id="wheelCanvas" id="wheelCanvasLegacy" class="wheel-canvas"></canvas> initCanvas() { // 优先走 type="2d" 的新接口,拿到的 ctx 是标准 CanvasRenderingContext2D const query = uni.createSelectorQuery().in(this) query.select('#wheelCanvas').fields({ node: true, size: true }).exec((res) => { if (res && res[0] && res[0].node) { const canvas = res[0].node const ctx = canvas.getContext('2d') this.canvas = canvas this.ctx = ctx // 这里的宽高来自 CSS 渲染后的实际尺寸,后面会结合像素比再做一次高清适配 this.canvasWidth = res[0].width this.canvasHeight = res[0].height this.drawWheel() } else { // 低版本基础库的降级方案,注意旧版 ctx 需要调 draw() 才会真正上屏 const ctx = uni.createCanvasContext('wheelCanvasLegacy', this) this.ctx = ctx this.ctxLegacy = true this.drawWheelLegacy() } }) }参数说明:select 的 fields 配置里,node 表示获取 canvas 节点实例,size 表示拿渲染尺寸。旧版 createCanvasContext 的第一个参数需要和模板里 canvas-id 保持一致。初始化的结果需要存到 this 上,后续绘制和动画都会用到。
这里有个容易忽略的点:type="2d" 的 canvas 在模板里写 style 的宽高,但画布内部的分辨率是 canvas.width 和 canvas.height 两个属性控制的。如果不做像素比适配,直接按 CSS 尺寸绘制,真机上会发虚,第 4 章会单独讲。
2.2 扇形绘制:moveTo、arc 与分区算法
绘制一个扇形,核心是三个步骤:把笔移动到圆心,画一段圆弧,再闭合路径。给每个奖品分配一个扇区,扇区角度是 360 度除以奖品数量。颜色和文案都从奖品数组里取,这样后台上配置奖品列表时,前端不需要改任何绘制代码。
/** * 绘制单个扇形 * @param {Object} ctx - canvas 上下文 * @param {number} cx - 圆心 x * @param {number} cy - 圆心 y * @param {number} radius - 扇区半径 * @param {number} startAngle - 起始角度,弧度制 * @param {number} endAngle - 结束角度,弧度制 * @param {string} color - 填充色 */ function drawSector(ctx, cx, cy, radius, startAngle, endAngle, color) { ctx.beginPath() ctx.moveTo(cx, cy) // 第三个参数 false 表示顺时针绘制,方向要与转盘旋转方向保持一致 ctx.arc(cx, cy, radius, startAngle, endAngle, false) ctx.closePath() ctx.fillStyle = color ctx.fill() // 加描边可以让扇区边界清晰,这里只描边一次,避免相邻两个扇区产生双重描边 ctx.strokeStyle = '#ffffff' ctx.lineWidth = 1 ctx.stroke() }调用时,startAngle 从上一块扇区的末尾开始累加,形成一个环形。奖品数组的每一项包含 name 和 color,颜色交替排列让视觉上能明显区分每个分区。如果把 arc 的最后一个参数改成 true,会变成逆时针绘制,转盘转动方向、指针判定、动画加速方向全部要跟着反,容易乱,我一般固定用 false。
分区数量这块有个边界:如果奖品只有 1 个,整个圆就只有一个扇区;如果奖品有 12 个以上,文字会非常拥挤,通常会把文字改成两行或者缩小字号。小程序转盘的合理取值范围是 4 到 10 个奖品,这个区间内每个扇区的弧度足够容纳 4 到 6 个汉字。
2.3 分区文字绘制:save/restore 与 textAlign
文字画在扇形中间,如果直接计算文字的绝对坐标,涉及三角函数,稍麻烦一点。更省事的办法是先把坐标原点移到圆心,旋转到某个扇区的中间角度,然后沿半径方向画文字。这样每个扇区的文字绘制逻辑完全一样,唯一变化的是 rotate 的角度。
/** * 在指定扇区绘制奖品文字 * @param {Object} ctx - canvas 上下文 * @param {number} cx - 圆心 x * @param {number} cy - 圆心 y * @param {number} radius - 扇区半径 * @param {number} angle - 扇区中心角度 * @param {string} text - 奖品名称 */ function drawSectorText(ctx, cx, cy, radius, angle, text) { // save 和 restore 一定成对出现,save 记录当前状态,restore 恢复,避免旋转状态互相污染 ctx.save() ctx.translate(cx, cy) ctx.rotate(angle) // 文字右对齐,fillText 的 x 坐标从扇形外边向内缩 20px,文字不会越界 ctx.textAlign = 'right' ctx.fillStyle = '#3c2e1f' ctx.font = '14px sans-serif' ctx.fillText(text, radius - 20, 6) ctx.restore() }rotate 之后,坐标系跟着旋转,x 轴方向仍然是从圆心向外延伸。textAlign 用 right,fillText 里的 x 传 radius - 20,意思是文字的最右端到圆心的距离是 radius - 20,这样文字自动向左展开,不会超出画的圆。y 轴方向画出来会偏上,传 6 是一个经验补偿值,让文字视觉上垂直居中。
这里最容易踩的坑是忘写 restore,导致下一次 drawSector 的 fillStyle 或者旋转角度被篡改。如果发现第二个扇区的颜色染到了第三个扇区、或者文字方向越转越歪,先怀疑 save/restore 配对有没有写全。文字是否需要跟随转盘旋转,取决于产品需求,抽奖转盘的视觉习惯是奖品文字跟着扇区转动,所以每次绘制时按当前扇区中心角度旋转是合理的。
3. 转盘动画与抽奖判定:旋转模型、缓动函数与点击锁
3.1 旋转模型:围绕圆心的旋转与角度记录
静态转盘画好之后,动画的本质是让整个图形围绕圆心不断改变角度。实现方式有两种选择:一是每一次都重绘所有扇区和文字,把角度作为偏移参数传入绘制函数;二是绘制到一个离屏画布上,旋转时只做 rotate 和 drawImage。
先看不做离屏缓存的直接方案,维护一个全局角度变量 currentAngle,每一次绘制时,所有扇形和文字的角度都加上这个偏移量。绘制扇区的代码基本不用改,只要在绘制前把画布整体旋转一下。
function drawWheelWithAngle(ctx, cx, cy, items, radius, currentAngle) { // 整体旋转,视觉上看就是转盘在转动 ctx.save() ctx.translate(cx, cy) ctx.rotate(currentAngle) ctx.translate(-cx, -cy) // 计算每个扇区,起始角度和结束角度都基于 0 度叠加 const sectorAngle = (2 * Math.PI) / items.length for (let i = 0; i < items.length; i++) { const start = i * sectorAngle const end = start + sectorAngle drawSector(ctx, cx, cy, radius, start, end, items[i].color) } // 文字也跟随整体旋转,角度基准与扇区一致 for (let i = 0; i < items.length; i++) { const midAngle = i * sectorAngle + sectorAngle / 2 drawSectorText(ctx, cx, cy, radius - 10, midAngle, items[i].name) } ctx.restore() // 中间指针画在最上层,不跟随旋转,指针固定指向上方 drawPointer(ctx, cx, cy) }这个方案的优点是容易理解,缺点是每次动画帧都要重新执行路径计算和 fill,如果奖品数量多、画布尺寸大,低端机的帧率会掉。对于 8 个以内的奖品、400px 左右的画布,实测表现足够顺滑。如果你想要更高性能,把静态扇区绘制缓存到离屏画布,每帧只旋转图片,这个写法在第 6 章展开。
3.2 缓动函数:从 easeOut 到可控时长的帧循环
转盘不能匀速转,匀速运动会显得很机械,而且用户看不清奖品落在哪个位置。常规做法是用 easeOut 缓动,让转盘先快后慢,最后平滑停下来。缓动函数的作用是把时间进度 t,从 0 到 1,映射成角度进度的比例。
这里我没有用 requestAnimationFrame,因为小程序里的 requestAnimationFrame 支持并不统一,兼容做法是用 setTimeout 实现帧循环,每次计算当前应该转到的绝对角度,然后重绘。
// easeOutCubic 缓动函数,t 从 0 到 1,返回值也是 0 到 1,先快后慢 function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3) } startSpin() { // 如果正在转,直接返回,这是最简单的防重复触发锁 if (this.spinning) return this.spinning = true const duration = 4000 // 动画时长 4 秒 const startAngle = this.currentAngle // 当前角度,作为起点 const totalRotate = Math.PI * 2 * 5 // 至少转 5 圈,制造仪式感 const targetOffset = this.getTargetOffset() // 计算命中某个奖品需要转到的偏移角度 const finalAngle = startAngle + totalRotate + targetOffset // 最终绝对角度 const startTime = Date.now() // 用 setTimeout 模拟帧循环,每 16ms 约一帧 const tick = () => { const elapsed = Date.now() - startTime const progress = Math.min(elapsed / duration, 1) const eased = easeOutCubic(progress) // 当前角度 = 起点 + 总旋转量 * 缓动进度 this.currentAngle = startAngle + (finalAngle - startAngle) * eased // 重绘整个画布 this.drawWheelWithAngle(this.ctx, this.cx, this.cy, this.items, this.radius, this.currentAngle) if (progress < 1) { // 动画未结束,继续下一帧 this.animationTimer = setTimeout(tick, 16) } else { // 动画结束,落定到最终角度,关闭动画锁 this.currentAngle = finalAngle this.spinning = false // 回调中奖结果,由外部传入 onFinish this.onFinish && this.onFinish(this.targetItem) } } tick() }参数说明:duration 控制整个动画时间,4 秒是比较常见的设置,想要更干脆可以缩短到 3 秒。totalRotate 决定最少转几圈,圈数太少会显得奖品是硬甩过去的,圈数太多则用户等待时间过长。finalAngle 是一个绝对角度,它不需要缓存模 2π,因为绘制时 Canvas 的 rotate 可以接受任意大的弧度,角度累积不会影响结果,这一点比直接操作 DOM 的 CSS transform 要省心。
要注意 progress 到达 1 之前,缓动函数输出的 eased 值不可能完全等于 1,所以最后一帧要手动把 currentAngle 赋成 finalAngle,否则可能出现停下来的瞬间角度轻微回退,视觉上像抖了一下。
3.3 命中判定:按奖品倒推目标角度
抽奖的核心问题是:要让指针停在哪个扇区。最容易出错的地方是先随机一个角度,再去查这个角度属于哪个奖品。这样做的问题在于,如果扇区的角度边界计算有一点误差,最终停下的位置会偏向相邻扇区,用户较真起来很麻烦。
更稳的做法是先随机决定中奖奖品,再根据奖品索引反推目标角度。这样可以保证目标角度必然落在该扇区的中心区域,留出足够的容错空间。
// 计算命中某个奖品需要的偏移角度 getTargetOffset() { const items = this.items const sectorAngle = (2 * Math.PI) / items.length // 随机抽取一个奖品索引,这里用 Math.random 实现等概率抽奖 // 注意:真实业务通常由后端返回中奖结果,前端只负责动画表现 const randomIndex = Math.floor(Math.random() * items.length) this.targetItem = items[randomIndex] // 扇区中心角度 = 索引 * 扇区大小 + 扇区大小的一半 const targetSectorCenter = randomIndex * sectorAngle + sectorAngle / 2 // 关键:指针固定指向 12 点钟方向。绘制时 0 度在 3 点钟方向, // 因此需要把目标角度换算成相对于 12 点钟方向的偏移。 // 这里统一约定:转盘旋转角度的起点,让第一个扇区起始边位于 12 点钟方向。 // 所以目标偏移 = 扇区中心角度 - Math.PI / 2 let targetOffset = targetSectorCenter - Math.PI / 2 // 转盘先正转到目标偏移,再叠加 5 圈旋转,就是最终绝对角度 return targetOffset }这个函数里的核心换算不复杂:先确定随机奖品索引,再找扇区中心。扇区中心角度按顺时间从 0 度开始计算,由于绘制指针时指针在 12 点位置,实际绘制 0 度在 3 点位置,所以需要减去 π/2。如果你把指针画到了右边,就不用减这个值,而是加 π/2 或者做别的换算。这里的角度基准一旦定下来,必须和绘制指针的位置保持一致,画指针的代码见上节 drawPointer。
关于随机策略,有一个后端交互场景值得提一下:如果抽奖结果由后端下发,前端拿到的是一个奖品 id,那么这里不应该用 Math.random,而应该根据后端返回的索引调用 getTargetOffsetById(index)。把这个逻辑从 getTargetOffset 里抽出去,动画部分保持不变,只替换命中索引的来源,这样前端与后端对接时改动面最小。
在动画结束的回调里,把 this.targetItem 传给 onFinish,业务层可以通过 uni.showToast 或弹窗给用户展示结果,同时把抽奖次数、库存等状态更新到服务端。注意不要在动画结束前就弹出结果,否则用户看到弹窗和转盘停稳不一致,会产生“造假”的负面体验。
4. uni-app 跨端适配:从 Context 到 Canvas 2D 的迁移与像素比处理
4.1 旧版 canvasContext 与 type="2d" 的差异
uni-app 同时支持两种 Canvas 上下文,早期项目大多用 uni.createCanvasContext 创建的 canvasContext,新项目可以直接上 type="2d" 的标准接口。这两者的差距不只是 API 名字不同,调用方式和渲染效果都有实质区别。
| 对比项 | canvas-id 旧版上下文 | type="2d" 新接口 |
|---|---|---|
| 获取方式 | uni.createCanvasContext('id', this) | canvas.getContext('2d') |
| 提交方式 | 调用 ctx.draw() 后批量渲染 | 同步写入直接显示 |
| 动画性能 | 每次整块重绘 | 更接近浏览器 Canvas 2D 标准,原生支持更好 |
| 基础库要求 | 几乎没有限制 | 基础库 2.9.0 以上 |
| 代码复用度 | 小程序的 wx 接口,没法用于 App 端 | 标准 Canvas 接口,H5 和 App 端更接近 |
旧版接口在 uni-app 里最大的问题是 draw() 是异步的,绘制结束后需要回调才能继续下一帧,对动画循环很不友好。而且旧版的 ctx 上很多方法名与浏览器标准略有不同,比如调用 ctx.setFillStyle 而不是 ctx.fillStyle,代码切到 H5 端时容易踩坑。
如果你现在新建项目建议直接用 type="2d",如果你维护的是老项目,旧版学起来也不亏,很多开源组件仍是旧版写法。一个工程里可以同时保留两种上下文,初始化时先探测新接口,拿到 node 走新逻辑,拿不到自动降级到旧接口。第 2 章 initCanvas 那段代码已经演示了降级流程。
4.2 像素比适配:让转盘在真机上不发虚
Canvas 发虚问题在真机上几乎必现,原因在于物理像素和 CSS 像素不是一比一。画布内部分辨率的默认值通常是 300x150,而 CSS 尺寸可能是 350x350,浏览器会把 300x150 的内容拉伸显示到 350x350 的容器里,结果是边缘模糊、文字发虚。
解决办法是获取设备的 pixelRatio,把 canvas 的内部宽高乘以像素比,再用 ctx.scale 放大坐标系,这样绘制时所有尺寸按 CSS 像素来写,实际渲染按物理像素输出。
setupCanvasDpr(canvas, ctx, cssWidth, cssHeight) { // uni.getSystemInfoSync 可以取到当前设备的像素比,iPhone 一般是 2 或 3 const dpr = uni.getSystemInfoSync().pixelRatio || 2 // 内部分辨率设置为 CSS 尺寸乘以 dpr canvas.width = cssWidth * dpr canvas.height = cssHeight * dpr // 缩放坐标系,绘制时仍然用 CSS 像素尺寸 ctx.scale(dpr, dpr) return dpr }注意这个函数要在 getContext 之后、第一次绘制之前调用,并且只需要调用一次。如果后续改变了容器的宽高,比如屏幕旋转或者窗口变化,需要重新执行一次 setup 逻辑。ctx.scale 是一个持久状态,如果后面还要做离屏绘制,记得在恢复尺寸的 save/restore 里包一层,或者单独建一个离屏画布实例,不要复用同一个 ctx。
还有一个细节:在 type="2d" 模式下,canvas 节点的宽度是 CSS 像素,但分辨率由 canvas.width 控制。很多教程里直接用 canvas.width = style.width * dpr,这里的 style.width 需要通过 SelectorQuery 的 size 字段获取,不能直接读 canvas.style,因为小程序里 canvas 的 style 是字符串。没有拿到准确尺寸就直接乘 dpr,会导致绘制内容被截断或位置偏移。
4.3 事件与触摸坐标换算
小程序转盘如果只是点击一次转一下,直接绑定 tap 事件就行,不需要处理触摸坐标。但如果要做“手指滑动转盘持续旋转”的交互,就需要把触摸坐标换算成相对于圆心的角度。
tap 事件拿到的 event.detail.x 和 event.detail.y 是相对页面左上角的坐标。要换算成相对圆心的坐标,需要先拿到画布在页面中的位置,然后做一次偏移计算。
onCanvasTap(e) { // 获取画布相对页面左上角的位置信息 const query = uni.createSelectorQuery().in(this) query.select('#wheelCanvas').boundingClientRect((rect) => { // 触摸点换算成相对画布左上角的坐标 const touchX = e.detail.x - rect.left const touchY = e.detail.y - rect.top // 再换算成相对圆心的坐标,圆心在画布中心 const centerX = rect.width / 2 const centerY = rect.height / 2 const relativeX = touchX - centerX const relativeY = touchY - centerY // 计算当前触摸点相对圆心的夹角 // Math.atan2 返回的是 -π 到 π 之间的弧度,需要转成 0 到 2π let angle = Math.atan2(relativeY, relativeX) if (angle < 0) { angle += Math.PI * 2 } this.lastTouchAngle = angle // 在这里可以选择立即旋转转盘或者仅记录角度,供滑动旋转使用 }).exec() }参数说明:atan2 比 atan 更适合这种场景,因为它能根据 x 和 y 的符号正确判断象限,返回的角度范围可以覆盖整个圆周。换算出的 angle 如果用于滑动旋转,需要和 currentAngle 建立差值关系,每次 touchmove 时计算手指角度的变化量,把差值加到 currentAngle 上。这里有一个纵向坐标的问题需要额外注意:Canvas 坐标系 y 轴向下,Math.atan2 的 y 参数在 Canvas 里应该直接使用相对坐标,不需要手动翻转符号,翻转之后旋转方向会反过来。
做滑动旋转时,还需要控制一下灵敏度。初始记录 lastTouchAngle,每次 touchmove 用新角度减去旧角度,得到 deltaAngle,加到 currentAngle。为了减速停止,可以给一个摩擦系数,比如每次位移后的角度增量乘以 0.95,让手指离开后转盘还能余转一小段。
5. 转盘项目排查与避坑记录:5 个高频问题与解决路径
5.1 现象一:转完指针停在两个扇区边界中间
做第一版的时候,我测试了几十次,发现有几次指针正好压在两个扇区的交界线上,用户看了肯定会觉得抽奖结果有黑幕。
原因:目标角度取到了扇区的起始角度,没有加上扇区半角偏移,导致目标角度的实际落点就在分界线上。此外,如果扇区起始角度计算时的索引从 0 开始,而视觉上第一个扇区对应的是 0 度,很容易把起始角度误当成中心角度。
解决:把中奖目标角度改为扇区起始角度加上半个扇区角度。这个半角偏移让指针停在扇区的视觉中心附近,而不是边缘。我还会在最终角度上再叠加一个很小的常量偏移,比如 0.02 弧度,避免因为浮点精度问题压线。
5.2 现象二:转盘在真机上边缘发虚、文字带毛边
我发现模拟器上预览效果还行,在几台安卓真机上跑,文字全是虚的,边缘有明显的锯齿。
原因:没有按像素比设置 canvas 内部分辨率,系统把低分辨率画布拉伸到了高清屏,导致渲染发虚。部分机型对 canvas 的默认分辨率处理方式不同,有些安卓机的默认宽度只有 300px,拉伸到 350px 之后就出现锯齿。
解决:统一在初始化时调用 setupCanvasDpr,把 canvas.width 和 canvas.height 乘以设备像素比,并对 ctx 做 scale。这个问题需要让美工或测试在至少三台不同像素比的机器上看效果,iPhone 和安卓的高端机、低端机各看一遍。
5.3 现象三:快速点击会连续触发两次抽奖
转盘还在旋转的过程中,用户再次点击按钮,发现抽奖接口被调了两次,后端出现了两笔抽奖记录。
原因:抽奖锁的置位时机不对。我一开始把 spinning 置 true 放在了动画真正开始的回调里,但 tap 事件和动画启动之间有一些同步代码,用户在这段时间内可以再次点击,锁还没来得及生效。
解决:把 isSpinning 的判断放在 tap 事件处理函数的第一行,并且在进入函数后立刻置为 true,不留给任何异步代码空间。动画结束回调里再置回 false。这样处理之后,快速点击最多只会触发一次抽奖。
5.4 现象四:旋转时文字位置错乱、颜色互相污染
转盘转起来之后,原本应该固定在扇区里的文字,出现了一个扇区的文字串到另一个扇区的情况,甚至每个扇区颜色都不对。
原因:文字绘制函数里的 save/restore 没配对,或者扇区绘制和文字绘制之间共用了一个 ctx 状态。具体来说,绘制文字时 rotate 改变了坐标系,没有在 restore 里恢复,下一个扇区继续以错误的角度偏移绘制。
解决:把绘制文字封装成独立函数,在函数开头 ctx.save,在结尾 ctx.restore。扇区绘制的 fillStyle 和 strokeStyle 也尽量放在各自函数内部,不要依赖外部传入的全局状态。如果发现相邻扇区颜色串色,优先检查是否有某个扇区绘制后忘记重设 fillStyle。
5.5 现象五:type="2d" 在小程序低版本上拿不到节点
在部分安卓机上初始化 Canvas 时,SelectorQuery 的 fields({ node: true }) 返回的 node 是 undefined,转盘直接白屏。
原因:type="2d" 的基础库要求是 2.9.0,低版本基础库不支持 node 接口,或者返回的节点结构不同。用户微信版本偏旧时,这类问题偶发。
解决:初始化时通过 res[0].node 判断是否存在,不存在则降级使用 uni.createCanvasContext 创建旧版上下文。这里注意旧版上下文需要调用 draw() 才能上屏,动画函数里需要把每次重绘后的 draw() 调用补上。如果业务要求兼容很老的版本,只能整体退回到旧版 canvas 接口,新接口拿不到节点时没有太好的补救办法。
6. 进阶技巧:帧循环调度、离屏缓存与命中验证
这一章把性能优化和可验证性做一下收尾。
先说你一定会遇到的帧循环调度问题。小程序端没有标准的 requestAnimationFrame,直接用 setTimeout 模拟 16ms 一帧会造成一个副作用:页面切到后台再切回来时,setTimeout 的时间线被打断,动画会出现短暂跳变。解决办法是不要依赖帧数累加,而是每次 tick 都使用绝对时间戳计算 progress。我在第 3 章的 startSpin 里已经用了 Date.now() 做差值,这就是正确的做法。如果你想封装成可以复用的调度器,可以写这样一个小函数。
// 一个轻量的 requestAnimationFrame 兼容封装,便于在多个 Canvas 动画里复用 function raf(callback) { // 小程序里 setTimeout 返回的是 number,clearTimeout 接受这个 id return setTimeout(() => { callback(Date.now()) }, 16) }这个封装不复杂,核心价值是统一了 h5、App、小程序的帧循环入口。后续如果要引入第三方动画库,也可以把 raf 作为 polyfill 传入,降低改造量。
再说离屏缓存。当转盘奖品有 8 个甚至更多时,每帧全部重绘扇区和文字的开销并不小。可以先把不会变化的扇区底图绘制到一个离屏 canvas 上,旋转时只做 rotate 和 drawImage。实现思路是创建一个新的 canvas 节点,设置和主画布相同的宽高,把扇区和文字画上去,然后主循环里用 ctx.rotate 旋转并 drawImage 这张底图,指针重新画在最上层。这样每帧只有一次 drawImage 加一次指针绘制,开销降低得很明显。
最后是命中验证。写一套可回放的命中测试逻辑,能帮你省下大量联调时间。每一次抽奖结束后,不要只回调结果,把本次的 finalAngle、targetItem、targetOffset 打印到 console 里,或者上报到一个调试接口。这样出现“指针没指向中奖奖品”的投诉时,你只需要带着这次的角度日志,按相同参数复现动画,就能立刻判断问题是出在角度换算还是随机数生成。
从那以后,我每次做 Canvas 动画都强制自己走一遍流程:先确认坐标基准,再定绘制函数和状态隔离,然后写帧循环调度,最后加上命中日志。这个流程帮我避开了大多数转盘类组件的坑,希望帮到你。
本文还有配套的精品资源,点击获取