年会照片墙滚动抽奖:Canvas动画、内存控制与随机抽取

发布时间:2026/10/28 15:11:47

年会照片墙滚动抽奖:Canvas动画、内存控制与随机抽取 每年一到这个时候总有人被拉去给年会写抽奖程序。需求方给的描述通常就一句话“大屏上照片滚起来喊停就停停下来那张就是中奖的。”听着简单真到现场你就知道照片墙滚动抽奖这个小程序的坑八成不在抽奖逻辑上而在 Canvas 动画的渲染细节、图片加载的内存控制以及“停下来那一刻必须和中奖结果完全对齐”这三件事上。我做过几场类似的大屏互动最惨的一次是彩排好好的正式开场两分钟后画面开始掉帧主持人喊停卡片还在滑最后停在了半格的位置全场哄笑。这篇就把这套 Canvas 动画加随机抽取的完整实现捋一遍从技术选型、渲染原理、随机算法到现场排查清单给到能直接抄的程度。适合正在赶年会需求的开发者、想做互动小游戏的前端以及任何要在大屏上跑 Canvas 动画的人参考代码不依赖框架原生 JS 就能跑。1. 一场年会抽奖背后的技术选型与整体设计1.1 需求拆解照片墙抽奖真正难在哪先把需求拆干净。看起来是“滚动抽奖”实际上有五个独立的技术诉求一是照片数量不固定从几十张到上千张都有可能渲染方案必须能吃下这个数量级二是滚动要“顺”大屏上一卡顿观感立刻掉档三是停止位置必须和抽奖结果严丝合缝地对上不能出现“停在 A 但宣布 B”的尴尬四是抽过的人不能重复中奖多轮之间的状态要串起来五是要有兜底网络断了、图片挂了、浏览器崩了得能现场救回来。这五条里第三条是最容易被低估的。很多人第一版写出来是“动画先随机滚滚完停下来再根据停下来的位置查这张照片是谁”逻辑上说得通但实际会有两个问题一是停止位置受缓动曲线和帧率影响最后一帧的落点常常差几个像素视觉上卡片没有正对高亮框二是如果现场有人较真你没法解释“为什么停在这张”。所以我后来的做法全部改成“先定结果再倒推动画”结果由随机算法在点击开始的那一刻就确定下来动画只是把这个结果表演出来。这个思路转换是整套方案里最关键的一次取舍。还有一个隐性需求照片要有“人味”。纯色块或者纯 ID 滚动观众没有代入感必须是真的照片最好带姓名或者部门标签。这就把图片加载和内存控制的问题引进来了后面会专门讲。1.2 Canvas 与 DOM 的取舍滚动列表的实现最直观的是 DOM一堆div或者img外面套一个overflow: hidden的容器改transform: translateX让它动。这套方案在几十张图的时候完全够用写起来快样式好调圆角、阴影、描边全交给 CSS。但它有两个天花板。第一是数量。超过两三百个 DOM 节点同时参与 transform 动画浏览器的主线程和合成线程压力会上来尤其是节点里还塞了图片每帧的图层合成开销不小。第二是“无缝循环”的实现成本。DOM 方案做无限循环常见做法是把列表复制一份首尾相接滚动到边界时瞬间把偏移量归零这个“瞬间归零”如果时机没卡准会出现肉眼可见的跳帧。Canvas 的优势正好在这两点上。它是命令式绘制一帧画多少个元素只取决于你写了几次drawImage没有 DOM 节点开销也没有样式计算和布局回流无缝循环更是天然合适因为绘制坐标是你自己算的取模就行不用复制节点。代价是所有视觉效果都得手写圆角要自己裁阴影要自己画文字要自己fillText高亮框要自己描。调试也没 DOM 那么直观console只能打状态看不到“元素在哪”。我的建议很明确照片总数超过 100 张、或者需要长时间连续滚动、或者要做无缝循环就上 Canvas如果只是二三十个人的小部门抽完就散场DOM 完全够用没必要为了技术而技术。这个判断标准我在几个项目里都用没失手过。1.3 三层结构数据、渲染、调度定了 Canvas接下来是结构。我习惯把它切成三层层与层之间只通过明确的数据接口通信这样任何一层出问题都能单独替换。数据层负责三件事维护全员名单id、姓名、部门、照片地址、是否已中奖、维护洗牌后的抽奖池、记录每一轮的中奖结果。这一层是纯数据不碰 DOM 也不碰 Canvas方便写单元测试也方便现场出问题时直接在控制台手动干预。渲染层只负责“给我一个偏移量我把当前应该看到的画面画出来”。它不知道什么是抽奖也不知道谁中奖了输入是offset、list、highlightIndex这些输出是画布上的像素。这样设计的好处是滚动阶段和停止阶段用的是同一套绘制代码只是偏移量来源不同视觉上不会有割裂感。调度层是中间的胶水管状态机idle / spinning / decelerating / result、管requestAnimationFrame循环、管缓动曲线、管什么时候该通知数据层“这一轮结束了”。它也是唯一知道“动画进行到哪一步”的地方。这个分法听起来有点重但代码量其实不大一个文件三四百行就能装下。它的价值在排查的时候体现得特别明显画面卡你只需要怀疑渲染层结果不对你只需要看数据层停止位置漂移那一定是调度层的落点计算出了问题。分层不是为了好看是为了出问题时能快速定位。2. Canvas 动画的渲染细节与实操要点2.1 高清屏与尺寸适配从 devicePixelRatio 说起Canvas 最经典的新手坑就是模糊。你写canvas.width 800在 2 倍屏上它就是糊的因为 canvas 的width/height属性是像素缓冲区尺寸而 CSS 的width/height是显示尺寸两者不是一回事。缓冲区小了浏览器就得拉伸拉伸就模糊。正确做法是缓冲区尺寸 CSS 尺寸 ×devicePixelRatio然后用ctx.scale(dpr, dpr)把坐标系缩回逻辑像素之后所有绘制代码都按逻辑像素写不用关心屏幕倍率。function resizeCanvas(canvas, cssWidth, cssHeight) { const dpr Math.min(window.devicePixelRatio || 1, 2); // 上限 2别盲目吃 3 canvas.width Math.round(cssWidth * dpr); canvas.height Math.round(cssHeight * dpr); canvas.style.width cssWidth px; canvas.style.height cssHeight px; const ctx canvas.getContext(2d); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 注意用 setTransform 而不是 scale return ctx; }这里有两个细节值得说。一是dpr我封了上限 2。三倍屏的缓冲区像素数是 9 倍绘制开销和显存占用都会明显上升而大屏投屏场景下肉眼看不出 2 倍和 3 倍的区别封顶是纯收益。二是一定要用setTransform而不是scale。因为scale是累乘的你在 resize 里反复调用几次坐标系就被缩得不成样子了画出来的东西一次比一次小。这个 bug 我见过不止一次排查的时候盯着代码看半天看不出来因为单看每一行都对。另外大屏场景通常是固定的 16:9 或 4:3 投影实际有效分辨率经常是 1920×1080 或者 3840×2160。我的做法是按设计稿 1920×1080 写死所有坐标运行时算一个整体缩放比scale Math.min(w / 1920, h / 1080)然后ctx.setTransform(dpr * scale, 0, 0, dpr * scale, offsetX * dpr, offsetY * dpr)把整个设计稿等比映射到屏幕中央。这样你在设计稿里量出来的每一个像素都是可以直接写进代码的不用到处算百分比。2.2 照片卡的等比例裁切与圆角处理照片卡是画面的基本单元。年会上来的照片尺寸五花八门横的竖的方的都有如果直接drawImage(img, x, y, w, h)人会被压扁观感极差。要用“cover”的方式裁切按长边撑满短边溢出部分裁掉居中显示。function drawCoverImage(ctx, img, x, y, w, h) { const scale Math.max(w / img.width, h / img.height); const dw img.width * scale; const dh img.height * scale; ctx.drawImage(img, x (w - dw) / 2, y (h - dh) / 2, dw, dh); }圆角就是先beginPath 路径 clip再画图最后restore。现代浏览器有roundRect没有的话用arcTo兜底逻辑一样。function drawRoundCard(ctx, img, x, y, w, h, r) { ctx.save(); ctx.beginPath(); if (typeof ctx.roundRect function) { ctx.roundRect(x, y, w, h, r); } else { ctx.moveTo(x r, y); ctx.arcTo(x w, y, x w, y h, r); ctx.arcTo(x w, y h, x, y h, r); ctx.arcTo(x, y h, x, y, r); ctx.arcTo(x, y, x w, y, r); ctx.closePath(); } ctx.clip(); drawCoverImage(ctx, img, x, y, w, h); ctx.restore(); }注意clip()是有代价的它会打断 GPU 的批量绘制。如果一帧里要对 50 张卡做 clip性能会明显下降。解决办法在 2.3 节讲用离屏缓存把“裁切圆角文字描边”这一整套预先烘焙成一张图主循环里只做一次drawImage。还有一个视觉细节中奖卡片的放大效果。不要在中奖瞬间去改卡片尺寸重新绘制那样会触发一整轮完整的绘制流程容易掉帧。更好的做法是给每张卡维护一个scale值在主循环里用它去缩放绘制参数然后用一个短小的缓动0.3 秒左右把 scale 从 1 推到 1.15。因为是逐帧插值视觉上是连续的放大成本只是每次多算两个乘法。2.3 图片预加载与内存控制这一节是我认为整个项目里最容易被低估的部分。很多人写抽奖直接new Image()然后onload里往数组一塞跑起来也没事直到照片数量上来页面白屏、标签页崩溃。先算一笔账。一张 4000×3000 的手机照片解码后在内存里占的字节数是4000 × 3000 × 4 48,000,000大约45.8MB。这是解码后的位图跟文件大小可能只有 3MB完全是两码事。200 张这样的照片同时驻留内存就是 9GB 量级任何浏览器都扛不住。所以图片进来第一件事是降采样不是先加载再缩而是尽量在加载阶段就把尺寸压下来。有三种做法按推荐程度排方案做法优点局限服务端缩略图上传时生成 300×300 的缩略图前端只加载这个最彻底内存和带宽都省需要后端配合Canvas 离屏降采样原图加载后画到小尺寸离屏 canvas再转成新图纯前端可控原图仍会短暂驻留内存createImageBitmap用resizeWidth/resizeHeight参数直接解码成小图不用经过中间 Canvas内存友好兼容性要看目标浏览器如果后端能配合一定要走第一条路。年会照片墙展示的卡片尺寸通常不超过 300×300 逻辑像素在 2 倍屏上也就 600×600加载原图纯属浪费。实测下来600×600 的缩略图解码后约 1.4MB200 张一共 280MB虽然还是不小但已经在可控范围内了。如果再压到 300×300200 张总共约 70MB非常稳。预加载本身要写得健壮。Promise.all里如果有任何一个Image既没触发onload也没触发onerror网络断了、图片地址 404 但没返回整个Promise.all会永远挂着你的加载进度条就卡死了。function preloadImages(urls, onProgress, timeout 8000) { let done 0; return Promise.all(urls.map(url new Promise(resolve { const img new Image(); img.decoding async; const finish (ok) { done; onProgress onProgress(done, urls.length); resolve(ok ? { ok: true, img, url } : { ok: false, url }); }; const timer setTimeout(() finish(false), timeout); img.onload () { clearTimeout(timer); finish(true); }; img.onerror () { clearTimeout(timer); finish(false); }; img.src url; }))); }Promise.all之后把ok: false的条目用一张本地生成的占位图画个灰色圆角矩形加姓名首字顶上去保证卡片数量不缺口。年会现场最怕的就是“少了一个人”哪怕照片挂了名字也得在。2.4 requestAnimationFrame 的循环与节流动画循环用requestAnimationFrame这是常识。但有几个细节决定了它跑得稳不稳。第一不要依赖固定步长。很多人写offset 5在 60Hz 屏幕上每秒滚 300 像素到了 120Hz 屏幕上就是 600 像素速度翻倍。必须用时间差做归一化let last 0; function loop(now) { if (!last) last now; const dt Math.min(now - last, 50); // 封顶 50ms防止切回标签页时瞬移 last now; update(dt / 16.667); // 1 表示标准 60fps 下的一帧 render(); rafId requestAnimationFrame(loop); }dt封顶这一步很关键。浏览器标签页切到后台时rAF会暂停切回来时dt可能是几万毫秒不封顶的话偏移量会瞬间跳出去几屏画面直接乱掉。这个坑我在一次彩排里踩过切了个窗口回来抽奖池飞出去了。第二渲染层做视口裁剪。一帧里不需要把全部照片都画一遍只画落在可视区域内的那几张。假设卡片宽 260、间距 24一张卡占 284 像素1920 宽的大屏一屏也就显示 7 张左右加上首尾各留一张做缓冲一帧画 9 张足够了。哪怕总共有 1000 张照片绘制成本也是常数级的。第三避开发动机级别的 API。ctx.shadowBlur、filter、频繁的createPattern这些都是逐像素操作在大屏高分辨率下非常吃性能。中奖卡片发光的效果不要用shadowBlur实时画提前生成一张带光晕的图片主循环里直接drawImage上去性能差一个数量级。3. 随机抽取算法怎么才算经得起质疑3.1 Math.random 的脾气与常见误用Math.random()返回[0, 1)的伪随机数用它取索引最常见的写法是Math.floor(Math.random() * n)。这个写法本身没问题均匀性也够用但它有三个容易翻车的用法。第一在循环里连续取然后去重。有人写“随机取一个如果已中奖就重取”在小池子里没问题但当池子里只剩最后几个名额时重试次数会指数级上升极端情况下会出现明显的卡顿观众能感觉到“点了之后卡了一下才出结果”。第二取模偏差。如果你用randomUint32() % n当n不能整除 2³² 时前面的数字被选中的概率会略高。年会抽奖这个场景下偏差小到可以忽略但如果要做严肃的公平性展示用拒绝采样更稳妥function secureRandomInt(max) { if (max 0) return 0; const buf new Uint32Array(1); const limit Math.floor(0x100000000 / max) * max; // 截断到 max 的整数倍 let v; do { crypto.getRandomValues(buf); v buf[0]; } while (v limit); return v % max; }用crypto.getRandomValues而不是Math.random主要是为了“可解释性”——现场如果有人问随机性怎么保证的你能说出用的是系统级密码学随机源这比说“用的 Math.random”有说服力得多。代价是速度慢一些但一秒钟抽一次完全无感。第三忘了排除已中奖的人。这是逻辑错误不是随机错误但经常一起出现。我的做法是抽奖池单独维护中奖后立刻从池子里剔除而不是每次抽的时候去原名单里过滤。前者是 O(1) 的删除后者每轮都要 O(n) 扫描而且容易漏。3.2 洗牌池与不重复抽取多轮抽奖要保证不重复最干净的方案是预先洗牌把所有候选人的 id 打乱成一个序列然后按顺序往外取。第几轮抽第几个天然不重复复杂度 O(1)。洗牌用 Fisher-Yates从后往前遍历每次和前面含自己的随机一个位置交换function shuffle(arr, rand Math.random) { const a arr.slice(); for (let i a.length - 1; i 0; i--) { const j Math.floor(rand() * (i 1)); [a[i], a[j]] [a[j], a[i]]; } return a; }这个算法必须用不要用arr.sort(() Math.random() - 0.5)。后者看起来简洁实际上分布是不均匀的某些元素被排到前面的概率会显著偏高。原因在于sort的比较函数返回的是随机值但排序算法本身假设比较关系是传递的随机值破坏了这个假设结果就会偏向某些位置。这个问题在网上被讨论过很多次结论很明确不能用。洗牌池还有一个好处是可复现。如果你把洗牌用的随机源换成带种子的伪随机数生成器比如 mulberry32那么同一个种子必然生成同一个序列。这在彩排时特别有用——你可以用固定种子跑一遍验证“第 3 轮的第 2 个人是谁”正式开始时再换成真随机。这个技巧在需要多机位联调或者和主持人串词的时候非常省事。3.3 权重、名额与预设序列真实的年会需求很少是“完全等概率”。常见的有这么几种按部门分配名额技术部 3 个、市场部 2 个保证每个部门都有人中领导或者嘉宾环节需要指定顺序某些人因为已经拿过别的奖要降低权重。这些都指向同一个能力带权重的抽取。最简单可靠的实现是“权重展开”——把每个人的权重换算成重复项塞进池子权重 3 的人放 3 个权重 1 的放 1 个然后正常洗牌。缺点是池子体积会膨胀但年会规模下几百人权重个位数完全无所谓。优点是实现简单不会出错而且天然支持“抽完自动降权”。function buildWeightedPool(people) { const pool []; people.forEach(p { const w Math.max(1, Math.round(p.weight || 1)); for (let i 0; i w; i) pool.push(p.id); }); return shuffle(pool); }名额分配也是同样的思路先按部门把池子分片每个分片内部洗牌然后按轮次从对应分片取。这样能保证“第 3 轮一定出技术部的人”而不是靠概率去碰。对于“预设序列”这个需求我的建议是把接口留出来但默认关闭数据层允许传入一个presetQueue如果传了就按队列顺序出结果不传就走洗牌池。代码上就多一个if但现场万一流程临时调整能立刻响应不用改代码重新部署。3.4 先定结果再倒推动画回到最开始说的那个核心思路。点击“开始”的瞬间数据层就把这一轮的中奖者算出来了拿到中奖者的索引targetIndex。接下来所有动画的工作都是让滚动最终精确停在这个索引上。具体怎么做假设卡片是横向排列每张卡占的宽度是stride cardWidth gap第 i 张卡的左边缘相对原点的偏移是i * stride。我们用一个offset表示“画布原点相对列表原点向左移动了多少”那么第 i 张卡绘制在屏幕上的 x 坐标就是i * stride - offset。要让第 i 张卡正对中间的高亮框就需要i * stride - offset 目标屏幕位置 offset i * stride - 目标屏幕位置我们有整数张卡片的索引所以最终offset一定能算出一个确定值。滚动过程中offset从当前值一路增到目标值中途穿过若干个整圈就形成了“一直在滚”的视觉。滚完后offset精确落在算出值上卡片严丝合缝地对齐高亮框绝不会出现半格偏移。这个方案还有一个额外的好处结束时可以立刻宣布结果不需要等动画停下再查。主持人喊停的那一刻结果已经在数据层躺着了动画只是走个过场你甚至可以提前把中奖名单渲染到另一个图层上。从“观感一致性”的角度看这是最稳的。4. 从零到跑通的完整实操过程4.1 工程结构与初始化我的目录结构大概是这样不复杂但边界清楚lottery/ ├── index.html ├── styles.css └── js/ ├── data.js // 名单、洗牌池、中奖记录 ├── renderer.js // Canvas 绘制 ├── engine.js // 状态机、主循环、缓动 └── main.js // 入口、事件绑定index.html里就一个全屏 canvas加一个隐藏的按钮区开始、停止、重置、切换轮次。所有视觉内容都在 canvas 里画按钮用 HTML 是为了好点也方便加键盘快捷键——现场经常是主持人按空格不是用鼠标点。初始化分三步走先算画布尺寸并设置 DPR再加载图片带进度最后构建数据层和渲染层。async function bootstrap() { const canvas document.getElementById(stage); const ctx resizeCanvas(canvas, window.innerWidth, window.innerHeight); const people await fetch(/api/lottery/people).then(r r.json()); const results await preloadImages( people.map(p p.thumb), (done, total) updateProgress(done / total) ); const withImage people.map((p, i) ({ ...p, bitmap: results[i].ok ? results[i].img : makePlaceholder(p), weight: p.weight || 1 })); const store createStore(withImage); const renderer createRenderer(ctx, canvas, store); const engine createEngine(store, renderer); bindUI(engine); engine.renderIdle(); }这里有个小细节resizeCanvas返回的ctx用了setTransform但如果你在后面的绘制里又调了ctx.scale两者会叠加。我的约定是——初始化时设一次变换之后所有绘制都在这套坐标系里写不再动全局变换需要局部变换的地方一律用save()/restore()包起来。这个约定写进注释里团队里谁接手都不会踩坑。4.2 状态机与主循环状态机我定义了四个状态转移关系很简单但每一个都必须有状态含义进入条件退出条件idle静止待机初始化完成、一轮结束用户点击开始spinning匀速滚动点击开始达到最短滚动时间decelerating减速停止最短时间已到offset 到达目标值result中奖展示减速结束用户点击下一轮spinning要有最短时间限制我一般设 1.2 到 1.8 秒。为什么因为如果这轮刚好抽到“下一张就是中奖者”动画可能 0.2 秒就滚完了主持人还没反应过来观众也没看清观感上像是“卡了一下就出结果”容易引起质疑。强制至少滚够圈数视觉上才成立。主循环的逻辑就是按状态分派function loop(now) { const dt last ? Math.min(now - last, 50) : 16.667; last now; const k dt / 16.667; if (state spinning) { offset spinSpeed * k; spinElapsed dt; if (spinElapsed minSpinTime) { targetOffset computeTargetOffset(offset, targetIndex, stride, total, loops); startOffset offset; decelStart now; decelDuration 1400; state decelerating; } } else if (state decelerating) { const t Math.min((now - decelStart) / decelDuration, 1); offset startOffset (targetOffset - startOffset) * easeOutQuart(t); if (t 1) { offset targetOffset; // 强制对齐杜绝浮点误差 state result; onResult(store.currentWinner); } } renderer.draw(offset, state); rafId requestAnimationFrame(loop); }offset targetOffset这一行看着多余但它能杜绝浮点累加带来的最后半像素误差。加了这一行停止时的对齐是数学上精确的。4.3 滚动阶段与减速停止落点计算是整个动画里唯一需要动点数学的地方我单独拎出来说。function computeTargetOffset(currentOffset, targetIndex, stride, totalCount, loops) { const cycle stride * totalCount; // 一整圈的长度 const ideal targetIndex * stride; // 目标卡对齐时的偏移 // 从当前位置到 ideal 的最近正向距离落在 [0, cycle) let delta ((ideal - currentOffset) % cycle cycle) % cycle; // 再加上若干整圈保证滚得够久 delta cycle * loops; return currentOffset delta; }loops我一般取 2 到 3。太少了滚得短太多了滚得久主持人会等得心焦。2 圈大概 1.5 秒左右配合最短滚动时间正好。缓动函数用easeOutQuart或者easeOutQuint起手快、收尾慢最后几十毫秒几乎是贴着目标滑过去的视觉上有“稳稳停住”的感觉。const easeOutQuart t 1 - Math.pow(1 - t, 4);这里有个经验值减速时长我一般设1200 到 1500 毫秒。短于 1 秒收尾太急像是被硬拽停的长于 2 秒观众会觉得“是不是卡了”。1.4 秒是实测下来最舒服的。另外减速阶段不要用物理阻尼比如每帧speed * 0.96那种写法的问题是停止时间不确定可能 1 秒停也可能 3 秒停主持人没法预判。用固定时长的缓动函数从点击到停止的总时长是可控的方便和主持人串词。4.4 中奖展示、二次校验与导出留痕动画停止后进入result状态要做四件事。第一是视觉强调中奖卡放大到 1.15 倍加一圈发光描边用预渲染的光晕图别用shadowBlur背景压暗一层半透明遮罩。这一步的重点是让大屏远处的人也能一眼看到是谁。第二是信息展示姓名、部门、工号字号要大字体权重高。姓名建议用系统中文字体里最粗的那一档大屏投影下细体字会糊成一团。第三是二次校验把中奖者的 id 和当前轮次、已中奖集合做一次比对确认没有重复。这一步纯属防御性编程正常情况下不会触发但年会现场容不得“哎呀抽重了”这种事。第四是留痕。每一轮结束后往localStorage里追加一条记录时间戳、轮次、中奖者 id、姓名、完整的中奖序列快照。同时提供一个“导出 CSV”的按钮。这个功能一开始我觉得没必要直到有一次主持人临时说“把前面几轮名单给我看一下”当时如果没记录就只能靠回忆。function saveResult(roundId, winner) { const key lottery_records_v1; const list JSON.parse(localStorage.getItem(key) || []); list.push({ round: roundId, id: winner.id, name: winner.name, dept: winner.dept, ts: Date.now() }); localStorage.setItem(key, JSON.stringify(list)); }提示localStorage只有 5MB 左右存几百条记录完全够用。但如果要存照片 base64 就不行了别往里塞大对象。还有一个容易被忽略的点重置能力。现场一定会有“这一轮不算重抽”的情况。所以数据层要暴露一个rollback()把最近一轮的中奖者从已中奖集合里移除、放回抽奖池、删掉对应的记录。这个接口要在彩排时反复测因为现场出问题时你只有十几秒的时间操作。5. 现场常见问题与排查技巧实录5.1 问题速查表这张表是我几次现场下来攒的基本都是真出过的问题按发生频率排序。现象最可能原因排查方法处理方式画面整体模糊canvas 缓冲区未按 DPR 放大检查canvas.width与style.width是否成比例重新执行 resize用setTransform滚动明显掉帧每帧重复做 clip/阴影/文字绘制临时注释掉文字绘制看是否恢复改用离屏缓存烘焙卡片页面白屏或崩溃原图未降采样内存爆掉看内存面板的图片占用统一换成缩略图停止位置偏半格落点未做强制对齐打印最终 offset 与理论值减速结束赋值offset targetOffset抽到重复的人中奖后未从池中剔除打印池子长度与中奖数出结果时立即splice切窗口回来画面乱飞dt未封顶检查 dt 计算Math.min(dt, 50)图片少量加载失败网络或地址失效统计ok:false数量用占位图顶替姓名兜底大屏两侧黑边过大缩放未兼顾宽高比检查Math.min(w/1920, h/1080)底色填充居中留白5.2 卡顿、白屏与图片加载失败掉帧的排查有个简单粗暴的办法在渲染函数里用performance.now()打时间戳统计每一帧的绘制耗时超过 16 毫秒就在控制台警告并打印耗时拆解。跑几分钟哪个环节最贵一目了然。我遇到过一次典型的“越跑越卡”滚动开始前几秒很流畅十几秒后开始掉帧。查下来是图片的onload里一直在往数组里 push而渲染循环里每帧都在遍历这个数组做一次“是否在视口内”的判断。数组从 50 涨到 800虽然只影响几百次比较但每帧都做加上 V8 的数组去优化就拖慢了主线程。改成在图片全部加载完、再一次性构建索引之后问题消失。这件事的教训是加载阶段和渲染阶段的数据结构应该分开不要让渲染循环去碰还在变化的数据。白屏和崩溃基本都是内存问题。除了前面说的降采样还有一个容易忽略的地方Image对象在加载完之后不会自动释放只要你还持有引用位图就一直占着内存。如果做了“重新加载名单”的功能旧的Image数组一定要显式置空并且如果有ImageBitmap要调用close()释放。function releaseImages(list) { list.forEach(item { if (item.bitmap typeof item.bitmap.close function) { item.bitmap.close(); // ImageBitmap 才有 } item.bitmap null; }); list.length 0; }ImageBitmap的好处是它已经是解码好的、可以直接喂给drawImage的格式绘制时不用再做解码比Image快一截。用createImageBitmap(blob, { resizeWidth: 300, resizeHeight: 300, resizeQuality: high })一步到位既降采样又预解码。5.3 大屏投屏与分辨率陷阱投屏这块的坑跟代码关系不大但影响巨大。第一个是缩放模式。笔记本连投影或者大屏系统或播放软件通常有“复制”和“扩展”两种模式。复制模式下分辨率会取两者的较小值如果笔记本是 2880×1800、大屏是 1920×1080复制后会变成 1920×1080但系统缩放比例可能还是 200%实际有效 CSS 分辨率只剩 960×540你的界面会被放得巨大。这个问题在彩排时一定要确认别到现场才发现。第二个是窗口尺寸变化。节目中途如果有人动了显示设置window.innerWidth会变你的 canvas 必须跟着重设。所以resize事件的处理要写得完整重新算尺寸、重设变换、重绘当前帧。千万别只改 CSS 不改缓冲区那样必然模糊。let resizeTimer null; window.addEventListener(resize, () { clearTimeout(resizeTimer); resizeTimer setTimeout(() { const ctx resizeCanvas(canvas, window.innerWidth, window.innerHeight); renderer.attachContext(ctx); renderer.draw(engine.currentOffset, engine.state); // 立刻重绘别等下一帧 }, 150); });防抖 150 毫秒是为了避免拖拽窗口时疯狂重建缓冲区那个开销不小。第三个是投影的色彩偏差。投影仪的对比度和饱和度普遍偏低你的深灰背景在显示器上看着有层次投到大屏上可能就是一片黑。所以配色要往高对比走文字用纯白背景用低饱和度的深色中奖高亮用高亮的暖色别玩小清新。5.4 彩排清单与兜底方案最后分享一份我自己的彩排清单每次上线前逐条过一遍比临时抱佛脚强。全量照片导入后检查总人数和名单是否一致重点看有没有重名重名不罕见展示时要带部门区分。用 3 倍于实际的圈数跑一遍观察内存和帧率看有没有随时间劣化的趋势。抽满全场确认最后一个人能正常抽出不出现“池子空了但还在抽”的死循环。断网测试关掉 WiFi看已加载的图片是否正常显示占位图是否生效。重置测试连续重置 5 次确认状态干净没有残留的中奖标记。切窗口测试滚动中切到别的程序再切回来确认画面没有跳变。键盘测试空格开始、回车停止、Esc 重置全部走一遍。兜底方案本地准备一份静态 HTML 版本数据内联主程序万一挂了直接双击打开继续别让全场等你 debug。我个人在实际操作中的体会是抽奖程序的技术难度并不高难的是“在现场不确定的环境里稳定输出”。所以我在写这类东西的时候花在防御性代码和彩排上的时间往往比写核心逻辑还多。宁可代码丑一点多几个if兜着也别在现场表演“优雅地崩溃”。还有一个屡试不爽的小技巧把关键状态当前轮次、剩余人数、已中奖名单用一行小字常驻在画面角落字号小到观众看不见但彩排时你一眼就能确认状态对不对比打开控制台翻对象快多了。
延伸阅读

更多相关文章

2026/10/28 15:11:47

信号发生器白屏故障定位:屏供电、时序与排线排查

信号发生器白屏,是维修台上最容易被低估的一类故障。很多人第一反应是"屏坏了",换屏、换排线,折腾一圈钱花了问题还在。我经手过的白屏机器里,真正屏体物理损坏的比例不到三成,剩下七成问题出在供电、时序、…

2026/10/28 15:11:47

Vibe Coding工作流:AI辅助编程的标准化实践

1. Vibe Coding 工作流概述Vibe Coding 是一种将 AI 代码生成工具(如 Codex)融入开发者日常工作流的实践方法。它通过建立标准化的配置文件和工具链,让 AI 辅助编程变得可预测、可复用。这套方法的核心在于三个关键组件:AGENTS.MD…

2026/10/28 15:06:44

GPU算子融合技术:提升深度学习计算效率的关键

1. 为什么我们需要榨干GPU的最后一滴血?当我在2018年第一次尝试训练ResNet-50模型时,发现即使使用当时顶级的NVIDIA Tesla V100 GPU,完整训练也需要近30个小时。更令人沮丧的是,GPU利用率监控显示,这张价值数万元的显卡…

2026/10/28 16:02:09

MaxKB:大模型知识库管理与RAG技术实践

1. MaxKB:大模型知识库管理的革命性工具在人工智能技术快速发展的今天,大模型知识库已成为企业和个人高效管理信息的核心基础设施。MaxKB作为一款专为大模型设计的知识库管理系统,正在改变我们构建、管理和利用知识的方式。我第一次接触MaxKB…

2026/10/28 16:02:09

PPO强化学习在大模型微调中的应用与实践

1. 项目概述:PPO强化学习与大模型微调大模型微调领域最近有个特别火的技术叫PPO(Proximal Policy Optimization),它能让大语言模型像ChatGPT那样"听话"。我第一次接触这个概念是在调试一个开源LLaMA模型时,发…

2026/10/28 16:02:09

STM32读写AD7190:24位ADC称重例程的寄存器配置与标定全解析

简介:面向STM32单片机开发者,压缩包内提供了基于AD7190的24位称重模块读写与TFT液晶显示完整例程。源码采用HAL库编写,涵盖系统时钟配置、串口调试、3.5寸液晶初始化、重量采集均值滤波、按键清零与砝码校准等关键流程,可直接移植…

2026/10/28 16:02:09

AI大模型技术栈解析与程序员转型指南

1. 为什么AI大模型成为程序员未来5年的黄金赛道?过去两年间,AI大模型技术以每月都有突破的速度发展。从GPT-3到最新的多模态模型,参数量从百亿级跃升至万亿级,这种技术迭代速度在计算机发展史上极为罕见。作为从业15年的全栈开发者…

2026/10/28 16:02:09

STM32主从车嵌入式竞赛源码解析:循迹、避障与双车协作

简介:面向百科融创杯嵌入式技术与应用开发赛项的主车及从车端完整项目源码,专为竞赛选手、嵌入式课程设计及毕业设计开发者打造。作者在这份高分项目中给出了完整的赛题实现方案,代码注释详尽,逻辑清晰,新手也能顺畅理…

2026/10/28 15:57:06

局部高斯分布拟合能量在活动轮廓模型中的应用

1. 项目概述:基于局部高斯分布拟合能量的活动轮廓模型在计算机视觉领域,图像分割一直是个经典而富有挑战性的任务。传统阈值分割、边缘检测等方法在面对复杂纹理、低对比度图像时往往表现不佳。而活动轮廓模型(Active Contour Model&#xff…

2026/10/28 14:42:12

全国计算机二级C语言真题PDF高效刷题指南

简介:这份PDF是全国计算机二级C语言考试的试题与答案解析,专为备考二级C语言的考生准备。内容涵盖C语言特点与设计理念、程序基本组成单位、main函数执行流程、函数嵌套调用规则、标识符命名规范、整型实型字符型数据类型、八进制十六进制数合法形式、运…

2026/10/28 15:28:56

requests+BeautifulSoup:爬虫抓取h1标题的入门实战指南

简介:一份以实战为导向的Python爬虫入门资源,面向具备基本编程基础、希望快速掌握网络数据抓取的初学者,可解决从零编写简单爬虫的常见困惑。实例以requests库发送HTTP请求、BeautifulSoup解析HTML文档,完整演示从网页中提取 标题…

2026/10/28 3:32:29

递推最小二乘参数辨识MATLAB编程:二阶系统与渐消记忆实现

简介:这是一篇面向控制系统、信号处理与机器学习等领域学习者的最小二乘参数辨识程序说明文档。资源共包含一个docx格式文档,压缩包大小七十八KB,内容围绕递推最小二乘算法和渐消记忆最小二乘算法展开:先构造一个极点位于单位圆内…

2026/10/28 0:02:13

Python股票自动交易系统源码解析:从行情获取到风控的完整链路

简介:这份资源是面向高校计算机、金融相关专业毕业设计场景的完整项目源码包,主题为基于Python的股票自动交易系统,适合需要完成毕设选题、课程设计或自学金融量化交易开发的学生与开发者参考。压缩包共收录687个文件,整体约14.93…

2026/10/28 0:02:13

视酷酷信IM源码部署指南:Java 17+MySQL 8.0.33实战配置

简介:这是一套2024年亲测可部署的视酷酷信IM即时通讯系统源码,面向具备Java、OC、C#及Vue开发能力的技术人员,解决私有化即时通讯平台快速搭建与二次定制需求,尤其适用于需集成商城、保障数据主权的中小型企业或独立开发者。资源共…

2026/10/28 0:02:13

Python爬取百度迁徙数据:接口解析、批量采集与避坑指南

简介:这份资源是一个用Python爬取百度迁徙数据的脚本压缩包,面向具备Python基础、希望入门网络爬虫与数据解析的开发者,解决按日期获取全国热门城市人口迁徙数据的问题。包内共1个文件,为单个py脚本,压缩包约1KB&#…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑