Loading
CamelliaV の BLOG
0%
INITIALIZING
DOC_ID // 3efca1ONLINE

[临时AI稿][2026.10.4] 修一个钉图跨屏 bug,顺带把截图工具的性能翻了五倍

2026-10-4
技术分享
READ 5 MIN/COUNT 1876
#开发#复盘#PRACTICAL
CamelliaV の BLOG
💡
本文由glm5.3撰写;一次"钉在副屏的截图会自己跳回主屏"的 bug 修复,最终演变成对 KDE layer-shell 协议、KWin 窗口管理内核和一张 patch 分发管线的三重深潜。这篇文章记录完整的排查链路:从隔离虚拟显示器复现,到读 KWin 源码找到协议级根因,再到修复过程中意外引爆的性能优化与 patch 自噬事故。

起因:一个很小的 bug

我用的是 Spectacle 的一个自维护 patch 分支,额外功能之一是"钉图"(把截图钉在桌面上置顶显示)。某天发现一个诡异现象:在主屏钉的图,被我用快捷键挪到副屏后,只要点一下或者滚轮缩放,它就会瞬移回主屏。
小 bug,但很硌手。

第一步:复现,但不在真实桌面弹窗

调试桌面软件最忌讳在真实会话里弹测试窗口——打断自己的工作流,还可能把窗口管理搞乱。我的验证环境是一套隔离的虚拟 KWin:
两个虚拟输出、模拟混合缩放、配合 KDE 的 fake-input 协议注入真实输入事件,再通过 KWin scripting DBus 接口执行和用户快捷键完全等价的 workspace.sendClientToScreen(pin, target)。
探针输出一行行 JSON:每一步之后 KWin 眼中这个窗口在哪个输出、什么坐标。很快拿到了决定性证据:
复现稳定,而且确认:纯点击不会跳回,跳回由缩放引发的某种"重排"造成。

第二步:读 KWin 源码,找到协议级根因

带着"缩放时发生了什么"去读 KWin 的 layer-shell 实现,答案在 LayerShellV1Window:
  • m_desiredOutput 在 layer surface 创建时一次性定死;
  • 每次重排,窗口都被放回 desiredOutput.topLeft + margins;
  • 没有任何协议请求能改变一个已存在 surface 的 output。
而"挪到副屏"这个操作,KWin 是当作普通窗口移动处理的:改内部 frameGeometry,完全不动 desiredOutput、不动 margins。于是缩放触发 setDesiredSize → KWin 重排 → 表面被强行放回创建屏。
根因清楚了,推论也很残酷:客户端改 margins 没用。我试过两种 margins 方案,探针都直接证伪——其中一种甚至算出了 x=-1400 这种荒谬坐标(相对坐标口径混乱,margin 相对新屏原点反而把窗口推出桌面外)。
唯一协议合法的路径只有一条:销毁旧 surface,在新 output 上重建。

第三步:重建方案,和它的两个坑

方案看起来直白:检测 windowHandle()->screen() 与目标屏不一致 → destroy → 重建 → 恢复全部 layer-shell 配置。但实现后探针显示窗口又跳回来了,而且日志里有一次诡异的"反向迁移"。
细看日志时间线才明白:重建后的新 surface 还没收到 wl_surface enter 事件时,Qt 的 windowHandle()->screen() 返回的是主屏(primary)。下一次 movePin 读到这个假值,判定"窗口在主屏",又把它迁移回来——自噬循环。
解法是引入一个状态闸门:只有实际观察到 surface 落在目标屏上之后(enter 事件已到),才允许下一次跨屏迁移:
初值还必须设为 false——否则构造函数里 show() 之前的第一次 movePin 读到假主屏值,会把刚钉到副屏的 pin 又拽回主屏。两个方向都堵死后,探针终于全绿:缩放、点击之后,窗口稳稳留在副屏。

第四步:修 bug 送的性能问题

回归测试跑起来后,顺手测了整个截图流程,发现新问题:一次矩形截图竟然要 5~20 秒,期间 CPU 多核打满。
采样器(轮询 /proc 记录进程树 CPU/内存轨迹)抓出的现场触目惊心:一次截图,Python OCR 子进程被串行拉起了 10 次,每次全屏检测 1.4 秒左右。深挖下去是三层问题叠加:
层
问题
耗时
检测调度
SelectionEditor patch 断链状态下重触发循环,10 次冗余检测
~14s
进程模型
每次检测冷启动 python + ONNX 会话重建
~0.5s/次
应用生命周期
每次 F1 都 systemd 冷启动整个 QML 应用
~1s/次
逐个击破:
  1. 正确性——修复 SelectionEditor patch(见下文的自噬事故),冗余检测归零;
  1. 进程复用——给 OCR 脚本加 --server 行协议模式,Python 进程常驻、模型会话只建一次,C++ 侧用单例 QProcess 复用,每次请求只付纯推理的 ~0.4s;
  1. 应用驻留——加 SPECTACLE_STAY_RESIDENT 开关,截图任务结束后不退进程只隐藏窗口,下次快捷键激活直接命中现成进程,省掉整个冷启动。配合一个 systemd 用户单元,开机即驻留。
顺带修掉一个退出卡顿:ESC 时如果 OCR 子进程还在推理,析构 QProcess 会同步等它——改成 kill 优先、限时收尸,退出不再被 Python 拖住。

第五步:patch 分发管线的自噬事故

这个项目的分发方式是 patch 链:上游 tarball + 五个按序应用的 patch,makepkg 构建时逐个打上。我修 SelectionEditor.cpp 时,把分发 patch 里对应段用"工作树 → 上游"的 diff 重新生成——这里埋了雷。
工作树并不等于"前四个 patch 应用后"的状态,它缺了另一个 patch(empty-selection,空选区=鼠标所在屏)对同一文件的改动。重新生成的段落把这处行为吞掉了,结果:bug 修好了,但"回车直接截当前屏幕"这个老功能神秘消失。用户一句"单独截某一个屏幕被你弄没了"把我打回排查——这次学乖了,直接在打完全链 patch 的树上 grep 关键标记(空选区改动的 screenAt 调用、被误删的函数定义),当场定位。
这轮学到的教训值得刻碑:
  • patch 链项目的唯一内容真相源,是"全部 patch 应用完成后的树",不是工作树;
  • 重新生成任何 patch 段后,必须对净源码树跑全链 dry-run + 关键标记断言 + 编译三连;
  • 我伪造的 index 00000000..0000000 行曾让 patch 把整段误判为文件删除;哈希行干脆不写比写错强。

最终架构

性能对比:一次截图从 5~20s 降到 ~1s,第二次 F1 从冷启动 ~1s 降到零等待。

复盘

🔍
  1. 先建可复现的隔离环境,再动手——虚拟双输出 + fake-input + KWin scripting,让每个假设都能被一行探针输出裁决。
  1. 别信客户端单侧视角——Qt 报告的屏幕和 KWin 内部状态可以短暂不一致,协议对象的"真相"只在事件往返之后。
  1. 性能问题的答案在采样数据里,不在猜测里——一次进程树采样直接把"10 次冗余 OCR"钉在墙上。
  1. patch 链项目里,内容真相源是全链应用后的树;重生成段落必须过"全链 dry-run + 标记断言 + 编译"三关。
NAVIGATION // Related Articles
Loading...
© 2024-2026 CamelliaV