
1. MiroFish 这个看着没什么用的项目为什么值得认真做一遍第一次把 MiroFish 挂在桌面上我盯着屏幕里那十几条鱼看了大概十分钟然后才想起来自己本来是要去开会的。它就是这么一个东西一个常驻在桌面上、没有边框、背景完全透明的桌面鱼缸鱼在水里自己游偶尔被鼠标惊一下散开过几秒又慢悠悠聚回来。听起来像十几年前屏保时代的老物件但真动手做一遍你会发现把看起来自然这件事做到位比想象中难得多——鱼群模拟的自然度、透明窗口的兼容性、低占用常驻的功耗控制每一段都是坑。我给它定的定位很明确桌面伴侣不是屏保也不是游戏。屏保只在你离开的时候跑桌面伴侣要在你全程工作时一直待在旁边这就意味着性能预算极其苛刻。你不可能接受一个占着 10% CPU、让笔记本风扇狂转的装饰品。所以我在项目初期就给自己划了三条硬指标窗口必须完全无边框并支持鼠标穿透不影响任何正常操作静止观察时渲染进程的 CPU 占用压到 2% 以内下载安装完双击就能用不装运行时、不配环境。这篇内容适合三类人看。第一类是想找个看起来简单、拆开全是细节的练手项目的开发者第二类是对群体行为模拟Boids、鸟群、鱼群好奇但一直没动手的同学第三类就是单纯想给工位加一点生气、顺手折腾一下的人。我会把 MiroFish 从行为算法、渲染选型、性能治理到打包分发这几段路完整走一遍踩过的坑和调参的手感都写出来能直接抄的部分给代码。1.1 桌面鱼缸和动态壁纸的根本区别在哪很多人第一反应是这不就是个动态壁纸吗我一开始也这么想做下去才发现两者目标完全不同。动态壁纸是铺满整个屏幕的它天然独占一层渲染错了大不了就是难看而桌面鱼缸是一个浮在所有窗口之上的小窗它必须和浏览器、编辑器、播放器共存任何一点渲染异常都会被无限放大——比如透明区域出现一圈黑边比如切换全屏应用时窗口闪烁。另一个关键差异是焦点。壁纸不需要交互鱼缸则需要鼠标穿透我能点到底下的编辑器也能在鱼身上悬停触发一点小反应比如鱼受惊散开。这个既要穿透、又要感知的需求是整个项目里最容易被低估的部分后面第 5 节我会专门讲它踩出来的坑。还有一点是常驻时长。壁纸通常在你锁屏或切换虚拟桌面时会被系统回收而鱼缸一旦开了就是十几个小时不关。这意味着任何一点点内存泄漏、任何一次每帧新建对象都会在长时间运行后被放大成明显的卡顿。我第二次跑通版本之后挂了八个小时内存从 90MB 涨到 480MB那一刻才真正理解常驻这两个字的分量。1.2 三条硬指标定下来之后技术选型其实就窄了先把三条硬指标摊开看透明无边框、低占用、开箱即用。透明无边框把 Web 页面直接扔进浏览器这条路堵死了因为浏览器标签页拿不到窗口合成权限低占用把用 DOM 元素做鱼、靠 CSS transform 动起来这条路也堵死了几百个 DOM 节点每帧改样式样式重算开销远比你想的可怕开箱即用则排除了让用户自己装 Python 再 pip install这种方案。最后落到桌面端框架上实际可选的其实就两类基于 Chromium 的 Electron 系和基于系统 WebView 的 Tauri 系。我在两个方案上都跑过 MiroFish 的最小可行版本结论是——如果你的目标用户里 Windows 用户占大头且你很在意安装包体积Tauri 更香如果你需要依赖某些只在 Chromium 上稳定的合成行为透明窗口就是典型Electron 的坑会少一些。注意透明窗口在不同平台上的实现路径完全不同。Windows 走的是分层窗口macOS 走的是NSWindow的opaquefalseLinux 则高度依赖合成器是否存在。选型之前建议先在你的目标平台上跑一个透明窗口 半透明 PNG的最小 demo这一步花掉的两小时能省下后面两天。2. 鱼不会随机游动Boids 三条规则在 MiroFish 里的真实手感新手做鱼群最常见的实现是给每条鱼一个随机方向和随机速度撞墙就反弹然后加个正弦波动。这个方案第一眼看还行看三分钟就露馅鱼和鱼之间毫无关系有的会叠在一起穿模有的孤零零贴在角落抖整体像一堆漂浮的贴纸而不是一群鱼。真正让鱼群像活的的算法是 1986 年 Craig Reynolds 提出的 Boids 模型核心就三条规则分离、对齐、聚合。我把这三条规则分开说因为它们在调参时的手感差异非常大。分离负责别贴太近是三条里权重最高、也最不能妥协的一条一旦它太弱鱼就会开始互相穿插视觉上立刻崩掉对齐负责和邻居保持方向一致它决定了鱼群有没有整体感权重太高会出现一大群鱼像被磁铁吸住一样同向愣游聚合负责往邻居中心靠它决定鱼群会不会散架但权重给大了会形成一团不停旋转的毛球非常假。2.1 把三条规则写成可调的力而不是直接改速度很多人第一次写 Boids 会直接把速度赋值给鱼比如fish.vx avgVx。这么做的问题是没有惯性鱼的反应像开关一样突兀而且三条规则之间没办法叠加权衡。正确做法是三条规则各自算出一个加速度转向力加权求和之后再去更新速度最后对速度做限幅。这样鱼既有惯性又有约束游动的加减速看起来才像水里的生物。具体到 MiroFish 的实现每条鱼每帧会拿到一个steer向量它由三部分构成分离转向是远离过近邻居的单位向量累加离得越近权重越大对齐转向是邻居平均速度减去自身速度聚合转向是邻居质心减去自身位置。三者的权重我最后收敛到1.5 / 0.9 / 0.7这个组合分离明显高于其他两项。加速度还要再乘一个最大转向力上限否则鱼会在密集时被弹飞。steer sep * 1.5 align * 0.9 coh * 0.7 steer clampLength(steer, maxForce) // 单帧转向力上限 fish.v steer * dt fish.v clampLength(fish.v, maxSpeed) // 速度上限 fish.p fish.v * dt这段伪代码看着简单但有两个细节特别容易忽略。第一maxSpeed必须比基础巡游速度大一点否则鱼遇到障碍物想逃都逃不掉只能原地打转第二边界处理不要用撞墙反弹把速度反向而要做一个软性的回中心力靠近边缘时逐渐加一个指向水域中心的力鱼会自然绕回来不会出现贴着边来回弹的机械感。2.2 邻居查询才是性能真正的战场Boids 三条规则本身很便宜贵的是找邻居这一步。最直观的写法是双重循环对每条鱼遍历所有其他鱼算距离小于感知半径就算邻居。200 条鱼的话每帧就是 200×199 ≈ 4 万次距离计算还得开平方根60 帧下来每秒 240 万次。这个量级在桌面端还不至于卡死但 CPU 占用会稳定在 8%~12%离我的 2% 目标差了一个数量级。真正解决问题的办法是均匀网格。把整个水域按感知半径切成方格每条鱼先插入到自己所在的格子里然后计算邻居时只遍历自己所在格和周围 8 格。因为格子边长等于感知半径所以半径外的鱼不可能出现在这 9 个格子里——这个数学保证是整个优化的前提格子边长一旦小于感知半径就会漏邻居鱼群会突然出现局部失联的诡异行为。class SpatialGrid { constructor(width, height, cellSize) { this.cellSize cellSize; // 必须 感知半径 this.cols Math.ceil(width / cellSize); this.rows Math.ceil(height / cellSize); this.buckets Array.from({ length: this.cols * this.rows }, () []); } clear() { for (let i 0; i this.buckets.length; i) this.buckets[i].length 0; } insert(fish) { const cx Math.min(this.cols - 1, Math.max(0, (fish.x / this.cellSize) | 0)); const cy Math.min(this.rows - 1, Math.max(0, (fish.y / this.cellSize) | 0)); this.buckets[cy * this.cols cx].push(fish); } // 只查 3x3 个格子邻居数量从 n 降到个位数 forEachNeighbor(fish, visit) { const cx (fish.x / this.cellSize) | 0; const cy (fish.y / this.cellSize) | 0; for (let y cy - 1; y cy 1; y) { if (y 0 || y this.rows) continue; for (let x cx - 1; x cx 1; x) { if (x 0 || x this.cols) continue; const bucket this.buckets[y * this.cols x]; for (let i 0; i bucket.length; i) { if (bucket[i] ! fish) visit(bucket[i]); } } } } }这里有个很实际的细节clear()我用的是把数组长度置 0 而不是重新new Array()。原因是每帧创建上百个新数组会持续产生垃圾对象虽然 V8 的年轻代回收很快但在常驻场景下它会带来周期性的一两毫秒抖动肉眼看到的就是鱼群每隔几秒顿一下。把数组复用、只清空内容这种抖动就基本消失了。2.3 主循环一定要用固定时间步长还有一个几乎每个人都会踩的坑直接用requestAnimationFrame给的时间差去更新位置。在 60Hz 屏幕上没问题但一旦用户的显示器是 144Hz鱼的速度就会变成原来的 2.4 倍反过来如果某一帧卡了 300ms比如系统弹了个更新提示所有鱼会瞬移一大段看着像集体闪现。我用的方案是固定步长 累加器物理固定按 1/60 秒推进渲染帧率跟随显示器。这样无论屏幕刷新率是多少鱼的行为完全一致同时给累加器设一个上限比如 0.25 秒防止电脑休眠唤醒之后一次性补上几百帧把主线程堵死。const STEP 1 / 60; let accumulator 0; let last performance.now(); function frame(now) { requestAnimationFrame(frame); let delta (now - last) / 1000; last now; if (delta 0.25) delta 0.25; // 休眠唤醒后的保护 accumulator delta; let steps 0; while (accumulator STEP steps 5) { // 单帧最多补 5 步 world.update(STEP); accumulator - STEP; steps; } renderer.draw(world, accumulator / STEP); // 剩余时间用于插值 }表里的参数是我在 200 条鱼、1080P 水域下反复调了两周收敛出来的直接拿去用大概率能省掉你一半的调参时间。参数取值调大后的表现调小后的表现感知半径60 px鱼群整体感强但反应迟钝分裂成小区块各自为政分离半径22 px鱼之间空隙大显得稀疏开始出现穿插和重叠最大速度1.6 px/帧转向变钝急停不自然一有扰动就乱窜最大转向力0.08掉头生硬反应迟钝躲不开鼠标分离权重1.5群体过于松散挤成一团毛球对齐权重0.9整群同向愣游缺少群体感聚合权重0.7收缩成球状旋转长时间漂散不聚拢3. 渲染选型Canvas 2D、WebGL 与透明窗口的三角关系选渲染方案的时候我犹豫了很久因为 MiroFish 的画面上限很低——就几百条鱼每条一张精灵图连阴影和光照都不需要。直觉上 WebGL 是杀鸡用牛刀但真实情况要复杂一些因为透明窗口这个约束会反向影响你对渲染方案的选择。先说被排除的方案DOM CSS 动画。用几百个div加transform: translate3d确实能吃到 GPU 合成但每条鱼的旋转、缩放、帧动画都要改样式几百个元素的样式重算在 Chromium 里是实打实的开销而且鱼一多就会触发分层爆炸显存占用比画布高一个量级。我做过一次对照测试150 条鱼的情况下 DOM 方案的渲染进程 CPU 稳定在 11% 左右而 Canvas 2D 只有 3%差距是碾压性的。3.1 Canvas 2D 到底够不够用结论是够但前提是你要用精灵图集sprite atlas。如果每条鱼每帧都从一张单独 PNG 里drawImage浏览器会频繁切换纹理源绘制调用开销陡增。正确的做法是把所有朝向、所有动画帧拼成一张大图我用的是 2048×2048 的 WebP 图集单个 128×128 的帧横 16 列、纵 16 行运行时只drawImage一次并指定源矩形。这样整个画面就是几百次同源绘制浏览器批处理得很好。朝向的处理也有讲究。不要让画师画 360 个方向的鱼那纯属浪费。做法是只保留向左游的动画序列向右游的时候用ctx.scale(-1, 1)水平翻转。上下方向的偏航用一点点旋转来近似就行鱼本来就是侧视为主的生物稍微旋转几度视觉上完全说得过去。我实测下来用 8 帧动画 水平翻转已经能让绝大多数人看不出来是同一套素材。还有一个很反直觉的点不要在每帧开头用clearRect清一整块大画布。在透明窗口里clearRect之后画布区域是全透明的而全透明的区域在窗口合成阶段仍然要参与一次混合。我最初就是全屏清空再重绘发现 CPU 里有接近 1% 纯粹消耗在这上面。后来改成只清上一次绘制留下的脏矩形把 200 条鱼的包围盒合并成一个矩形区域清开销立刻降下来了。3.2 什么时候该上 WebGL如果你的目标是 1000 条以上的鱼或者你想加折射、水波、光斑这些需要逐像素计算的效果Canvas 2D 就会顶不住。这时候的升级路径不是全部重写而是把鱼的绘制部分换成 WebGL 的实例化渲染一份顶点数据一个四边形几百个实例每个实例带一个offset / rotation / frameIndex的属性用一次drawArraysInstanced画完。我在这条路上试过一次效果确实好500 条鱼的 CPU 占用比 Canvas 2D 还低但代价是代码复杂度上升了大概三倍你要自己管理纹理图集的 UV 切分、自己写顶点着色器做朝向翻转、自己处理画布尺寸变化时的视口更新。对于一个桌面小挂件来说我认为这笔账不划算所以 MiroFish 主线仍然停在 Canvas 2D。这个取舍逻辑其实可以推广在性能达标之前不要优化达标之后每一点复杂度都要算进维护成本。方案150 条鱼 CPU500 条鱼 CPU实现复杂度适用判断DOM CSS transform约 11%明显掉帧低只适合几十个元素Canvas 2D 图集约 3%约 9%中200 条以内首选WebGL 实例化约 1.5%约 3%高500 条以上再考虑SVG SMIL约 14%不可用低不推荐重绘代价太高3.3 透明窗口本身也在消耗你的性能预算这一点是我做完渲染优化之后才意识到的。透明窗口意味着每一帧的窗口内容都要和底下的桌面做一次 alpha 混合这个活儿是系统合成器干的不走你的渲染进程所以你在 DevTools 里看不到它但它真实存在并且和你的窗口面积成正比。一个铺满 4K 屏幕的透明窗口哪怕画面上只有三条鱼合成开销也不小。所以 MiroFish 的默认窗口尺寸我压到了 720×420 左右只有用户主动拖拽放大才会变大。另外一定要关掉hasShadow窗口阴影在透明窗口上会额外增加一层离屏合成也要避免在画布上叠加 CSS 的filter: blur()或者backdrop-filter这类效果在透明窗口里会强制走一遍额外的合成通道实测能让 GPU 占用翻倍。注意透明窗口里千万不要给 body 设不透明背景色。很多人调试时为了看清布局设了深色背景发布前忘了删结果用户看到的是一个黑方块鱼缸。建议在 CI 里加一条检查或者在代码里直接把 body 背景写成transparent并注释说明原因。4. 把 CPU 从 12% 压到 1.5%一次完整的排查链路性能优化这件事最忌讳上来就猜。我最开始的做法是感觉哪里慢就改哪里结果改了半天占用纹丝不动。后来我改成老老实实按链路走从现象到定位到修复一共花了三个晚上最终把静止状态下的渲染进程 CPU 从 12% 压到 1.5%。这一段我把完整的排查过程写出来因为排查思路比结论值钱得多。4.1 现象鱼还没游起来风扇先转起来了最开始的版本我把鱼的数量设成 60 条想先跑通逻辑结果笔记本风扇十分钟内就明显变响。打开系统监视器一看渲染进程稳定吃 11%~13% CPUGPU 也有 6% 左右。这个数字单看不夸张但对比一下一个正在播放 1080P 视频的浏览器标签页也不过 5%~8%。一个只有 60 条鱼的桌面挂件吃掉这么多显然有问题。第一步我做的不是改代码而是把进程拆开看。Chromium 的多进程架构下主进程、渲染进程、GPU 进程是分开的你必须在系统监视器里分清是哪一类在吃 CPU。我的情况是主进程 1%、GPU 进程 7%、渲染进程 12%也就是说两头都有问题GPU 侧是合成开销渲染侧是脚本开销。分开看之后优化的目标一下就具体了。4.2 第一层定位用 Performance 面板录制一段真实帧接下来我在渲染进程里打开 DevTools切到 Performance 面板录制 5 秒的空闲状态。这里有个小技巧录制前先把 DevTools 窗口独立出来别让它占据页面区域否则录制本身会干扰结果。录完之后我主要看 Fire Animation Frame 这一段里的调用树。第一眼看到的问题就很清楚了calculateNeighbors占了 62% 的脚本时间。这正是第 2 节里说的 O(n²) 邻居查询60 条鱼看不出来但代码结构本身就已经埋了雷。我顺手把鱼的数量调到 200 试了一下果然帧时间从 4ms 涨到 19ms说明这是一个会随规模恶化的问题必须换成网格。第二个问题是update函数里出现了大量黄色的 GC 标记。展开一看每帧都在new Vec2()一条鱼三个转向向量200 条鱼 600 个临时对象每秒 3.6 万个。这就是第 2.2 节提到的抖动来源。解决办法很土但很有效把向量运算改成对成员变量直接计算或者在鱼对象上预分配几个可复用的临时向量用完清零复用。4.3 第二层定位被遮挡时 rAF 还在跑脚本问题解决之后CPU 降到了 5% 左右但离目标还差得远。这时候 Performance 面板已经看不出明显热点了我换了个思路——去看它到底在什么时候跑。我在主循环里加了一行计数每秒往控制台打一次帧数然后做了一个测试把其他窗口拖到鱼缸前面完全遮住它。结果是帧数一点没降仍然是满 60 帧。问题就在这儿了——被完全遮挡的窗口用户根本看不到渲染毫无意义但requestAnimationFrame还在照常执行。这里有个细节值得说清楚Chromium 默认的backgroundThrottling是针对窗口失焦降频的而桌面鱼缸这种窗口从来就不会获得焦点所以必须把它关掉否则窗口一切到后台就降频鱼会卡但关掉之后又会导致它被遮挡时也不停。这两个需求是矛盾的只能靠手动判断。我的处理是监听document.visibilityState和窗口的遮挡状态在不可见时直接停掉 rAF只留一个低频的定时器比如每 500ms来检查是否需要恢复。恢复之后要记得重置last时间戳否则累加器会把这段休眠时间当成一次巨大的 delta触发前面说的集体瞬移。现象根因处理方式处理后的 CPU空闲时脚本时间长O(n²) 邻居查询换成 3×3 均匀网格12% → 6%每隔几秒顿一下每帧新建向量对象触发 GC临时向量复用、数组清空复用6% → 4.5%被遮挡时仍满帧运行rAF 与可见性无关不可见时暂停循环4.5% → 2%大面积透明区域开销全屏 clearRect 离屏合成脏矩形清屏、缩小默认窗口2% → 1.5%4.4 加一层环境感知把功耗交还给用户做到 1.5% 之后我以为可以收工了结果有用户反馈说他在用电池的时候鱼缸让续航明显变短。这个反馈很合理1.5% 的 CPU 加上 3% 的 GPU在插电的台式机上无所谓在轻薄本上就是实打实的电量消耗。于是我加了两个环境感知策略。第一个是全屏检测。当检测到前台有全屏应用在跑比如用户在放视频、在做演示鱼缸自动降频到 15 帧并且暂停所有非必要动画等退出全屏再恢复。在 Windows 上可以通过查询前台窗口的矩形是否覆盖整个显示器来判断在 macOS 上则监听空间切换事件。这个功能上线之后用户反馈里的看视频时鱼在动很干扰这类意见直接消失了。第二个是电池模式。检测到设备在电池供电时帧率上限降到 30同时鱼的最大数量减半。这个策略不需要做得很复杂重要的是让用户感觉到这个程序知道我现在的处境而不是不管不顾地一直吃电。我个人的判断标准是一个桌面常驻程序如果它在电池模式下的功耗和插电时完全一样那它就是个不合格的常驻程序。5. 装到别人电脑上才开始暴露的问题本地开发环境和真实用户环境之间有一条很宽的鸿沟MiroFish 这条鸿沟主要体现在三件事上高分屏的渲染模糊、鼠标穿透的交互边界、以及三平台打包的差异。这三件事在我自己的机器上全都复现不出来全是收到反馈之后才补的。5.1 高分屏下的模糊与多屏混合 DPI第一个反馈是鱼看起来糊糊的边缘有一层毛边。我一开始以为是图集压缩的问题把 WebP 质量从 80 提到 95 也没改善。后来才想明白问题出在画布分辨率上在 125% 或 150% 缩放的 Windows 上CSS 里写着 720×420 的画布实际占用的物理像素是 900×525而画布的width/height属性还是 720×420浏览器就把 720 宽的画面拉大到 900 显示模糊是必然的。修法很明确画布的width/height用逻辑尺寸 × devicePixelRatio来设置CSS 的宽高用逻辑尺寸绘制前统一ctx.scale(dpr, dpr)。麻烦的是devicePixelRatio会在窗口从一个屏幕拖到另一个屏幕时变化比如从 100% 的主屏拖到 150% 的副屏必须监听resize事件重新设置画布尺寸并且重新初始化网格因为网格的格子是按物理像素算的画布变了格子也得跟着变。有一类更棘手的情况是一个窗口横跨两个不同 DPI 的显示器。这时候单个窗口只能有一个 DPR 值跨过去的那一半一定会糊。我的选择是直接禁止跨屏当检测到用户把窗口拖到屏幕边界时吸附到当前屏幕的范围内。这个限制看着粗暴但比让用户看到一半清晰一半模糊要体面得多。5.2 鼠标穿透既要点不到又要感觉得到鼠标穿透这件事我踩了两次坑。第一次我用的是setIgnoreMouseEvents(true)穿透效果完美但鱼彻底失去了交互——鼠标悬停在鱼身上完全没反应因为窗口根本收不到鼠标事件。用户开始抱怨这鱼像个死物。第二次我改成了setIgnoreMouseEvents(true, { forward: true })这个参数的意思是鼠标事件正常穿透给底下的窗口但同时也把事件转发一份给当前窗口让它能感知到鼠标位置。这样我就能在渲染进程里拿到鼠标坐标算出鼠标离最近那条鱼有多近超过阈值就触发受惊散开的行为。底层应用依然能正常收到点击两边都不耽误。但forward: true带来一个新问题Windows 上这个参数在某些系统版本里会让转发频率变低鼠标快速移动时鱼的反应有延迟。我的补救方案是在渲染进程里对鼠标位置做插值平滑同时降低触发阈值让鱼在感觉到附近有东西时就提前散开而不是等到精确命中。视觉上反而更自然——真实的鱼群本来就是在危险靠近之前就先散。提示如果你需要保留偶尔点一下鱼的交互别去动穿透设置而是加一个全局快捷键切换交互模式。按下快捷键后取消穿透、窗口获得焦点用户可以点几下、投个食再按一次恢复穿透。这个设计比让用户去配置文件里改参数友好得多。5.3 三平台打包体积、签名和误报打包是我最不喜欢的环节因为它几乎没有写代码的快感全是琐碎的适配。下表是我在三个平台上实际遇到的主要差异供你评估工作量。平台体积Electron / Tauri主要坑点建议做法Windows约 75MB / 约 6MB未签名时触发作安全提示透明窗口在远程桌面下变黑底尽量签名远程桌面场景降级为不透明背景加边框macOS约 90MB / 约 7MB未签名需要用户手动放行全屏空间默认不显示窗口设置窗口在所有工作区可见提供拖拽安装的 dmgLinux约 80MB / 约 5MB依赖系统 WebView 版本无合成器的环境下透明失效打包时声明依赖检测不到合成器就退回普通窗口体积上的差距是实打实的Electron 把一个完整的运行时打进去了安装包七八十兆起步而 Tauri 复用系统 WebView普遍在个位数兆。对于一个装饰品定位的程序安装包体积对下载转化率的影响远超大多数人的想象这也是我在后期认真考虑过 Tauri 方案的原因。真正劝退我的还是透明窗口的跨平台一致性。Windows 下的透明窗口在某些远程桌面场景会渲染成黑底macOS 下如果窗口没有正确配置收集行为在全屏空间里会直接消失Linux 下没有运行合成器时透明直接退化成黑色。这些问题的共同点是你无法从代码层面彻底解决只能检测到之后优雅降级。所以 MiroFish 里专门有一层环境能力探测拿不到透明能力就自动切到一个带柔和边框的半透明窗口至少不难看。5.4 长时间运行才暴露的内存问题前三个问题都是视觉和交互层面的第四个是真正让我后背发凉的有个用户说他挂了两天程序占用了 1.2GB 内存。我在本地挂机测了八小时内存从 90MB 缓慢爬到 480MB曲线是一路向上的斜线典型的泄漏。排查方式是连续做五次打开配置面板 → 关闭配置面板每次强制 GC 之后记录堆快照对比快照里的对象数量。对比出来的是事件监听器在累积配置面板每次打开都会给滑块绑定input监听关闭时没有解绑因为面板本身被隐藏而不是销毁监听器一直挂在那里闭包里还持有了整个面板的 DOM 引用。修法有两种要么在关闭时显式移除监听要么把面板做成打开时创建、关闭时销毁。我选了后者因为对于这种低频打开的界面创建销毁的成本完全可以忽略而打开即创建天然避免了状态残留。这个思路我后来用在了所有弹出层上内存曲线终于变成了一条平稳的横线。6. 从看鱼到用鱼让它不只是一个装饰做到这里MiroFish 已经是一个合格的桌面装饰了漂亮、安静、不打扰。但我总觉得缺了点什么——一个每天要在我屏幕上待十个小时的东西如果只能看价值天花板太低了。所以我花了一段时间想怎么把它变成有点用的东西下面这几个方向是我实际做出来并且自己在用的。6.1 让鱼的数量对应你的待办数量这是我最喜欢的一个改动也非常简单读取一个本地 JSON 文件里面写着当前未完成的任务数鱼的数量就跟着变。完成一件任务文件里减一鱼缸里就有一条鱼慢慢游出画面消失新加一件任务就有一条小鱼从边缘游进来。这个映射不需要任何提醒和弹窗你瞥一眼鱼缸就知道今天还剩多少事而且它不像红点角标那样带攻击性。实现上要注意的是变化要平滑。一开始我做成瞬间增减视觉上非常跳。后来改成新鱼从画面外游入、离开的鱼游到边缘再淡出配合一点延迟随机整体感觉就自然了。另外鱼的数量最好做个上限我设的是 30否则任务一多鱼就挤成一锅粥既不好看也影响性能。6.2 开一个本地小端口让别的程序也能投喂第二个玩法是让外部的程序能往鱼缸里推送事件。我在主进程里起了一个只监听本地回环地址的 HTTP 小服务暴露一两个极简的接口比如放一条鱼进来让所有鱼受一次惊把水色切成夜晚模式。这样一来很多原本没有反馈的事情就有了即时可见的结果。构建脚本跑完放一条金色的鱼测试全绿放一条小银鱼测试失败让所有鱼受惊散开——你不需要看终端余光扫一眼就知道结果。安全上我只做两件事只绑定本地回环地址不接受外部连接接口本身没什么破坏性最坏情况也就是多出几条鱼。另外我加了一个开关默认关闭这个服务需要的人在配置里手动打开。对于桌面常驻程序来说默认关闭一切网络能力应该是基本素养。6.3 配置热重载把调参变成一件随手的事开发阶段我最痛苦的时刻是改一个参数 → 重启 → 看效果一轮下来两分钟就没了。后来我把所有可调参数鱼的数量、颜色主题、感知半径、最大速度都放进用户数据目录下的一个 JSON 文件并且用文件监听做热重载。改文件保存的一瞬间窗口里的鱼群立刻按新参数重算调参效率提升得非常明显。这里有个小坑文件监听在保存瞬间可能触发多次事件编辑器先写临时文件再重命名如果不做去抖会连续重载好几次导致画面闪。我加了 200ms 的去抖并且在解析失败时保留上一份有效配置绝不因为一个逗号写错就让用户面对一个白屏。6.4 我个人对这个小项目的看法我不认为 MiroFish 是什么了不起的东西它解决的需求非常小甚至可以说没有需求。但正是这种没有需求、没有 KPI、纯靠自己审美驱动的项目才能让人把一些平时被业务压着不做的细节做扎实——为了 1% 的 CPU 去翻可见性 API为了不糊去处理混合 DPI为了看起来自然去反复调一个转向力的权重。这些东西在业务项目里通常会被一句先这样吧带过。如果你也想动手做一个我的建议是从最小的闭环开始先只做一个透明窗口 一条会转圈游的鱼确认在你的目标平台上透明和穿透都正常再去加鱼群算法和渲染优化。反过来先写算法再处理窗口很可能会在最后一步被某个平台的透明限制卡住然后整段代码都要推倒重来。至于调参别追求一次到位把参数做成可热重载的然后花几个晚上慢慢看、慢慢改这个过程本身还挺解压的。