ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微信小程序转盘抽奖的canvas渲染与app.json权限校验

微信小程序转盘抽奖的canvas渲染与app.json权限校验 简介本资源是一份面向微信小程序初学者与进阶开发者的实战型抽奖功能源码案例聚焦移动端互动营销场景帮助开发者快速掌握转盘抽奖这一高频业务模块的完整实现逻辑。压缩包共22个文件含5个JS文件承载抽奖控制、动画触发与中奖逻辑、3个WXML文件定义转盘、按钮及结果弹窗结构、4个WXSS文件实现响应式样式与视觉动效、4个JSON配置文件管理页面路由与基础属性以及PNG图片资源与.gitignore等辅助文件整体体积仅1.46MB轻量易导入调试。已有1005人学习下载说明其在社区中具备良好实践口碑。读者可直接运行项目理解WXML/WXSS/JS三端协同机制深入剖析随机算法设计、Canvas或CSS动画实现细节、奖品概率配置策略并基于现有结构快速适配企业级活动需求如更换主题皮肤、接入后端奖品池或集成用户授权流程。1. 微信小程序转盘大抽奖不是炫技而是对 canvas 渲染控制、事件响应节奏和 app.json 权限配置的三重校验很多开发者拿到“微信小程序开发-转盘大抽奖案例源码.zip”后第一反应是解压、npm install、微信开发者工具打开——结果卡在app.json 文件内容错误或canvas 绘图空白上。这不是源码有问题而是这个场景天然暴露了小程序开发中三个最易被忽略的硬性约束canvas 必须用type2d显式声明上下文类型否则在基础库 3.8.10当前主流版本下无法获取绘图接口app.json中permission字段若存在未申请的 scope如scope.record哪怕代码里根本没调用也会导致整个项目启动失败而“转盘转动”本身不是 CSS 动画能搞定的它必须依赖 requestAnimationFrame 的帧级控制 角度累加 离屏缓存优化否则在低端安卓机上会出现明显卡顿甚至指针错位。这个 ZIP 包的价值不在于它实现了抽奖而在于它把这三道关卡压缩在一个可运行的最小闭环里从app.json的结构校验开始到canvas的初始化时机选择再到转动逻辑中e.detail.rotate的真实来源解析。适合刚完成“小程序基础组件”学习、正要接触自定义绘制与用户交互耦合的开发者也适合需要快速交付营销活动页的前端工程师——你不需要重写转盘但必须读懂它为什么这样写。2. 用 type2d canvas 在本地跑通转盘绘制的最小命令链微信小程序的 canvas 机制在 2022 年底已全面切换至2d上下文模型旧版webgl或无type声明的写法在基础库 3.8.10标题中明确标注的版本下直接失效。本案例源码中index.wxml的 canvas 标签绝非简单占位而是整个转盘视觉层的唯一载体。2.1 canvas 节点声明与上下文获取的强制约定!-- index.wxml -- canvas canvas-idluckyCanvas type2d bindtouchstartonTouchStart bindtouchmoveonTouchMove bindtouchendonTouchEnd stylewidth: 300px; height: 300px; /注意type2d是硬性要求不可省略。若缺失wx.createCanvasContext(luckyCanvas, this)将返回null后续所有drawArc、fillText调用均静默失败。开发者工具控制台不会报错只会渲染空白——这是本 ZIP 包首次运行失败的最高频原因。在index.js中上下文获取必须严格遵循生命周期// index.js Page({ data: { isSpinning: false, currentAngle: 0 }, onReady() { // 必须在 onReady 阶段获取onLoad 时 canvas 尚未挂载 const query wx.createSelectorQuery(); query.select(#luckyCanvas).fields({ node: true, size: true }).exec((res) { const canvas res[0].node; const dpr wx.getSystemInfoSync().pixelRatio; const width res[0].width * dpr; const height res[0].height * dpr; const ctx canvas.getContext(2d); const rect canvas.getBoundingClientRect(); ctx.scale(dpr, dpr); // 缩放适配高清屏 this.ctx ctx; this.canvasWidth rect.width; this.canvasHeight rect.height; this.dpr dpr; // 此时才可安全调用 drawLuckyWheel() this.drawLuckyWheel(); }); }, drawLuckyWheel() { const ctx this.ctx; const centerX this.canvasWidth / 2; const centerY this.canvasHeight / 2; const radius Math.min(centerX, centerY) - 20; // 清空画布关键避免多帧叠加 ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); // 绘制扇形区域8个奖项 const colors [#FF6B6B, #4ECDC4, #45B7D1, #96CEB4, #FFEAA7, #DDA0DD, #98D8C8, #F7DC6F]; const prizes [一等奖, 二等奖, 三等奖, 谢谢参与, 再来一次, 幸运奖, 纪念品, 安慰奖]; for (let i 0; i 8; i) { const startAngle (i * Math.PI * 2) / 8 this.data.currentAngle * Math.PI / 180; const endAngle ((i 1) * Math.PI * 2) / 8 this.data.currentAngle * Math.PI / 180; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.arc(centerX, centerY, radius, startAngle, endAngle); ctx.closePath(); ctx.fillStyle colors[i]; ctx.fill(); // 绘制文字需旋转坐标系 ctx.save(); ctx.translate(centerX, centerY); ctx.rotate((startAngle endAngle) / 2); ctx.fillStyle #333; ctx.font bold 14px sans-serif; ctx.textAlign right; ctx.fillText(prizes[i], radius - 10, 5); ctx.restore(); } } });2.1.1 关键参数说明与常见误用对比参数/操作正确做法常见误用后果canvas.getContext(2d)在onReady中通过createSelectorQuery获取节点后调用在onLoad中直接wx.createCanvasContext(id, this)返回null绘图无效果ctx.scale(dpr, dpr)必须在getContext后立即执行且clearRect前在drawArc后调用或完全忽略图形模糊、尺寸错乱、文字锯齿ctx.clearRect(0,0,w,h)每次drawLuckyWheel()开头必须清空仅在初始化时清空一次多帧绘制叠加扇形边界重叠、文字重影文字旋转定位save()→translate()→rotate()→fillText()→restore()直接计算x,y坐标并fillText(x,y,text)文字全部挤在圆心或偏离扇区2.2 app.json 的权限字段校验为什么删掉一行就能启动成功标题中明确提示错误[app.json 文件内容错误]app.json: invalid permission[scope.record]这直指app.json的permission字段。该字段用于声明小程序需要的用户授权但它只允许声明当前代码中实际调用的 API 所需的 scope。若源码中从未出现wx.startRecord()或wx.getRecorderManager()却在app.json中写了scope.record: {desc: 用于语音反馈}基础库 3.8.10 会直接拒绝加载整个小程序并在开发者工具控制台抛出该错误。本 ZIP 包的app.json典型结构如下{ pages: [pages/index/index], window: { navigationBarTitleText: 转盘大抽奖 }, permission: { scope.userLocation: { desc: 获取位置信息用于就近门店推荐 } } }提示检查你的app.json删除所有permission下未在 JS 代码中调用对应 API 的条目。例如若代码里没有wx.chooseImage()就不要保留scope.writePhotosAlbum若无录音逻辑scope.record必须彻底移除。这是小程序启动校验的第一道门跨不过去后续所有 canvas 绘制都无从谈起。3. 实现可交互转盘的 3 个必调参数与防抖策略转盘的核心交互不是“点击即转”而是“拖拽启动 松手惯性转动 结果锁定”。这要求对触摸事件的touches数组、角度增量计算、以及requestAnimationFrame的帧循环进行精确控制。本案例源码中onTouchStart/onTouchMove/onTouchEnd三函数构成完整事件链但真正决定体验是否丝滑的是以下三个参数的设定。3.1 触摸起始点与中心偏移量的归一化处理onTouchStart不仅记录起点更要计算触摸点相对于转盘中心的极坐标角度作为后续转动的基准onTouchStart(e) { if (this.data.isSpinning) return; const touch e.touches[0]; const rect this.canvas.getBoundingClientRect(); const centerX rect.left rect.width / 2; const centerY rect.top rect.height / 2; // 计算触摸点相对于中心的角度弧度 const dx touch.clientX - centerX; const dy touch.clientY - centerY; this.startAngle Math.atan2(dy, dx); // 注意y轴向下atan2(dy,dx)才正确 // 记录初始时间戳用于后续速度计算 this.startTime Date.now(); this.lastTime this.startTime; this.lastAngle this.data.currentAngle; },3.1.1 为什么Math.atan2(dy, dx)不可替换为Math.atan(dy/dx)Math.atan(dy/dx)在dx0垂直触摸时返回NaN且无法区分第二、三象限Math.atan2(dy, dx)接受两个独立参数自动处理除零并返回-π到π的完整角度完美匹配 canvas 的极坐标系统若此处写错会导致拖拽时指针“跳变”或“反向旋转”这是新手调试中最耗时的坑。3.2 拖拽过程中的实时角度更新与防抖阈值onTouchMove的核心任务是将手指移动映射为转盘角度变化但必须加入最小位移阈值避免微小抖动触发误转onTouchMove(e) { if (!this.startAngle || this.data.isSpinning) return; const touch e.touches[0]; const rect this.canvas.getBoundingClientRect(); const centerX rect.left rect.width / 2; const centerY rect.top rect.height / 2; const dx touch.clientX - centerX; const dy touch.clientY - centerY; const currentAngle Math.atan2(dy, dx) * 180 / Math.PI; // 转为角度制 // 计算本次拖拽引起的角度差考虑跨 -180/180 边界 let deltaAngle currentAngle - (this.startAngle * 180 / Math.PI); if (Math.abs(deltaAngle) 180) { deltaAngle deltaAngle 0 ? deltaAngle - 360 : deltaAngle 360; } // 防抖仅当角度变化超过 2 度才更新避免手指微颤 if (Math.abs(deltaAngle) 2) { this.setData({ currentAngle: this.data.currentAngle deltaAngle }); this.startAngle Math.atan2(dy, dx); // 更新基准角 } },3.2.1 防抖阈值 2° 的工程依据人眼对小于 1.5° 的角度变化难以察觉设为 2° 可过滤绝大多数生理抖动若设为 0.5°低端安卓机触摸采样率低约 60Hz会导致deltaAngle频繁正负跳变转盘“颤抖”若设为 5°则拖拽手感迟滞用户需大幅移动手指才能看到响应。3.3 松手后的惯性转动requestAnimationFrame 的三阶衰减模型onTouchEnd不是简单地“转到某个固定角度”而是启动一个基于物理模型的衰减动画。本案例采用三阶多项式衰减angle a*t^3 b*t^2 c*t d比线性或指数衰减更符合真实转盘停顿感。onTouchEnd() { if (this.data.isSpinning || !this.startAngle) return; // 计算松手瞬间的角速度度/毫秒 const now Date.now(); const deltaTime now - this.lastTime; const deltaAngle this.data.currentAngle - this.lastAngle; const speed deltaAngle / deltaTime; // 当前瞬时角速度 // 若速度过低 0.5 deg/ms视为手动精准停止不启动惯性 if (Math.abs(speed) 0.5) { this.stopSpinning(); return; } this.setData({ isSpinning: true }); this.animationStartTime now; this.animationStartAngle this.data.currentAngle; this.animationTargetAngle this.calculateTargetAngle(speed); // 启动 requestAnimationFrame 循环 this.animateSpin(); }, animateSpin() { const now Date.now(); const elapsed now - this.animationStartTime; const duration 3000; // 总动画时长 3s if (elapsed duration) { this.setData({ currentAngle: this.animationTargetAngle, isSpinning: false }); this.triggerPrizeResult(this.animationTargetAngle); return; } // 三阶衰减f(t) 1 - (1 - t/T)^3 const progress elapsed / duration; const easeFactor 1 - Math.pow(1 - progress, 3); const currentAngle this.animationStartAngle (this.animationTargetAngle - this.animationStartAngle) * easeFactor; this.setData({ currentAngle }); requestAnimationFrame(() this.animateSpin()); }, calculateTargetAngle(speed) { // 根据初速度估算总转动角度经验公式speed * 1000 const baseTurn speed * 1000; // 添加随机扰动±15°模拟真实转盘摩擦不均 const randomOffset (Math.random() - 0.5) * 30; return this.data.currentAngle baseTurn randomOffset; }注意requestAnimationFrame在小程序中必须通过wx.nextTick或直接调用基础库 2.25.0 支持本例使用递归调用确保兼容性。若用setTimeout替代帧率将不稳定导致转动“卡顿”或“加速”。4. 验证转盘逻辑正确性的 4 种现场检测法当转盘转动后结果总是固定在某一项或指针与扇区对不齐不要急于重写逻辑。先用这四种方法在开发者工具中做现场验证90% 的问题能 5 分钟内定位。4.1 canvas 坐标系校准用临时矩形框验证中心点在drawLuckyWheel()函数开头插入调试代码绘制一个红色十字线// 在 drawLuckyWheel() 开头添加 ctx.strokeStyle red; ctx.lineWidth 2; ctx.beginPath(); ctx.moveTo(centerX - 20, centerY); ctx.lineTo(centerX 20, centerY); ctx.moveTo(centerX, centerY - 20); ctx.lineTo(centerX, centerY 20); ctx.stroke();运行后观察红色十字是否精确位于转盘视觉中心。若偏移说明getBoundingClientRect()获取的rect与 WXML 中style设置的宽高不一致需检查canvas外层是否有padding或margin干扰。4.2 角度数据流追踪console 输出每一帧的 currentAngle在setData({ currentAngle })调用前添加日志console.log([SPIN] time${Date.now()-this.animationStartTime}ms, angle${currentAngle.toFixed(2)}, target${this.animationTargetAngle.toFixed(2)});观察控制台输出若angle值在animateSpin()中恒定不变说明requestAnimationFrame未触发检查是否在setData后遗漏了递归调用若angle从120.5突变为120.50000000000001是 JavaScript 浮点精度问题不影响视觉可忽略若angle在onTouchMove中跳跃式变化如10 - 150 - -20说明Math.atan2计算有误需检查dx/dy符号。4.3 扇区角度映射表构建静态对照表验证中奖逻辑转盘最终结果由currentAngle % 360决定但必须映射到 8 个扇区。手动构建一张表验证calculateTargetAngle的输出是否落在预期区间扇区编号角度范围°对应奖项currentAngle % 360应落在此区间0[0, 45)一等奖0 angle 451[45, 90)二等奖45 angle 902[90, 135)三等奖90 angle 135............在triggerPrizeResult(angle)中打印const normalized ((angle % 360) 360) % 360; // 处理负数 console.log([PRIZE] normalized angle ${normalized.toFixed(2)});若normalized值稳定在350~359说明calculateTargetAngle的随机扰动过大需将randomOffset范围从±30改为±10。4.4 app.json 权限字段的自动化校验脚本手动检查app.json易漏可用以下 Node.js 脚本扫描项目 JS 文件列出所有调用的wx.*API再与app.json中的permission比对# save as check-permission.js const fs require(fs); const path require(path); const appJson JSON.parse(fs.readFileSync(./app.json, utf8)); const jsFiles fs.readdirSync(./pages).filter(f f.endsWith(.js)); const usedScopes new Set(); jsFiles.forEach(file { const content fs.readFileSync(path.join(./pages, file), utf8); // 匹配 wx.startRecord(), wx.chooseImage() 等 const apiCalls content.match(/wx\.(\w)\(/g) || []; apiCalls.forEach(call { const api call.replace(/wx\.|\(/g, ); if (api.includes(Record)) usedScopes.add(scope.record); if (api.includes(Image)) usedScopes.add(scope.writePhotosAlbum); if (api.includes(Location)) usedScopes.add(scope.userLocation); }); }); console.log(Used scopes:, Array.from(usedScopes)); console.log(Declared in app.json:, Object.keys(appJson.permission || {})); const missing Array.from(usedScopes).filter(s !Object.keys(appJson.permission || {}).includes(s)); const redundant Object.keys(appJson.permission || {}).filter(s !usedScopes.has(s)); if (missing.length) console.warn(MISSING permissions:, missing); if (redundant.length) console.warn(REDUNDANT permissions:, redundant);运行node check-permission.js输出REDUNDANT permissions: [scope.record]即表示该字段可安全删除。5. 修改刚进入的加载页面从 app.json 到 page.json 的三级覆盖策略标题中热词“修改刚进入的加载页面”并非指 splash screen小程序无原生启动图而是用户从微信聊天页点击进入小程序时看到的白屏过渡页。这个页面的样式由app.json的window字段全局控制但可被page.json局部覆盖最终由 WXML 中的view实际渲染。本 ZIP 包的优化路径是先用app.json设定全局骨架再用page.json覆盖首页特有属性最后在index.wxml中注入动态 loading 状态。5.1 app.json 的 window 字段设置全局背景与导航栏{ window: { backgroundTextStyle: light, navigationBarBackgroundColor: #ffffff, navigationBarTitleText: 转盘大抽奖, navigationBarTextStyle: black, backgroundColor: #f8f8f8 } }关键点backgroundColor设为#f8f8f8浅灰而非#ffffff纯白可避免 iOS 下白屏闪动。这是微信客户端渲染机制决定的——纯白背景在 WebView 初始化完成前会短暂显示为刺眼白光。5.2 page.json 的局部覆盖首页禁用导航栏启用自定义 loading// pages/index/index.json { navigationStyle: custom, usingComponents: { loading-comp: /components/loading/loading } }navigationStyle: custom移除默认导航栏让index.wxml完全控制顶部区域。此时需在index.wxml中手动实现!-- index.wxml -- view classloading-container wx:if{{isLoading}} view classloading-icon/view text classloading-text转盘准备中.../text /view canvas canvas-idluckyCanvas type2d bindtouchstartonTouchStart bindtouchmoveonTouchMove bindtouchendonTouchEnd stylewidth: 300px; height: 300px; wx:else /5.3 WXML 中的 loading 状态管理用 setData 控制显隐在index.js的onLoad中模拟资源加载onLoad() { this.setData({ isLoading: true }); // 模拟 canvas 初始化耗时实际中可能是图片加载、数据请求 setTimeout(() { this.setData({ isLoading: false }); }, 800); },对应的 WXSS/* index.wxss */ .loading-container { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; background-color: #f8f8f8; } .loading-icon { width: 40rpx; height: 40rpx; border: 4rpx solid #e0e0e0; border-top-color: #007AFF; border-radius: 50%; animation: rotate 1s linear infinite; } keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .loading-text { margin-top: 20rpx; font-size: 28rpx; color: #666; }提示此 loading 页面的height: 100vh必须配合page.json的navigationStyle: custom否则会被默认导航栏遮挡。若忘记设置customloading 会显示在导航栏下方造成视觉割裂。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进