news 2026/9/18 9:41:33

从零实现桌面鱼缸 MiroFish:Boids 鱼群模拟与透明窗口性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零实现桌面鱼缸 MiroFish:Boids 鱼群模拟与透明窗口性能优化

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 走的是NSWindowopaque=false,Linux 则高度依赖合成器是否存在。选型之前建议先在你的目标平台上跑一个"透明窗口 + 半透明 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 动画。用几百个divtransform: 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% CPU,GPU 也有 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未签名需要用户手动放行;全屏空间默认不显示窗口设置窗口在所有工作区可见;提供拖拽安装的 dmg
Linux约 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,为了看起来自然去反复调一个转向力的权重。这些东西在业务项目里通常会被一句"先这样吧"带过。

如果你也想动手做一个,我的建议是从最小的闭环开始:先只做"一个透明窗口 + 一条会转圈游的鱼",确认在你的目标平台上透明和穿透都正常,再去加鱼群算法和渲染优化。反过来先写算法再处理窗口,很可能会在最后一步被某个平台的透明限制卡住,然后整段代码都要推倒重来。至于调参,别追求一次到位,把参数做成可热重载的,然后花几个晚上慢慢看、慢慢改,这个过程本身还挺解压的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 9:38:04

Agent-Reach 工程实践:工具调用、记忆分层与多 Agent 协作

把 Agent 接进真实业务的第一天&#xff0c;十有八九会遇到这种场面&#xff1a;本地 Demo 里工具调用、记忆检索、多轮规划全都跑得通&#xff0c;一上线就出现工具选错、参数拼错、循环停不下来、上下文爆掉。问题往往不在模型&#xff0c;而在模型和外部世界之间那一层——我…

作者头像 李华
网站建设 2026/9/18 9:37:20

Windows服务管理实战:用sc命令与批处理脚本实现自动化运维

Windows下折腾服务&#xff0c;我第一个想到的命令就是sc。它是系统自带的Service Control&#xff0c;不需要额外装任何软件&#xff0c;安装、开启、配置、关闭甚至删除windows服务&#xff0c;一行命令就能搞定&#xff1b;配合bat批处理之后&#xff0c;更是能把“手动开服…

作者头像 李华
网站建设 2026/9/18 9:36:08

基于Flask的医院挂号与质控系统开发实战

1. 医院挂号与质控系统开发实战&#xff1a;基于Flask的全栈解决方案在医院信息化建设中&#xff0c;挂号系统与医疗质量监控是两大核心需求。去年我参与某三甲医院系统升级项目时&#xff0c;深刻体会到传统手工排班和纸质质控报告的痛点——医生排班冲突频发、质控数据滞后一…

作者头像 李华
网站建设 2026/9/18 9:36:01

基于Node.js+Vue的自习室座位预约签到系统实战解析

自习室座位签到预约系统&#xff0c;这六个字背后其实是大多数自习室管理者的真实痛点&#xff1a;座位靠“占”、来了没座、人走位空&#xff0c;管理全靠吼。用Node.js加Vue做一套预约签到系统&#xff0c;本质上就是把“占座”从线下冲突变成线上契约&#xff0c;让每一个座…

作者头像 李华
网站建设 2026/9/18 9:35:48

用C++ ProtectedInt结构体为游戏关键数值加锁:防内存修改实战

那是一个周末&#xff0c;我刚把一个成长系统放进测试服。还没等我看完后台日志&#xff0c;就有两个玩家离线前用同一种姿势“卡”出了超过服务器上限的金币&#xff0c;接着在排行榜上来了一波操作。查来查去&#xff0c;问题出得特别朴素&#xff1a;客户端内存里的int gold…

作者头像 李华