微信小程序抽奖攻略:Canvas转盘绘制与概率算法实战
简介简易抽奖小程序项目源码基于微信原生开发框架编写面向小程序入门开发者与需要快速搭建抽奖活动的业务人员可直接用于学习原生组件、事件绑定及数据渲染也可作为课程设计或毕业设计的参照。压缩包共十九个文件其中九张效果截图展示不同页面和弹窗状态三个脚本逻辑文件实现随机抽奖、页面交互与数据更新两个页面结构文件定义主界面与结果页两个样式文件控制整体视觉风格另含配置文件、导入说明文档与项目说明整体仅二百三十八KB轻量便于分发。目前已有一百零七人学习适合边看边练。源码将抽奖核心流程封装清晰随机数生成、中奖判断、结果展示等关键模块一目了然借助附带的效果截图和说明文档可快速在微信开发者工具中预览运行并可按业务需要调整奖品池、抽奖次数和界面主题。项目采用pages目录组织页面结构清晰便于二次扩展。1. 简易抽奖小程序的工程骨架与原生框架选型拿到这份wechatapp-choujiang-master源码时第一反应是它把微信小程序的经典工程结构摆得很正pages下按页面拆目录app.json统一注册页面路由utils里放工具函数imgs单独托管静态图。相比 uniapp 或 Taro 的跨端方案原生框架没有编译层setData的链路更短对抽奖这类需要高频刷新界面状态的场景反而更可控。源码里没有引第三方 UI 库所有弹层、按钮、转盘都用 WXML 和 WXSS 手写这种做法的好处是产物包体积小首屏加载快而且不会因为组件库版本升级把页面样式带崩。如果你的目标是快速上线一个带转盘抽奖、中奖记录、分享裂变的营销小工具这套项目的代码结构可以直接作为起步模板。接下来按工程、逻辑、支付、调优四层拆开讲每一步都能对照源码找得到落点。2. 页面路由与原生组件在抽奖页中的实际配置2.1 app.json 的路由注册与窗口表现源码首页是典型的小程序入口配置。app.json里pages数组的第一项就是启动页抽奖主页面放在首位这样扫小程序码后直接进抽奖界面减少用户跳转流失。window块里需要关注navigationBarBackgroundColor和navigationBarTitleText这两个值会直接影响抽奖页顶部栏的视觉统一。很多新手直接把标题写成“抽奖”但更稳的做法是带上品牌词或活动名例如“XX 签到抽奖”这样在微信聊天窗口转发卡片时标题能传递具体活动信息。窗口配置还涉及enablePullDownRefresh。抽奖页不建议全局开启下拉刷新因为转盘旋转时用户误触下拉会触发onPullDownRefresh导致页面数据重置。源码里单独的抽奖 page 配置在.json文件中设置为enablePullDownRefresh: false而中奖记录页单独开启。这种按页面维度做配置的习惯比在全局统一开关更符合实际运营需求。{ pages: [ pages/lottery/lottery, pages/record/record, pages/index/index ], window: { navigationBarBackgroundColor: #e64340, navigationBarTitleText: 签到抽奖, navigationBarTextStyle: white }, style: v2, sitemapLocation: sitemap.json }这段配置里style: v2启用新版组件样式按钮和弹层的圆角、边框会有基础统一样式如果你的业务 UI 有严格设计稿建议把v2去掉否则部分组件默认内边距会干扰自定义样式。sitemapLocation用于微信搜索收录抽奖类工具通常不需要被检索保持默认即可。2.2 页面 wxml 中的组件拆分与数据流进入pages/lottery/lottery.wxml可以看到源码把抽奖区、奖品列表、中奖弹层拆成了三个独立块。抽奖区用canvas绘制转盘奖品列表用scroll-view纵向滚动弹层用cover-view覆盖在 canvas 之上。这里有个容易被忽略的坑cover-view只支持覆盖原生组件如 canvas、map、video而弹层里如果要显示中奖礼品的网络图片图片必须用cover-image不能直接用image否则在 iOS 端会出现弹层被 canvas 穿透的情况。源码在弹层上使用wx:if控制显隐而不是hidden。这是因为 canvas 绘制完成后会保留画布状态hidden只是 CSS 隐藏canvas 仍占渲染层资源wx:if直接销毁重建保证每次弹出都是干净的画布帧。代价是弹层内部的动画状态每次都要重新初始化源码里通过animation数据字段控制弹层缩放动画避免重建时闪烁。view classlottery-panel canvas canvas-idwheelCanvas idwheelCanvas bindtaponCanvasTap/canvas view classbtn-area bindtapstartLottery text立即抽奖/text /view /view cover-view wx:if{{showResult}} classresult-mask cover-view classresult-card cover-image src{{prizeIcon}} classprize-icon/cover-image cover-view classprize-name{{prizeName}}/cover-view cover-view classbtn-confirm bindtapcloseResult收下奖品/cover-view /cover-view /cover-view这里的bindtap绑定在view上但 canvas 本身是原生组件bindtap在 canvas 上的命中区域需要计算偏移。常见做法是在 canvas 上再覆盖一层透明的view来捕获点击源码里直接在 canvas 上监听bindtap然后在event.detail.x和event.detail.y上做坐标换算判断点击是否落在“开始抽奖”的扇形区域内。这样的做法省了一层视图节点但要注意不同机型 canvas 的width与真实像素比对可能不一致需要在wx.createSelectorQuery里读取实际节点尺寸再做比例换算。2.3 工具函数与全局状态的职责划分utils目录下源码放了一个util.js里面包含日期格式化、随机数生成、奖品概率计算三个函数。概率计算函数是抽奖逻辑的核心后面第 3 章展开讲。全局状态方面源码没有用globalData存抽奖结果而是每次抽奖请求拿到结果后直接写入当页data并同步wx.setStorageSync缓存。这样做的原因在于抽奖结果与用户账户权益绑定如果只存内存小程序被微信回收后再次进入会丢失状态如果只存缓存多页面间同步依赖onShow重新读取容易产生延迟。一个值得借鉴的细节是源码在app.js里只初始化了版本更新检查没有放业务逻辑。很多项目把用户登录态也堆在app.js但抽奖这种工具型小程序用户可能还没登录就进入页面浏览奖品过早获取登录态会白屏等待。源码把wx.login放在用户点击“抽奖”那一刻触发属于典型的按需授权对散流量转化更友好。3. 转盘绘制、概率分配与抽奖算法的代码实现3.1 Canvas 2D 扇形绘制与角度基准转盘抽奖的视觉核心是 Canvas 绘制扇形。源码在onReady生命周期里调用drawWheel传入奖品列表prizeList每项包含name、icon、weight三个字段。绘制时以圆心为基准每个扇形角度为360 / prizeList.length用ctx.beginPath()ctx.arc()画出路径后闭合填充。绘制扇形有一个容易搞混的点ctx.arc(x, y, radius, startAngle, endAngle)的角度单位是弧度而且 0 弧度指向 x 轴正方向也就是三点钟方向。如果想让第一个奖品从十二点钟方向开始需要把起始角度手动偏移-Math.PI / 2。源码里单独封装了一个angleToRadian函数统一处理角度到弧度的转换并在绘制前把整体旋转偏移计算好避免每个扇形都单独做偏移。function drawWheel() { const ctx wx.createCanvasContext(wheelCanvas, this); const len this.data.prizeList.length; const angle 360 / len; const centerX 150; const centerY 150; const radius 140; for (let i 0; i len; i) { const startAngle (i * angle - 90) * Math.PI / 180; const endAngle ((i 1) * angle - 90) * Math.PI / 180; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.arc(centerX, centerY, radius, startAngle, endAngle); ctx.closePath(); ctx.setFillStyle(i % 2 0 ? #ffe0b2 : #ffcc80); ctx.fill(); ctx.setStrokeStyle(#ffffff); ctx.setLineWidth(2); ctx.stroke(); } ctx.draw(); }代码里moveTo到圆心再arc是绘制扇形的标准三段式先移动画笔到圆心画出弧线闭合路径后填充。setFillStyle用交替色区分相邻奖品视觉上更清晰。ctx.draw()是原生 Canvas 的最终提交动作必须调用才会把画布上的指令渲染出来。源码把drawWheel放在onReady中执行是因为onReady时 canvas 节点已经完成布局能拿到正确的节点尺寸。3.2 带权重的中奖概率算法抽奖算法决定了用户能否抽中奖以及中奖后的物品是否合理。源码的calculatePrize函数使用“区间映射”方式处理权重而不是直接把随机数对奖品个数取模。这两种方式有本质区别取模只能让每个奖品命中概率相等区间映射则能指定某件奖品被抽中的百分比。实现上先计算所有奖品权重之和totalWeight然后生成0 ~ totalWeight的随机浮点数依次累减每个奖品的权重当剩余值小于等于 0 时当前奖品即为中奖项。这种算法天然支持权重为 0 的情况权重为 0 的奖品永远不会被随机命中适合做“谢谢参与”或未开放奖品。function calculatePrize(prizeList) { const totalWeight prizeList.reduce((sum, p) sum p.weight, 0); let random Math.random() * totalWeight; for (let i 0; i prizeList.length; i) { random - prizeList[i].weight; if (random 0) { return prizeList[i]; } } return prizeList[prizeList.length - 1]; }这个实现有一个边界需要留意Math.random()的取值范围是[0, 1)所以random恒小于totalWeight最后一个奖品只有在前面所有奖品权重累减后依旧没能让random降到 0 以下的极端情况才会被兜底返回。正常概率分布下最后一个奖品被命中的概率仍然等于它的权重占比兜底只是为了保证函数一定有返回值。3.2.1 抽奖概率的后端一致性校验小程序端计算概率只能用于界面演示或本地模拟。真实线上项目中中奖结果必须由后端接口返回否则用户可以通过反复篡改prizeList数据来提升中奖率。源码里保留了一段模拟接口的示例代码用wx.request请求/lottery/draw请求参数为userId响应体为包含prizeId、prizeName、hit等字段的 JSON。前后端概率一致性校验是抽奖系统最容易出问题的地方。常见做法是后端记录每次抽奖的随机数种子和权重快照生成抽奖记录后加密返回。前端拿到结果后还应再调用一次中奖记录查询接口与本地展示结果做比对防止在网络劫持或缓存错乱场景下出现“展示 A 奖、入库 B 奖”的冲突。源码在getRecordList中做了两层判断先检查本地wx.getStorageSync(record)再请求远程数据并覆盖本地这能保证列表页展示的数据与服务端最终一致。3.3 转盘动画与停止角度的精确控制抽奖的体验感很大程度来自转盘旋转动画的收尾。源码使用setInterval每 16ms 更新一次旋转角度相当于 60fps。每次旋转增加一个递减的加速度让转盘先快后慢最终停在目标奖品对应的角度上。停止角度要反向计算转盘顺时针旋转targetAngle表示目标奖品扇区中心在画布原始坐标系中的角度。为了让转盘转完停下来后指针固定在正上方十二点方向正好指向目标奖品中心需要让转盘最终旋转的总角度满足(totalAngle targetAngle) % 360 0。源码中先随机生成一个基础圈数baseTurns一般 3~5 圈再算出不足一圈的偏移量最后得到最终旋转角度。function rotateToPrize(prizeIndex, callback) { const anglePerPrize 360 / this.data.prizeList.length; const targetAngle prizeIndex * anglePerPrize; const baseTurns 3 Math.floor(Math.random() * 3); const currentAngle this.data.currentAngle; const finalAngle baseTurns * 360 (360 - targetAngle) - currentAngle; this.setData({ currentAngle: currentAngle finalAngle }, () { this.startAnimation(finalAngle, 0, callback); }); }这里减掉currentAngle是为了累积计算因为setData的角度是历史值加新增值之后动画帧里每帧累加就能在有限步骤内完成多圈旋转并落在指定中心点。如果漏掉这个计算转盘第二次抽奖时会从上次停靠位置继续转停止位置会逐渐偏移最终出现“指针指向两个奖品交界处”的模糊结果。4. 支付、分享与授权流程在抽奖场景中的接入细节4.1 微信支付拉起与订单幂等处理抽奖类小程序最常见的付费模式是“付费抽奖次数”比如 1 元抽一次、6 元抽六次。源码在支付模块封装了requestPayment函数流程是前端点击抽奖按钮 - 检查剩余次数 - 调用后端createOrder接口创建订单 - 后端返回payParams- 前端用wx.requestPayment拉起支付面板。这个顺序不能反如果前端直接调wx.requestPayment缺少服务端订单号支付回调将无法对账。支付完成后不能只在wx.requestPayment的success回调里给用户加抽奖次数因为存在用户支付成功后立刻杀进程导致回调未执行的情况。源码的做法是在onShow生命周期里主动查询“待确认订单”用后端返回的trade_state字段做最终判定。这样即使支付回调丢失用户重新进入页面时也能通过订单状态补偿次数。wx.requestPayment({ timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, signType: payParams.signType, paySign: payParams.paySign, success: (res) { wx.showToast({ title: 支付成功 }); this.queryOrderStatus(orderId); }, fail: (err) { if (err.errMsg.includes(cancel)) { // 用户主动取消不提示错误 } } });timeStamp是支付发起时间来自后端前端不能用Date.now()代替paySign的签名算法也需要在后端统一完成涉及商户密钥绝不允许下发到前端代码中。fail回调里errMsg包含cancel时代表用户主动取消此时应静默处理不要弹错误 toast否则用户会感觉被打扰。4.1.1 支付结果与抽奖机会的联动支付成功后源码没有立刻把lotteryCount直接加一而是先调用checkAndAddCount接口由后端根据订单号做幂等处理。幂等键一般用orderId userId后端通过唯一索引防止重复发放。前端拿到最新的lotteryCount后再渲染到按钮文案“剩余 X 次”避免手动累加导致与后端数据不同步。4.2 分享裂变带参数与群识别抽奖小程序天然适合做“分享给好友获得额外次数”的裂变活动。源码里在button上设置了open-typeshare并在onShareAppMessage中返回当前活动页路径和参数scene。scene是一个经过编码的邀请人 ID 或活动 ID好友通过分享卡片进入时在onLoad(options)中解析options.scene再调用后端接口绑定邀请关系。onShareAppMessage: function () { const inviteCode wx.getStorageSync(inviteCode); return { title: 送你一次免费抽奖机会, path: /pages/lottery/lottery?scene encodeURIComponent(inviteCode), imageUrl: /imgs/share-bg.png }; }这里有个细节path中不能直接拼 userId因为分享卡片在微信中被传播时路径会经历多次转发和解析明文 userId 容易暴露用户信息而且拼接后 URL 过长个别 Android 机型会解析失败。用scene参数后后端在generateScene时同时存入“来源用户 ID 活动 ID 时间戳”解析时再做有效期校验防止过期链接被反复刷奖励。4.2.1 分享回调的时机选择onShareAppMessage只在用户点击右上角菜单或带open-typeshare的按钮时触发。源码中的“分享得次数”按钮没有直接调用wx.shareAppMessage这个 API 已废弃而是先弹出引导层告知用户“分享给好友后返回即可获得一次额外名额”。实际发放逻辑放在onShow中校验分享状态标记通过wx.getStorageSync(justShared)判断分享动作是否刚刚发生。因为微信没有提供分享是否成功的回调用“分享后回到小程序”的时机发奖励是现有规则下最可靠的方案虽然用户可以分享后不返回但此时奖励未发放不会造成资源浪费。4.3 用户授权与手机号快速填写的兼容处理抽奖活动中如果需要登记中奖人手机号旧版wx.getPhoneNumber直接用open-typegetPhoneNumber的按钮就能触发授权弹窗。但新版基础库对手机号快速验证组件做了调整源码的做法是先用wx.getUserProfile获取头像昵称用于展示同时保留一个“跳过绑定”按钮允许用户先抽奖等到中奖后再强制绑定手机号。这种“先享后绑”的流程能显著降低抽奖环节的流失率。button open-typegetPhoneNumber bindgetphonenumberonGetPhoneNumber微信一键登录/buttonbindgetphonenumber返回的detail.errMsg为getPhoneNumber:ok时detail.code为动态令牌需要后端用这个 code 换取真实手机号。前提是access_token有对应的权限且小程序已完成认证。开发者工具里经常遇到返回getPhoneNumber:fail no permission这多半是测试号没有绑定手机号验证能力真机预览使用已认证小程序账号即可通过。5. 体验优化与上线前的边界处理5.1 setData 频控与 Canvas 渲染降级抽奖转盘动画期间源码每一帧都会调用setData更新currentAngle这在大屏低端机上会造成明显卡顿。优化手段是旋转动画期间不把currentAngle实时写入data而是直接用CanvasContext的rotate方法在画布上做旋转这样渲染进程只在画布内变化不触发 WXML 节点同步。只有动画完全停止后才做一次setData更新最终状态。第二个容易忽略的点是 canvas 的尺寸适配。源码使用固定像素150px作为圆心坐标在 iPhone SE 和 iPhone 15 Pro Max 上转盘实际显示大小不同但绘制坐标不变。更好的做法是通过wx.getSystemInfoSync().windowWidth计算转盘半径让 canvas 宽度等于屏幕宽度减去边距再按比例缩放内部奖品文字大小。这里要注意wx.getSystemInfoSync在部分 Android 机型上windowWidth与实际逻辑像素存在 0.5px 偏差建议采集后取整。5.2 糖果剥离修改启动加载页的实用替换法源码自带的pages/index/index是活动宣传页很多人在接入时想直接换掉启动页。常见做法是新建一个pages/splash/splash把app.json的pages第一项替换成新页面原index保留但不再作为入口。进来后用wx.navigateTo在onReady中跳转到抽奖主页面同时调用wx.setNavigationBarTitle修改当前页标题。不过这里有一个隐藏问题如果启动页只是在onLoad里做wx.redirectTo在 iOS 上会看到短暂的白色闪屏因为新页面还在加载。建议在启动页的渲染层直接放一张全屏图片图片加载完毕后用wx.redirectTo并把新页面的背景色与启动页图片主色设置为一致这样视觉上几乎没有跳变。5.3 真机验证的三个重点场景源码 README 中提到了真机调试实测中至少有三个场景必须验证。第一个是 canvas 在 iPhone 真机上偶发空白。原因是 canvas 的type属性未指定时默认是 2d 上下文但在基础库 2.9.0 之后初始化方式有变化。如果发现wx.createCanvasContext绘制后清屏需要在 canvas 标签上显式设置type2d然后通过wx.createSelectorQuery().select(#wheelCanvas).fields({ node: true, size: true })获取 2D 上下文用canvas.getContext(2d)重写绘制代码。源码中保留的是旧式 API在最新基础库上兼容性需要做一次升级。第二个是抽奖次数减少与支付结果不同步。当用户快速连续点击“立即抽奖”时源码在startLottery函数入口处加了if (this.data.isDrawing) return;锁防止重复请求。这个锁必须在动画结束回调里释放不能在网络请求返回时就释放否则转盘未停稳时用户再次点击会叠加旋转。第三个是中奖弹层在 Android 部分机型上出现边框发黑。这是cover-view的阴影兼容问题源码在弹层样式上把box-shadow改用border模拟并移除border-radius过大的值Android 端显示更稳定。5.4 抽奖记录本地缓存与过期策略中奖记录页从wx.getStorageSync(record)读取数据渲染。源码为每条记录增加createTime时间戳在读取时过滤超过 30 天的旧数据防止本地缓存无限膨胀。对于未登录用户本地记录可以作为临时展示但页脚需要提示“登录后中奖记录永久保留”引导用户完成授权这样运营后台才能拿到完整的用户画像和抽奖漏斗数据。utils/storage.js中封装了setExpire方法在写入数据时同时写入过期时间戳读取时先比对当前时间过期则删除。这个方法比单纯用wx.removeStorageSync手动清理更省心特别适合奖品列表、活动 banner 这类不常变化但需要定期刷新的数据。实际使用时可把过期时间设短一点抽奖活动通常以小时为单位配置避免活动规则调整后客户端仍使用旧缓存。项目源码的整体链路很清晰从app.json注册页面到 canvas 转盘绘制再到概率计算、支付与分享闭环最后落到性能和真机适配每一步都比较克制没有过度设计。当前基础库已经迭代到 3.x把 canvas 部分升级到 2D 接口后这套源码仍能作为抽奖类小程序的可靠起点。本文还有配套的精品资源点击获取