实机用了一段时间之后,出现了一个很有代表性的反馈:慢一点点,切桌面都正常;点得快一点、来回连点,就容易“不起作用”。这种“和速度有关”的问题,八成是竞态,而不是性能。

这一篇把整个排查和重写的过程放在一起:怎样先复现,根因分了哪两层,为什么“固定等 0.62 秒”虽然有效却不是好办法,最后怎样改成“看结果”,以及测试本身踩了哪些坑。

先复现:连点压力测试

我先写了一个压力测试:在窗口分别在两个桌面的两个 App 之间,用真实的切换入口来回快速点击,依次点 B、A、B、A、B,最后应该停在 B 的桌面上。测试时把正在运行的 DockTouchBar 暂停,免得我自己在 Touch Bar 上的点击搅乱结果。

复现出来了:点击间隔 400 毫秒时 2/4 次正确,250 毫秒时 0/4,150 毫秒时 0/1。再把每次点击时程序看到的状态和做的决定记进日志:第一下点 B,约 110 毫秒时已经请求系统切桌面;第二下点 A(约 300 毫秒)时,程序读到的“当前桌面”还是旧的,A 的窗口也“看得见”,于是判断“已经在这个桌面了”,退回普通激活,桌面纹丝不动。

根因分两层

第一层:动画期间读到的状态是过期的。系统切桌面的动画在这台机器上是 450 到 530 毫秒,“当前桌面”和“屏幕上看得见的窗口”要到动画结束才更新。我的第一版修复只修了这一层:记住“刚刚请求去的桌面”,动画结束前以它为准。结果每一步决定都对了,最终桌面还是错。

第二层:动画进行中发出的第二次切桌面请求,系统会直接丢掉,接口却照样返回成功。所以光判断对不够,还得避开这段时间,或者确认请求真的被接受了。

第一版:固定等 0.62 秒,有效,但不是好办法

最直接的办法是:两次切桌面之间至少隔 0.62 秒(比最长的 530 毫秒动画再多一点),等的时候如果有更新的点击取代了它就跳过。在受控条件下,这个办法 16/16 全部正确,后来更大的样本里是 29/29。

但它是“拍脑袋”的:动画时长是这台机器上量出来的,机器很卡、动画变长时 0.62 秒不够;关掉动画或者机器很快时,又白白多等。用户很快指出了这一点,并问:官方有没有信号,可以按结果判断切换成没成功?这个批评是对的,于是改成“看结果”。

看结果:官方信号,加上确认请求被接受

官方确实有信号:`NSWorkspace.activeSpaceDidChangeNotification`,切换桌面时发出。但它是在动画开始还是结束时发,决定了能不能用,所以先量。实测它在动画结束时发出,比窗口画面到位只晚 5 到 10 毫秒;而“App 变成前台”的通知要早得多(40 到 80 毫秒),不能拿来当完成信号。

只等这个通知还不够。我把逻辑改成“收到信号就立刻发下一次”,16 轮里只有 13 轮正确。抓了一个失败现场:最后一下点击的请求,是在信号之后仅仅 8 毫秒发出的,被系统丢了,等了 1.6 秒也没有任何切换;成功的请求都是在信号后 20 到 60 毫秒发出的。也就是说,“切完了”的通知比系统真正能接受新请求的时刻早了一点点。

所以还要确认请求被接受了:发出请求后,目标 App 应该很快变成前台(实测 40 到 80 毫秒),如果 0.4 秒内没变,就当作被丢了,间隔 50 毫秒重发,最多 3 次。判断依据是官方的前台 App 状态,不是时间。中途我还试过 SkyLight 里查“屏幕是否正在做切换动画”的接口,它存在,但整个切换过程中它从来没有变过,就放弃了。

一个失败现场教会的三件事

加上“确认被接受”之后成功率反而变差了,原因是测试出了问题(见下)。等测试条件受控之后,又抓到一个真实的失败现场:最后一下点击的“提前窗口”调用卡了 1.5 秒后失败,而代码把这次失败当成致命错误,直接放弃,没有重试。

这说明三件事。第一,失败要分“彻底失败”和“系统正忙”,后者应该稍后重发。第二,辅助功能调用在系统正忙时会卡一秒多才失败,要给它设短的超时(0.35 秒),快速失败、快速重试。第三,每次点击都重新按元素编号枚举窗口,在动画期间要 240 毫秒,应该把找到的窗口元素缓存起来。这三点,都是“适应机器当时的状态”,而不是固定等待。

再往前一步:盯着结果并纠正

用户接着提出一个更进一步的想法:就算一个切换没完成,突然被切回去,或者被别的地方抢走焦点,能不能强制更新回目标?让人有很强的响应感。我评估下来认为方向对,但必须守住一条边界:绝不能和用户争焦点。

做法是:点击处理完之后,在约 2.4 秒内每 0.12 秒核对一次“前台是不是目标 App、当前桌面上有没有它的窗口”,不一致就重新提前窗口,最多纠正 3 次。你一动键盘、鼠标、滚轮,或者点了别的图标,它立刻放弃;系统还在切桌面时也不重发。

验证用的是一个“捣乱模式”:最后一下点击之后随机时刻,故意抢走焦点或者把桌面切回去,看能不能被拉回目标。没有纠正机制时是 4/15,做了纠正是 12/13。第一版盯梢是“稳定 0.3 秒就结束”,只有 6/9,因为真实的异常往往发生在动作完成之后一会儿;改成一直盯到窗口结束才有 12/13。

测试本身踩的坑

这一阶段,花在测试上的时间比修问题还多。第一,我用系统的“键鼠空闲时间”判断有没有人在操作,结果程序自己激活 App、提前窗口也会把它清零,测试一开始就把自己的动作误判成人在操作而中止;改成只看真实的键盘、鼠标、滚轮事件。第二,Touch Bar 上的触摸在这些事件里看不见,而它会被正在运行的 DockTouchBar 处理;所以受控测试时把它暂停,测完自动恢复。

第三,有一轮数据里两种方案同时变差,旧方案从 29/29 掉到 22/27,这是明显的“测试环境变了”信号。查下来是桌面上多了台前调度的 WindowManager 窗口,排在真实窗口前面,“屏幕上最靠前的窗口是谁”这个判据把成功误判成失败,改成前台 App 加当前桌面。第四,失败是成批出现的:一整段时间里所有请求都不生效,之后自己恢复,新旧方案都有,这种数据不能下结论。

还有一件要老实说的事:最后一轮“正常连点,新旧并排对照”因为要交给我手动测试而提前中止了,没有数据。最终的确认来自手动测试:连点、故意打断,都稳定。

手感:哪些半秒是程序的,哪些不是

用户还提到“有些不跟手”,所以我也量了点击本身:被点的 App 变成前台,中位数只要 40 多毫秒(新旧逻辑一样);但窗口画面真正出现在最前面要约 530 毫秒。多出来的约 480 毫秒是开着台前调度时系统的窗口切换动画,不是程序造成的,程序也改不了。

能改的部分改了:连点不再失效,被丢掉或被抢走的结果会被纠正;不能改的部分,在 README 里写明,并建议想要更快画面的人关掉台前调度或开启“减弱动态效果”。这个建议我没有替用户去测,因为那是系统设置。