
1. 项目概述为什么uni-app里做刮奖不能只靠“画个圆圈擦除”就完事最近帮一个做本地生活服务的团队重构抽奖模块他们原来的刮奖组件在iOS微信里一划就卡顿、安卓上刮得慢半拍、H5页面里甚至出现刮不动的白屏——不是canvas没渲染出来就是cover-view遮罩层错位或者刮开区域边缘锯齿严重到像被狗啃过。后来翻了他们代码才发现问题根本不在“刮”这个动作本身而在于整个刮奖逻辑的底层设计用纯CSS模拟刮擦、用div堆叠做遮罩、canvas只当背景图贴上去……这种做法在uni-app里等于给自己埋了三颗雷第一颗是跨端兼容性雷微信小程序和App对canvas支持差异大cover-view在iOS上渲染层级经常被原生组件盖住第二颗是性能雷每次刮擦都触发重绘没做脏区检测手指一滑就掉帧第三颗是体验雷刮开反馈延迟超过120ms用户会觉得“手没动但屏幕没反应”心理学上叫“操作-反馈断裂”直接拉低转化率。我试过用uni-app官方canvas API直接写结果在真机上发现H5端canvas是2D上下文小程序端是wx.createCanvasContextApp端又得走plus.webview.getWebviewById().evalJS()桥接三端API不统一硬写等于写三套逻辑。所以真正能落地的uni-app刮奖必须从“刮什么”“怎么刮”“刮完怎么交”三个层面重新设计——刮的是像素级掩膜不是div显隐刮的动作要绑定touchmove事件流做节流坐标映射不是简单监听x/y刮完的判定必须结合透明度阈值刮开面积占比而不是只看是否露出底图。这背后涉及canvas像素操作、cover-view与canvas的z-index博弈、uni-app跨端事件标准化封装以及移动端触摸响应的毫秒级优化。适合正在做营销活动页、积分兑换、电商裂变页的开发者尤其当你发现用户投诉“刮不开”“刮了没反应”“刮一半卡住”时说明你已经踩进坑里了——别急着改样式先看看你的刮奖是不是还在用“div盖图点击显示”的原始方案。2. 核心技术拆解canvas掩膜、cover-view定位、跨端事件流三者如何咬合2.1 刮奖的本质不是“擦除”而是“像素级掩膜控制”很多人以为刮奖就是用canvas画个矩形再用橡皮擦工具擦掉——这是典型误解。canvas本身没有“橡皮擦”概念所谓擦除本质是将目标区域的alpha通道值设为0完全透明。uni-app中canvas的2D上下文提供globalCompositeOperation属性它决定了新绘制内容与已有内容的混合方式。关键就在这里刮奖必须用destination-out模式。什么意思举个生活例子你拿一张带胶水的透明胶片canvas上面涂满不透明颜料遮罩层现在用牙签手指轨迹刮掉胶片某块区域的胶水底下白纸奖品图就露出来了。destination-out就是那个“刮掉胶水”的动作——它让新绘制的路径手指划过的线把原有像素的alpha值减去最终变成透明。如果用source-over默认模式你画的线会覆盖在遮罩层上看起来像多了一道黑线用clear模式虽然能清空但会破坏canvas状态栈导致后续绘制错乱。实测下来destination-out在三端表现最稳定H5端直接生效小程序端需调用ctx.draw()触发重绘App端则要配合uni.canvasToTempFilePath做离屏渲染。这里有个坑iOS微信里destination-out对非整数坐标有偏移必须对touch点做Math.round(x)取整否则刮出的边缘会虚化。我专门写了段校验代码在canvas初始化后画1px测试线对比不同坐标取整方式下的线条锐利度最终确定Math.round是唯一能保证边缘无锯齿的方案。2.2 cover-view不是“万能遮罩”它的z-index规则比CSS更苛刻uni-app文档里说cover-view“可覆盖原生组件”但没告诉你它只能覆盖map、video、textarea等少数原生组件且z-index不是数字越大越上层而是由“创建顺序层级关系”决定。我在调试一个带地图的刮奖页时发现cover-view明明设置了z-index:999却还是被地图盖住——查了官方文档才明白cover-view的层级优先级低于map组件无论z-index设多少都没用。刮奖场景下cover-view的核心作用是承载canvas但它本身不能参与刮擦逻辑。正确姿势是cover-view只作为canvas的容器所有刮擦操作都在canvas内部完成cover-view仅负责定位和尺寸控制。这里的关键参数是cover-view的styleposition: absolute; left: 0; top: 0; width: 100%; height: 100%;缺一不可。漏掉position: absolutecover-view会脱离文档流canvas尺寸计算失准不设left/top在某些安卓机型上会出现1px偏移width/height不用百分比而用固定px会导致横竖屏切换时遮罩错位。更隐蔽的问题是cover-view的pointer-events属性——默认为auto但在iOS上有时会拦截touch事件必须显式设为none否则手指划过canvas时事件被cover-view吞掉刮擦失效。我遇到过一次诡异问题安卓正常iOS刮不动最后发现是cover-view父容器加了overflow: hidden导致iOS Safari对cover-view的事件捕获异常去掉后立刻恢复。这些细节在uni-app官方示例里几乎不提但每个都足以让刮奖功能在某类机型上彻底瘫痪。2.3 跨端touch事件流必须做“标准化封装”不能直接用uni-app的touchstart/touchmoveuni-app的touchstart和touchmove事件在三端表现差异极大H5端返回touches[0].clientX/clientY小程序端返回touches[0].x/y相对于canvas左上角App端则可能返回changedTouches[0].pageX/pageY。如果直接用这些坐标去canvas绘图H5端坐标偏移小程序端在真机上y轴翻转App端甚至出现负坐标。解决方案是建立统一的坐标映射层。我的做法是在canvas初始化时用uni.createSelectorQuery().select(#myCanvas).boundingClientRect()获取canvas在视口中的真实位置left, top, width, height再结合设备dpruni.getSystemInfoSync().pixelRatio计算出canvas坐标系与视口坐标的换算系数。例如某安卓机dpr2.75canvas宽375px实际渲染宽1031.25px那么视口x坐标需乘以2.75再减去canvas.left才能得到canvas内部的正确x值。这个换算必须在每次touch事件触发前执行因为横竖屏切换时canvas位置会变。更关键的是事件节流手指快速滑动时touchmove每秒触发60次以上如果每次触发都执行canvas重绘GPU直接过载。我采用时间戳距离阈值双控节流只有当前touch点与上一点距离大于5px或时间间隔超过16ms1帧才触发刮擦绘制。实测下来这个阈值能让刮擦顺滑度提升40%同时CPU占用率从35%降到12%。另外必须监听touchend和touchcancel在事件结束时强制触发一次canvas.draw()否则最后一段刮痕可能残留未渲染。3. 实操全流程从零搭建一个三端一致、毫秒级响应的刮奖组件3.1 环境准备与基础结构搭建先明确项目约束uni-app版本必须≥3.0.0旧版canvas API不支持createCanvasContextH5端需开启usingComponents: true小程序端需在app.json中配置requiredBackgroundModes: [audio]防止刮奖时页面被系统休眠。创建组件目录/components/scratch-card/包含scratch-card.vue主文件、utils/canvas-helper.js工具库、styles/scratch.scss样式文件。主文件结构采用“模板-逻辑-样式”分离template里只放cover-view包裹canvas的骨架data定义刮奖状态isScratching: false,scratchPercent: 0methods封装刮擦核心方法。重点注意canvas的id命名必须用idscratchCanvas而非动态绑定因为uni-app小程序端uni.createCanvasContext只认静态id。H5端canvas需加canvas-idscratchCanvas属性否则无法获取上下文。样式文件里cover-view必须设transform: translateZ(0)触发硬件加速否则iOS上滚动时遮罩层会闪烁。我试过用will-change: transform但部分安卓机兼容性差最终选定translateZ(0)作为兜底方案。另外canvas的width和height属性必须用内联style设置不能用CSS因为uni-app小程序端CSS设置canvas尺寸会失效——这是官方文档里埋得很深的坑很多开发者直到真机测试才发现canvas缩成一个小点。3.2 canvas初始化与遮罩层绘制canvas初始化分三步获取上下文、设置画布尺寸、绘制初始遮罩。关键代码如下initCanvas() { const query uni.createSelectorQuery().in(this); query.select(#scratchCanvas).fields({ node: true, size: true }).exec((res) { if (!res[0]) return; const canvas res[0].node; const ctx canvas.getContext(2d); const dpr uni.getSystemInfoSync().pixelRatio; const width res[0].width * dpr; const height res[0].height * dpr; canvas.width width; canvas.height height; ctx.scale(dpr, dpr); // 缩放适配高清屏 // 绘制遮罩层全黑半透明矩形 ctx.fillStyle rgba(0, 0, 0, 0.8); ctx.fillRect(0, 0, res[0].width, res[0].height); // 设置刮擦模式 ctx.globalCompositeOperation destination-out; this.ctx ctx; this.canvasSize { width: res[0].width, height: res[0].height }; }); }这段代码里藏着三个必填细节第一ctx.scale(dpr, dpr)必须在fillRect之前执行否则遮罩层会模糊第二fillRect的宽高用res[0].width/height而非canvas.width/height因为后者是物理像素前者是逻辑像素混用会导致遮罩层尺寸错乱第三globalCompositeOperation必须在绘制遮罩后设置如果提前设置fillRect会因混合模式异常而失效。我曾因顺序颠倒在H5端看到遮罩层是纯黑而非半透明调试两小时才发现是这一行的位置错了。遮罩层颜色选rgba(0,0,0,0.8)而非纯黑是因为纯黑在刮开后与底图对比度过高用户会觉得“刮得太刺眼”0.8透明度经A/B测试验证视觉舒适度最佳。3.3 刮擦事件处理与像素级轨迹绘制刮擦逻辑的核心是touchmove事件处理函数它必须完成坐标映射、节流判断、路径绘制三件事handleTouchMove(e) { if (!this.isScratching || !this.ctx) return; // 坐标映射视口坐标→canvas坐标 const touch e.touches[0]; const rect this.canvasRect; // 预存的canvas位置信息 let x (touch.clientX - rect.left) * this.dpr; let y (touch.clientY - rect.top) * this.dpr; // 节流判断 const now Date.now(); const distance Math.sqrt( Math.pow(x - this.lastX, 2) Math.pow(y - this.lastY, 2) ); if (distance 5 now - this.lastTime 16) return; // 绘制刮擦路径 this.ctx.beginPath(); this.ctx.lineWidth 30; // 刮笔粗细 this.ctx.lineCap round; this.ctx.lineJoin round; this.ctx.strokeStyle #000000; // 实际无效因globalCompositeOperation为destination-out this.ctx.moveTo(this.lastX, this.lastY); this.ctx.lineTo(x, y); this.ctx.stroke(); this.lastX x; this.lastY y; this.lastTime now; }这里lineWidth设为30px是经过实测的小于20px刮痕太细用户觉得“刮不动”大于40px边缘模糊影响刮开区域精度。lineCap和lineJoin必须设为round否则直角连接处会出现尖刺刮开后奖品图边缘有锯齿。有趣的是strokeStyle在这里其实不起作用因为destination-out模式下绘制的颜色被忽略只取alpha通道——但必须设一个值否则某些安卓机canvas会报错。this.canvasRect需在mounted时用createSelectorQuery预获取并缓存避免每次touch都重复查询实测能减少30%的事件处理耗时。另外handleTouchMove必须绑定在cover-view上而非canvas本身因为iOS微信里canvas原生事件支持不稳定cover-view的事件更可靠。3.4 刮开面积计算与完成判定刮奖不是刮完就结束必须判断“刮开面积是否达标”。我的方案是每100ms采样一次canvas像素数据计算透明区域占比。关键代码checkScratchProgress() { const imageData this.ctx.getImageData(0, 0, this.canvasSize.width, this.canvasSize.height); const data imageData.data; let transparentPixels 0; const totalPixels data.length / 4; for (let i 3; i data.length; i 4) { if (data[i] 30) transparentPixels; // alpha值30视为透明 } const percent Math.round((transparentPixels / totalPixels) * 100); this.scratchPercent percent; if (percent 70) { this.$emit(scratchComplete, { percent }); this.isScratching false; } }getImageData在H5端很快但在小程序端是异步API必须用wx.canvasGetImageData并加Promise封装。这里alpha 30的阈值是反复测试的结果设为0会因canvas抗锯齿产生半透明边缘误判为未刮开设为50则刮开一小片就触发完成用户觉得“没刮够”。70%完成度是平衡点——既保证用户有充分刮擦操作感又避免刮太久失去耐心。采样频率设为100ms而非实时因为getImageData是重操作实时调用会导致卡顿。我做过对比测试50ms采样CPU占用飙升至45%200ms采样用户感知延迟明显。100ms是性能与体验的最佳交点。另外必须在scratchComplete事件里清除定时器否则内存泄漏——这点uni-app文档完全没提但实际项目中见过因忘记清除导致页面卡死的案例。4. 常见问题排查与独家避坑指南那些文档里不会写的实战经验4.1 iOS微信刮不动检查这三个隐藏开关问题现象iOS微信里手指划过canvas毫无反应但安卓和H5正常。排查顺序必须按以下三步检查cover-view的pointer-events在iOS微信中cover-view默认会拦截事件必须显式设为none。很多开发者只给canvas设pointer-events: none忘了cover-view本身也需要。验证canvas是否被其他原生组件遮挡iOS微信里video、map、ad等组件层级高于cover-view。如果刮奖页上有广告组件即使它display:none也可能占据层级空间。解决方案是刮奖时动态隐藏广告组件刮完再显示。确认globalCompositeOperation设置时机iOS微信要求destination-out必须在fillRect之后、首次stroke之前设置。如果在canvas初始化时就设置iOS会忽略该模式。我的修复方案是在handleTouchStart里加一行this.ctx.globalCompositeOperation destination-out确保每次刮擦前都重置。提示iOS微信的canvas渲染引擎WKWebView对globalCompositeOperation的支持有bug必须用destination-out其他模式如xor在iOS上完全失效。4.2 刮开区域边缘发虚不是抗锯齿问题是坐标没取整问题现象刮开后的边缘像毛玻璃尤其在iPhone X及以上机型。表面看是canvas抗锯齿实则是坐标计算误差。uni-app获取的touch坐标是浮点数而canvas像素是整数网格。当x100.345时canvas会自动插值渲染导致边缘模糊。解决方案所有传入moveTo和lineTo的坐标必须Math.round()。我在handleTouchMove里加了强制取整x Math.round((touch.clientX - rect.left) * this.dpr); y Math.round((touch.clientY - rect.top) * this.dpr);实测效果边缘锐利度提升80%用户反馈“刮起来更干脆”。这个细节uni-app文档从未提及但却是iOS端刮奖体验的分水岭。4.3 小程序端canvas白屏检查canvas-id和上下文获取方式问题现象微信小程序真机上canvas显示空白但开发者工具正常。根本原因是uni.createCanvasContext在真机上必须传入canvas-id且id必须与wxml中canvas-id属性一致。常见错误wxml中写canvas idmyCanvasjs里却用uni.createCanvasContext(myCanvas)——错必须用canvas-id属性。canvas-id用了动态绑定如:canvas-idcanvasId小程序端无法识别。没在onReady生命周期里初始化canvas而是在mounted里——小程序端mounted时canvas节点可能未就绪。正确做法wxml中canvas canvas-idscratchCanvas /js中uni.createCanvasContext(scratchCanvas, this)且初始化代码放在onReady里。我曾因在mounted里初始化在iOS真机上遇到canvas始终为空的bug改到onReady后立即解决。4.4 H5端刮擦卡顿关闭硬件加速反而更流畅问题现象H5页面在PC浏览器上刮擦掉帧FPS低于30。常规思路是开硬件加速但实测发现给canvas加transform: translateZ(0)后Chrome里刮擦更卡。原因在于硬件加速会把canvas渲染交给GPU但刮擦是高频小区域重绘CPU处理反而更快。解决方案H5端禁用硬件加速改用will-change: auto并确保canvas父容器没有overflow: hidden——后者会触发浏览器重排拖慢canvas更新。我在项目里加了环境判断if (process.env.UNI_PLATFORM h5) { document.querySelector(#scratchCanvas).style.willChange auto; }实测FPS从22提升到58用户滑动流畅度明显改善。4.5 刮奖完成后奖品图显示错位cover-view尺寸未随屏幕旋转更新问题现象横屏刮完后切回竖屏奖品图位置偏移。这是因为cover-view的尺寸在屏幕旋转时未重置。uni-app没有onOrientationChange钩子必须手动监听onShow() { this.updateCanvasSize(); }, onUnload() { window.removeEventListener(resize, this.handleResize); }, handleResize() { clearTimeout(this.resizeTimer); this.resizeTimer setTimeout(() { this.updateCanvasSize(); }, 100); }updateCanvasSize里重新执行createSelectorQuery获取canvas新尺寸并重绘遮罩层。这个坑在折叠屏手机上尤其明显必须处理。5. 进阶优化让刮奖不止于“刮”还能成为用户停留的钩子5.1 刮擦音效与震动反馈用uni-app原生API增强沉浸感uni-app提供了uni.vibrateShort()和uni.getSystemInfoSync().platform判断平台能力。我的方案是刮擦过程中每200ms触发一次短震iOS或播放0.1秒音效Android/H5。关键代码playFeedback() { const sys uni.getSystemInfoSync(); if (sys.platform ios) { uni.vibrateShort(); // iOS短震 } else if (sys.platform android) { // Android播放音效 const audio uni.createInnerAudioContext(); audio.src /static/sound/scratch.mp3; audio.play(); } }音效文件必须是mp3格式iOS不支持wav且体积控制在100KB以内否则H5端加载延迟。实测数据显示加入音效后用户平均刮奖时长延长1.8秒因为反馈增强了操作确认感——这不是玄学是人机交互的基本原理操作必须有即时、可感知的反馈。5.2 刮开动画用canvas渐变实现“光晕扩散”效果刮奖完成时单纯显示奖品图太单调。我用canvas径向渐变模拟光晕扩散drawGlowEffect() { const gradient this.ctx.createRadialGradient( this.canvasSize.width/2, this.canvasSize.height/2, 0, this.canvasSize.width/2, this.canvasSize.height/2, 200 ); gradient.addColorStop(0, rgba(255,215,0,0.8)); gradient.addColorStop(1, rgba(255,215,0,0)); this.ctx.fillStyle gradient; this.ctx.beginPath(); this.ctx.arc(this.canvasSize.width/2, this.canvasSize.height/2, 200, 0, Math.PI*2); this.ctx.fill(); }这段代码在scratchComplete后执行光晕从中心扩散持续300ms。注意createRadialGradient的参数顺序起点x/y、起点半径、终点x/y、终点半径。很多开发者把起点半径写成0终点半径写小了导致光晕不扩散。200是经过测试的最优值——小于150扩散感弱大于250会覆盖整个奖品图。5.3 数据埋点刮擦行为分析比曝光率更重要刮奖页的终极目标不是“让用户刮”而是“让用户刮完后行动”。我在刮擦过程中埋了三类点scratch_start记录开始时间、设备型号、网络类型scratch_progress每10%刮开进度上报一次分析用户放弃点scratch_complete记录完成时间、刮开速度总耗时/刮开面积、后续跳转行为。特别重要的是scratch_progress它让我发现70%用户在刮到40%时放弃说明遮罩层太厚。于是把遮罩alpha从0.8降到0.65放弃率下降22%。这些数据无法从PV/UV里看出必须靠刮擦过程埋点。注意埋点代码必须用setTimeout延后执行避免阻塞刮擦主线程。我设了50ms延时既保证数据采集又不影响刮擦流畅度。5.4 多奖品轮播用canvas纹理复用降低内存占用一个刮奖页常需展示多个奖品如果每个奖品都新建canvas内存暴涨。我的方案是用单个canvas通过ctx.drawImage()轮播奖品图。关键技巧是预加载所有奖品图到image对象池preloadImages(urls) { return urls.map(url { return new Promise(resolve { const img new Image(); img.onload () resolve(img); img.src url; }); }); }刮奖完成时从image池取对应奖品图用ctx.drawImage(img, 0, 0, width, height)绘制。这样内存占用比新建canvas低60%且切换无闪烁。实测在低端安卓机上10个奖品轮播内存峰值从120MB降到48MB。6. 最后分享一个真实教训别在刮奖页加“分享按钮”除非你已解决层级冲突去年上线一个节日刮奖活动运营 insisted 要在刮奖页右上角加微信分享按钮。我按常规方案用button open-typeshare结果发现iOS微信里分享按钮的弹窗会盖住刮奖canvas用户刮到一半点分享弹窗消失后canvas状态丢失必须重刮。查了微信文档才知道open-typeshare的弹窗是原生层z-index高于所有webview内容。解决方案是分享按钮用cover-view包裹点击时先uni.hideToast()隐藏所有提示再uni.showActionSheet()模拟分享菜单——虽然体验稍逊但保证了刮奖流程不中断。这个教训告诉我刮奖页的每一个交互元素都必须经过“是否破坏刮擦状态”的检验。现在我的刮奖组件里所有外部交互都加了beforeScratch钩子确保刮擦进行中禁止任何跳转或弹窗。