MiroFish:Tauri+Canvas 2D 数据状态桌面鱼群

发布时间:2026/9/18 11:14:18
MiroFish:Tauri+Canvas 2D 数据状态桌面鱼群 MiroFish 这个名字是我在凌晨两点敲出来的当时屏幕上只有一条鱼在原地打转尾巴一帧一帧地跳像卡了带的录音机。它不是什么大项目就是一个跑在自己电脑上的桌面小工具把本地那些零散的笔记、待办、日志、代码提交记录当成水喂给一缸虚拟的鱼鱼的状态就是你的数据状态——记录断更几天鱼会变瘦待办堆成山鱼会变得焦躁、游得更快连续一周有产出鱼群里会多出一条新鱼。整件事听起来毫无性价比但它解决的其实是一个很实际的问题绝大多数自我量化工具都需要你主动打开它、看它、理解它而 MiroFish 是把数据状态直接变成你余光里就能感知到的东西。这篇文章会从需求来源讲到技术选型从群游算法讲到功耗控制再到打包分发和六个通宵级 bug 的排查过程适合两类人看一类是想给自己做一个没什么用但很想做的桌面工具的人另一类是在做桌面端常驻应用、正在被透明窗口、性能占用和跨平台折磨的人。下面是这个项目里我真正踩过的东西能抄的部分我会写得足够具体。1. MiroFish 的定位把数据状态翻译成一群会游动的鱼1.1 需求来源不是我想要一个宠物而是我不想再打开仪表盘自我量化的工具我前后用过七八个日历热力图、习惯打卡、番茄钟统计、Git 提交日历功能都很好问题也都一样它们都要求你进入它。你要主动切窗口、主动打开网页、主动看那个图。我统计过自己连续 30 天的行为打开打卡类应用的次数在第三周之后断崖式下跌最后一周只打开了两次而且是被推送提醒逼的。真正有用的信息其实只需要三秒钟就能感知不需要理解。就像你走进一个房间不需要测量温度计就知道有点闷。MiroFish 的设计初衷就是把这个体感补上——鱼缸永远在屏幕角落你不需要看它自己会通过颜色、体态、聚集程度告诉你最近的状态。这是一个把读数降级成感觉的设计牺牲了精度换来了持续可见性。1.2 三条主线数据源、镜像层、生态模拟整个项目在架构上被我切成了三段这个切法不是为了好看是为了每一段都能单独测试、单独替换。数据源Source负责拿到原始数据。我支持三种接入方式——监听本地 Markdown 目录的变化、读取一个 SQLite 待办库、抓取本地 Git 仓库的提交时间戳。这一层的输出是事件流格式统一不掺杂任何业务语义。镜像层Mirror把事件流压缩成一组状态量比如近 7 天活跃度待办积压指数断更天数节奏稳定性。这一层是整个项目的核心也是最容易写歪的地方后面会单独展开。生态模拟Ecosystem把状态量映射成鱼的体征和群体行为包括条数、体长、颜色饱和度、游速、聚集倾向、个体活跃周期。这一层完全不关心数据是从哪来的只吃状态量。这么切的好处很直接我想换数据源的时候只改 Source我想调鱼看起来像不像活的的时候只碰 Ecosystem。中间那一层是唯一的契约层我用一份 JSON Schema 固定住了它的字段任何一边加字段都得先改 Schema否则直接报错。这个约束在后期帮我省了非常多的调试时间。1.3 明确一下它不适合谁我不想把这东西说得太万能。MiroFish 不适合需要精确统计的人它的显示粒度是体感不是数据不适合把它当作唯一的追踪手段它只是提醒层真实记录还得靠原来的工具也不适合追求开箱即用的人它需要你手动配置至少一个数据源路径首次启动有一个不算短的向导。提示如果你想要的是一个精确的统计面板MiroFish 会让你失望。它的价值在于不用打开也能知道而不是看得很清楚。2. 技术选型为什么最后落在 Tauri Canvas 2D 上2.1 体积和内存的硬账先算清楚桌面常驻工具的第一条隐性规则是占用必须低到让人忘记它存在。我一开始用的是 Electron理由是熟、生态全、调试方便。跑起来之后我做了个对比测试同样渲染 20 条鱼、同样挂在透明置顶窗口上结果如下。指标Electron 方案Tauri 方案差值安装包体积78 MB6.4 MB约 12 倍空闲内存占用210 MB62 MB约 3.4 倍冷启动到可见1.8 s0.6 s约 3 倍空闲 CPU60fps3.5%1.9%约 1.8 倍首次开发耗时2 天6 天反而更久前四项是决定性因素最后一项是我付出的代价。Tauri 用的是系统 WebViewWindows 上是 WebView2macOS 上是 WKWebViewLinux 上依赖 WebKitGTK。这意味着我要处理三套渲染差异尤其是透明窗口和多显示器那块坑特别多。但一个常驻工具占用 210 MB 内存我自己都不好意思装在自己电脑上所以选了 Tauri。2.2 透明置顶窗口这件事两个方案各踩了什么坑透明、无边框、置顶、鼠标穿透这四个属性同时开是桌面宠物类工具的标配但也是跨平台最恶心的部分。下面是我在tauri.conf.json里最终稳定下来的配置。{ app: { windows: [ { label: tank, width: 480, height: 320, transparent: true, decorations: false, alwaysOnTop: true, skipTaskbar: true, resizable: false, shadow: false } ] } }关键点在shadow: false和decorations: false的组合。我一开始没关 shadowWindows 上透明区域的边缘会出现一圈很脏的半透明灰边怎么调 CSS 都没用因为那是系统绘制的窗口阴影不属于网页层只能从窗口配置里关掉。macOS 上则是另一个故事alwaysOnTop在应用进入全屏空间时会失效鱼缸会掉到后面去需要在进入全屏的时机重新设置置顶层级。鼠标穿透我用的是运行时的set_ignore_cursor_events(true)而不是启动时就设死。原因是启动就设死的话向导和设置面板根本点不了。我的做法是默认穿透开启同时在托盘菜单里留一个暂停穿透的开关需要调整位置或改配置时先关掉穿透改完再打开。这个交互看起来笨但实际用起来比什么悬停 3 秒自动取消穿透要可靠得多后者在鱼游过鼠标的时候会疯狂误触发。2.3 渲染层为什么 2D Canvas 就够了我算过一笔账。20 条鱼每条鱼用 12 个顶点做身体形变加上尾鳍的 6 个顶点一共 360 个顶点再叠加背景的水波和光斑。这个量级放在 WebGL 里属于望远镜打蚊子光是上下文初始化和着色器管理带来的复杂度就已经超过收益了。2D Canvas 的Path2D加drawImage我把鱼做成了离屏预渲染的精灵图集在 60fps 下 CPU 占用稳定在很低的水平。具体做法是启动时把每条鱼的每种姿态正常、加速、转向、减速预渲染成雪碧图运行时只做变换和drawImage不做逐帧路径重算。身体形变靠的是对精灵图做非均匀缩放加轻微的旋转视觉效果已经足够成本几乎为零。真正消耗性能的其实是背景水波我一开始用逐像素噪声生成帧率直接掉到 30 以下后来换成了一张预生成的循环渐变纹理做位移采样问题就没了。2.4 状态数据用 SQLite 还是 JSON这个问题的答案取决于数据量。我实测过状态量每天一条一年 365 条一条大约 200 字节十年也就 700 KB 左右。这个量级用 SQLite 是过度设计但用单个 JSON 文件又会遇到并发写入和损坏风险。最终的方案是分片 JSON 追加日志每天的聚合结果写成独立文件命名为state-YYYY-MM-DD.json原始事件流写成一个按月滚动的追加日志文件一行一条 JSON。读取时只需要读最近 30 天的分片文件历史数据不加载。这样既避免了单文件越来越大的问题也避开了数据库依赖打包体积不受影响。日志文件的写入我用的是追加模式不做重写断电最多丢最后一行。3. 数据镜像层把笔记和待办变成水质参数3.1 数据源接入的三种粒度这一层最容易走歪的地方是什么都想接。我一开始设计了插件系统结果两周后发现自己在写各种格式解析器而鱼一条都没多。后来我把接入粒度硬性限制成了三种超出范围的一律不做。粒度典型数据源采集方式采样频率目录级Markdown 笔记库文件监听 修改时间事件触发记录级本地待办库定时轮询 变更检测每 5 分钟事件级本地 Git 仓库读取引用日志时间戳每 15 分钟这三种粒度覆盖了我 95% 的使用场景笔记代表输入待办代表负债提交记录代表产出。三者构成了一个很粗糙但足够用的闭环输入少、负债多、产出断鱼的状态就难看这个直觉反馈是成立的。3.2 从原始记录到状态量的一整套映射镜像层做的事情本质上是降维。原始事件可能有几千条最终只输出 8 个 0 到 1 之间的浮点数。这一步我写得很谨慎因为一旦映射规则设计得不好鱼的反应就会失真——比如待办积压如果直接按数量线性映射那积压 50 条和积压 200 条的观感差别会非常小因为都顶到上限了。我最后用的是分段归一化加时间衰减。分段归一化的意思是每一个状态量都定义自己的舒适区和爆表区中间用一条平滑曲线过渡而不是简单除一个最大值。// 分段归一化把原始值映射到 0~1拐点可配置 function normalize(raw, softCap, hardCap) { if (raw 0) return 0; if (raw softCap) { // 舒适区内缓慢上升 return 0.5 * (raw / softCap); } if (raw hardCap) return 1; // 舒适区到爆表区之间加速上升 const t (raw - softCap) / (hardCap - softCap); return 0.5 0.5 * (1 - Math.pow(1 - t, 3)); } // 时间衰减越久远的事件影响越小 function decay(daysAgo, halfLife) { return Math.pow(0.5, daysAgo / halfLife); }softCap和hardCap我留成了配置项因为每个人的节奏不一样。同样是每周写 3 篇笔记对有些人来说是低产对另一些人来说已经是高强度了。3.3 增量同步和文件监听的坑文件监听这件事理想是文件变了就通知我现实是文件变了会通知你十七次。文本编辑器保存文件的方式千奇百怪有的先写临时文件再重命名有的一次保存触发创建、修改、属性变更三种事件还有的编辑器会写隐藏的备份文件。如果我每收到一个事件就重算一次状态磁盘 IO 和 CPU 都会被无意义地打满。我的处理方式是在监听层和业务层之间加一个去抖与去重层同一个路径在 800 毫秒内的事件合并成一次根据扩展名和文件名前缀做白名单过滤排除.swp、.tmp、以.开头的隐藏文件重命名类事件统一转换成删除 创建两条逻辑事件处理。做完这三件事事件量从每小时上千条降到几十条。还有一个更隐蔽的坑符号链接和循环目录。如果你的笔记目录里有个链接指回上级目录监听器会陷入无限递归内存直接爆掉。我在扫描目录时加了一个已访问 inode 的集合来兜底遇到重复就跳过并打一条日志。3.4 一张能直接抄的映射表下面是我目前实际在跑的状态量定义字段名和映射规则都可以直接照搬参数按自己的习惯调。状态量数据来源原始计算方式映射区间影响的鱼的体征vitality笔记修改 提交记录近 7 天加权次数soft 5 / hard 25整体活跃度、游速backlog待办未完成数当前未完成条数soft 8 / hard 40体色饱和度、聚集度silence全部数据源距上次活动天数soft 2 / hard 10体长、颜色明度rhythm事件时间戳日间方差归一化0 到 1 直接映射游动节奏的规律性focus单次会话时长中位数时长分钟soft 20 / hard 90个体游动范围variety数据源覆盖数活跃源数量 / 总源数0 到 1 直接映射鱼群种类多样性这张表我改过四五个版本最大的教训是状态量之间必须尽量正交。我早期同时用了总事件数和笔记数两个量结果笔记一多两个量一起涨鱼群的反馈过载看起来非常夸张。后来把总量类指标砍掉只保留活跃度和节奏两个维度观感立刻正常了。4. 群游算法让十几条鱼看起来不像十几条傻鱼4.1 Boids 三定律讲人话群游效果我用的还是最经典的 Boids1986 年 Craig Reynolds 提的那套核心就三条规则用大白话讲分离Separation离我太近的邻居我要躲开别挤在一起。对齐Alignment我大致朝邻居们的平均方向游别显得格格不入。聚合Cohesion我朝邻居们的中心靠拢但别靠得太近。三股力加权求和得到一个加速度再作用到速度和位置上。听起来简单实际上 90% 的调试时间都花在权重调参上。function updateFish(fish, neighbors, dt) { let sepX 0, sepY 0, sepCount 0; let aliX 0, aliY 0; let cohX 0, cohY 0, cohCount 0; for (const other of neighbors) { const dx fish.x - other.x; const dy fish.y - other.y; const d2 dx * dx dy * dy; if (d2 PERCEPTION * PERCEPTION d2 0) { // 分离距离越近排斥越强用平方反比 sepX dx / d2; sepY dy / d2; sepCount; // 对齐 aliX other.vx; aliY other.vy; // 聚合 cohX other.x; cohY other.y; cohCount; } } let ax 0, ay 0; if (sepCount 0) { ax normalizeVec(sepX, sepY).x * W_SEP; ay normalizeVec(sepX, sepY).y * W_SEP; } if (cohCount 0) { const cx cohX / cohCount - fish.x; const cy cohY / cohCount - fish.y; const c normalizeVec(cx, cy); ax c.x * W_COH; ay c.y * W_COH; const ax2 aliX / cohCount - fish.vx; const ay2 aliY / cohCount - fish.vy; const a normalizeVec(ax2, ay2); ax a.x * W_ALI; ay a.y * W_ALI; } fish.vx ax * dt; fish.vy ay * dt; clampSpeed(fish, MIN_SPEED, MAX_SPEED); }4.2 权重调参三组数字决定观感我把三组权重都做成了可热更新的配置因为手感这件事只能靠眼睛调。下面是我试出来的三组典型值以及对应的观感。权重组合分离对齐聚合观感描述松散群1.60.80.5各自游各自的偶尔凑一起适合表现状态涣散紧密群1.21.51.6像一群沙丁鱼动作整齐适合表现节奏稳定焦躁群2.40.60.4互相闪躲、频繁变向适合表现待办积压发现规律了吗分离权重高的时候鱼群会显得紧张聚合权重高的时候鱼群会显得安定。这两个是可以直接和状态量挂钩的——backlog高就提高分离权重rhythm高就提高聚合和对齐权重。这一步做完之后鱼的行为本身就成了一个可读的仪表。注意权重不要一次改两个以上。我早期调参的时候同时改分离和对齐结果完全判断不出是哪一项导致了观感变化白白浪费了一个晚上。一次只动一个参数改完观察至少 30 秒。4.3 边界处理决定了像不像水族箱最开始的边界处理是硬反弹碰到边缘速度取反。效果极其糟糕鱼会贴着边缘来回抽搐像弹球游戏。后来改成软边界接近边缘时施加一个指向中心的力力的大小随距离平方增长。这样鱼会自然地绕开边缘游出一条弧线。更真实一点的做法是边界避让 转向延迟。我加了一个 0.3 秒的转向缓冲也就是检测到接近边界时不立刻转向而是先记录一个目标方向再平滑地插值过去。这样鱼在接近边缘时会有一个明显的甩尾后转的动作观感上就活了。这个细节看起来微不足道但它是像鱼和像贴图之间最明显的那条分界线。4.4 个体差异才是灵魂对称的、完全一致的鱼群无论算法多正确看起来都是死物。我后来给每条鱼加了四个个体属性基础游速偏移±15%、转向灵敏度偏移±25%、体型缩放0.85 到 1.15、以及一个独立的发呆概率。发呆的意思是每隔 8 到 20 秒鱼会随机进入 1 到 3 秒的低速状态速度上限降到 30%然后再恢复。加上发呆行为之后整个鱼缸的观感有质的变化。因为整齐划一的运动本身就是不自然的真实鱼群里总有那么几条在偷懒。这个改动只花了二十行代码但效果比我前面调了两天的权重加起来都明显。5. 状态机与生长系统鱼为什么会变瘦、变色、变少5.1 把状态量翻译成鱼的体征体征映射我用的是一套固定的转换规则全部线性插值不做花哨的曲线因为体征变化本身就是连续可感知的。体长由silence决定。断更 0 天是基准长度 1.0断更到 10 天缩到 0.72。缩水速度不能太快否则一天没写笔记鱼就瘦一圈看着很假。颜色饱和度由backlog决定。积压少的时候是高饱和的青蓝色积压多的时候往灰紫色走饱和度从 0.9 降到 0.35。游速上限由vitality决定映射到 40 到 110 像素每秒的区间。发光强度由focus决定专注时间长的时候鱼身会有一层很淡的辉光这个是用一层径向渐变叠加做的成本很低。所有体征变化都用 3 秒的过渡动画不做瞬时跳变。跳变会让整个鱼缸看起来像在闪烁非常出戏。5.2 时间尺度为什么不能实时反映这是我踩过的一个认知坑。一开始我想让鱼的状态实时反映数据结果就是我写完一条笔记鱼立刻变精神我删掉一条待办鱼立刻变亮。这种即时反馈看起来爽但完全破坏了体感这个核心价值——因为它变成了一个需要你盯着看的即时反馈系统又回到了仪表盘的老路上。后来我把所有状态量都改成7 天滑动窗口 每日结算。也就是说鱼反映的是最近一周的整体状态今天写了多少笔记对它的影响很小。这个改动让整个工具的性质变了它不再是即时奖励而是长期趋势的余光感知。我个人的体验是后者的持续性远好于前者。5.3 反馈闭环看到鱼变瘦之后会发生什么这一层我没有做任何游戏化设计没有经验值、没有成就、没有推送。原因是我试过加提醒加完之后鱼缸变成了一个会催我的东西反而让我想关掉它。现在的设计是它只显示不催促。但反馈闭环依然存在只是发生在人这一侧。我的实际体验是看到鱼缸里三条鱼游得慢吞吞、颜色发灰的时候我会产生一种很轻微的该写点什么了的念头比任何推送都有效因为它不打断我只是在那里。这个效果是我做这个项目最意外的收获。6. 性能和功耗常驻工具最容易被骂的两件事6.1 实测数据与优化路径一个挂在屏幕上的东西如果风扇开始转用户三天内就会卸载它。我把性能当成硬指标在做下面是三个阶段的实测数据1080p 屏幕20 条鱼透明置顶窗口。阶段空闲 CPU空闲内存帧率主要问题初版9.2%148 MB55 到 60逐帧重算路径、背景噪声逐像素生成精灵图优化后4.1%96 MB60每帧创建大量临时对象触发 GC对象池 降频后1.9%62 MB60可见/ 15不可见无明显瓶颈第二阶段的瓶颈很典型我在每帧的循环里创建了临时对象和数组导致垃圾回收频繁触发。解决办法是用预先分配好的对象池所有中间计算结果写进固定的缓冲区不产生新对象。这个优化做完帧率曲线立刻从锯齿状变成了直线。6.2 空闲降频与不可见暂停窗口被其他窗口完全遮挡、或者应用失焦超过 30 秒的时候帧率从 60 降到 15。降到 15 是因为鱼的运动本身是低频的15 帧在人眼看来依然流畅但 CPU 占用能砍掉六成以上。判断是否可见这件事比想象中麻烦。我试过用document.visibilityState但那个只在窗口最小化时变化被遮挡时不会变。后来改成自己在 Rust 侧用系统 API 查询窗口的遮挡状态和焦点状态两者都不可见才降频。降频的恢复是立即的一旦窗口重新可见下一帧就回到 60。6.3 内存泄漏的完整排查过程跑了两天之后后台内存从 62 MB 涨到 340 MB我一开始怀疑是鱼的数组在累积查了一圈发现鱼的数量是固定的。排查过程分成三步先确认是不是渲染层的锅。我把渲染循环完全停掉只保留数据层观察一小时内存稳定在 58 MB说明泄漏在渲染侧。再确认是不是画布累积。我在每帧记录canvas的引用计数发现雪碧图的离屏画布数量在缓慢增加。原因是每次状态量更新触发体征变化时我都会重新生成一次精灵图但旧的画布没有被释放。这个逻辑本身是对的问题在于状态量更新的频率比我预想的高得多——rhythm在每次事件到达时都会重算事件密集的时候一秒能触发十几次。修复方案是用一个缓存键来控制精灵图重建。键由体征的量化值拼成比如体长保留两位小数、饱和度保留一位键没变就不重建。这一改精灵图重建从每分钟上百次降到每分钟个位数内存曲线立刻平了。提示离屏画布的释放不像普通对象那样及时即使你把引用置空浏览器也可能在若干帧之后才回收。做这类工具时最好给所有离屏资源加一个显式的close()或尺寸归零操作能显著降低内存峰值。7. 打包分发与首次启动的体验打磨7.1 安装包体积的取舍Tauri 打包出来的 6.4 MB 里前端资源占了 4 MB 出头其中绝大部分是精灵图集。我原来用的是 PNG 序列帧后来换成了 WebP体积直接砍掉 40%画质肉眼看不出差别。但要注意Linux 上部分 WebKitGTK 版本对 WebP 的支持有差异所以我在打包时保留了降级路径检测到解码失败就回退到内嵌的 PNG 版本代价是包体多 1 MB。另外要提醒一句Windows 上 WebView2 运行时不是所有系统都预装。Tauri 提供了三种处理方式我选的是优先使用系统已安装版本否则引导用户下载。不要选那种静默捆绑安装的方式包体会膨胀到 100 MB 以上得不偿失。7.2 首次配置向导的设计向导我改了三版最后砍到只有两屏。第一屏是选择数据源目录第二屏是设置自己的节奏基准也就是前面说的softCap和hardCap。第二屏我用了很具体的问法比如你一周大概写几篇笔记会觉得状态不错而不是让用户填数字参数。这个问题问出来答案直接就能映射到配置里。流程上有一点很重要向导必须能跳过而且要允许用户什么都不配。我加了一个先看看再说的按钮点了之后会加载一个内置的演示数据集让用户直接看到鱼缸的样子再决定要不要配。加上这个按钮之后向导的完成率提升非常明显——因为用户看到了目标才知道为什么要填这些东西。7.3 更新与数据迁移自动更新我用的是 Tauri 的更新插件走的是签名校验加差量包。这里有一个容易忽略的点状态数据和配置的版本必须独立于应用版本。我在配置里加了一个schemaVersion字段每次读配置时先做版本比对不匹配就走迁移函数。迁移函数我写成了一条链式结构从版本 1 到版本 5 依次升级每一步都是纯函数可以单独写测试。这个设计在后期救过我一次有一次我把状态量的字段名从activity改成了vitality如果没有版本迁移所有老用户的鱼会全部变成最瘦的状态因为新代码读不到老字段默认值就是最低值。8. 六个通宵级问题的排查链路8.1 鼠标穿透开启后托盘菜单失灵现象开启穿透后托盘的暂停穿透选项点击无响应但系统托盘图标本身是正常的。排查链路先怀疑是托盘事件没有绑定打日志发现事件确实触发了。接着怀疑是窗口层级问题试着把窗口层级调低无效。最后发现是穿透设置作用在了所有窗口上包括我后来弹出的设置面板——设置面板本身就是透明窗口的兄弟窗口一起被设成了穿透。修正是把穿透状态按窗口标签管理只对tank这个窗口生效。8.2 透明窗口在 Windows 上的黑边现象窗口边缘有一圈 1 像素的黑色描边尤其在深色壁纸上特别明显。排查链路一开始以为是 CSS 的border或outline全局重置后无效。然后怀疑是 WebView 的默认背景色设置了transparent也没用。最后在窗口配置里找到根因——是decorations: false但没有关闭shadow系统在绘制窗口阴影时会在边缘留下一圈合成痕迹。关掉shadow之后问题消失。这个坑我在社区里看到过至少五个人问都没有明确答案。8.3 macOS 全屏空间与多显示器现象用户把应用拖到外接显示器上主屏进入全屏时鱼缸会消失或者跑到错误的屏幕上。排查链路第一反应是坐标系问题检查了逻辑坐标和物理坐标的转换发现是对的。接着怀疑是窗口在虚拟桌面之间被移动了用系统 API 查询窗口所在的空间发现它确实被分配到了一个不在当前激活空间里的位置。修正是监听空间变化事件在变化时重新把窗口设置到当前活跃空间同时记录用户最后停留的显示器 ID恢复时优先回到那个显示器。8.4 文件监听把磁盘 IO 打满现象挂载了云同步目录之后磁盘活动持续在 100%笔记本风扇狂转。排查链路先看事件频率发现每秒有几十个事件。加了日志打出路径发现大量是云同步客户端产生的临时文件。修正方案有三层扩展名白名单、路径前缀黑名单排除同步客户端的工作目录、以及前面提到的 800 毫秒去抖。改完之后事件频率降到每分钟个位数IO 恢复正常。8.5 中文路径导致的读取失败现象部分用户配置了含中文的目录后事件监听完全没反应没有任何报错。排查链路这个最阴。我一开始在开发机上测试一直是好的因为我的测试路径都是英文。后来让朋友帮忙测发现他那台机器上就是不行。排查后发现是路径在跨语言边界传递时编码不一致JavaScript 侧是 UTF-16 字符串传递到 Rust 侧如果中间经过了某个按字节处理的环节就会被截断或变形。修正是所有路径传递统一用 UTF-8 显式编码接收端显式解码并且在启动时对配置路径做一次读写探测探测失败就明确提示用户。这条经验值得单独说所有涉及用户输入的路径都要在配置保存的那一刻做一次真实的读写探测不要等到运行时才发现问题。这一条让我后来的支持成本下降了非常多。8.6 长时间运行后画面逐渐卡顿现象连续运行 8 小时以上帧率从 60 缓慢降到 40 左右重启就恢复。排查链路内存是平的CPU 占用也是平的只有帧率在掉这个很反常。我用了帧时间采样发现不是渲染慢而是每帧的间隔在变长。最后定位到定时器——我用setInterval做状态量的周期性重算里面有一个没有清理的监听器数组每重算一次就往里加一个回调8 小时下来累积了上万个回调。修正很简单重算前先清空回调数组同时把所有定时器改成由统一的时间轮管理退出时统一注销。这个问题的教训是常驻应用的每一个定时器和监听器都要有明确的生命周期归属。我现在养成了一个习惯所有setInterval和事件订阅都必须在同一个模块里注册和注销不允许跨模块随手注册。最后分享一个我自己用下来最舒服的配置把鱼缸放在副屏的最下沿宽度占满高度压到 180 像素左右只露出水面的上半部分和鱼的背脊。这样它就像屏幕边缘的一道光带完全不占视觉焦点但余光扫过去就能知道今天的水质怎么样。这个尺寸和位置我调了两周才定下来比全尺寸的鱼缸窗口实用太多。