ARTICLE DETAIL

资讯详情

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

MiroFish:透明悬浮窗、点击穿透与低占用渲染实践

MiroFish:透明悬浮窗、点击穿透与低占用渲染实践 凌晨一点半我盯着屏幕右下角那条红色的斗鱼。它刚从任务栏上方溜过去钻进浏览器窗口和显示器边缘之间那条不到四十像素的缝隙里尾巴一甩又游了出来。这条鱼不存在的它是我用代码养出来的。这个项目叫 MiroFish一个把虚拟鱼缸塞进桌面夹缝里的小工具核心就是三件事一个透明且置顶的悬浮窗、一套让鱼看起来像鱼的行为模拟、一条尽量不烧电的渲染管线。它不解决任何正经业务问题但它把桌面宠物这个品类里最麻烦的几个技术点全都逼出来了——透明窗口的跨平台差异、鼠标点击穿透、鱼群运动算法的裁剪、常驻进程的资源占用。如果你是做桌面端工具的、写前端但想碰一碰窗口层的东西、或者单纯想给自己写一个没用但好玩的常驻小程序这里面的坑我基本都替你踩过一遍了。1. MiroFish 要解决的不是养鱼而是怎么在桌面缝隙里活下来1.1 这个项目的起点是一次很具体的烦躁我做 MiroFish 的动机特别朴素。当时我在写一个长期项目一天要在编辑器、浏览器、终端之间来回切换几百次眼睛累得厉害想找个东西放在屏幕角落在我思考的时候有一点缓慢的、不抢注意力的运动。我试过几个现成的桌面宠物问题都很一致要么是一个不透明的大方块压在桌面上要么是一个能拖动但会挡住按钮的贴图要么就是吃内存吃到让我怀疑人生。MiroFish 想做的就是把这三件事同时解决掉——窗口本身必须完全透明除了鱼和水波什么都看不见鼠标必须能穿透它点到下面的窗口常驻内存要压到我根本不会去关它的程度。这里有个经验桌面类小工具的用户留存几乎不取决于功能多少而取决于我能不能忘了它的存在。任何需要用户主动管理的设计——手动开、手动关、手动调位置——都会让它在两周内被卸载。所以 MiroFish 从第一版开始就把透明、穿透、低占用当成不可谈判的底线而不是可以后续优化的加分项。1.2 三条不能让步的硬约束第一条是全窗口透明。注意是全窗口透明而不是窗口背景色透明。很多教程会告诉你把backgroundColor设成transparent就完事了实际上在 Windows 上如果同时开了硬件加速、又用了非整数的窗口缩放透明区域会出现边缘发黑或者整块闪烁。这个我后面会细讲。第二条是点击穿透。鱼在屏幕上飘但鼠标划过它的时候必须当它不存在点击要直接落到下面的窗口上。这是整个项目里跨平台差异最大的一块Windows 能给你转发鼠标事件macOS 能给你一个布尔开关Wayland 下则基本看运气。第三条是不抢焦点。一个悬浮窗如果会抢焦点那它就是灾难你打字打到一半光标跳走了。所以窗口必须是不可激活的任务栏里也不能出现图标。这三条约束看起来都是窗口层面的配置实际上它们互相打架。透明窗口在有些平台上不能开阴影不可激活的窗口在很多框架里收不到键盘事件点击穿透之后又拿不到鼠标位置——而投喂功能恰恰需要鼠标位置。这就是 MiroFish 有趣的地方每一个看起来只是个开关的需求背后都有一条绕路。1.3 它适合谁不适合谁MiroFish 适合三类人参考。第一类是做桌面常驻工具的人比如番茄钟、便签、桌面歌词、悬浮监控面板这些工具和 MiroFish 共享同一套窗口层需求代码可以直接搬。第二类是想学行为模拟的前端鱼群算法本身是个很经典的多智能体问题比粒子特效更能让人理解转向力和状态机的价值。第三类是想给自己做点无用之物的人这类项目最大的收益其实不是产物而是你会被迫去读平台层的文档。它不适合两类人。一类是想要一个完整桌面宠物生态的MiroFish 没有养成系统没有等级没有社交鱼不会因为你不来看它就死掉——我刻意没做这些因为一旦有了必须每天打开的压力它就从解压工具变成了另一个待办事项。另一类是想要开箱即用的商业级软件的人它现在的定位还是一个开源的小玩具配置项藏在 JSON 里改鱼种要自己准备图集。2. 透明悬浮窗三个平台的脾气完全不一样2.1 Windows分层窗口、点击穿透与那个烦人的黑边Windows 上最省事的组合是 Electron 的transparent: true加上frame: false再配合skipTaskbar: true和alwaysOnTop: true。窗口一建出来就是一块完全透明、置顶、不占任务栏的玻璃。但这里有几个必须知道的细节。const win new BrowserWindow({ width: 420, height: 320, transparent: true, frame: false, resizable: false, hasShadow: false, skipTaskbar: true, alwaysOnTop: true, backgroundColor: #00000000, webPreferences: { backgroundThrottling: false } }); win.setIgnoreMouseEvents(true, { forward: true });hasShadow: false不是可选项。Windows 上透明窗口如果保留系统阴影阴影是按窗口矩形算的你会得到一个透明的窗口配一圈灰黑色的矩形轮廓非常明显。backgroundColor要写成八位的#00000000只写transparent在部分驱动上会被忽略。点击穿透的关键是setIgnoreMouseEvents(true, { forward: true })。第二个参数forward的意思是我虽然穿透了但请继续把鼠标移动事件转发给我这样你才能在透明窗口里追踪鼠标坐标做出鼠标靠近时鱼会躲开这种效果。注意forward只在 Windows 和 macOS 上有效Linux 下传了也没用。另外forward会增加一点系统调用开销如果你不需要鼠标跟随果断关掉别省这点事。还有一个坑是透明窗口和 GPU 加速的关系。早期版本在部分老显卡驱动上透明区域会随机闪白块。我的处理方式是给用户留一个--disable-gpu-compositing的启动开关但不默认开因为关掉之后 CPU 绘制开销会上升反而更费电。这类问题永远是在闪和烫之间二选一。2.2 macOS跨空间、压全屏与不可激活macOS 上透明窗口本身不难难的是让它在你切 Space、开全屏应用的时候还在。win.setAlwaysOnTop(true, screen-saver); win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); win.setIgnoreMouseEvents(true);setAlwaysOnTop的第二个参数是层级默认是floating这个层级压不住全屏窗口。要让鱼浮在别人的全屏视频上面得提到screen-saver这一层。这个参数名看着别扭但它确实是从屏保窗口那一层借来的语义。setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })解决的是切工作区的问题。不加这行你切到另一个 Space鱼就消失了加了但不开visibleOnFullScreen全屏应用一开鱼又没了。两个都要。macOS 的点击穿透比 Windows 简单setIgnoreMouseEvents(true)就够了但它没有forward语义上的等价物——你的窗口会真的收不到任何鼠标事件。所以 MiroFish 在 macOS 上做投喂交互时走的是另一套逻辑按住一个全局快捷键时临时把穿透关掉松开再打开。这个体验比 Windows 稍差但胜在稳定。2.3 LinuxX11 能跑Wayland 得看合成器脸色Linux 是三个平台里最让人头疼的。X11 下透明和置顶基本可用但需要合成器compositor支持也就是用户得跑着 picom、compton 之类的东西。如果用户用的是极简窗口管理器又没开合成器透明区域会直接变成黑色这个你没法在代码里救只能检测并给提示。Wayland 下问题更碎。窗口定位在很多合成器里是不允许的——它不让你把一个窗口放到指定的绝对坐标你只能建议位置。置顶策略也各不相同有的合成器直接忽略。MiroFish 的做法是检测到 Wayland 会话时就降级不承诺精确位置只保证窗口存在且透明同时在启动日志里打印一行说明。这比硬撑着做出一个位置乱跳的窗口要好得多。平台透明点击穿透穿透后转发鼠标跨工作区备注Windows需要hasShadow: false支持支持forward需要手动处理注意 DPI 缩放下的边缘闪烁macOS原生支持支持不支持需要setVisibleOnAllWorkspaces压全屏要提到screen-saver层Linux/X11依赖合成器支持不支持由 WM 决定无合成器时透明区变黑Linux/Wayland部分支持不稳定不支持由合成器决定建议降级为不定位模式3. 让鱼像鱼状态机加转向力的组合拳3.1 先给每条鱼一套性格参数最早我的鱼只有一个移动逻辑朝随机方向匀速飘碰到边界就反弹。结果看起来像台球不像鱼。问题不在于运动本身而在于所有鱼的行为完全一样。真实的鱼缸里有的鱼一直在水面附近晃有的贴着缸底扒拉有的一有动静就窜到角落躲起来。差异感才是活着的来源。所以 MiroFish 的第一步是给每条鱼生成一组性格参数在鱼被创建时随机采样一次之后不再变化。{ id: fish_07, species: betta, personality: { cruiseSpeed: 42, burstSpeed: 180, timid: 0.78, sociable: 0.15, wanderBias: 0.6, preferredDepth: 0.25 }, state: CRUISE, energy: 0.9 }这几个参数的含义很直白。cruiseSpeed是巡游速度单位是像素每秒burstSpeed是受惊时的爆发速度timid决定它对鼠标和投喂点的反应阈值sociable决定它多愿意跟着别的鱼走wanderBias是随机游走的强度preferredDepth是它偏好的垂直位置0 是水底1 是水面。有了这组参数二十条鱼在视觉上立刻就区分开了——哪怕用的是同一张图集。这里有个很实用的技巧性格参数不要用均匀分布采样用两次随机相乘也就是偏向 0 的三角形分布。均匀采样会让大部分鱼都落在中等性格反而没有记忆点。偏向极端的分布会自然产出一两条特别好动、一两条特别胆小的鱼用户记住的就是这几条。3.2 裁剪版 Boids只保留分离和对齐鱼群运动绕不开 Boids也就是分离、对齐、聚合三条规则。但我想说的是原版的完整 Boids 对桌面挂件来说是过度设计。完整版需要每条鱼每帧遍历所有邻居复杂度是 O(n²)而且聚合规则会让鱼群迅速缩成一团毛球看起来非常假。MiroFish 的裁剪版只保留了两条力另外加了一条游荡。第一条是分离力。当两条鱼的距离小于personalSpace时产生一个远离对方的力强度与距离成反比。这一条必须留因为鱼穿模是视觉上最致命的破绽两条鱼叠在一起的那一瞬间所有的沉浸感都没了。第二条是对齐力。让鱼的速度方向向邻近鱼群的平均速度方向靠拢但权重很低只有 0.15 左右。这一条的作用是让鱼偶尔形成短暂的同向游动制造它们好像在一起的错觉但不至于变成整齐划一的舰队。第三条是游荡力。给每条鱼一个随时间缓慢变化的随机方向偏移用 Perlin 或者简单的正弦叠加就能实现。这条力是让鱼看起来有自己想法的关键它替代了被砍掉的聚合规则。function computeSteering(fish, neighbors, dt) { const acc { x: 0, y: 0 }; // 分离距离越近推得越狠 for (const other of neighbors) { const dx fish.x - other.x; const dy fish.y - other.y; const distSq dx * dx dy * dy; if (distSq 0 distSq PERSONAL_SPACE_SQ) { const w 1 / distSq; acc.x dx * w; acc.y dy * w; } } // 对齐向邻居平均速度靠拢权重压低 if (neighbors.length 0) { let ax 0, ay 0; for (const other of neighbors) { ax other.vx; ay other.vy; } ax / neighbors.length; ay / neighbors.length; acc.x (ax - fish.vx) * 0.15; acc.y (ay - fish.vy) * 0.15; } // 游荡用一个缓慢漂移的角度作为目标方向 fish.wanderAngle (Math.random() - 0.5) * 0.4 * dt; acc.x Math.cos(fish.wanderAngle) * fish.personality.wanderBias; acc.y Math.sin(fish.wanderAngle) * fish.personality.wanderBias; return acc; }3.3 为什么改速度而不是直接改坐标这是我在这个项目里最有价值的一个认知转变。第一版我是直接改坐标的算出合力取符号然后fish.x sign(acc.x) * speed。结果鱼的运动全是折线看着像在打台球。正确的做法是把每一帧当成一次微小的物理积分合力是加速度加速度改变速度速度改变位置。function integrate(fish, acc, dt) { fish.vx acc.x * dt; fish.vy acc.y * dt; // 限速不能超过这条鱼自己的爆发速度 const speed Math.hypot(fish.vx, fish.vy); const maxSpeed fish.state FLEE ? fish.personality.burstSpeed : fish.personality.cruiseSpeed; if (speed maxSpeed) { fish.vx (fish.vx / speed) * maxSpeed; fish.vy (fish.vy / speed) * maxSpeed; } fish.x fish.vx * dt; fish.y fish.vy * dt; // 朝向由速度决定而不是由目标点决定 fish.angle Math.atan2(fish.vy, fish.vx); }这么改之后鱼在转向时会有一个自然的弧线因为速度是有惯性的——它想转弯但旧速度还在两者合成出一条曲线。这条曲线就是像鱼的全部秘密。鱼的尾巴摆动之所以看起来有节奏本质上是因为身体有惯性而尾部在持续修正方向我们只是用两层积分把这个物理过程近似了一遍。还有一点是关于dt的处理。绝对不要用固定的dt也就是别写死fish.x fish.vx * 0.016。帧率一波动所有速度都会跟着变用户在低配机器上看到的是一缸慢动作。但也不能直接用浮动的dt因为dt偶尔会出现 200 毫秒的尖峰比如系统在做垃圾回收一次积分跳过去鱼会瞬移到墙外。MiroFish 用的是固定步长累加器物理固定按 1/60 秒跑一步把实际经过的时间攒起来够一步就跑一步最多补三步。4. 渲染方案的两级跳从二十个 div 到分层 Canvas4.1 第一版用 DOM第二天就被自己骂了原型阶段我用的是最直觉的方案每条鱼一个div里面放一张img用transform: translate3d()每帧更新位置再加上 CSS animation 做尾巴摆动。二十条鱼的时候还能跑但打开任务管理器我就沉默了——GPU 进程的内存直接飙到三百多兆。原因不难理解。每条鱼都是一个独立的合成层因为translate3d会触发图层提升。二十条鱼就是二十个图层每个图层都要一份独立的纹理再叠加透明窗口本身的离屏缓冲显存就是这么被吃掉的。更糟的是我在每帧里写element.style.transform这会触发样式重算虽然不触发布局但二十次样式重算加上二十次图层属性更新主线程的帧时间稳定在 8 到 12 毫秒之间留给其他逻辑的空间已经很小了。所以第二天我就砍掉了 DOM 方案换成单个 Canvas。这个决定没有任何犹豫。4.2 分层绘制水波、鱼体、UI 各画各的换成 Canvas 之后也不是一步到位的。中间我犯过一个错误把所有东西画在同一个drawImage循环里包括会动态模糊的水波。水波当时用的是ctx.filter blur(8px)想着这样代码最简洁。结果就是帧率直接掉到 15。原因很明确ctx.filter在 Canvas 2D 里是同步的 CPU 操作每一帧都要做一次全画布的卷积像素量越大越慢——而我的画布是按 DPR 放大的4K 屏二倍缩放下实际像素面积是 CSS 面积的四倍。改法是把水波预渲染。启动时把水波用一次性的OffscreenCanvas画好、模糊好、缓存起来运行时只做两件事用globalAlpha调节透明度用translate做缓慢的横向偏移。// 启动时做一次代价可以忽略 const waveCanvas new OffscreenCanvas(width, height); const wctx waveCanvas.getContext(2d); wctx.filter blur(8px); wctx.drawImage(waveSource, 0, 0); wctx.filter none; // 运行时每帧只做平移和叠加没有任何滤镜计算 function drawWaves(ctx, t) { ctx.globalAlpha 0.35; ctx.globalCompositeOperation screen; const offset (t * 12) % width; ctx.drawImage(waveCanvas, -offset, 0); ctx.drawImage(waveCanvas, width - offset, 0); ctx.globalCompositeOperation source-over; ctx.globalAlpha 1; }鱼体本身用的是图集加drawImage的九参数版本一次调用画一帧不再切换纹理。二十条鱼二十次drawImage加上水波两次一帧总共二十二次绘制调用主线程帧时间掉到 1.5 毫秒左右。这个数字才是一个常驻挂件该有的水平。4.3 让鱼在没人看的时候偷懒常驻程序最大的道德问题就是耗电。用户不会因为你画得好看就容忍笔记本续航掉一小时。MiroFish 做了一套分级降频策略判断依据是我实测下来最有效的三个信号。第一个信号是窗口是否和显示器可见区域相交。用户把鱼拖到屏幕外的副屏、或者副屏被拔掉之后窗口的坐标会落在所有显示器之外这时候直接停掉整个渲染循环。这个检测用screen.getAllDisplays()拿到的矩形列表做相交运算每秒跑一次就够了成本可以忽略。第二个信号是无操作时长。鼠标和键盘超过三分钟没动静大概率用户离开了帧率从 30 降到 10。这时候鱼的动作变慢但因为是按物理步长跑的不会出现动作失真只是单位时间内的步数变少了。第三个信号是前台是否有全屏应用。用户在看视频或者玩游戏的时候鱼是不该动的。全屏检测在 Windows 上可以通过窗口的isFullScreen()或者对比显示器工作区来判断虽然不完美但准确率够用。const FPS_ACTIVE 30; const FPS_IDLE 10; const FPS_HIDDEN 0; function currentFps() { if (!isWindowOnAnyDisplay()) return FPS_HIDDEN; if (idleMs 180000) return FPS_IDLE; return FPS_ACTIVE; }提醒一句不要用requestAnimationFrame然后指望它自己省电。在透明窗口上rAF的调用频率跟窗口是否可见、是否获焦的关系很不可靠很多情况下它照样按显示器刷新率跑满。主动控制节流是必须的别把省电的责任交给浏览器。5. 从 Electron 迁到 Tauri 2为了常驻内存砍掉的一半5.1 迁移前的实测数据坦白说Electron 版本的 MiroFish 在功能上是完整的但它有个致命缺点空载内存 165 兆左右。对一个占着桌面角落、用户一年都不会主动打开设置的小工具来说这个数字会直接劝退一大批人。我在自己的两台机器上做了三天记录用process.memoryUsage()和系统监视器交叉验证得到的稳定数据大概是这样的。指标Electron 版本Tauri 2 版本空载常驻内存约 165 MB约 68 MB二十条鱼满负载内存约 210 MB约 95 MB安装包体积约 92 MB约 9 MB冷启动到首帧约 1.4 s约 0.6 s满负载平均 CPU约 4.2%约 3.8%CPU 差别不大因为真正的开销在绘制上而绘制逻辑几乎没变。省下来的主要是运行时本身。这个结果让我决定迁虽然迁移过程比预想的麻烦。5.2 WebView 差异带来的三个具体问题第一个问题是点击穿透没有事件转发。前面提过Electron 的setIgnoreMouseEvents(true, { forward: true })能在穿透的同时继续收到鼠标移动事件。Tauri 的set_ignore_cursor_events(true)只有布尔值穿透之后窗口就是一个黑洞。投喂和鱼躲鼠标这两个功能立刻挂掉。我的解决方案是分级降级。默认情况下穿透开启鱼群失去鼠标跟随能力只有纯自主游动当用户按下全局快捷键默认是CtrlAltF时把穿透关掉窗口获得完整的鼠标事件用户可以拖拽、投喂、点开设置面板三十秒无操作后自动恢复穿透。这个设计虽然不如 Electron 顺滑但好在语义清晰而且省掉了在 Windows 上轮询全局鼠标位置的额外开销——顺便说一句轮询GetCursorPos那套我试过60Hz 轮询下光这一项就能吃掉 1% 的 CPU得不偿失。第二个问题是透明背景的实现方式不同。Tauri 需要在配置文件里显式声明透明并且在 Rust 侧确认窗口属性。{ app: { windows: [ { label: tank, transparent: true, decorations: false, alwaysOnTop: true, skipTaskbar: true, shadow: false, resizable: false } ] } }shadow: false在 Windows 上同样必要原因和 Electron 版一样。另外 Tauri 在某些平台下需要手动调用一次set_background_color(Color(0, 0, 0, 0))光靠配置里写transparent: true有时会得到一个半透明的灰底这属于 WebView 初始化的默认背景色没被覆盖掉。第三个问题是WebView 版本造成的渲染差异。Windows 上 Tauri 用的是 WebView2也就是 Chromium 内核和 Electron 的渲染行为基本一致Canvas 性能没有可感知的差别。但 macOS 上用的是 WKWebViewLinux 上用的是 WebKitGTK这两者在 Canvas 的合成路径上和 Chromium 有区别。具体表现是在 macOS 上globalCompositeOperation screen的叠加水波颜色偏亮在 Linux 上OffscreenCanvas的某些版本里取不到 2D 上下文。我的处理是在启动时做一次能力探测——画一个 2x2 的测试画布读取像素值如果OffscreenCanvas不可用就退回普通 Canvas 缓存。这段探测代码只有三十行但省掉了几十个 issue。5.3 打包与多显示器 DPI打包本身不难tauri build一步到位麻烦的是多显示器下的窗口尺寸。Windows 支持每显示器独立 DPI笔记本内屏 150%、外接显示器 100% 是很常见的组合。窗口从一个屏拖到另一个屏时系统会发 DPI 变更消息框架通常会帮你重算但如果你用的是逻辑像素并且自己缓存了尺寸就会出现窗口忽大忽小。我的做法是窗口尺寸只在一处定义用物理像素每次收到 DPI 变更或者显示器变更时重新计算并重新设置一次画布尺寸和ctx.scale。function syncCanvasToWindow(canvas, ctx) { const dpr window.devicePixelRatio || 1; const cssW canvas.clientWidth; const cssH canvas.clientHeight; const pw Math.round(cssW * dpr); const ph Math.round(cssH * dpr); if (canvas.width ! pw || canvas.height ! ph) { canvas.width pw; canvas.height ph; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } }这段代码里的if判断非常重要。给canvas.width赋值是一个有真实成本的操作它会清空画布并重新分配后备缓冲。如果每帧都赋值性能会直接崩掉。只在尺寸真的变了的时候才赋值这是最容易被忽略的一行优化。另外开机自启在多显示器环境下要小心。用户可能有三块屏但你不知道鱼该出现在哪一块上。MiroFish 的策略是记住用户上次拖动结束时的显示器 ID 和相对位置如果那块屏不存在了就退回到主屏的右下角。这个逻辑听起来简单但如果你只存绝对坐标用户换了显示器排列之后鱼就会跑到屏幕外面再也找不回来——我第一版就犯了这个错收到过三条鱼不见了的反馈。6. 用户反馈倒逼出来的三个功能与一次性能排查6.1 投喂、快捷键与穿透热区投喂功能是我原本没打算做的。最初的想法是鱼自己游就行了要什么互动。但反馈里出现频率最高的一条是能不能喂它——用户对桌面宠物的期待天然带着互动性。实现本身不复杂鼠标位置撒下一粒食物食物缓慢下沉附近的鱼根据sociable和饥饿度决定是否追击吃到的鱼energy加满、状态切到EAT三秒。真正麻烦的是穿透冲突前面已经说过了。这里有个细节值得单独拿出来讲食物不要做真实的自由落体。我一开始按重力加速度算食物掉得太快从水面到缸底不到一秒鱼根本来不及反应。改成缓慢匀速下沉加一点横向摆动之后用户体验立刻好了很多。这提醒我一件事模拟类项目里物理正确和感觉正确经常是矛盾的而用户永远站在感觉正确这一边。6.2 把鱼种数据抽出去之后最早的代码里每种鱼的行为参数是写死在switch分支里的。想加一条新的鱼就得改代码、重新编译。这在一个自己玩的项目里没什么但当有人开始提 PR 说我想加一条神仙鱼的时候写死参数就成了阻碍。重构的思路很直接逻辑层完全不知道鱼种的存在它只认参数。每种鱼变成一个目录里面是图集、一个 JSON 描述文件、以及可选的着色器参数。{ id: guppy, displayName: 孔雀鱼, atlas: sprites/guppy.png, frame: { w: 48, h: 32, count: 8, fps: 12 }, bodyLength: 34, personalityRange: { cruiseSpeed: [55, 75], burstSpeed: [150, 200], timid: [0.3, 0.95], sociable: [0.5, 0.9] }, preferredDepth: [0.5, 0.85] }重构之后加一条新鱼只需要准备一张图集和一个 JSON不需要碰任何一行逻辑代码。personalityRange用区间的形式而不是固定值是因为同一个鱼种的不同个体也应该有差异——真实鱼缸里没有两条完全一样的孔雀鱼。顺带说一下图集准备的实际经验。鱼的游泳动画最容易做错的地方是帧数太多。我一开始画了十六帧结果发现十二帧和十六帧在肉眼上几乎没区别但显存和绘制成本差了三成。最后定点在八帧并且用循环的相位错开每条鱼的起始帧视觉上完全看不出重复感。这个小技巧几乎零成本效果却很明显。6.3 一次风扇狂转的完整排查链路最后讲一个真实的排查过程因为我觉得这条链路比结论更有价值。有用户反馈装了 MiroFish 之后笔记本风扇一直转拔掉电源续航明显变短。我第一反应是不可能我自己的机器上 CPU 只有 4%。但反馈的人不止一个所以必须查。第一步先确认现象。我让他用系统自带的资源监视器记录了十分钟的进程占用结果是 CPU 稳定在 12% 到 15%比我本机高了三倍。差异确认存在。第二步找变量。我和他的环境差异有他是 4K 屏笔记本200% 缩放我是 2K 屏 100% 缩放。他是 Windows 11我是 Windows 10。他装了三条鱼我装了二十条。第三步优先验证最可疑的变量。让他在设置里把鱼的顺序、数量都保持不变只把窗口拖到一块 1080P 外接屏上。CPU 立刻掉到 4%。问题锁定在 DPI 缩放上。第四步定位具体代码。回到 DPR 相关的逻辑我发现画布的像素尺寸被正确放大了但我漏了一处水波图层的离屏画布是按 CSS 尺寸创建的然后在绘制时被drawImage拉伸到画布尺寸。也就是说每帧都在做一次上升采样。4K 200% 缩放下这个拉伸要把一块 420x320 的位图拉到 840x640而且因为用了globalCompositeOperation screen拉伸发生在合成阶段无法走快速的路径。第五步量化并修复。把离屏水波改成按 DPR 创建拉伸消失。同时顺手做了两件事把满帧率从 60 降到 30因为在透明窗口上 30 帧和 60 帧的主观感受几乎一样把鱼的边界检测从每帧一次改成每两帧一次因为鱼撞墙的时机差一帧完全看不出来。改完之后他的 CPU 从 12% 到 15% 降到了 2% 左右。这个结果比我自己预期的还好。这次排查给我留下了两个习惯。第一个是任何绘制缓存都必须按设备像素创建不能按 CSS 像素创建再拉伸这是隐形的性能税。第二个是别在透明窗口上用高开销的合成模式screen、overlay、soft-light这几个在离屏合成路径上的代价比source-over高不少能预渲染就预渲染。我个人在实际操作中的体会是桌面类小工具的性能问题九成都不出在算法上而出在我以为是免费的操作上——一次canvas.width赋值、一次滤镜、一次位图拉伸、一次样式写入。把这些隐性成本一个个找出来比换框架、换语言带来的收益大得多。MiroFish 现在在我的机器上常驻了快一年除了偶尔想加条新鱼的时候会打开它的配置文件其他时间我真的已经忘了它在跑。对一个桌面挂件来说这可能就是最好的评价了。
返回列表