ARTICLE DETAIL

资讯详情

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

uni-app跨端Canvas刮奖组件实现:从像素擦除到面积判定实战

uni-app跨端Canvas刮奖组件实现:从像素擦除到面积判定实战 最近做活动页接了个刮奖需求需要在 uni-app 里实现一个能跑在 App、H5、微信小程序上的刮奖效果。我原本以为网上找个现成插件改改就行结果翻了一圈发现要么是原生 Canvas 写法没法直接跨端要么是刮层逻辑写得稀烂刮完不干净、触发判定飘忽真到线上肯定被测试追着打。索性自己动手封了一个这篇文章就把这套实现思路、核心代码和踩过的坑完整拆出来给后面要做类似功能的同学省点时间。1. 刮奖需求分析与技术选型1.1 刮奖场景背后的核心需求刮奖这个交互表面上看起来就是“手指划过露出下面的奖品文字”但站在产品和体验的角度拆解它其实有四个必须满足的硬性要求。第一是真实感。涂层跟随手指的移动轨迹被刮开速度、力度不能有明显延迟刮过的地方要干净通透不能有残影或者马赛克一样的噪点。第二是判定准确。用户刮开面积达到某个比例通常 40% 到 60%之后需要自动弹出中奖结果判定不能忽快忽慢。第三是跨端一致。这套逻辑在微信小程序里和 App 里表现不能差太多尤其是 Canvas 的绘图行为和触摸事件差异处理不好就会出现“iOS 上能刮、Android 上刮不动”这种经典问题。第四是易集成。业务方要的只是一个组件传入奖品名称、中奖图片、背景样式回调一个onFinish事件就够了不需要理解 Canvas 底层。从这几个需求出发方案选型就有方向了核心交互用 Canvas 2D 实现刮层用触摸事件动态更新涂层透明度配合像素级清除逻辑做判定。渲染层和业务层解耦组件只负责“刮”和“判定”中奖弹窗、上报逻辑全部由外部业务方自己处理。1.2 为什么不用 CSS 遮罩方案网上有很多 CSS 实现刮奖的教程核心思路是用一个覆盖层监听 touchmove 事件把手指经过的位置的 DOM 节点透明度改成 0。这种方案在纯 H5 页面里确实能用但放到 uni-app 跨端场景里有两个致命问题。第一个问题是性能。刮奖涂层本质上是密集的 DOM 节点或者背景渐变在低端 Android 手机上touchmove 事件触发频率非常高如果每次移动都要操作 DOM 改样式浏览器需要不断触发重绘掉帧是必然的。第二个问题是跨端兼容。微信小程序的 WXML 节点操作和 H5 的 DOM 操作差异很大rpx 和 px 的换算、事件对象的坐标获取方式都不同CSS 方案要写多套兼容代码维护成本极高。Canvas 方案则天然适合这个场景。它是一块独立的绘图区域刮开操作通过clearRect清除涂层像素本身不涉及 DOM 重排只需要把绘制和清除的逻辑封装好多端就是一个 Canvas context 的差异而已。uni-app 对小程序的 Canvas 接口做了统一封装5 端也支持同等的canvas.getContext(2d)API一套代码基本全覆盖。1.3 整体实现流程拆解我的实现流程分为四步初始化获取 Canvas 节点设置画布尺寸加载背景图或填充背景色绘制覆盖层刮层。监听触摸在touchstart记录起始点touchmove里根据位移矢量连线清除涂层touchend做面积判定。面积判定通过遍历 Canvas 像素数据计算被清除区域占整个涂层的比例。回调通知达到阈值时触发finish事件业务方弹出中奖结果同时销毁刮层交互。这套流程看起来简单但每一步都有细节要处理。接下来我会按顺序把每一步的原理和代码讲透。2. Canvas 刮奖核心原理解析2.1 刮涂层的绘制逻辑刮奖涂层本质就是用 Canvas 画一个覆盖层常见有两种画法纯色填充和图片填充。纯色填充最简单一个fillRect搞定图片填充则要保证图片和画布尺寸一致不然涂层会被拉伸变形。在实际项目里刮层的质感决定了用户的第一感受。我见过很多实现直接用浅灰色#CCCCCC刮起来像在刮水泥地体验很生硬。推荐用渐变或者带纹理的图片作为涂层比如银灰色渐变加一些细碎的噪点视觉上更接近真实刮刮卡。绘制涂层时还有一个关键点Canvas 的坐标系统和 CSS 像素的对应关系。在 uni-app 中Canvas 节点的 width 和 height 属性决定画布的实际像素尺寸而节点的 CSS 样式尺寸可能因为 rpx 换算而不同。为了刮层完整覆盖画布必须把绘制坐标统一成 Canvas 内部像素坐标而不是 CSS 坐标。// 绘制涂层 function drawCover(ctx, width, height) { // 底色渐变模拟金属刮层 const gradient ctx.createLinearGradient(0, 0, width, height) gradient.addColorStop(0, #E8E8E8) gradient.addColorStop(0.5, #C8C8C8) gradient.addColorStop(1, #A8A8A8) ctx.fillStyle gradient ctx.fillRect(0, 0, width, height) // 加一层噪点纹理提升真实感 ctx.fillStyle rgba(255, 255, 255, 0.3) for (let i 0; i 200; i) { const x Math.random() * width const y Math.random() * height ctx.fillRect(x, y, 2, 2) } }绘制完成之后Canvas 上就有了一个完整的覆盖层。此时用户看到的是一张带有纹理的卡片摸上去实际上是滑上去涂层会被擦除露出底层内容。2.2 手指刮擦的差分实现刮擦的核心逻辑是监听触摸移动把移动轨迹映射到 Canvas 上做局部清除。具体做法是维护上一次触摸点lastX/lastY在touchmove中拿到当前点curX/curY以这两个点为端点画一条线线的宽度就是刮擦的笔触大小然后清除这条线经过的所有像素。这里有个非常容易踩的坑只清除当前点而不处理两点之间的连线。如果手指移动速度很快触摸事件的采样点之间会有很大的空隙结果就是刮层上留下一串断断续续的点像虚线一样体验很差。正确做法是使用 Canvas 的lineTo绘制一条路径然后用stroke配合globalCompositeOperation destination-out来擦除。destination-out是 Canvas 合成模式里专门用来“擦除”的模式。它会让当前绘制的图形区域变成透明露出下层内容。用这个模式配合一根粗线条就能把两点之间的区域完整擦除不会出现断点。// 清除涂层路径 function scratch(ctx, lastX, lastY, curX, curY, size) { ctx.save() ctx.globalCompositeOperation destination-out ctx.lineCap round ctx.lineJoin round ctx.lineWidth size ctx.beginPath() ctx.moveTo(lastX, lastY) ctx.lineTo(curX, curY) ctx.stroke() ctx.restore() }很多人忽略lineCap和lineJoin的设置导致擦除路径有尖角或者锯齿。设置成round之后笔划的起点和拐角都是圆润的刮出来的手感和真实笔触更接近。这个细节在视觉上影响很大尤其当笔画比较粗的时候。2.3 面积判定与阈值触发的实现思路面积判定是整个刮奖组件里计算量最大、也最容易出错的部分。核心思路是遍历画布像素统计透明像素的数量计算透明像素占总像素的比例当比例超过阈值比如 45%时触发完成事件。在 H5 端可以直接使用ctx.getImageData(0, 0, width, height)拿到像素数据数组然后遍历 data 数组的 alpha 通道每 4 个字节一组第 4 个字节是透明度。透明像素的 alpha 值为 0实际可能有少量误差比率即可得出。在微信小程序端getImageData的 API 同样存在但需要特别注意wx.canvasGetImageData和标准的 2d context 差异。在 uni-app 中推荐统一使用uni.createCanvasContext或者canvas.getContext(2d)获取的 context再调用getImageData这样能保证跨端兼容。// 计算刮开比例 function getScratchPercent(ctx, width, height) { const imageData ctx.getImageData(0, 0, width, height) const pixels imageData.data let transparent 0 const total pixels.length / 4 for (let i 3; i pixels.length; i 4) { if (pixels[i] 0) { transparent } } return transparent / total }有一个性能陷阱要提前说getImageData虽然好用但在大画布上频繁调用会有性能损耗尤其是在低端 Android 的微信小程序里每次遍历几十万像素可能造成明显的卡顿。所以面积判定不能每帧都做而是应该在touchend事件里只做一次。3. 组件完整代码与多端适配3.1 基于 uni-app 的完整刮奖组件代码考虑到真实场景里业务要的是“拿来就能用”我这里给出一份完整的 uni-app 组件代码。这份代码在 H5、微信小程序和 App 端都实测通过基础功能齐全涂层绘制、刮擦交互、自动判定、回调通知。核心逻辑控制在 180 行左右方便后续按业务需要修改。template view classscratch-card view classscratch-card__result !-- 底层奖品内容由外部通过插槽传入 -- image v-ifprizeImage :srcprizeImage classscratch-card__prize-img modeaspectFill / text v-ifprizeText classscratch-card__prize-text{{ prizeText }}/text slot nameprize/slot /view canvas classscratch-card__canvas :style{ width: canvasWidth px, height: canvasHeight px } :canvas-idcanvasId :idcanvasId touchstartonTouchStart touchmoveonTouchMove touchendonTouchEnd /canvas /view /template script export default { name: ScratchCard, props: { canvasId: { type: String, default: scratchCanvas }, canvasWidth: { type: Number, default: 300 }, canvasHeight: { type: Number, default: 150 }, brushSize: { type: Number, default: 30 }, finishPercent: { type: Number, default: 45 }, prizeImage: { type: String, default: }, prizeText: { type: String, default: } }, data() { return { ctx: null, isScratching: false, lastX: 0, lastY: 0, hasFinished: false } }, mounted() { this.initCanvas() }, methods: { initCanvas() { this.ctx uni.createCanvasContext(this.canvasId, this) this.drawCover() }, drawCover() { const { ctx, canvasWidth: w, canvasHeight: h } this const gradient ctx.createLinearGradient(0, 0, w, h) gradient.addColorStop(0, #E8E8E8) gradient.addColorStop(0.5, #C8C8C8) gradient.addColorStop(1, #A8A8A8) ctx.setFillStyle(gradient) ctx.fillRect(0, 0, w, h) // 加噪点纹理 ctx.setFillStyle(rgba(255,255,255,0.3)) for (let i 0; i 150; i) { const x Math.random() * w const y Math.random() * h ctx.fillRect(x, y, 2, 2) } ctx.draw() }, onTouchStart(e) { if (this.hasFinished) return const { x, y } this.getTouchPos(e) this.isScratching true this.lastX x this.lastY y }, onTouchMove(e) { if (!this.isScratching || this.hasFinished) return const { x, y } this.getTouchPos(e) const { ctx, lastX, lastY, brushSize } this ctx.save() ctx.globalCompositeOperation destination-out ctx.lineCap round ctx.lineJoin round ctx.lineWidth brushSize ctx.beginPath() ctx.moveTo(lastX, lastY) ctx.lineTo(x, y) ctx.stroke() ctx.restore() ctx.draw(false) this.lastX x this.lastY y }, onTouchEnd() { if (!this.isScratching || this.hasFinished) return this.isScratching false this.checkFinish() }, getTouchPos(e) { const touch e.touches e.touches[0] ? e.touches[0] : e.changedTouches[0] return { x: touch.x - 0, y: touch.y - 0 } }, checkFinish() { const { ctx, canvasWidth: w, canvasHeight: h, finishPercent } this uni.canvasGetImageData({ canvasId: this.canvasId, x: 0, y: 0, width: w, height: h, success: (res) { const pixels res.data let transparent 0 for (let i 3; i pixels.length; i 4) { if (pixels[i] 0) { transparent } } const percent (transparent / (pixels.length / 4)) * 100 if (percent finishPercent) { this.hasFinished true this.$emit(finish, { percent }) } } }) } } } /script style scoped .scratch-card { position: relative; width: 100%; height: 100%; } .scratch-card__result { position: absolute; left: 0; top: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #FFF5E6; border-radius: 12px; overflow: hidden; } .scratch-card__prize-img { width: 100%; height: 100%; } .scratch-card__prize-text { font-size: 28px; font-weight: bold; color: #D4380D; } .scratch-card__canvas { position: absolute; left: 0; top: 0; z-index: 10; } /style这段代码用到了uni.createCanvasContext和uni.canvasGetImageData这两个 API它们是 uni-app 官方封装好的跨端接口底层会自行判断是 H5 还是小程序对开发者屏蔽了大部分差异。3.2 微信小程序端 Canvas 上下文获取的版本差异微信小程序在基础库 2.9.0 之后Canvas 接口经历了从旧版到新版Canvas 2D的升级。uni-app 编译到微信小程序时默认使用旧版 Canvas 接口但也支持通过配置开启新版 Canvas 2D。两套体系的 API 有区别需要特别留意。旧版接口uni.createCanvasContext在频繁 draw 时有性能瓶颈因为每次ctx.draw()都是一次完整的画布提交。新版 Canvas 2D 接口则直接返回 CanvasRenderingContext2D 对象绘图调用更接近 H5 原生体验性能也好很多。如果项目只面向微信小程序建议直接在 manifest.json 里开启usingComponents: true并采用 Canvas 2D 方式但为了保持 uni-app 多端统一默认用官方封装的uni.createCanvasContext是更稳妥的方案。我个人在做这个组件时优先采用兼容方案先用uni.createSelectorQuery()拿到 Canvas 节点尝试获取 2d 上下文获取失败则回退到uni.createCanvasContext。这样在 H5 和新版小程序里都能跑且性能表现都不错。3.3 App 端 Canvas 坐标与触摸事件坐标转换App 端尤其是 nvue 页面和普通 vue 页面的 Canvas 处理略有不同。如果是普通 vue 页面行为接近 H5如果是 nvue 页面Canvas 的 API 和 vue 页面差异很大需要单独适配。我这个组件主要针对 vue 页面nvue 场景建议用 plus.canvas 或者原生插件不在这篇文章讨论范围内。App 端触摸事件的坐标在 vue 页面中和 H5 一样都可以从e.touches[0].x/e.touches[0].y获取。不过要注意这个坐标是相对于屏幕的不是相对于 Canvas 节点的。如果 Canvas 不在页面左上角需要做一次偏移计算。实际使用中我建议给 Canvas 加一个 fixed 定位或 absolute 定位并确保它位于页面固定位置这样坐标偏移量是常量直接减去偏移值即可。简化做法是利用uni.createSelectorQuery()动态获取 Canvas 节点的位置信息getCanvasOffset() { return new Promise((resolve) { const query uni.createSelectorQuery().in(this) query.select(#${this.canvasId}).boundingClientRect((rect) { resolve({ x: rect.left, y: rect.top }) }).exec() }) }拿到偏移量之后触摸坐标就是touch.x - offsetX和touch.y - offsetY。4. 刮奖组件封装与工程化实践4.1 参数配置与外部扩展设计上面的基础组件可以直接使用但真实业务中刮奖的尺寸、刮层的样式、奖品展示方式、完成回调的交互逻辑都各不相同所以组件一定要做参数化设计。我的建议是至少暴露这几个关键 propscanvasWidth/canvasHeight画布尺寸控制整体视觉大小。brushSize刮擦笔触大小数字越大刮得越快但真实感会下降。一般 20 到 40 之间表现较好。finishPercent触发完成事件的刮开比例45 到 60 是常见区间。prizeImage/prizeText底层奖品内容如果复杂直接使用插槽slot自定义。coverColor/coverImage刮层样式方便调整成品牌色或特殊纹理。回调事件方面至少要有两个finish刮开达到阈值时触发返回刮开的百分比用于弹窗或上报。reset外部调用组件方法重置状态方便“再刮一次”业务场景。4.2 重置与复用别让用户“刮完就没了”刮奖组件很容易被忽视的是重置逻辑。很多实现只考虑了“刮开 - 弹出结果”这一条路径但真实业务里“再刮一次”和“重新抽奖”的场景非常常见。如果组件已经触发 finish用户再次触摸时就不应该有反应但业务方可能在中奖弹窗里点“继续刮奖”这时候需要把涂层重新画出来并重置所有状态。初始化 Canvas 和重置换涂层本质上是同一件事所以drawCover()要设计成可重复调用的方法同时要重置hasFinished和lastX/lastY。我习惯把重置逻辑封装成组件方法用ref从外部调用this.$refs.scratchCard.reset()核心代码reset() { this.hasFinished false this.lastX 0 this.lastY 0 this.drawCover() }还有一个细节如果业务方用的是两张不同的奖品图第一次和第二次刮奖结果不同重置时需要同步更新底层插槽或 props 的内容。这块建议由业务方自己处理组件只负责涂层和判定逻辑保持单一职责。4.3 组件引用时的性能优化建议降低 Canvas 像素尺寸如果只是奖品展示不需要渲染高清大图Canvas 保持 300x150 左右的逻辑像素即可太大时遍历像素数据的开销成倍增加。避免频繁的 setData微信小程序里频繁setData会造成视图层和逻辑层通信开销所以不要把刮擦过程中的坐标通过setData暴露给模板只在组件内部用 js 变量维护。控制噪点数量绘制噪点纹理时用随机点的数量不要过多150 到 200 个足够多了在低端机上绘制涂层时会有可感知的耗时。判定防抖如果用户在临界点反复刮擦每次触发判定都遍历像素会造成卡顿。我建议在checkFinish里加一个 loading 标记判定期间禁止重复触发判定完再释放。5. 常见问题与排查技巧实录5.1 刮不开或刮了没反应这是最常见的反馈。如果你在微信开发者工具里测试正常但真机上刮不动一定要先检查 Canvas 的层级。Canvas 元素必须覆盖在奖品内容之上且不能有其它元素遮挡触摸事件。在 iOS 上Canvas 默认是一个原生组件层级比普通 view 高但在 Android 上可能会被后续插入的弹窗组件遮挡。另一个常见原因是坐标错位。如果touchmove里拿到的坐标没有减去 Canvas 的偏移量刮擦路径会落在画布外面于是看起来像刮不开。排查方法很简单在touchmove里打印当前坐标和lastX/lastY对比 Canvas 的实际位置一目了然。5.2 刮完不触发回调触发判定走的是touchend-canvasGetImageData- 遍历像素。如果刮开面积已经明显超过阈值但不触发优先检查canvasId是否一致。uni.createCanvasContext里用的canvasId必须和模板里的canvas-id属性完全一致大小写、拼写都不能差。此外canvasGetImageData在小程序端对 canvas 的尺寸有上限要求。如果画布过大在某些 Android 设备上会直接 fail。我这里实测画布尺寸 500x300 时部分低端机会偶发失败降到 300x150 之后稳定很多。如果业务上必须用大画布可以考虑后台缩放再遍历而不是直接读全尺寸画布。5.3 iOS 和 Android 刮擦手感不一致这几乎是 Canvas 交互组件绕不开的话题。iOS 的触摸事件采样频率高轨迹连贯Android 低端机采样频率低轨迹容易出现断点。我在组件里用lineTolineWidth的做法已经能覆盖大部分断点问题但在部分 Android 机型上如果touchmove两次事件的间隔太长仍然会出现刮擦不连贯的情况。一个有效的缓解方案是插值补点。当两个采样点的距离超过某个阈值比如 10px时在两个点之间插入若干中间点逐一执行moveTo和lineTo。这样即使触摸采样率低也能补出平滑的刮擦轨迹。// 距离超过阈值时插值补点 const distance Math.sqrt(Math.pow(x - lastX, 2) Math.pow(y - lastY, 2)) if (distance 10) { const steps Math.floor(distance / 5) for (let i 1; i steps; i) { const ratio i / steps const midX lastX (x - lastX) * ratio const midY lastY (y - lastY) * ratio ctx.lineTo(midX, midY) } } else { ctx.lineTo(x, y) }5.4 获取像素数据失败或读取结果不准canvasGetImageData在某些情况下拿到的像素数组是空的或者全是 255不透明。这种情况在 H5 端不常见但在小程序端偶发原因大多是 Canvas 尚未完全渲染图层还没有画上去就执行了读取。解决方法是确保在调用canvasGetImageData之前ctx.draw()的回调已经触发完成。旧版 Canvas 的ctx.draw是异步提交的在drawCover()里画完涂层之后应该等draw的回调执行完再允许用户刮擦或者在draw回调里再初始化触摸监听。还有一点如果涂层图片是一张外链图片必须确保图片加载完成后再绘制。很多同学在图片还没加载完的时候就drawImage结果是画布上什么都没有或者画了一片空白。我建议用uni.getImageInfo先拿到图片信息确认加载完成再执行绘制。5.5 多端表现差异排查速查表现象排查点解决方案刮不开/刮不动Canvas 层级遮挡、坐标偏移检查层级 z-index动态获取 Canvas 偏移刮擦轨迹断裂触摸采样率低、事件处理太慢插值补点、降低每帧 Canvas 操作复杂度触发不了回调canvasId 不一致、像素读取失败核对 canvasId、降低画布尺寸、确认 draw 完成iOS 正常 Android 卡顿Android WebView 性能差异减小画布尺寸、减少噪点数量、避免频繁 getImageData涂层有锯齿边缘lineCap/lineJoin 未设置绘制路径时设置 round round6. 个人实操中额外想分享的一点这套 Canvas 刮奖方案做完之后我又把它扩展到了另一个场景——用户协议签名板。同一个 Canvas 擦除逻辑只需要把destination-out换成默认的source-over再把笔触颜色改成黑色就是一个可用的手写签名组件。刮奖和签名本质上都是“在画布上追踪手指轨迹并绘制”只是合成模式不同而已。所以如果你后面要做的不是刮奖而是涂鸦、签名、手势解锁之类的功能这套组件里的触摸坐标处理、Canvas 层级管理、多端适配思路完全可以复用。核心经验就是触摸轨迹要连成线别只画点坐标定位要动态算别写死跨端接口要统一封装别各写一套。记住这三条Canvas 类的交互功能基本都能稳住。以后有空我打算把这份组件再加两个能力一是支持多层涂层叠加模拟刮开一层再刮一层二是把面积判定改成服务端下发阈值这样运营可以远程配置不同奖品的刮开难度。如果你也做过类似的 Canvas 交互欢迎在评论区一起聊聊你踩过的坑。
返回列表