ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

无限画布性能优化实录:压测暴露的坐标精度、内存泄漏与渲染瓶颈

无限画布性能优化实录:压测暴露的坐标精度、内存泄漏与渲染瓶颈 我至今还记得第一次把一份两万多节点的真实数据拖进画布的那个瞬间画面像慢放了十倍拖动时能清清楚楚看到每一步卡顿帧率连掩耳盗铃的余地都不给我。什么无限画布在真实数据面前就是一页普通的 HTML。最初我只是想复刻一个类似 libtv 的无限画布demo 做得风生水起平移、缩放、框选样样齐全发到群里还有人夸了一句“可以啊”。然后朋友丢来这份数据我沉默了。接下来的三天我给自己布置了一项任务搭一套能重复跑、能采指标、能暴露问题的压力测试环境然后把这个无限画布从里到外锤一遍。整个流程走完花了差不多 72 小时。这篇文章就是那 72 小时的完整记录——我设计了哪些测试、测出了哪些问题、每一处问题是怎么一步步定位到根因的、最后落地了哪些优化。如果你也在做无限画布、白板、地图编辑这类“视口无限但资源有限”的应用这篇应该能帮你少踩几个坑。1. 事出有因一个“能用”的 demo 为什么逼我搭了压测架子1.1 无限画布的压力模型和普通页面完全不是一回事普通网页的性能瓶颈翻来覆去就那几样DOM 节点太多、重排重绘太频繁、图片懒加载没做好。你在一个普通页面里塞两万个元素浏览器会骂你但还不至于直接瘫掉。无限画布不一样。它的核心场景是“世界坐标”无限大而“视口坐标”永远只占屏幕那一小块。听起来很美好所有渲染只要处理可视区里的几十个几百个元素就行。但要让这个模型成立你必须在每次视口变化时回答三个问题哪些元素现在可见——可见性剔除它们在世界坐标下的位置换算到屏幕坐标是多少——坐标变换用户的手势、缩放、平移怎么和这套坐标系统一——事件处理这三个问题环环相扣任何一个环节写得不严谨元素量一上来就现原形。普通页面不会遇到“世界坐标里的浮点数精度不够用”这种奇葩问题但无限画布一定会遇到后面我会详细讲。我最开始的 demo 之所以“能用”是因为我只用几十个元素自测数据少到所有问题都被掩埋了。等到真实数据灌进来复杂度直接从 O(可视区元素) 退化成了 O(全部元素)不卡才怪。1.2 为什么不能靠“手滑乱点”来压测很多人觉得压测就是打开页面鼠标疯狂拖一拖卡了就截图吐槽两句。这种玩法只能给你一个模糊的感觉“好像有点卡。”它回答不了三个关键问题卡在哪里为什么卡改完代码之后是变好了还是变坏了我当时给了自己两个明确目标第一压测过程必须可复现第二每次压测必须有客观指标可以对比。手动操作这条直接废掉我需要脚本化。我用 Puppeteer 驱动一个真实的 Chrome 实例通过 CDPChrome DevTools Protocol往里灌数据、模拟鼠标指针轨迹、模拟滚轮缩放同时周期性采集帧率、内存、长任务耗时。简单说一下骨架const puppeteer require(puppeteer); const browser await puppeteer.launch({ headless: false, args: [--disable-gpu-sandbox, --window-size1440,900] }); const page await browser.newPage(); await page.goto(http://localhost:5173); // 在页面里注入帧率采样器 await page.evaluate(() { window.__fpsSamples []; let frames 0; const probe () { frames; window.__fpsSamples.push(performance.now()); }; setInterval(() { window.__fps frames; frames 0; }, 1000); requestAnimationFrame(probe); }); // 用 CDP 模拟鼠标拖拽 const cdp await page.target().createCDPSession(); await cdp.send(Input.dispatchMouseEvent, { type: mousePressed, x: 700, y: 450, button: left, buttons: 1 }); for (let x 700; x 300; x - 10) { await cdp.send(Input.dispatchMouseEvent, { type: mouseMoved, x, y: 450, button: left, buttons: 1 }); await new Promise(r setTimeout(r, 16)); } await cdp.send(Input.dispatchMouseEvent, { type: mouseReleased, x: 300, y: 450, button: left, buttons: 0 });这套脚本本身不复杂但它把“我随便拖一拖”变成了“每一次拖拽的路径、速度、时间间隔都是固定的”。后续我再跑第二次、第三次拿到的是同一个条件下可以横向对比的数字。1.3 72 小时这个数字是怎么花掉的先说清楚不是三天三夜盯着屏幕不睡觉。压力测试这个东西等结果的时间比干活的时间多得多。我大概的时间分配是第一天前 8 小时搭测试环境、写采集脚本、调通自动化链路第一天到第二天铺数据和跑基准测试把 5k、20k、80k 三档数据都生成出来每档跑好几轮操作序列存下 trace第二天大部分时间长时间稳定性测试一轮 4 小时期间反复看内存曲线和性能指标第三天针对暴露的问题定位、修复、再跑回归真正分析问题和改代码的时间可能只占三分之一其余时间全在等 Chrome 跑完、等内存曲线画出来、等 heap snapshot 生成。但恰恰是这些等待时间暴露了平时手动操作根本发现不了的问题——比如内存悄悄上涨这种事你手滑两分钟是绝对看不出来的。2. 测试矩阵怎么搭才有参考价值规模、操作、指标三件套2.1 规模设计数据量只是第一层一开始我天真地以为压力测试就是把元素数量翻倍20k 不够就 80k。跑完一轮之后发现元素的“种类”和“分布方式”很大程度上决定了瓶颈会在哪里。我把测试数据设计成了三个维度元素类型混合比。矩形、路径、文本、位图这四类元素的渲染路径完全不同。矩形和路径在 Canvas 2D 里走的是几何填充文本涉及字体测量和字形缓存位图涉及贴图上传和 GPU 内存。如果只测矩形你根本测不出文字缓存泄漏的问题如果只测位图又会误以为所有元素都那么吃显存。我最终的混合比大概是矩形 40%、路径 30%、文本 20%、位图 10%算比较贴近真实白板类应用。几何复杂度。同样是路径一条直线和一条由几千个贝塞尔曲线拼成的钢笔路径处理成本天差地别。我在压测数据里掺了一部分高复杂度路径——每条路径几百个点——来模拟真实用户画的那些“灵魂草图”。分布方式。元素在无限画布里的空间分布也很关键。我生成了三类分布均匀散布模拟平铺的笔记、中心聚簇模拟用户疯狂放大一个区域画图、带状分布模拟横向流程图。这三类分布对可见性剔除的压力完全不同聚簇分布很容易暴露空间索引退化的场景。最终我定了三档测试规模5k 元素作为基准档20k 作为常规压力档80k 作为极限档。每档都有固定的元素类型混合比和分布方式保证不同档位之间只有“量”的差异没有“质”的变量。2.2 操作设计缩放才是无限画布的隐藏杀手画布交互里最耗性能的操作不是平移而是缩放尤其是连续、深度的缩放。原因很简单平移只改变视口原点而缩放改变的是整个坐标变换的尺度所有可见元素的世界坐标到屏幕坐标的换算全部要重算浮点误差也跟着放大。我的操作序列分成了四组慢速阅读以大约 200px/s 的速度匀速平移模拟用户在查看内容中速拖拽以 800px/s 拖动中间穿插几次停顿快速甩动模拟鼠标快速甩过画布直接触发惯性滚动连续深度缩放从 1x 连续放大到 100000x再缩小回来反复三轮缩放中心固定在视口左上角三分之一处而不是视口中心重点说下最后这个。很多人测缩放是“一次性 setTransform 跳到某个倍率”这完全测不出精度问题。真实用户缩放是一格一格滚动滚轮每次缩放都会在前一次的基础上再乘一个系数浮点误差会在这个过程中不断累积。连续缩放三轮坐标系统的漂移就会显露出来。另外缩放中心不能总在视口中心。真实用户通常把鼠标指向自己关注的内容缩放中心在鼠标位置。这个操作路径对坐标系的要求更高也很容易把隐藏的 bug 暴露出来后面讲第二跪的时候会细说。2.3 指标设计FPS 之外还要盯什么帧率只是最表面的指标它只能告诉你“卡了”但说不清卡在主线程还是合成器、是 CPU 不够还是 GPU 内存爆了。我给自己定了五个指标指标采集方法我设的红线FPS页面内 rAF 计数器每秒记录一次常规操作不低于 55fps快速甩动不低于 30fps长任务PerformanceObserver 观察 longtask10 分钟内超过 50ms 的长任务不超过 5 个JS 堆内存performance.memory.usedJSHeapSize长跑 4 小时后曲线应回落或保持平稳不许线性爬升事件响应延迟事件派发到下一帧渲染完成的时间不超过 100msGPU 进程内存外部监控 chrome GPU 进程 RSS不允许持续增长排除纹理泄漏FPS 和长任务用 Performance API 就能拿到JS 堆内存靠 performance.memoryGPU 进程内存我是用系统命令每隔 30 秒拉一次 chrome 子进程的物理内存虽然不精确但纹理泄漏这类问题它会先报警。这五个指标要综合看。比如 FPS 掉到 30但长任务很少那问题可能不在 JS 计算而在合成器的光栅化压力如果 JS 堆内存曲线平稳但 GPU 进程内存一直涨那八成是离屏 canvas 或纹理对象没释放。2.4 固定测试用例集保证回归有同一个基准我把整轮操作序列写成了一个用例文件里面包含了操作类型、坐标、时长、间隔。所有代码改完之后我都是重新跑这份完全相同的用例文件做回归。这比“手动拖一拖差不多不卡了”靠谱得多因为它能给出前后对比的数字变化。最近我还把这套用例文件做成了简单的 CI gate只跑基准档和常规压力档两个档位都过了阈值才允许合并代码。极限 80k 档跑得太慢留给每周手动跑一次。3. 七十二小时里最冲击人的三连跪问题复现与排查链路3.1 第一跪节点一多Pan 就掉到 30fps问题根本不在 draw现象很干脆20k 元素中速拖拽FPS 稳定在 30 上下视觉上已经明显掉帧。当时的直觉是“绘制太慢”——毕竟元素多Canvas 2D 指令多重绘开销大。所以我第一反应是去看绘制函数的耗时火焰图拉出来却傻眼了整个 draw 阶段只占每帧总耗时的 20%真正的大头是一个叫updateViewport的函数每帧都要跑 25ms 以上。这个函数做了什么它遍历了画布里的所有元素把每个元素的世界坐标转换到屏幕坐标写回元素对象再做一次脏矩形检测判断哪些区域需要重绘。问题是遍历的是“全部元素”而不是“可见元素”。20k 个元素哪怕只有 300 个在可视区内循环依然要跑满 20k 次。更蠢的是每次拖拽的每一帧都要跑一遍等于我拖 10 秒这个循环就跑了 600 次每次都是 20k 级别的遍历。这是非常典型的错误认知以为 Canvas 2D 的绘制很贵需要用软件层面的坐标变换去优化结果把优化写成了新的瓶颈。Canvas 2D 的ctx.setTransform本身可以在 GPU 侧完成视口变换完全不需要你在 JS 里手工改元素坐标。根因定位之后其实修复很简单元素坐标永远保持世界坐标不动视口变换全部交给 ctx 的变换矩阵updateViewport 只要根据视口范围算出需要遍历哪几个格子然后调 ctx.setTransform 就行。这个改动做完20k 元素平移直接回到 58fps 左右火焰图里那个大头消失了。3.2 第二跪深度缩放后图形开始“发抖”定位到 double 精度问题这个问题的排查过程比第一个曲折得多。表现是当画布缩放到 300000% 左右再平移时图形边缘出现肉眼可见的抖动像坐标被“取整”了一样一格一格地跳。缩放再深一点图形干脆“乱飞”。我一开始怀疑是抗锯齿或者像素对齐的问题毕竟 Canvas 2D 在非整数坐标下绘制会触发次像素渲染。我把绘制坐标全部改成Math.round取整确实有所缓解但只是把抖动从“一格一格”变成了“偶尔跳一下”治标不治本。接下来我怀疑是 Path2D 构造的问题——会不会是路径字符串在构造时丢精度我把所有路径缓存换成离屏 canvas抖动依旧。排除了绘制层剩下的只有坐标变换层。我写了一个小实验固定两个相邻元素它们的屏幕坐标差应该是 0.5px然后持续缩放到 1e6 级别每帧打印这两个元素的实际屏幕坐标。结果发现真正的问题是浮点数在两次运算顺序下的舍入差异。我当时的代码做的是screenX element.worldX * viewport.scale - camera.worldX * viewport.scale;先乘后减。当 viewport.scale 很大时element.worldX 乘出来是一个天文数字camera.worldX 乘出来也是一个天文数字两个天文数字相减要得到一个小数字此时 double 精度根本不够用误差被放大到肉眼可见。正确做法是先减后乘让大数在减完之后变小再乘screenX (element.worldX - camera.worldX) * viewport.scale;但光这样还不够。当缩放足够深时element.worldX 和 camera.worldX 本身已经是 1e12 级别的数字两个这么大的数相减结果的小数精度照样损失。最终的解法是把世界坐标拆成“瓦片索引 瓦片内偏移”——大数部分用整数索引表达小数部分用浮点偏移表达先减偏移再乘 scale。这套思路地图引擎里很常见我后面第四节会贴具体方案。3.3 第三跪明明什么都没动内存 4 小时涨了 400MB这个是我跑长时间稳定性测试时才发现的。第一轮长跑前 30 分钟一切正常JS 堆内存有涨有落属于正常的 GC 波动。但 1 小时之后我意识到曲线不对劲它涨一点回一点但每次回落都比上次的谷底高一点总体在爬坡。4 小时跑完涨了差不多 400MB。这种问题最讨厌因为它不会让你的页面一夜之间崩掉但在用户的真实使用场景里挂一整天之后画布卡成幻灯片是一定的。排查思路是连续抓三份 heap snapshot。我先跑到 2 小时节点抓了一份再跑 30 分钟抓一份又过 30 分钟抓一份然后对比三份快照里的对象增长。diff 结果显示了两个异常第一个是 Path2D 缓存对象数量暴涨。我做了个 Path2D 缓存key 是路径字符串想着能复用就复用。但 key 设计太粗糙文字元素的缓存 key 只有 text 和 fontSize不同颜色的、不用旋转角度的文字全部命中同一个 key导致缓存不断把旧值顶掉再存新值命中率低得可怜纯纯的负优化。第二个是离屏 canvas 对象出现大量 Detached说明有 canvas 从缓存里移除时没有正确释放 GPU 资源。这个其实只改一行代码就能解决把 canvas 的 width 和 height 置为 0强制让它释放底层纹理。根因清楚了修复也很直接。Path2D 缓存换成 LRU限制最大条目数key 里加上填充色、描边色、透明度、旋转角度这些影响路径实例的属性。离屏 canvas 在移除时统一执行canvas.width 0; canvas.height 0;。改完后再跑一轮 4 小时长测内存曲线基本平了。3.4 一个被顺带打出来的 bug双击后手势识别失灵这不是压测主线上的问题但操作序列里包含双击缩放几次循环之后我注意到一个诡异的现象双击缩放后下一次单击会被识别成拖拽工具从 select 切到了 pan体验直接崩坏。查下来根因让人哭笑不得事件处理器里的 lastX/lastY 没有跟随缩放更新。第一次双击缩放后相机位置变了但 lastX/lastY 还是老位置。下一次按下鼠标时计算位移距离用的是新的屏幕坐标减去旧的 lastX/lastY距离直接超出手势库的阈值被判定为拖拽。这类问题很像“低概率偶发 bug”——实际不是偶发而是我的事件层和渲染层各自维护了一套坐标基准没有统一。修复方式是把事件处理器改成每次事件都用同一套 worldToScreen 函数重新计算当前坐标不再用累加的 lastX/lastY。4. 针对根因我最终落地的优化方案与实测数据4.1 渲染分层把“每帧重算”改成“分层缓存 可见区瓦片”这一轮压测让我彻底放弃了“每次视口变化就重绘整个内容层”的思路改成三层 canvas 叠加背景网格层离屏 canvas 缓存只有缩放层级变化时才重画内容层主画布承载所有业务元素交互指示层框选、悬停高亮等高频变化但内容很少的东西单独一层对于内容层我并没有做全量离屏缓存——因为元素太多时全量离屏一次重绘的成本也不低。我选择了瓦片缓存把可视区按 512x512 切块只缓存可视区周边一圈的瓦片超出范围的瓦片走 LRU 淘汰。某个瓦片内的元素发生变化时只重绘那个瓦片不影响其他区域。这套方案相当于把无限画布拆成了“有限个小画布”既避免了每帧全量重绘又不会因为缓存无上限而吃光内存。4.2 变换重构世界坐标拆成“瓦片索引 瓦片内偏移”针对 double 精度问题我借鉴了地图引擎的坐标方案。每个元素的世界坐标不再直接是一个浮点数而是拆成worldX tileIndexX * TILE_WORLD_SIZE tileOffsetX; worldY tileIndexY * TILE_WORLD_SIZE tileOffsetY;tileIndexX 是整数tileOffsetX 是浮点偏移。渲染时视口变换走的是const screenX Math.round((offsetXFromCamera) * viewport.scale); const screenY Math.round((offsetYFromCamera) * viewport.scale); ctx.setTransform(viewport.scale, 0, 0, viewport.scale, screenX, screenY);这里的关键是把带大数的部分从浮点运算里剥离出去只对小数偏移做浮点乘加最大程度保留精度。屏幕端保留 Math.round避免次像素抖动。4.3 事件侧合并rAF 节流 坐标基准统一pointermove 事件的触发频率比 60fps 高得多如果每个事件都去更新视口、命中检测、重绘必然造成大量重复工作。我在事件层加了个节流器事件回调只负责把最新的坐标存下来真正的处理逻辑统一放到 requestAnimationFrame 里执行。另外事件处理器和渲染层共用同一套坐标系换算函数。这个看似不起眼的改动直接消除了上一节提到的 lastX/lastY 漂移问题——所有坐标每次都是从 worldToScreen 重新计算不存在累积误差。let pendingMove null; canvas.addEventListener(pointermove, (e) { pendingMove { x: e.clientX, y: e.clientY }; }); function onFrame() { if (pendingMove) { const { x, y } pendingMove; // 统一走 worldToScreen 的结果做手势判定和渲染 pendingMove null; } requestAnimationFrame(onFrame); } requestAnimationFrame(onFrame);4.4 优化后复测的数据对比改完上面几处之后我用同一个用例集重新跑了一轮。挑三组有代表性的数据测试场景优化前优化后20k 元素中速平移 FPS31fps58fps连续深度缩放后平移 FPS22fps有乱跳55fps坐标稳定4 小时长跑 JS 堆内存增量410MB45MB随 GC 波动第三组数据特别说明45MB 的增量在 GC 正常波动范围内曲线不再持续爬坡。GPU 进程内存也不再增长纹理泄漏问题解除。4.5 这些方案为什么算“够用”而不是“最优”必须说清楚这套方案是针对我自己的压测数据设计的。空间索引我用的均匀网格因为测试数据分布比较均匀均匀网格的 O(1) 定位在可视区查询时很划算。但如果真实场景里元素高度聚簇——比如用户疯狂往一个区域里塞内容均匀网格会退化到 O(n)这时候四叉树或 R 树是更合适的选择。瓦片缓存也一样我有意设置了缓存上限避免极端缩放时缓存爆炸。更极端的场景需要引入 LOD ——缩放层级很深时不再渲染细节元素只显示聚合后的摘要图形——这是第二阶段的优化任务了。5. 跑完这轮压测我对无限画布性能优化的一些重新认识5.1 静态渲染流畅不等于交互流畅以前我觉得“打开画布能立刻看到全部内容”就算流畅现在我知道这只是及格线。真正考验一个无限画布的是交互过程中每一帧的稳定性拖拽时能不能保持 60fps缩放时坐标会不会漂移连续操作十几分钟之后内存稳不稳。静态渲染再快交互时掉帧用户照样觉得卡。5.2 压测的投入产出比高得出人意料搭这套东西前三天我一度觉得“有这个时间我功能都多写俩了”。跑完才发现一个性能问题在用户手里被发现通常是不可复现、不好定位、消息滞后的但一个性能问题在自动化压测里被发现它能稳定复现、能采集上下文、能在修复后回归验证。这套测试环境从搭好到现在已经帮我抓出好几个我手测根本发现不了的问题回本了。5.3 一些可以直接带走的具体建议总结几条我在这次压测里真正用上、也觉得最值得分享的实践压测前先定义三个必须达标的场景比如“20k 元素平移不掉帧”“深度缩放不漂移”“长跑 4 小时内存平稳”后续所有优化都以这个为准绳。内存测试一定要单独跑并且至少跑 2 小时以上。短时间手动操作无法暴露缓慢的内存泄漏。坐标计算不要写成“先乘后减”的形式大数乘完再减等于把精度问题放大到不可收拾永远先减后乘。任何缓存都要想清楚 key 和淘汰策略。没有 LRU、没有容量上限、key 设计粗糙的缓存在低数据量下是“优化”在高数据量下就是泄漏源。长跑比猛跑更容易发现问题。单次高负载能测出计算瓶颈但只有长时间运行才能测出缓存和数据结构的累积问题。5.4 最后再分享一个小技巧这套测试用例我后来做成了 JSON 文件里面就是一组操作指令和期望阈值每次改完代码跑一遍用脚本自动判定通过与否。不需要真的写复杂的测试框架一个几十行的 Node 脚本足够。这个习惯一旦养成后续每次重构都有同一把尺子量着心里踏实很多。72 小时换来的不是一次“通过的测试”而是一份我对这个项目性能底线的清晰认知。无限画布这类应用看起来难的是“无限”实际上难的是在无限的空间里始终保持有限的计算成本。每一次优化本质上都是在跟这个原则对齐。
返回列表