1. 项目背景为什么一个刮奖功能能让人头疼一整天先说个真实场景运营那边周五下午提需求下周一要上线一个刮刮卡活动奖品是优惠券和实物周边用户每天有三次刮奖机会。听起来很简单对吧但真要在uni-app里把刮奖做出来、做好、做稳涉及的坑远比想象中多。我最早接到这个需求时第一反应是“canvas画一层遮罩手指滑动擦除不就行了”。实际动手才发现事情没那么简单uni-app要同时兼容App端、H5端和微信小程序端同样的canvas代码在这三个端上的表现完全不一样。小程序端的canvas API和浏览器端存在差异App端的渲染机制又跟H5不同有些写法在一端能跑换到另一端直接白屏或者报错。这篇文章我会把整个刮奖功能从设计到落地的完整过程拆开讲清楚包括核心原理、代码实现、多端适配方案、性能优化技巧以及我在实际开发中踩过的坑和排查思路。如果你是做uni-app开发、正好需要实现刮刮卡或者类似的涂抹擦除交互比如指纹解锁动画、海报遮挡展示这篇文章可以直接作为参考。先交代一下我的最终方案用canvas绘制覆盖层监听touch事件做擦除通过getImageData检测刮开区域的像素透明度来判断是否达到“刮开成功”的阈值。整套逻辑封装成了一个可配置的组件支持自定义奖品图、覆盖层颜色和纹理、刮开百分比阈值、回调事件一套代码跑通三端。2. 技术方案选型为什么最终选了Canvas路线2.1 刮奖功能的核心需求拆解在写代码之前先把需求拆清楚。一个完整的刮奖功能本质上包含三层逻辑覆盖层用户看到的“刮刮乐图层”可以是一层灰色、带纹理或者带文字的涂层用户需要把它刮掉才能看到下面的奖品。奖品层覆盖层下面的内容通常是奖品名称、图片或者一段兑奖码。交互层用户手指在屏幕上滑动的触摸事件需要精确地识别手指轨迹并把轨迹映射到覆盖层的擦除操作上。除了这三层还有几个容易被忽略的细节设备像素比适配否则高清屏上刮出来的轨迹是模糊的、触摸事件的防抖和节流否则容易掉帧、刮开面积的实时计算判断是否达到中奖阈值、多端兼容问题App/H5/小程序。2.2 对比了几种实现方式之后的选择我在选型时对比过三条技术路线方案A纯CSS mask遮罩。用CSS的mask属性配合背景渐变做遮罩靠hover或touch事件更新蒙层位置。这个方案实现最简单但问题也很明显CSS mask在小程序端的支持度很差而且无法做像素级的擦除判断刮开面积的计算非常麻烦。体验也一般擦除效果不自然基本是“一笔一个圆”的生硬效果。方案BWebView内嵌HTML5页面。把刮奖做成一个独立的H5页面嵌到WebView里。这个方案在App端可行但小程序端要开webview还得配业务域名活动页还要额外维护一套独立部署成本太高。而且热词里正好有人问“uni-app微信小程序webview如何像H5通信”说明这条路的通信问题也让人头大能用原生组件解决的事不必绕这么大圈子。方案CCanvas原生绘制最终选择。用canvas把覆盖层画出来然后监听touch事件通过globalCompositeOperation destination-out把擦除轨迹对应的像素点设置为透明。刮开面积的判断用getImageData逐像素扫描透明度。这个方案在所有端上都有canvas支持擦除效果自然面积判断精准唯一的缺点是代码量稍大但封装成组件后一次编写到处运行。我的选择是方案C。从项目长期维护的角度看Canvas是一个通用方案以后做涂鸦、签名、图像裁剪都能复用同一套技术栈。2.3 Canvas刮奖的底层原理说明如果你不熟悉canvas的合成模式这里先补个基础。canvas绘制时可以通过globalCompositeOperation属性来控制新绘制的图形如何与画布上已有的内容进行颜色合成。source-over默认模式新图形直接覆盖在旧图形上面。destination-out新图形绘制区域内的旧内容会被清除变成透明。这个模式就是刮奖效果的核心相当于用“橡皮擦”把覆盖层擦掉。destination-over新图形绘制在旧内容的下方。为了理解这个逻辑可以把它想象成一张便利贴贴在奖品图上source-over是在便利贴上面再贴一张纸而destination-out是拿刀片把便利贴的特定区域割掉让底下的奖品图露出来。奖品图先画到canvas上然后画一层灰色涂层就是便利贴用户手指滑动时用destination-out模式画一条线就是刀片割这条线经过的地方涂层被清除露出下面的奖品图。这就是完整的刮奖交互逻辑。2.4 刮开面积的判定逻辑getImageData的像素级计算刮奖还有一个关键点怎么判断用户“刮完了”这就需要实时计算被刮开的区域占整个覆盖层的比例。方案是调用canvas的getImageData方法拿到覆盖层区域的像素数据。getImageData返回一个数组每四个元素代表一个像素的RGBA值红、绿、蓝、透明度。我只需要统计alpha通道透明度的数值如果某个像素的alpha值接近0说明它已经被刮开如果接近255不透明说明覆盖层还在。计算过程大致如下遍历像素数组每隔4个元素取一次alpha值即索引位置i3统计alpha值为0的像素个数除以总像素数得到刮开百分比。当这个百分比超过阈值一般是50%-60%可配置就触发刮开完成的回调。不过这里有个性能隐患如果canvas的物理尺寸很大getImageData拿到的数据量会很夸张。比如一个375x667的canvas在3倍屏上物理像素是1125x2001总像素超过225万个遍历一次需要225万次循环。如果每次touchmove都做一次全量遍历很容易卡顿掉帧。我后面会在性能优化部分细讲怎么解决这个问题。3. 完整实现从零封装一个跨端刮奖组件3.1 组件的基本结构设计我先说组件文件结构。在uni-app项目里我建议把刮奖逻辑封装成一个独立组件放在components/scratch-card/目录下这样不仅这个项目能用以后其他项目也能直接复用。components/scratch-card/ scratch-card.vue // 组件主体组件对外暴露的属性props和事件events设计如下prizeImage奖品图片的路径也可以是网络URL。coverColor覆盖层的颜色默认灰色也可以传渐变色或者纹理图。coverText覆盖层上显示的文字比如“刮一刮”。coverTextColor文字颜色。width/height刮奖区域的宽高单位rpx默认350rpx。threshold刮开多少百分比算成功默认60。disabled是否禁用刮奖。scratch-start开始刮时触发。scratch-progress刮奖过程中触发回调参数是当前刮开百分比。scratch-end刮开率达到阈值时触发。3.2 模板部分canvas的容器和绘制层先写模板部分。核心就是一个canvas标签注意必须给它设置id后面要通过id获取canvas的上下文和节点信息。template view classscratch-card :style{ width: width rpx, height: height rpx } canvas classscratch-canvas :idcanvasId :canvas-idcanvasId touchstartonTouchStart touchmoveonTouchMove touchendonTouchEnd :style{ width: width rpx, height: height rpx } /canvas view v-if!isFinished classscratch-cover :style{ width: width rpx, height: height rpx } image v-ifprizeImage :srcprizeImage modeaspectFill classprize-image / text v-else classprize-text{{ prizeText }}/text /view /view /template这里有个设计细节要注意我把奖品层prizeImage / prizeText放在了canvas的下面用绝对定位的方式叠在canvas底层。这样设计的好处是canvas负责显示“被刮掉的涂层露出的内容”而真正的奖品图片是独立的view层不需要和canvas共享绘制逻辑代码逻辑更清晰。3.3 小程序端的Canvas类型选择type2d还是旧版接口这个点非常关键。在微信小程序端canvas的获取方式有新旧两套API旧版uni.createCanvasContext(canvasId, this)对应type属性不设置的情况。缺点是性能差而且部分API比如canvas.getImageData拿不到像素数据。新版canvas type2d通过uni.createSelectorQuery().select(#canvasId).fields({ node: true, size: true })获取canvas节点然后通过node.getContext(2d)拿到绘图上下文才能使用完整的canvas 2D API。我在测试中发现小程序端如果用旧版APIgetImageData这个方法是拿不到真实像素的会导致刮开面积始终计算为0功能直接失效。但这里有个矛盾type2d在微信小程序是标准写法但在App端尤其是App-vue和H5端的支持情况又不太一样。所以我在代码里做了运行时环境判断分平台处理。3.4 初始化canvas兼容三端的获取方式下面是核心的初始化逻辑。因为不同端获取canvas上下文的方式不同我封装了一个统一的初始化函数methods: { async initCanvas() { const systemInfo uni.getSystemInfoSync(); // 注意新版uni-app推荐使用uni.getWindowInfo()获取窗口信息 this.dpr systemInfo.pixelRatio || 2; // #ifdef MP-WEIXIN // 小程序端需要获取canvas节点再通过node.getContext(2d)获取上下文 const query uni.createSelectorQuery().in(this); const canvasNode await new Promise((resolve) { query.select(.scratch-canvas).fields({ node: true, size: true }).exec((res) { resolve(res[0]); }); }); const canvas canvasNode.node; this.canvas canvas; this.ctx canvas.getContext(2d); // 设置canvas实际渲染尺寸为逻辑尺寸 * dpr // 注意这里的宽高需要从rpx转成px再乘以dpr const { width, height } this.getCanvasSize(); canvas.width width * this.dpr; canvas.height height * this.dpr; this.ctx.scale(this.dpr, this.dpr); // #endif // #ifdef H5 || APP-PLUS // H5和App端可以直接通过createCanvasContext获取 this.ctx uni.createCanvasContext(this.canvasId, this); // 用uni的canvas API绘制 // #endif this.drawCoverLayer(); }, }这段代码有两个容易踩坑的地方我详细说一下。第一个坑是rpx转px的问题。canvas的width和height属性只接受数字单位是px不认rpx。但组件接收的width和height属性是rpx因为uni-app里用rpx做响应式适配最方便。所以在实际绘制前必须先把rpx转成px再乘以dpr设置canvas的物理像素尺寸。rpx转px的公式px rpx * (systemInfo.windowWidth / 750)。第二个坑是canvas宽高设置。如果不把canvas的物理像素设置成逻辑像素 * dpr在高分屏比如iPhone X的3倍屏上绘制出来的文字、线条会非常模糊刮奖的涂层和文字边缘全是锯齿。很多开发者的刮奖效果模糊就是这个原因。3.5 绘制覆盖层涂层、文字和纹理的绘制初始化完成后就开始画覆盖层。覆盖层可以是纯色、带文字、带渐变甚至可以换成一张纹理图片。我这里做了一个带文字和渐变效果的版本drawCoverLayer() { const ctx this.ctx; const { width, height } this.getCanvasSize(); // 绘制灰色渐变涂层 const gradient ctx.createLinearGradient(0, 0, width, height); gradient.addColorStop(0, #c0c0c0); gradient.addColorStop(1, #a0a0a0); ctx.fillStyle gradient; ctx.fillRect(0, 0, width, height); // 绘制涂层上的文字 if (this.coverText) { ctx.fillStyle this.coverTextColor || #ffffff; ctx.font bold ${Math.floor(height * 0.12)}px sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(this.coverText, width / 2, height / 2); } // #ifdef MP-WEIXIN this.ctx.draw(); // #endif // #ifdef H5 || APP-PLUS ctx.draw ctx.draw(); // #endif }这里需要特别注意一个兼容性问题小程序端用node.getContext(2d)拿到的ctx是标准的Canvas 2D上下文绘制完成不需要调用ctx.draw()但H5端和App端如果用uni.createCanvasContext拿到的ctx绘制完成后必须调用ctx.draw()才会把内容渲染到画布上。我在代码里用条件编译分别处理这个细节如果不注意小程序端会多调用一次无意义的draw而H5端不调用draw直接白屏。3.6 触摸事件如何让擦除轨迹平滑跟手触摸事件是刮奖体验的关键。最原始的写法是touchstart时画一个圆点touchmove时画一条线但这样画出来的轨迹是锯齿状的折线非常难看。正确做法是touchstart时以上一次点的位置为起点、当前点为终点画一条线lineTo然后stroketouchmove时用当前点和前一个点做二次贝塞尔曲线quadraticCurveTo连接这样轨迹是平滑的曲线。onTouchStart(e) { if (this.isFinished || this.disabled) return; this.hasScratched true; const { x, y } this.getTouchPosition(e); this.lastX x; this.lastY y; this.drewPoints [{ x, y }]; this.ctx.globalCompositeOperation destination-out; this.ctx.lineWidth this.lineWidth || 20; this.ctx.lineCap round; this.ctx.lineJoin round; // 画一个圆点确保起始位置立即被擦除 this.ctx.beginPath(); this.ctx.arc(x, y, this.lineWidth / 2, 0, Math.PI * 2); this.ctx.fill(); this.$emit(scratch-start); }, onTouchMove(e) { if (this.isFinished || this.disabled) return; const { x, y } this.getTouchPosition(e); this.ctx.beginPath(); this.ctx.moveTo(this.lastX, this.lastY); // 用二次贝塞尔曲线连接轨迹更平滑 this.ctx.quadraticCurveTo( this.lastX, this.lastY, (this.lastX x) / 2, (this.lastY y) / 2 ); this.ctx.lineTo(x, y); this.ctx.stroke(); this.lastX x; this.lastY y; this.drewPoints.push({ x, y }); // 节流每若干帧检查一次刮开面积 this.throttledCheckProgress(); },这里有几个细节值得展开。第一擦除的线宽lineWidth决定了刮一下能擦掉多大的区域一般建议20-30px太细刮起来费劲太粗一划就刮完了体验不真实。我踩过的坑是忘了设置lineCap和lineJoin为round导致刮出来的轨迹边缘是方形的真机上看起来特别生硬。第二getTouchPosition需要把触摸事件的坐标转换为canvas坐标系内的坐标。小程序端触摸事件的touches数组里是pageX/pageY需要减去canvas节点相对于页面的偏移量。这里用uni.createSelectorQuery()查询canvas节点的left和top然后做一次偏移计算。getTouchPosition(e) { const touch e.touches[0]; // canvasOffset在initCanvas时获取保存了canvas节点相对页面的位置信息 return { x: touch.clientX - this.canvasOffset.left, y: touch.clientY - this.canvasOffset.top }; }3.7 刮开面积检测节流和降采样配合touchmove事件触发的频率非常高在部分手机上可以达到每秒60次甚至更高。如果每次move都执行一次getImageData全量遍历性能压力很大。我的处理方式是每隔一定事件频率才去检查一次刮开面积同时在检查时使用降采样策略。什么叫降采样很简单我不扫描所有像素而是每隔5个像素采样一个点比如x 5, y 5。这样扫描次数直接从225万次降到9万次性能提升巨大而精度损失几乎可以忽略——因为判断刮开比例本来就不需要那么精细。checkScratchProgress() { const { width, height } this.getCanvasSize(); const canvasWidth Math.floor(width * this.dpr); const canvasHeight Math.floor(height * this.dpr); // 注意小程序type2d的canvasgetImageData需要传入物理像素尺寸 const imageData this.ctx.getImageData(0, 0, canvasWidth, canvasHeight); const pixels imageData.data; let transparentCount 0; let totalCount 0; // 降采样遍历每隔4个像素采样一次 for (let y 0; y canvasHeight; y 4) { for (let x 0; x canvasWidth; x 4) { const alphaIndex (y * canvasWidth x) * 4 3; if (pixels[alphaIndex] 0) { transparentCount; } totalCount; } } const percent Math.floor((transparentCount / totalCount) * 100); this.currentPercent percent; this.$emit(scratch-progress, percent); if (percent this.threshold) { this.finishScratch(); } }再强调一个坑小程序端type2d的canvasgetImageData接收的width和height是canvas的物理像素也就是乘以dpr之后的值。如果你把逻辑像素传进去能拿到的数据是不完整的计算出来的百分比会永远偏小刮完了都不触发回调。这个问题花了我几个小时排查最后是打印canvas.width才发现尺寸不对。3.8 刮奖完成的处理自动补全和结果回调当刮开面积超过阈值我做了两件事一是自动把整个覆盖层擦干净用户看到的结果就是奖品完整呈现二是触发scratch-end事件告诉业务层“刮奖完成了”。finishScratch() { this.isFinished true; const { width, height } this.getCanvasSize(); // 自动清除全部涂层 this.ctx.globalCompositeOperation destination-out; this.ctx.fillRect(0, 0, width * this.dpr, height * this.dpr); // 触发完成事件 this.$emit(scratch-end, { percent: this.currentPercent, prizeText: this.prizeText }); }这里有个业务逻辑值得提醒刮奖完成的判定不要在前端做“中奖判断”的核心逻辑前端只是告诉后端“刮完了”具体中没中奖、中了什么奖应该由后端接口返回。不然懂技术的用户直接看前端代码或者改请求参数就能把刮奖结果给改了活动就废了。4. 性能优化与多端适配的关键细节4.1 解决requestAnimationFrame在小程序端的兼容性在H5端可以用requestAnimationFrame做擦除动画或性能优化但小程序端默认不支持这个API。不过uni-app在小程序端做了polyfill可以直接使用全局的requestAnimationFrame。我在刮开面积检测时用了这个API把检测动作延迟到下一帧执行避免在touchmove中阻塞UI渲染throttledCheckProgress() { if (this._checking) return; this._checking true; requestAnimationFrame(() { this.checkScratchProgress(); this._checking false; }); }实测在低端Android机上这样做了之后触控的跟手度有明显提升刮起来不再有“拖着一条线跑”的迟滞感。4.2 H5端canvas模糊问题的终极解决方案前面提过dpr这里再展开说。H5端如果在普通屏上没问题但在Retina屏幕上如果没有把canvas的width和height乘以dpr绘制出来的线条、文字都是模糊的。具体操作分两步canvas的物理尺寸设置为逻辑尺寸 * dpr绘制前调用ctx.scale(dpr, dpr)这样后面绘制时还是按逻辑尺寸绘制但渲染清晰度会大幅提升。如果你用的是H5端的uni.createCanvasContext由于这个API封装了内部实现有时候scale设置不生效。我的建议是H5端直接用原生canvas的写法不用uni的封装。好在H5端本来就是浏览器环境可以直接用document.getElementById获取canvas节点走标准Canvas API。4.3 关于离屏canvas的优化思路如果你想做到更极致的性能还有一个进阶思路离屏canvas。你先用离屏canvas内存中的canvas不渲染到页面上绘制覆盖层然后再把离屏canvas的内容绘制到显示canvas上。这样擦除时只更新显示canvas的一部分性能会更好。不过说实话对于刮奖这种轻量级交互常规方案已经足够顺滑离屏canvas的方案一般用不到。只有当你需要同时显示多个刮奖卡、或者刮奖区域的尺寸特别大时才值得上这个优化。我就不放复杂代码了给个提示uni.createOffscreenCanvas在小程序基础库2.16.1以上可用。4.4 多端真机测试中的表现对比我在开发中分别用iPhone 13、华为Mate 40 ProAndroid、微信开发者工具、H5端做了实测结论如下H5端性能最好没有兼容性问题触摸跟手度最佳。微信小程序端需要确保用type2d的canvas基础库版本建议2.9.0以上。旧版本会出现getImageData拿不到数据的问题。App端如果使用App-vue性能不错如果用App-nvuecanvas支持度较差建议用vue页面承载刮奖组件或者用原生插件替代。这个对比也解释了为什么我把canvas的获取方式做了分平台处理一套代码适配三端的关键就是不要强行统一API而是识别各端的能力边界针对性地调用对应接口。5. 遇到过的那些坑问题排查与修复实录5.1 问题一小程序端刮奖后getImageData返回全透明现象手指滑动能刮开涂层但百分比一直为0刮完也不触发完成事件。排查过程我一开始以为是getImageData的坐标或尺寸问题反复打印imageData.data的长度和alpha值发现返回的数组长度不对而且所有alpha值都是0。后来查文档发现微信小程序旧版canvas API非type2d对getImageData的支持是不完整的虽然能调用但拿不到真实像素数据。解决方案改用type2d的canvas通过createSelectorQuery().fields({ node: true })获取canvas节点然后调用node.getContext(2d)获取标准2D上下文。改完之后getImageData就能正常返回像素数据了。5.2 问题二H5端刮奖后canvas无内容显示现象H5端初始化完成后覆盖层能绘制但刮开后露出的奖品图没有显示整个canvas区域是空白的。排查过程打印canvas的width和height发现是正常的物理像素尺寸。再检查绘制流程发现问题出在奖品图的绘制方式上我用的是ctx.drawImage绘制图片但图片是异步加载的初始化时图片还没加载完成就执行了drawImage导致绘制了个寂寞。解决方案在绘制奖品层之前先用uni.getImageInfo加载图片确保图片加载完成后再开始绘制。async loadImage(src) { return new Promise((resolve, reject) { uni.getImageInfo({ src, success: (res) resolve(res.path), fail: reject }); }); }5.3 问题三真机上canvas模糊开发者工具正常现象开发者工具里看起来一切正常但真机上涂层文字、刮痕边缘都模糊。排查过程这是典型的dpr适配问题。开发者工具的默认模拟器dpr可能和真机不一致导致你看到的canvas尺寸在真机上被拉伸。解决方案明确计算物理像素尺寸canvas.width和canvas.height必须乘以系统dpr并且绘制前调用ctx.scale(dpr, dpr)。5.4 问题四touchend后自动补全涂层但用户不想刮了现象用户只刮了一个角就松手了结果百分比没到阈值但涂层已经被擦除了一部分再想刮时手感奇怪。排查过程这不是bug而是交互设计问题。很多刮奖活动为了让用户有“刮开了一部分但没刮完”的期待感会保留部分涂层。但有些产品经理希望“没刮开的地方不要显示奖品”这就需要在取消刮奖和完成刮奖之间做额外处理。解决方案我封装了一个resetCoverLayer方法当用户在未达到阈值时松开手指可以调用这个方法恢复覆盖层resetCoverLayer() { // 清除当前canvas内容 this.ctx.clearRect(0, 0, canvas.width, canvas.height); // 重新绘制覆盖层 this.drawCoverLayer(); this.isFinished false; this.hasScratched false; }5.5 常见问题速查表问题可能原因解决方案小程序端getImageData全透明使用了旧版canvas API改用type2d并通过节点获取上下文H5端图片不显示图片异步加载未完成就绘制绘制前先调用getImageInfo加载图片真机canvas模糊未适配dprcanvas物理尺寸乘以dpr绘制前scale(dpr, dpr)刮完不触发完成事件getImageData尺寸传错传入物理像素尺寸而非逻辑像素App-nvue端canvas不支持nvue渲染机制限制改用vue页面承载组件或用原生插件触摸轨迹锯齿明显未设置lineCap/lineJoin设置为round并用贝塞尔曲线连接轨迹点低端机卡顿掉帧touchmove中频繁getImageData使用requestAnimationFrame节流降采样5.6 关于“uni-app微信小程序webview如何像H5通信”扯两句热词里出现了一个高频问题webview通信。虽然刮奖功能本身不需要webview但它让我想到一个更通用的开发思路在uni-app里跨端通信的复杂性是一直存在的但很多场景其实可以绕过webview直接用原生组件解决。刮奖就是典型例子。如果你的业务里确实必须用webview那么通信逻辑通常是webview页面通过uni.postMessage向小程序宿主发送消息宿主通过bindmessage监听宿主向webview发消息则通过wx.miniProgram.postMessage或URL参数传递。这个逻辑和刮奖组件用props events uni.$emit的通信模式其实是相通的核心思路都是“宿主负责分发消息子组件负责响应事件”。6. 做过的优化方向从能用升级到好用6.1 给刮奖组件加一个倒计时重置逻辑实际项目中运营经常要求“每天三次机会刮完重置”。这个逻辑可以直接在组件内完成通过props传入resetAfter参数单位是毫秒到时间后自动重置覆盖层并且重置刮开状态。倒计时本身用一个setInterval实现在reset时clear掉。这个功能不要在业务页面里做因为业务页面管的是活动状态刮奖组件管的是刮奖状态职责要分清楚。6.2 支持网络图片和base64图片作为奖品层奖品图片可能是运营后台上传的远程URL也可能是存储在oss上的高清大图。处理思路是远程URL先转成base64或本地缓存路径再绘制到canvas上。直接绘制远程URL在某些端会报跨域错误。async preparePrizeImage() { const src this.prizeImage; if (src.startsWith(http)) { // 远程图片先下载到本地 const res await new Promise((resolve) { uni.downloadFile({ url: src, success: (r) resolve(r), fail: () resolve(null) }); }); if (res res.statusCode 200) { this.prizeImagePath res.tempFilePath; } } else if (src.startsWith(data:image)) { this.prizeImagePath src; } else { this.prizeImagePath src; } }6.3 给覆盖层加颗粒纹理的进阶玩法如果不想涂层是单调的灰色可以给覆盖层加颗粒纹理效果模拟真实刮刮乐的磨砂质感。方法有两种一是用一张带颗粒的纹理图片作为fillPattern填充二是用随机分布的微小白点模拟颗粒// 用随机点模拟磨砂颗粒 drawGrainTexture(ctx, width, height, opacity 0.1) { for (let i 0; i 200; i) { const x Math.random() * width; const y Math.random() * height; const radius Math.random() * 2 0.5; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fillStyle rgba(255, 255, 255, ${opacity}); ctx.fill(); } }纹理颗粒能让涂层看起来更精致但绘制性能会有少量损耗颗粒数量控制在200个以内比较合适。7. 顺手把全局弹窗组件也做了为什么说组件化是刮奖项目的衍生收获热词里还有一条“告别uni-app官方toast手把手教你封装高颜值、多功能的全局弹窗组件”。说实话我在做刮奖项目时也顺手把全局弹窗做了因为刮奖结束之后需要弹窗提示用户“恭喜获得XX奖品”而官方toast太简陋只能显示文字不能展示图片和按钮。全局弹窗组件的核心思路在App.vue或主布局中挂载一个全局组件通过uni.$on和uni.$emit做全局消息通信。业务页面只需要调用一行代码uni.$emit(show-toast, { title: 恭喜获得5元优惠券, icon: success, duration: 2000 });弹窗组件内部监听show-toast事件并根据参数渲染出不同样式。这个方案的好处是所有页面都可以直接使用不需要在每个页面里都引入组件。这种思路其实和刮奖组件的事件通信模式是一致的都是在uni-app的框架下用事件总线或props/events实现页面与组件的解耦。弹窗组件和刮奖组件的技术栈完全相同都是基于uni-app的组件化体系和事件通信机制。做完刮奖之后你会发现uni-app的很多组件封装套路是相通的先定义props和events的接口协议再分平台处理兼容性最后针对业务场景做细节打磨。8. 组件完整代码一份可直接运行的参考实现这里放一份简化版但完整的组件代码方便你直接抄作业。业务字段、样式细节可以根据需要调整。template view classscratch-card-wrap view classprize-layer image v-ifprizeImage !isFinished :srcprizeImage modeaspectFill classprize-img / view v-else-if!isFinished classprize-default{{ prizeText }}/view view v-else classprize-result text{{ prizeText }}/text button sizemini clickhandleConfirm领取/button /view /view canvas v-if!isFinished classscratch-canvas :idcanvasId :canvas-idcanvasId :style{ width: width rpx, height: height rpx } touchstartonTouchStart touchmoveonTouchMove touchendonTouchEnd /canvas /view /template script export default { name: ScratchCard, props: { canvasId: { type: String, default: scratchCanvas }, width: { type: Number, default: 350 }, height: { type: Number, default: 350 }, prizeImage: { type: String, default: }, prizeText: { type: String, default: 恭喜获得优惠券 }, coverColor: { type: String, default: #c0c0c0 }, coverText: { type: String, default: 刮一刮 }, coverTextColor: { type: String, default: #ffffff }, threshold: { type: Number, default: 60 }, lineWidth: { type: Number, default: 20 }, disabled: { type: Boolean, default: false } }, data() { return { ctx: null, canvas: null, dpr: 2, isFinished: false, hasScratched: false, currentPercent: 0, lastX: 0, lastY: 0, canvasOffset: { left: 0, top: 0 }, _checking: false }; }, mounted() { this.initCanvas(); }, methods: { // 初始化 async initCanvas() { // ...完整实现见前文 }, // 绘制覆盖层 drawCoverLayer() { // ...完整实现见前文 }, // 触摸事件 onTouchStart(e) { // ...完整实现见前文 }, onTouchMove(e) { // ...完整实现见前文 }, onTouchEnd() { if (!this.hasScratched) return; // touchend时可以再做一次面积检测防止松手时刚好达到阈值 this.checkScratchProgress(); }, // 区域检测 checkScratchProgress() { // ...完整实现见前文 }, // 完成 finishScratch() { // ...完整实现见前文 }, // 重置 reset() { // ...完整实现见前文 } } }; /script style scoped .scratch-card-wrap { position: relative; } .prize-layer { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; background: #fff; border-radius: 16rpx; overflow: hidden; } .prize-img, .prize-result { width: 100%; height: 100%; } .scratch-canvas { position: relative; z-index: 2; } /style9. 项目上线后的反馈与扩展想法项目上线后运营反馈用户参与度不错刮奖的流畅度在主流手机上都能保持稳定。在这期间我也收到几个有意思的需求反馈这里一并整理出来供你参考。第一个需求是刮奖次数限制与防重复。运营要求同一用户每天只能刮三次这个逻辑不能在组件里做应该交给后端校验。前端只是把用户ID和活动ID传给后端后端确认次数未用完才返回刮奖结果。第二个需求是奖品概率控制。运营希望“一等奖概率1%二等奖10%”这个逻辑同样放在后端。前端拿到刮开结果后展示即可千万不要在前端写死概率否则后端一改配置前端又得发版本。第三个需求是刮奖轨迹回放。运营想要知道用户的刮奖轨迹用于分析用户是否真的有在认真刮还是随便划拉两下就走。这个需求通过drewPoints数组可以实现把每帧的坐标点记录下来上传给后端即可。不过要注意隐私和性能问题坐标点建议降采样之后再上传。第四个需求是多人实时刮奖排行榜。这个就比较重了需要实时通信WebSocket而且刮奖本身是一个短频快的动作排行榜更像是一个噱头。如果业务有类似需求建议先确认核心目标别让排行榜喧宾夺主。10. 最后再分享一个实际开发中的小技巧关于触摸事件和canvas坐标转换我再强调一遍不要直接用touches[0].x和touches[0].y作为canvas坐标一定要先减去canvas节点左上角的偏移量。这个偏移量在页面滚动、组件嵌套、安全区适配的情况下是动态变化的最好在组件mounted时计算一次并在页面滚动时更新。如果你在组件内部用了position: fixed或者吸顶之类的布局还要注意canvasOffset的更新时机。我遇到过的情况是页面滚动后canvas的偏移量变了但组件里的canvasOffset还是旧值导致刮痕和手指位置错位。解决办法是监听页面滚动事件在onPageScroll里重新计算// 页面内 onPageScroll() { if (this.$refs.scratchCard) { this.$refs.scratchCard.updateCanvasOffset(); } }这个小问题排查起来特别隐蔽因为它在开发者工具中几乎不会复现只有真机上滚动页面后再去刮才会出现。像这种偏移计算的问题最早发现时我一度怀疑是canvas初始化失败排查了很久才定位到是偏移量过期。最后唠叨一句刮奖这个功能虽然看起来简单但它的价值在于它把canvas绘制、触摸事件、像素级计算、多端兼容、性能优化这些基础能力都串起来了。做完这个组件之后你再去做签名板、涂鸦、图像裁剪、拼图验证码这些功能会发现很多思路都是通用的。希望这篇文章能帮你少走点弯路。