如果你最近在做营销活动相关的前端需求,大概率会遇到"刮奖"这个交互。我在 uni-app 里实现刮奖功能时,原本以为只是画个 canvas 盖层、刮开就完事,真正落地才发现里面牵扯到跨端 canvas 差异、触摸坐标换算、刮开率计算、性能优化这些一连串问题。这篇文章就把我完整实现 uni-app 刮奖的过程拆开来讲,从原理到代码,从踩坑到封装组件,希望能帮你少走弯路。
1. 需求拆解:刮奖不只是"画个图层"这么简单
1.1 业务场景里最常见的几种刮奖玩法
刮奖在移动端营销活动里出现频率很高,主要分这么几类:第一种是转盘之外最常见的"刮刮卡",用户用手在屏幕上刮开灰色涂层,露出底下的奖品文案或图片;第二种是"刮出优惠码",刮开后显示一串兑换码,用户截图保存去使用;第三种是"刮开答题"或"刮开解锁",在刮开的瞬间触发下一步动作。
这些玩法看似不同,技术底层完全一致:上层是一块可被手指擦除的遮罩,下层是真正要展示的内容。我实现的这个组件就是基于这个逻辑,把上下两层分离、刮擦手势、刮开率判定全部封装起来,上层给 canvas,下层留着让外部传入任意内容,这样不管是显示文字、图片还是跳转按钮,都灵活得多。
1.2 技术选型:为什么最终用 Canvas 而不是 CSS
接这个需求时我第一个念头是:uni-app 是跨端框架,能不能用 CSS 的 mask 或者 clip-path 做刮擦效果?实测下来不行。CSS mask 在 H5 端表现还行,但微信小程序对动态修改 mask 的支持非常有限,App 端的 webview 和原生渲染层行为也不一致,真机上很容易出现图层不同步。而且 CSS 方案在做"手指滑过即擦除"这种连续路径时非常麻烦,需要维护大量 SVG 路径节点,性能反而更差。
Canvas 是各端支持最一致的方案。微信小程序、H5、App 的 vue 页面都支持 canvas 绘制,API 层面 uni-app 提供了uni.createCanvasContext统一封装,可以在大部分场景下一套代码跑三端。虽然新出的 Canvas 2D 接口在小程序端性能更好,但兼容性坑更多,后面我会专门讲。
1.3 组件需要解决的几个核心问题
梳理完需求后,我把技术问题归纳成四块:
- 画布初始化:不同设备的像素比不同,直接按 CSS 尺寸画会导致模糊。
- 刮擦实现:手指移动时如何精准擦除对应区域的覆盖层。
- 刮开率判定:怎么知道用户"刮完了",阈值设多少合理。
- 跨端兼容:小程序、H5、App 在 canvas API 和事件对象上的差异如何处理。
后面的内容就是围绕这四个问题逐个展开。
2. 刮奖核心原理:覆盖层、擦除模式与画布初始化
2.1 为什么必须用两层结构
先讲一个关键的设计决策。刮奖页面必须分成两层,底层是奖品内容,上层是覆盖层。有人图省事把奖品文字和覆盖层画在同一个 canvas 上,结果手指一擦,奖品文字也一起被擦掉了。原因很简单:canvas 的擦除操作是基于像素透明的,设定好globalCompositeOperation之后,画笔经过的地方所有像素都会被透明化,不分前景背景。
所以正确做法是:底层用普通的 view 或 image,展示奖品文字或图片;上层放一个 canvas,填充灰色或其他颜色作为刮奖涂层。用户在 canvas 上刮擦,canvas 被擦成透明的区域会露出下面的 view。uni-app 里这样写的话,两者之间不存在层级冲突,覆盖关系通过 CSS 定位天然实现。
2.2 初始化画布:设备像素比与模糊问题
初始化的第一步就是设置 canvas 尺寸。很多初学者直接写死width: 300; height: 200,在部分手机上刮奖涂层会发虚,尤其在 Retina 屏上。原因是 CSS 像素和物理像素不一致,canvas 默认按 CSS 像素创建位图,在高分屏上会被拉伸填充。
解决办法是拿到系统的pixelRatio,把 canvas 的实际宽高乘以这个倍数:
const info = uni.getSystemInfoSync() const dpr = info.pixelRatio this.canvasWidth = width // CSS 宽度 this.canvasHeight = height // CSS 高度 this.canvasRealWidth = width * dpr this.canvasRealHeight = height * dpr const ctx = uni.createCanvasContext('scratchCanvas', this) ctx.scale(dpr, dpr) // 让绘制坐标仍按 CSS 尺寸计算这里有个坑:uni.createCanvasContext这个旧版接口的scale会和之后绘制的所有内容叠加。如果你的项目里其他地方也用了同一个 canvasId,记得在draw之后重新设置或clearRect。我在初始化时就把 dpr 处理固化成一个方法,避免后续二次绘制时重复缩放。
2.3 覆盖层绘制:要有"刮刮乐"的质感
覆盖层不能只是纯灰。我用线性渐变模拟金属刮奖卡的质感,同时绘制提示文字,让用户一眼就知道这里可以刮。绘制代码如下:
drawCover() { const ctx = uni.createCanvasContext('scratchCanvas', this) const gradient = ctx.createLinearGradient(0, 0, this.canvasWidth, this.canvasHeight) gradient.addColorStop(0, '#d0d0d0') gradient.addColorStop(0.5, '#c0c0c0') gradient.addColorStop(1, '#a8a8a8') ctx.setFillStyle(gradient) ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight) ctx.setFillStyle('#999999') ctx.setFontSize(16) ctx.setTextAlign('center') ctx.fillText('刮一刮', this.canvasWidth / 2, this.canvasHeight / 2 + 6) ctx.draw() }注意draw()是异步的,如果后续马上要绑定触摸事件,其实问题不大。但是如果在draw的 callback 里再调canvasToTempFilePath之类的接口,就需要等draw完成。我习惯把draw包装返回一个 Promise,管理起来更顺手。
3. 手指刮擦的实现:touch 事件与 globalCompositeOperation 的组合
3.1 destination-out:唯一靠谱的擦除模式
Canvas 的globalCompositeOperation属性控制绘制内容如何叠加到已有内容上。默认是source-over,直接覆盖。擦除效果需要设为destination-out,含义是"保留目标图像中与源图像不重叠的部分",也就是画笔经过的地方会变成透明。
实现刮擦的代码逻辑如下:
scratch(e) { if (this.disabled || this.finished) return const touch = this.getTouchPos(e) if (!touch) return const ctx = uni.createCanvasContext('scratchCanvas', this) ctx.setGlobalCompositeOperation('destination-out') ctx.setLineWidth(40) ctx.setLineCap('round') ctx.setLineJoin('round') ctx.beginPath() ctx.moveTo(this.lastX, this.lastY) ctx.lineTo(touch.x, touch.y) ctx.stroke() ctx.draw(false) this.lastX = touch.x this.lastY = touch.y }这里有几个性能细节很重要。第一,ctx.draw(false)表示不采用异步绘制 callback,连续快速触摸时能减少卡顿感。第二,使用moveTo+lineTo+stroke画出连续路径,比每次arc+fill更平滑。第三,lineWidth决定了刮擦的"笔触"大小,40 像素左右手感比较合适,太小用户要刮很多下,太大又不真实。
setLineCap('round')和setLineJoin('round')可以让笔画端点和拐角是圆形的,避免刮出方形的锯齿边缘。这个细节对手感影响很大,不加的话刮出来的线条很生硬。
3.2 触摸坐标的统一封装
这是跨端开发最容易出问题的地方。微信小程序的触摸事件touches[0]里有x和y属性,H5 端则是clientX和clientY,App 端通常和小程序一致但也可能因版本不同有差异。如果直接写死某一端的取值,换个环境就崩了。
我封装了一个方法:
getTouchPos(e) { const touch = e.touches && e.touches[0] if (!touch) return null return { x: touch.x ?? touch.clientX, y: touch.y ?? touch.clientY } }还要注意,这个x和y是相对页面的坐标,如果 canvas 不在页面左上角,需要减去 canvas 本身的偏移量。在 H5 端可以通过uni.createSelectorQuery获取 canvas 的 boundingClientRect,在小程序端也可以用同样方式。实际操作中我通常把画布固定在页面中央并占满固定宽度,这样偏移量是固定的,计算一次即可。
touchstart时要记录初始坐标this.lastX = touch.x; this.lastY = touch.y,否则每次 touchmove 都是从(0,0)开始连线,会出现一条从左上角拉到手指位置的"闪电线"。这个坑我刚实现时踩过,排查半天发现只是忘记在start里初始化坐标了。
3.3 防抖和节流:别让 getImageData 拖垮真机
如果用户在刮奖时每帧都去执行像素统计,低端机直接卡死。我在性能优化上用了两招:一是给getImageData的调用加节流,固定 200ms 才检测一次;二是用 requestAnimationFrame 优化触摸绘制的频率。uni-app 里 H5 端有requestAnimationFrame,小程序端需要在生命周期里自己封装,或者直接利用draw的异步特性来控制节奏。
节流代码大致是这样的:
if (this.canDetect && !this.finished) { this.canDetect = false setTimeout(() => { this.checkScratchRate() this.canDetect = true }, 200) }不要每次 touchmove 都调checkScratchRate,真机上用canvasGetImageData读取全量像素是相当重的操作,20 毫秒内密集调用会直接导致 canvas 卡顿。
4. 刮开率判定:像素级统计与阈值策略
4.1 getImageData 采样原理
判断用户是否刮开的核心思路是统计 canvas 上透明像素的比例。canvas 的像素数据存放在ImageData对象里,data是一个一维数组,每四个元素代表一个像素的 RGBA 值。当覆盖层被destination-out擦除后,被擦区域的 alpha 通道值接近 0。
旧版接口的获取方式:
checkScratchRate() { uni.canvasGetImageData({ canvasId: 'scratchCanvas', x: 0, y: 0, width: this.canvasRealWidth, height: this.canvasRealHeight, success: (res) => { const data = res.data let count = 0 const total = data.length / 4 for (let i = 3; i < data.length; i += 4) { if (data[i] < 128) count++ } const ratio = count / total if (ratio >= this.threshold) this.finishScratch() } }) }这里有个容易忽略的地方:uni.canvasGetImageData接收的width和height必须是 canvas 物理像素尺寸,而不是 CSS 尺寸。如果你初始化时乘了 dpr,这里也一定要传this.canvasRealWidth和this.canvasRealHeight,否则会报参数错误或读取区域不足。
阈值选取方面,我测试下来 70% 是比较舒服的值。太低会出现用户刮了几笔就触发的违和感,太高则要求用户把角落也刮干净,挫败感强。如果覆盖层上有大片文字图标,可以考虑把阈值调到 75% 以上,因为文字区域本身不透明,会影响统计结果。
4.2 降采样检测:全量统计太慢,跳步采样更实用
全量遍历data在小尺寸 canvas 上问题不大,但如果 canvas 做大了,比如宽度 375、高度 250,物理像素翻倍后接近 750x500,data 数组长度就有 150 万个元素,每次遍历 20 多万次循环。节流之后还行,但还想更快的话就用跳步采样。
思路是这样的:不需要检查每一个像素,每隔 4 或 6 个像素取一个点做统计。因为刮开的区域是大面积连续的,采样结果足以反映真实刮开比例:
const step = 6 let count = 0 let total = 0 for (let y = 0; y < this.canvasRealHeight; y += step) { for (let x = 0; x < this.canvasRealWidth; x += step) { const i = (y * this.canvasRealWidth + x) * 4 + 3 if (data[i] < 128) count++ total++ } } const ratio = count / total实测跳步采样把检测耗时从 40ms 降到了 6ms 左右,真机上体感明显。阈值建议在原基础上稍微上调一点,因为采样点可能恰好落在未刮开的碎屑上。
4.3 中奖逻辑不能全放前端
刮开率判定是前端行为,但中奖结果最好由服务端下发,前端只负责展示。原因很简单:纯前端判定中奖,用户改个请求或者断网重连就能刷出不同结果,活动风控基本等于没有。
实际设计时,我会在finishScratch时调服务端接口获取奖品,且只给一次机会,服务端记录该用户的抽奖状态。如果必须本地兜底展示,也要在进入页面时先拉取本次活动的奖池配置和剩余次数,刮开只是触发一次"开奖"动作。
这种前后端职责分离还有一个好处,就是刮开面积检测有误差时用户不会真正"亏"。即使前端误判刮开,服务端还能根据活动规则做二次确认。
4.4 关于"刮完发现还有残留"的处理
有用户反馈刮到 70% 后触发了完成回调,但左下角还有一小块灰色残留,从视觉上很难看。这是抗锯齿边缘导致的:擦除画笔路径边缘会有半透明像素,alpha 值在 128 附近,既不是完全透明也不算完全不透明。
处理方案有两个:一个是在触发完成时给 canvas 加一个fadeOut动画,把覆盖层透明度渐变为 0,视觉上盖住残留;另一个是最终检测时使用更低阈值,比如 alpha 小于 200 都算透明。我在组件里默认用后者,效果更自然,推荐你也试一下。
5. 跨端兼容:小程序、H5、App 的实测差异与踩坑记录
5.1 小程序端:canvas 类型和同层渲染
微信小程序里 canvas 存在老版原生组件和新版同层渲染两种形态,对应 uni-app 中就是uni.createCanvasContext和Canvas 2D两种方式。
我用uni.createCanvasContext在小程序端能跑通基础功能,但会遇到一个经典问题:canvas 是原生组件,默认层级最高,即使设了 z-index 也无法被普通 view 盖住。要想在 canvas 上弹出一个"恭喜中奖"的弹窗或按钮,必须用cover-view。好在基础刮奖场景主要是 canvas 在顶层、下面露奖品,不太受影响。如果你还要在 canvas 上方放按钮,就得用 cover-view 或改用 Canvas 2D 的同层渲染。
Canvas 2D 接口在微信小程序里写法如下:
const query = uni.createSelectorQuery().in(this) query.select('#scratchCanvas').fields({ node: true, size: true }).exec((res) => { const canvas = res[0].node const ctx = canvas.getContext('2d') const dpr = uni.getSystemInfoSync().pixelRatio canvas.width = res[0].width * dpr canvas.height = res[0].height * dpr ctx.scale(dpr, dpr) })Canvas 2D 的ctx.getImageData是同步方法,可以直接取数据,不需要走 uni 的异步封装,写起来更清爽。但它的兼容性不如老接口,基础库版本要求较高,低版本微信打开页面会白屏。我在做活动页时通常考虑用户群体,如果主要用户微信版本较新才敢用,否则退回老接口更稳妥。
5.2 App 端的限制:nvue 里别想用 canvas
uni-app 的 App 端分 vue 页面和 nvue 页面。nvue 页面走的是原生渲染,canvas 支持非常弱,很多绘制 API 在 Android 上直接不生效,iOS 也时好时坏。我踩过一次坑:把一个刮奖弹窗放进 nvue 页面,结果在 Android 真机上覆盖层画不出来,查了半天文档才发现是 nvue 的限制。
结论很明确:刮奖这类强 canvas 交互,页面必须用 vue 页面,不能用 nvue。如果你的 App 整体是 nvue 做的,要单独把这个页面抽出来用 vue 实现,或者用 webview 内嵌 H5 页面。另外 App 端老接口的canvasGetImageData在某些 Android 版本上有兼容问题,建议在真机上多测几个机型,特别是低端 Android。
5.3 H5 端的特殊问题:坐标接收与图片跨域
H5 端 canvas 是普通 HTML 元素,触摸事件和坐标体系和小程序不同,所以我前面封装的getTouchPos中touch.x ?? touch.clientX就是为这种情况准备的。H5 端还存在一个经典问题:如果奖品图片是跨域资源,直接绘制到 canvas 上会污染画布,导致后续getImageData无法读取数据。解决办法是给图片加crossOrigin属性。
不过我的组件设计里底层奖品内容是用 view 和 img 展示的,canvas 上只画覆盖层,所以没有图片跨域污染的问题。这是两层结构相对单 canvas 方案的另一个隐藏优势。
5.4 各端差异对照表
| 能力 | H5 | 微信小程序(老接口) | 微信小程序(Canvas 2D) | App vue 页面 |
|---|---|---|---|---|
| uni.createCanvasContext | 支持 | 支持 | 不支持 | 支持 |
| Canvas 2D 上下文 | 支持 | 基础库 2.9+ | 支持 | Android 兼容一般 |
| canvasGetImageData | 支持 | 支持 | 用 getImageData | 部分机型异常 |
| 单 canvas 层级覆盖 | 正常 | 需 cover-view | 同层渲染正常 | 基本正常 |
如果你是面向全端的营销活动,我建议默认走uni.createCanvasContext,只在能确认用户微信版本足够高且只做小程序活动时才切换 Canvas 2D。
6. 组件化封装:从页面代码到可复用的刮奖组件
6.1 Props 和 Events 设计思路
页面里的刮奖代码一旦写死,下一个活动又要复制粘贴,维护成本太高。我把整个刮奖逻辑封装成了一个组件ScratchCard.vue,对外暴露最少量的配置项。
组件的主要 props 设计如下:
| Prop | 类型 | 默认值 | 说明 |
|---|---|---|---|
| width | Number | 300 | 画布 CSS 宽度 |
| height | Number | 180 | 画布 CSS 高度 |
| threshold | Number | 0.7 | 刮开触发阈值 |
| disabled | Boolean | false | 是否禁止刮奖 |
| coverText | String | '刮一刮' | 覆盖层提示文字 |
| lineWidth | Number | 40 | 刮擦笔触大小 |
事件方面,组件对外只发两个:start(用户开始刮时触发)和complete(刮开率达到阈值时触发)。奖品内容通过默认插槽传入,这样每个活动页可以自由定制奖品展示区域。
代码里定义一个内部状态finished,一旦complete触发就把finished置为 true,后续 touchmove 不再处理擦除,避免用户刮完还在继续操作。同时提供一个reset方法,供外部调用重置组件,用于"再来一次"场景。
6.2 和全局弹窗组件结合:从活动页到弹窗
做营销活动时,刮奖通常不是独立页面,而是弹窗形式。用户点击活动入口,弹出一个刮奖弹窗,刮完显示奖品。我之前的做法是把弹窗逻辑和刮奖逻辑写在一起,后来发现弹窗本身也是个可以复用的东西,于是拆成了两个组件:全局弹窗负责遮罩、显示/隐藏动画和插槽,刮奖组件只负责刮擦区域。
组合使用时,页面结构大致如下:
<global-popup :visible="showScratchPopup" @close="closePopup"> <scratch-card :threshold="0.7" prize="5元红包" @complete="handleComplete" > <view class="prize-content"> <image src="/static/prize.png" /> <text>5元红包</text> </view> </scratch-card> </global-popup>这样做的好处是刮奖弹窗不只在"活动中心"能用,任何页面的运营位都能随时唤起,接一个新的营销活动页面时,只需要替换插槽内容和complete回调里的逻辑。
6.3 弹窗场景下需要注意的 canvas 生命周期
弹窗里的 canvas 有个棘手问题:弹窗默认是隐藏的,第一次显示时才渲染。如果 canvas 所在的元素初始是display: none,某些端上 canvas 的初始化会失败,或者绘制出来尺寸为 0。我在弹窗显示后的 nextTick 里再调用初始化方法,同时给组件加一个visible属性,确保只有弹窗真正展示时才执行绘制的第一步。
watch: { visible(newVal) { if (newVal) { this.$nextTick(() => { this.initCanvas() this.drawCover() }) } } }还有一种情况是弹窗从display: none切换到display: block时,canvas 的宽高会被重置,需要重新初始化。我在组件的 props 里增加了resetKey字段,弹窗每次打开时给它一个递增的数字,组件 watch 到这个变化就重置内部状态。
6.4 封装组件的完整使用示例
组件封装完成后,页面使用非常简洁:
<template> <view class="page"> <scratch-card ref="scratchCard" :width="300" :height="180" :threshold="0.7" :disabled="disabled" cover-text="刮开有惊喜" @start="handleStart" @complete="handleComplete" > <view class="prize-box"> <text class="prize-name">{{ prizeName }}</text> </view> </scratch-card> </view> </template>handleComplete里做两件事:调服务端接口确认奖品,然后弹出结果弹窗。如果服务端返回"未中奖"或活动已结束,需要调用this.$refs.scratchCard.reset()恢复覆盖层,让用户重新参与其他活动规则。
7. 体验优化与细节打磨:刮奖手感的"高级感"从哪里来
7.1 手感三要素:线宽、圆角和连续路径
刮奖手感是用户对这个功能最直观的感知。我调试下来,影响手感的三个关键参数是:lineWidth的大小、lineCap和lineJoin是否设置为round、以及每次 touchmove 是否能画出连续路径而不是离散的点。
lineWidth太小,用户要反复刮很多次才露出一小片区域,容易烦躁;太大,比如 60 以上,刮擦效果显得不真实,轻轻一碰就掉一大块。我做活动页时默认 40,同时在组件里开放了这个参数,运营反馈"刮起来费劲"就调大,"刮得太快没参与感"就调小。
路径连续性方面,不要用arc+fill画圆点,而是moveTo上一个点、lineTo当前点、stroke画出轨迹。这样才能形成平滑的刮擦路径,视觉上更像真实的刮刮卡。
7.2 锯齿和边缘残留的终极处理
即便设置了lineCap: round,刮擦边缘在某些机型上还是能看到细微锯齿。这主要是 canvas 抗锯齿能力有限和 dpr 缩放导致。我的处理方式是在覆盖层绘制时增加一点模拟颗粒感的噪点纹理,让画面看起来更"钝",反而削弱了锯齿的视觉突兀感。
如果刮完触发complete后还有明显残留,我会执行一次快速清理:直接把覆盖层整体擦除,不留尾巴。
finishScratch() { this.finished = true const ctx = uni.createCanvasContext('scratchCanvas', this) ctx.setGlobalCompositeOperation('destination-out') ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight) ctx.draw() this.$emit('complete') }7.3 埋点、防重复点击与重置机制
线上活动最重要的就是埋点和防刷。我在组件里预留了@start和@complete两个事件,埋点直接在这两个时机上报。防重复方面,finished状态一旦置为 true,所有触摸逻辑都会短路,这是第一道防线;第二道防线是disabledprop,页面可以在调服务端接口期间把组件设为禁用,防止用户疯狂触摸。
还有一个小细节:弹窗关闭时 canvas 并没有销毁,下次打开如果直接复用,用户看到的还是上次刮开后的透明画布。所以弹窗每次打开都必须调用reset(),重绘覆盖层。我的reset会做三件事:清空画布、重置finished为 false、重新执行drawCover()。
7.4 针对低端机的降级体验
最后补充一个经验。在做全端活动时,低端 Android 机的 canvas 性能会明显吃紧,有时甚至出现触摸轨迹延迟。我给组件加了一个简单的降级策略:如果是低端机(通过getSystemInfoSync的benchmarkLevel判断),刮擦默认使用稍小的lineWidth和更低的检测频率,避免长时间卡顿。还有一种极端的降级方案是提供一个"刮开"按钮,用户点击按钮直接触发完成,保住活动基本流程。
我做这个组件过程中,最深的体会有两点:一是两层结构是刮奖实现的基石,千万别为了省事把奖品和覆盖层画在同一个 canvas 上;二是阈值和采样逻辑一定要在真机上反复调试,开发者工具里的表现和真机经常差出不少。每次接到新的活动需求,我只要复制这个组件,换一下奖品插槽内容和接口地址,基本半小时就能上线一个完整的刮奖活动页。