ARTICLE DETAIL

资讯详情

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

uni-app跨端刮奖组件实战:canvas涂层绘制与真机兼容

uni-app跨端刮奖组件实战:canvas涂层绘制与真机兼容 做营销H5的时候产品突然丢过来一个需求这期活动不做大转盘了改成刮刮卡用户在活动页刮开涂层直接看到中奖结果。我当时第一反应是这东西又不难网上随便找个刮奖插件改改就行。结果真开始在uni-app里落地才发现坑比想象中多得多。PC端H5的刮奖demo拿来直接跑真机上要么涂层刮不动要么刮完判定不触发要么iOS和安卓表现完全不一样。这篇文章就记录一下我在uni-app里从零实现一个刮奖功能的完整过程包括canvas方案选型、涂层绘制与擦除、中奖判定、真机兼容处理以及最后怎么封装成一个可以直接给业务方使用的组件。如果你正在用uni-app做营销活动需要一个能直接上线的刮奖功能这篇文章应该能帮你省下不少弯路。我尽量把关键代码和排查过程都写清楚不只是给结论也讲清楚为什么这么做。1. 刮奖需求背后的真实业务场景与方案选型1.1 为什么不能直接拿H5刮奖插件改一改就上线很多人觉得刮奖就是一张图片盖在奖品上手指擦掉涂层就算完事。但放到uni-app这个跨端环境里问题就变复杂了。uni-app要同时兼容微信小程序、App、H5三个平台这三个平台对canvas的支持方式有本质差别。H5上你随便用DOM操作canvas没问题但小程序里canvas是原生组件App端又分vue页面和nvue页面渲染机制完全不同。如果直接从github上拉一个纯H5的刮奖库通常是用relative定位加canvas覆盖实现的拿到小程序里大概率会碰到层级错乱、触摸坐标偏移、canvas被弹窗遮挡这类问题。我最初也图省事找了个比较知名的刮奖插件代码里全是web标准API什么getContext(2d)、requestAnimationFrame、canvas.getBoundingClientRect()在uni-app的H5端跑得挺好一编译到微信开发者工具就报错说找不到canvas.getBoundingClientRect。这个函数在小程序的canvas节点上根本不存在小程序的canvas获取节点信息必须通过uni.createSelectorQuery()来查。所以结论很明确在uni-app里做刮奖必须按uni-app自己的canvas体系来写不能指望跨端插件一劳永逸。1.2 两层结构底层展示结果上层蒙层被刮开刮奖的本质是两层叠加。底层是活动结果比如中奖图片、奖品文案、按钮上层是一层不透明的涂层盖住结果。用户手指在涂层上滑动时程序实时把手指经过区域的像素变成透明露出底层。涂层刮到一定比例后程序判定为已刮开自动展示完整结果。在uni-app里的实现方式底层可以直接用普通的view组件配合图片和文字来布局上层用一个canvas覆盖。canvas绘制一层灰色或彩色的涂层然后用canvas的globalCompositeOperation属性来做擦除。这个属性的作用是设置新绘制图形的合成方式把它设置成destination-out新画的图形就只会在原有内容上挖洞把该区域的像素变透明。每根手指头移动经过的路径就是一组连续挖出来的透明区域视觉上就是涂层被刮开了。这里要说明一个关键点涂层层必须用canvas而不能用一张静态图片做蒙层。原因很简单在移动端我们拿不到图片某个区域变成透明的能力图片是像素数据除非你用WebGL做像素级操作否则只能通过canvas的绘图API来动态改变像素。canvas天然支持读取和写入像素数据所以刮奖这类需求canvas基本是唯一选择。2. 核心实现canvas初始化、涂层绘制与手指擦除的完整代码2.1 新版type2d的canvas接口才是跨端首选uni-app官方在Vue 3版本之后推荐使用type2d的canvas接口。这个接口和Vue 2时期常用的uni.createCanvasContext相比最大的优势是它直接暴露了原生canvas节点对象你可以用标准的getContext(2d)来获取绘图上下文。这和Web端开发者的习惯一致而且性能也比老的uni.createCanvasContext好一些因为后者每次调用绘图API都要走一层bridge封装在触摸高频绘制的时候性能损耗非常明显。我实际测试下来在老版本uni.createCanvasContext模式下touchmove事件里频繁调用ctx.arc()画圆在低端安卓机上会有明显卡顿刮起来像PPT翻页一样一顿一顿的。换成type2d后流畅度提升非常明显。所以如果你项目已经升级到Vue 3 Vite直接用type2d没必要为了兼容旧代码继续用老接口。canvas节点的初始化代码如下注意要放在onReady()生命周期里执行这个阶段DOM节点已经渲染完成才能通过uni.createSelectorQuery()查询到canvas节点。onReady() { this.$nextTick(() { this.initCanvas() }) }, methods: { initCanvas() { const query uni.createSelectorQuery().in(this) query.select(#scratchCanvas).fields({ node: true, size: true }).exec((res) { if (!res || !res[0]) return const canvas res[0].node const ctx canvas.getContext(2d) // 关键canvas实际像素尺寸必须乘以设备像素比 const dpr uni.getSystemInfoSync().pixelRatio canvas.width res[0].width * dpr canvas.height res[0].height * dpr ctx.scale(dpr, dpr) this.canvas canvas this.ctx ctx this.drawMask() }) }, drawMask() { const ctx this.ctx const { canvas } this const width canvas.width / uni.getSystemInfoSync().pixelRatio const height canvas.height / uni.getSystemInfoSync().pixelRatio // 绘制涂层底色 ctx.globalCompositeOperation source-over ctx.fillStyle #b0b0b0 ctx.fillRect(0, 0, width, height) // 涂层上写提示文字 ctx.fillStyle #e8e8e8 ctx.font bold 18px sans-serif ctx.textAlign center ctx.textBaseline middle ctx.fillText(刮一刮, width / 2, height / 2) } }2.2 高清屏适配canvas.width不乘dpr涂层会模糊到怀疑人生上一段代码里最容易被忽略的就是canvas.width res[0].width * dpr这一步。res[0].width拿到的是canvas元素的CSS逻辑尺寸比如375px但手机屏幕的物理像素是逻辑像素的2倍或3倍也就是设备像素比dpr。如果直接把canvas的width设成375那canvas的实际像素分辨率只有375x对应高度屏幕上显示的时候系统会把它拉伸到750x物理像素结果就是涂层边缘全是锯齿文字发虚刮出来的边缘像狗啃一样。正确做法是让canvas的物理像素等于CSS逻辑尺寸乘以dpr然后通过ctx.scale(dpr, dpr)把绘图坐标系缩放回逻辑尺寸。这样写出来的绘图代码所有的坐标都基于逻辑像素来算视觉上就是高清的。顺带提一个容易被坑的点fields({ node: true, size: true })里必须带size: true否则拿不到res[0].width和res[0].height后续的canvas固定尺寸就无从谈起。我第一次写的时候漏了size结果赋值出来的canvas宽高全是undefinedcanvas宽度变成默认的300px涂层只覆盖了一小块区域排查了半天。2.3 touch事件绑定与连续擦除路径优化涂层画好后接下来是触摸事件。canvas上的触摸事件监听在type2d模式下和普通view组件没什么区别直接在canvas标签上绑定touchstart、touchmove、touchend即可。这里有一个重要的坐标问题触摸事件返回的e.touches[0].x和e.touches[0].y在小程序的canvas场景下是基于canvas元素的相对坐标不是页面坐标。所以不需要像H5端那样手动减掉canvas的偏移量。如果你用的是uni.createSelectorQuery()查canvas节点位置再去做坐标换算那多半是走了弯路而且容易算错。擦除的核心逻辑如下onTouchStart(e) { const touch e.touches[0] this.lastX touch.x this.lastY touch.y }, onTouchMove(e) { const touch e.touches[0] const x touch.x const y touch.y const ctx this.ctx ctx.globalCompositeOperation destination-out ctx.lineWidth 28 ctx.lineCap round ctx.lineJoin round ctx.beginPath() ctx.moveTo(this.lastX, this.lastY) ctx.lineTo(x, y) ctx.stroke() this.lastX x this.lastY y // 节流每移动一定距离才做像素采样判断 this.lastMoveTime Date.now() this.throttleCompute() }这里的核心优化是用lineTostroke画线而不是用arc()画圆。arc()画圆的问题是每个触摸点都要写一个路径touchmove每秒触发几十次叠加起来对canvas的填充性能压力非常大。而画线只需一条路径配合lineCap: round让线条两端呈圆形视觉上就是连续的圆珠笔效果性能要好得多。lineWidth就是刮条的粗细我一般设28px太小刮得慢太大刮几下就全开了没有仪式感你根据实际卡片尺寸调整。2.4 触摸过程中的防滚动与手势冲突这一步非常关键也是大多数新手最容易漏掉的。在小程序里如果刮奖canvas放在一个可以滚动的页面中手指在canvas上滑动时很容易触发页面的滚动。一旦页面开始滚动touch事件就会中断涂层刮到一半就断了体验很糟糕。解决办法是在canvas上绑定touchmove.stop.prevent或者在小程序里用catchtouchmove来阻止事件冒泡。uni-app的写法是canvas idscratchCanvas type2d classscratch-canvas touchstartonTouchStart touchmove.stop.preventonTouchMove touchendonTouchEnd /canvastouchmove.stop.prevent是uni-app支持的修饰符写法同时在微信小程序和App端生效。这样手指在canvas上滑动时页面不会跟着滚动刮奖体验才稳定。不过要注意一点加了.prevent之后如果canvas本身的高度很小用户手指滑出canvas范围触摸事件就会丢失所以在touchend里要做一次兜底判定确保即使触摸中断也能完成当前区域的擦除。3. 中奖判定、像素采样算法与业务闭环设计3.1 用getImageData采样透明像素而不是傻乎乎遍历全部像素涂层刮开后什么时候判定已经刮完了业界的通用做法是计算当前canvas中透明像素占整个涂层的比例超过阈值比如40%-60%就认为是刮开了。这个比例定多少合适阈值设太低用户才刮了一个角就弹结果基本没有刮奖快感太高的话边缘残留的碎屑会导致迟迟到不了阈值用户以为卡死了。我一般设45%-55%之间比较合适具体看涂层面积和刮条粗细实测下来50%的手感最好。获取像素数据用ctx.getImageData(0, 0, width, height)返回的data是一个Uint8ClampedArray每四个元素表示一个像素的RGBA值。遍历判断alpha通道是否为0即可。但这里有个性能陷阱如果涂层是375x200的canvas总共7.5万个像素每次getImageData要拷贝30万字节的数据再加上遍历计算在touchmove这种高频事件里调用手机上很容易卡顿。我的解决方案是采样计算。比如说每隔4个像素取一个来判断这样计算量直接降到原来的1/16精度损失完全可以接受。另外不在每次touchmove里都计算而是用节流每300ms计算一次或者每移动一定像素距离计算一次。实测下来这两种优化叠加后Android中端机上也没有任何卡顿感。computeScratchPercent() { const ctx this.ctx const { canvas } this // 采样每隔4个像素取一个 const imageData ctx.getImageData(0, 0, canvas.width / 4, canvas.height / 4) 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的时候就缩小范围把宽高除以4这样拿到的像素总量已经缩小了16倍遍历时再按下标i 4隔4个元素判断一次计算量进一步缩小到原来的1/64。两个优化叠加中端手机上也能秒出结果。3.2 中奖概率由后端控制前端只做展示刮奖最容易犯的错误是把是否中奖的逻辑写在前端。比如前端生成一个随机数如果小于某个概率值就发一等奖。这样做看起来没问题但营销活动有真实的利益关系前端代码可以被逆向、被改概率、被刷券。而且如果前端判断了中奖直接给用户展示恭喜中奖实际发奖却要调后端接口接口里还得再判断一次概率两边不一致就会出现用户看到中奖但接口不认账的尴尬情况。正确的做法是活动配置从后端接口拉取配置里包含奖项列表、刮开后的展示文案、兑奖规则等信息。用户刮到阈值后前端只负责展示一个等待弹窗然后调后端兑奖接口。后端根据用户信息、活动库存、参与次数来决定返回什么结果。前端拿到结果后展示对应的中奖页面或未中奖页面。代码层面前端刮开后不用关心概率是多少只需要在达到阈值时调用兑奖接口async onScratchComplete() { this.isComplete true this.showResult true this.scratchPercent 1 // 后续不需要继续刮了 uni.showLoading({ title: 开奖中... }) try { const res await this.drawAward() // 调后端兑奖接口 if (res.code 0) { this.awardInfo res.data this.resultVisible true } else { // 提示活动已结束或奖品已领完 } } finally { uni.hideLoading() } }这里有个体验优化的点在后端返回结果之前可以先把涂层完全透明掉让用户看到底层的中奖图片。这个先看到结果再弹窗的顺序符合用户的心理预期——刮开就立刻看到东西再等一下才进入兑奖流程。如果反过来刮完后涂层透明了但底层只是一个loading体验就差很多。3.3 防作弊门禁判断不能全压在客户端活动上线后优先要考虑的是刷量问题。很多运营活动吃亏就吃亏在没做风控被羊毛党把库存全刷走了。在uni-app前端能做的防刷手段很有限但至少要做这几点第一设备维度限制。用uni.getStorageSync记录本机当天参与次数超过N次就不允许再刮。虽然用户清了缓存就没用但至少挡住了一部分小白刷子。第二人机校验。可以考虑在刮奖前加一个简单的验证码或者滑块验证但这对用户体验伤害比较大通常在低成本活动里不做而是把参与资格绑定到登录态上必须登录后才能参与。第三不能信任前端的刮开结果。前面已经说了真正是否发奖必须由后端接口决定后端可以做频控、黑名单、库存校验。前端不管算出什么结果都只是体验层的状态不是发奖凭证。还有一个细节canvas绘制完涂层后默认涂层是盖住的。但小程序开发者工具里可以点显示所有canvas内容方便调试。正式上线时不要用这个来作弊但开发时可以用来快速验证底层布局是否正常。4. 真机兼容性踩坑实录iOS、Android与小程序端的差异4.1 iOS上canvas绘制内容偶发空白这个坑出现得很诡异在iOS真机上第一次进入页面时canvas里面什么都没有灰色涂层完全不显示但触摸刮的时候又有效果刮开区域能看到底层说明canvas其实是在正常工作的只是涂层像素没画上去。排查后发现是canvas的context初始化时序问题。在onReady()里调用initCanvas()时虽然canvas节点已经查询到了但iOS的canvas上下文还没有完全就绪此时直接调用ctx.fillRect()可能被系统丢弃。解决办法是在drawMask()外面包一层延迟或者在initCanvas()里加一个canvas.requestAnimationFrame回调再绘制。initCanvas() { // ... this.canvas.requestAnimationFrame(() { this.drawMask() }) }为什么这样能解决requestAnimationFrame是canvas节点自带的方法它会在下一帧渲染前执行回调这时候canvas上下文必然已经就绪。这个问题的根源是iOS的canvas在WebKit内核里有一个异步初始化过程H5端和Android上通常没这个问题但iOS上必须小心。4.2 Android部分机型触摸坐标偏移第二个坑是部分Android机型尤其是一些非主流品牌的定制系统上触摸点位置和实际刮出的轨迹有偏移刮的位置和手指触摸的位置对不上越往边缘越明显。为什么会有偏移因为这些机型的系统webview在设置viewport缩放后canvas内部的坐标映射出现了问题。type2d的canvas触摸事件理论上返回的是canvas相对坐标但某些webview版本在scale(dpr, dpr)之后触摸坐标会被额外缩放导致实际画的线条位置偏离。这个问题的通用解法是把触摸坐标手动转换成canvas逻辑坐标。touch事件里除了x、y之外还有clientX、clientY相对于视口的坐标可以通过canvas的边界信息换算。但如果canvas位置固定不滚动直接用一个偏移校正即可onTouchMove(e) { const touch e.touches[0] const x touch.x * this.coordinateScale const y touch.y * this.coordinateScale // ... }coordinateScale的取值根据具体机型调试一般取1/dpr或者直接不乘。不过说实话这个坑很难完全通过前端代码百分百覆盖所有机型比较务实的做法是准备一个真机调试列表把主流机型都跑一遍遇到问题再针对性地做机型判断兼容。4.3 canvas层级遮挡弹窗问题这是小程序端的老大难问题。在旧版微信小程序基础库中canvas是原生组件层级天然高于普通view。也就是说不管你在页面上怎么调整z-indexcanvas永远会盖住弹窗、浮层、导航栏。如果刮奖结束后要弹出一个恭喜中奖的modal这个modal很可能被canvas挡住。新版微信小程序基础库基本都开启了同层渲染canvas不再是独立于webview的原生层这个问题已经弱化。但为了保险我通常的做法是刮奖完成后直接把canvas隐藏掉显示结果区域。这样即使有层级问题也不影响弹窗展示。canvas v-if!isComplete idscratchCanvas type2d ... /canvas4.4 内存清理与页面销毁最后一个真机问题是内存。如果页面反复进入退出比如用户从活动列表页反复进入刮奖页canvas实例每次都会重新创建。如果没有在页面销毁时释放canvas引用内存会持续增长尤其在低端Android机上四五次进出之后页面会变得极卡甚至白屏。建议在onUnload()里做清理onUnload() { this.canvas null this.ctx null // 如果是离屏canvas绘制的涂层图片也需要释放 if (this.offscreenCanvas) { this.offscreenCanvas null } }同时把canvas绑定的触摸事件置空避免组件实例销毁后还有事件回调残留。5. 封装成可复用的刮奖组件与实践总结5.1 组件设计props、事件、插槽缺一不可前面几节的内容都是在一个页面里写的如果项目里只有一个活动要用刮奖那够用了。但实际运营场景往往是一个活动套一个活动这个月刮奖下个月可能还刮奖只是奖项、涂层样式、卡面尺寸不同。所以把它封装成一个独立组件是提升效率的关键。我的组件设计如下参数类型默认值说明widthNumber650卡片宽度单位rpxheightNumber400卡片高度单位rpxmaskColorString#b0b0b0涂层颜色maskTextString刮一刮涂层文字brushSizeNumber28刮条直径单位pxthresholdNumber0.5刮开比例阈值0-1组件事件complete刮开超过阈值后触发不携带参数业务方自己弹窗和处理后续progress刮开进度实时回调参数为0-1的小数可用于做进度条或提示组件默认插槽展示底层结果内容可以是中奖图片、品牌Logo、按钮等由业务方自由控制。5.2 业务方接入示例三行代码跑通封装完成后业务方的接入变得非常简单。以页面里放一个刮奖卡片为例template view classpage scratch-card :width650 :height400 mask-color#c0c0c0 mask-text刮开有惊喜 :threshold0.45 completeonScratchComplete view classaward-box image src/static/award.png modeaspectFit/image text奖品名称/text /view /scratch-card /view /template script export default { methods: { async onScratchComplete() { // 调用后端兑奖接口 const res await this.drawAward() // 弹出结果 } } } /script这里有个小坑需要提一下组件里使用uni.createSelectorQuery().in(this)时this必须指向组件实例否则查询不到canvas节点。如果你在封装组件时忘了加.in(this)在页面里用会报错找不到节点。unexpectedly这是很多人在封装uni-app组件时最容易犯的错误。如果用的是easycom规范组件放在components/scratch-card/目录下页面里无需注册就能直接使用。这个用法在uni-app里非常方便配好了之后业务方甚至不需要知道组件的实现细节只关注插槽内容和事件回调即可。5.3 还有哪些可以扩展的方向到这里一个完整的uni-app刮奖功能已经可以落地了。但顺着这个基础还有几个可以继续玩的方向涂层定制现在的涂层是纯色加文字实际上drawMask()里可以用ctx.drawImage()把一张带品牌Logo的图片画到涂层上视觉上更有品牌感。只要注意图片路径在小程序里要使用静态资源或网络图片网络图片需要先在后台配置downloadFile合法域名。结果前置预判如果你希望用户刮开后经过一个短动画再显示结果比如刮开后先显示恭喜你三个字再弹出领奖按钮可以在onComplete里控制插槽内容的显示状态这属于业务层逻辑组件不用管。多人共用一套组件同一个组件如果同时出现在活动页中多个卡片要注意每个canvas实例独立管理状态不要用一个全局变量存储canvas引用。用组件实例属性this.canvas、this.ctx就没问题。我在实际项目中遇到过的情况是这个组件在三个不同活动里复用每个活动的涂层颜色、卡片尺寸、中奖比例阈值都不一样但组件代码一行没改只改传参就完成适配。这种一次封装多处复用的感觉才是封装的真正价值。如果你正在做的项目后续也有营销类需求非常建议把刮奖组件沉淀下来不要每次都从零开始写。
返回列表