uni-app跨端刮奖组件实战:Canvas擦除、像素统计与性能优化

📅 发布时间:2026/10/5 7:43:58
uni-app跨端刮奖组件实战:Canvas擦除、像素统计与性能优化
如果你最近在做营销活动相关的前端需求大概率会遇到刮奖这个交互。我在 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连续快速触摸时能减少卡顿感。第二使用moveTolineTostroke画出连续路径比每次arcfill更平滑。第三lineWidth决定了刮擦的笔触大小40 像素左右手感比较合适太小用户要刮很多下太大又不真实。setLineCap(round)和setLineJoin(round)可以让笔画端点和拐角是圆形的避免刮出方形的锯齿边缘。这个细节对手感影响很大不加的话刮出来的线条很生硬。3.2 触摸坐标的统一封装这是跨端开发最容易出问题的地方。微信小程序的触摸事件touches[0]里有x和y属性H5 端则是clientX和clientYApp 端通常和小程序一致但也可能因版本不同有差异。如果直接写死某一端的取值换个环境就崩了。我封装了一个方法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物理像素翻倍后接近 750x500data 数组长度就有 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 里别想用 canvasuni-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 2DApp 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类型默认值说明widthNumber300画布 CSS 宽度heightNumber180画布 CSS 高度thresholdNumber0.7刮开触发阈值disabledBooleanfalse是否禁止刮奖coverTextString刮一刮覆盖层提示文字lineWidthNumber40刮擦笔触大小事件方面组件对外只发两个start用户开始刮时触发和complete刮开率达到阈值时触发。奖品内容通过默认插槽传入这样每个活动页可以自由定制奖品展示区域。代码里定义一个内部状态finished一旦complete触发就把finished置为 true后续 touchmove 不再处理擦除避免用户刮完还在继续操作。同时提供一个reset方法供外部调用重置组件用于再来一次场景。6.2 和全局弹窗组件结合从活动页到弹窗做营销活动时刮奖通常不是独立页面而是弹窗形式。用户点击活动入口弹出一个刮奖弹窗刮完显示奖品。我之前的做法是把弹窗逻辑和刮奖逻辑写在一起后来发现弹窗本身也是个可以复用的东西于是拆成了两个组件全局弹窗负责遮罩、显示/隐藏动画和插槽刮奖组件只负责刮擦区域。组合使用时页面结构大致如下global-popup :visibleshowScratchPopup closeclosePopup scratch-card :threshold0.7 prize5元红包 completehandleComplete view classprize-content image src/static/prize.png / text5元红包/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 classpage scratch-card refscratchCard :width300 :height180 :threshold0.7 :disableddisabled cover-text刮开有惊喜 starthandleStart completehandleComplete view classprize-box text classprize-name{{ prizeName }}/text /view /scratch-card /view /templatehandleComplete里做两件事调服务端接口确认奖品然后弹出结果弹窗。如果服务端返回未中奖或活动已结束需要调用this.$refs.scratchCard.reset()恢复覆盖层让用户重新参与其他活动规则。7. 体验优化与细节打磨刮奖手感的高级感从哪里来7.1 手感三要素线宽、圆角和连续路径刮奖手感是用户对这个功能最直观的感知。我调试下来影响手感的三个关键参数是lineWidth的大小、lineCap和lineJoin是否设置为round、以及每次 touchmove 是否能画出连续路径而不是离散的点。lineWidth太小用户要反复刮很多次才露出一小片区域容易烦躁太大比如 60 以上刮擦效果显得不真实轻轻一碰就掉一大块。我做活动页时默认 40同时在组件里开放了这个参数运营反馈刮起来费劲就调大刮得太快没参与感就调小。路径连续性方面不要用arcfill画圆点而是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 上二是阈值和采样逻辑一定要在真机上反复调试开发者工具里的表现和真机经常差出不少。每次接到新的活动需求我只要复制这个组件换一下奖品插槽内容和接口地址基本半小时就能上线一个完整的刮奖活动页。