本文只记测过的数字和验证过的结论,用来避免把同一条路重新走一遍。 测量环境:Mac17,4(Apple Silicon)· macOS 26.5 (25F71) · 2026-09。 换机器或换系统版本后数字会变,但相互之间的量级关系是结构性的。
用 --duo-ax-bench 从签名后的 app bundle 运行得到(它需要辅助功能权限,
未签名的测试二进制拿不到,AX 调用会直接返回而不发 IPC):
| 操作 | 耗时 | 相对 |
|---|---|---|
| SkyLight 查询(走 WindowServer,不经 App) | 0.013ms | 1× |
| AX 单个属性读(位置 / 尺寸 / 标题 / 角色),目标 App 空闲时 | 0.0–0.1ms | ~8× |
| 同上,目标 App 正忙时(例如刚被要求隐藏自己) | 约 19ms | ~1500× |
| CGWindowList 全量枚举(所有 App 所有窗口) | 3.3ms | ~250× |
AX 取单个 App 的窗口列表(kAXWindowsAttribute) |
18–25ms | ~1500× |
两条反直觉的结论,都直接决定了优化方向:
-
同一个调用的成本取决于对方忙不忙。 上表第二行和第三行是同一个
axPosition:目标 App 空闲时 0.1ms,正忙时 19ms,差 190 倍。批量折叠时 我们刚要求一批 App 隐藏自己,它们全都处在最忙的状态——在那之后对它们做 任何 AX 调用都要按「忙」的价格算。据此定下的规矩:能在批量操作开始前 做的读取,全部提前到预热阶段并发做完(那时 App 还空闲)。 -
AX 本身不慢,慢的是「要一份列表」。 单属性读 0.1ms,可以随便调; 而
kAXWindowsAttribute一次就 20ms,比把全系统窗口列一遍还贵 6 倍。kAXChildrenAttribute(工具栏探测、红绿灯几何用它)是同一个量级。 优化的单位是「减少列表枚举的次数」,不是「减少 AX 调用的次数」。 -
时间花在等,不在算。 上面所有耗时都是 IPC 往返和目标 App 的 runloop 响应时间,与本机 CPU 快慢基本无关——所以 M1 上不会明显更慢,M5 上也不会 明显更快。用「换更快的机器」来解释或解决这里的性能问题是错的。
其它实测常数:
| 项目 | 耗时 |
|---|---|
Metal 管线创建(makeRenderPipelineState ×3 + 载入 metallib) |
首次 69ms,之后 0.4ms(Metal 内部有管线缓存) |
快速预览截图 CGWindowListCreateImage |
约 35ms/窗口 |
| 桌面效果会话创建 + 流启动 + 显示 + 销毁 | 合计 150–220ms,其中无任何主线程阻塞 > 50ms |
| 目标 App 执行 app-hide / minimize | 19–474ms/窗口 |
最后一项是折叠路径的地板。它是别的进程在响应我们的请求,方差极大: Ghostty 之类几毫秒就回,Safari 在排版、转码类 App 在忙时能拖到几百毫秒。 同一份代码折叠 8 个窗口可能是 184ms,折叠 11 个可能是 2131ms,差别几乎全在这里。 报「提速多少倍」时必须说清窗口构成,否则数字没有意义。
结论:在 SIP 开启的系统上不可行,不要再尝试。
思路是合理的——SLSSetWindowAlpha 把窗口 alpha 归零、或 SLSMoveWindowWithGroup
把窗口挪到屏外,请求直接到 WindowServer(0.013ms),完全不经过目标 App 的
runloop,可以绕开上面那个 19–474ms 的地板。
实测结果:调用返回成功,但回读发现值没变。
private SLS alpha did not apply id=7806 pid=744 current=1.00
private SLS offscreen did not park id=227 pid=715 reason=fallback
15 次尝试,15 次如此。跨进程的 SkyLight 窗口改动在 SIP 开启时被静默丢弃 (yabai 需要部分关闭 SIP 正是为此)。这不是 API 用法问题,是权限边界。
要让它生效只能让用户关掉 SIP,而正常用户不会为了折叠快一点去关 SIP,所以
这条路被永久排除。曾经加过的「快速隐藏」实验开关已删除(见 197b1f7):
留一个必须关 SIP 才生效的开关,只会让人打开、看不到变化、然后以为是自己
配置错了。
privateSLSAlphaHide 仍留在 fallbackHide 的最后一环(万一环境允许),
但带一次性探测:首次确认无效后本会话不再重试,不必每个窗口都白付一次
写入 + 一次回读。
按收益排序。共同前提是:先测再改——这一轮里有两次「凭直觉的优化」反而 让代码变慢(见第四节)。
1. 用 CGWindowList 筛掉注定为空的 AX 枚举。
专注模式原本遍历全部运行中的 App 并对每个调用 appWindows(pid:)。实测 84 个
候选进程里只有 22 个拥有窗口——另外 62 个只可能返回空数组,却各要付一次
同步 AX 往返(最坏卡满 2s 消息超时)。判据是可证明安全的:没有任何
CoreGraphics 窗口的进程,不可能有 AX 窗口。发现阶段因此从约 8 秒降到
200ms 以内。启动时的救援扫描原本有同样的循环,且连 activationPolicy 都不筛。
2. 并发做只读的 AX 工作。
AX API 可在任意线程调用。各 App / 各窗口之间没有依赖的只读工作都可以
DispatchQueue.concurrentPerform,总耗时从「求和」变成「最慢的那一个」:
- 窗口枚举(
concurrentAppWindows) - 外框画像解析(
ChromeProfileCache.prewarm)——980ms → 0ms - 快速预览截图(
concurrentQuickPreviews)——串行 70ms → 并发 41ms(2 窗口)
写入侧不能这么做:建 overlay 是 AppKit、改 shaded 字典是共享状态,都必须
留在主线程串行。并发只用在「读」上,是这一节全部收益的边界。
预热缓存时注意:ChromeProfileCache.entries 没有锁,所以是后台并发算、
回主线程一次性写,保持「只在主线程读写」这个前提不变。
3. 事务级备忘,而不是 TTL 缓存。
一次折叠里同一个 App 的窗口列表会被要三四遍(刷新元素、找焦点继承者、
数窗口数决定隐藏策略),而事务内它不可能变。appWindows 因此支持一个
显式开启的备忘作用域:shade / unshade 进入时开启,退出即丢弃。
每窗口枚举次数从 3.6 次降到 1.3 次。
刻意不用 TTL 缓存:那种缓存会在「App 在 unhide 后重建了 AX 元素」这种 场景下悄悄返回陈旧元素,而这恰恰是恢复阶梯存在的理由。
4. 判断元素是否可用,要用存活探测,不要用几何匹配。 见第四节。
5. 窗口动画要成组,不要串行。
NSWindow.setFrame(_:display:animate:) 会阻塞调用方直到自己那段动画播完。
放在循环里逐个调用,总耗时就是各自动画时长之和——9 条卷帘条归位实测 3201ms,
而且用户看到的是它们一条接一条地挪。改成一个 NSAnimationContext 组、用
animator() 代理,所有窗口同时动,总耗时收敛到一段动画,降到 200ms 以内。
注意:动画期间 window.frame 是中间值。紧接着要读最终位置的调用方
(展开真窗口时要按卷帘条位置定位)必须走不带动画的分支。
6. 主线程上的长任务要分帧。 折叠 N 个窗口是线性成本,躲不掉;但可以不一次性占住主线程。按时间预算 推进(每轮 runloop 折到 120ms 用完就让出,下一轮继续),总时长基本不变, 但菜单栏和输入不再被整段冻结——十几个窗口时这是「卡住了」和「正在进行」 的区别。首帧阻塞从 3434ms 降到 291–495ms。
会话建立等收尾动作仍然等全部折完后一次性完成,语义与同步版一致;级联
期间要拒绝新的同类请求(focusCascadeActive),否则两次操作会交叉修改
同一份状态。
7. 不是每件事都必须在关键路径上做完。
折叠后注册的「窗口被外部唤回」观察者(makeRevealObserver)是
AXObserverCreate + 3 次 AXObserverAddNotification,四次同步 IPC,而且发给的
正是上一行刚被要求隐藏自己、此刻最忙的那个 App——实测 75ms/窗口,18 个窗口
1363ms,占整次折叠的 32%,比「等 App 隐藏自己」的地板还大。
而它监听的三件事(⌘Tab 取消隐藏、点 Dock 恢复、窗口被关闭)没有一件能发生在 刚折叠完的那一瞬间。推迟到下一轮 runloop 注册之后,安装阶段从 2484ms(18 窗口) 降到 736ms(20 窗口),而且注册本身也降到 28ms/窗口——因为对方不再忙。
推迟这类注册时要重新校验:窗口是否仍处于折叠态、元素是否仍是同一个、是否已经 注册过。任一不满足就跳过,否则会留下没人移除的 runloop source。
8. 别为了打日志付 IPC。
applyRestoredGeometry 每次调用 4 次 AX IPC:2 次写 + 2 次回读,而回读
的唯一用途是拼日志里的 actual=(...),不参与任何判断。恢复阶梯每个窗口
跑 80/250/550/1100ms 四档,恢复 15 个窗口就是约 120 次纯为日志的往返。
现在只有首档、末档、以及写入报错时才回读。
9. 空闲时不要跑 CADisplayLink。
FoldRenderer 的 MTKView 是 isPaused = true,只有显式 render() 才绘制;
而设置窗口原本无条件挂一个 60fps 的 display link,窗口开着就一直每帧
render(),哪怕实时预览关着、画面一帧没变。改成只在实时预览真的在推帧时
才跑时钟,静态示意图在参数变化 / 切回页面 / 视图尺寸变化时各画一次
(MTKView 暂停后不会自己重画,尺寸变化要补一帧)。设置窗口空闲 CPU
从 2.0% 降到 0.6%。
同理,效果会话在等待首帧期间也在以 60fps 空转 render(),所以首帧预算
不能给太长:实测成功呈现的中位延迟 93ms,而原本的预算是 2s。批量折叠时
多个会话互抢还会形成「越慢越超时、越超时越慢」的正反馈。预算收到 0.5s,
并且批量操作期间直接不起这个效果(十几个卷帘动画同时播本来也看不出来)。
10. 预取不要打扰刚被点击的应用。
菜单里“置顶/取消置顶当前窗口”的标题靠一次后台预取:每次全局点击(去抖 0.25s)
和每次切换前台应用,都向前台应用要一次焦点窗口。可这个应用此刻正在处理那次点击,
/tmp/windowshade.log 里这类请求有 150 多次超过 50ms,最长约 2s(2026-09 实测);
而绝大多数点击都落在同一扇窗口里,结果根本不变。它还和置顶预览的交互接管共用
同一条串行 AX 队列,会把接管往后推。
现在自动触发的预取先查 WindowServer:前台应用最上层的普通窗口如果仍是上一次完整
解析出的那扇窗,就沿用结果,不发 AX 请求。显式置顶、打开菜单时的刷新照旧走完整
解析,所以动作本身不可能作用到旧窗口上。快速路径的实际效果还没有真机数字:
快速路径本身不打日志(它在阈值以内),下次看日志时比较 slow: pin-target 的条数。
11. 收起动画复用已经预取好的窗口枚举。
双击标题栏的第一下就会预取 SCShareableContent(ShareableContentCache,TTL 1.5s),
卷帘条截图也用它;但收起动画 WindowFoldEffects 一直自己再枚举一次(不含缓存)。
日志里唯一一次带动画的收起,从准备到盖板出现 328ms,其中会话创建与流启动按
第一节是 150–220ms,余下约 100–180ms 就是这次枚举;这一带日志里单次枚举 316–830ms。
收起路径现在用缓存(缓存里没有源窗口时它自己强制刷新)。展开路径不能这样做:
背景截图要排除收起时才创建的卷帘条,1.5s 内的旧快照里可能还没有它。
改动后的真机数字还没有,看日志里 duo-window: prepare fold 到 presented cover 的间隔。
12. 收起截图先用 CGWindowListCreateImage,ScreenCaptureKit 兜底。
卷帘条(“跟原来一样”外观)原来用 SCScreenshotManager 截整窗:单独测 82ms,
放在收起途中(焦点刚停靠、窗口在重绘成非活跃态)变成 229ms,超过 450ms 还会退回
代理标题栏。现在先在后台队列用 CGWindowListCreateImage(Capture/FastCapture.swift,
按符号动态取,单独测 48–57ms),拿不到或整张全透明才走 ScreenCaptureKit。收起动画的
背景同理改用 CGWindowListCreateImageFromArray:同样去掉源窗口、动画面板和卷帘条,
其余在屏窗口按原顺序合成,34ms;尺寸对不上才走 ScreenCaptureKit。
同一进程连续收起 5 次、取后 4 次的中位数(tests/run-glance-probe.sh --single --fold-timing,
设 WINDOWSHADE_DISABLE_FAST_CAPTURE=1 得到对照组),Mac17,4 · macOS 27.0 · 2026-09-24:
| 第一眼看到变化 | 卷帘条出现 | |
|---|---|---|
| 不带动画,只用 ScreenCaptureKit | 342ms | 499ms |
| 不带动画,快速截图 | 147ms | 328ms |
| 带动画,只用 ScreenCaptureKit | 591ms | 1015ms |
| 带动画,快速截图 | 478ms | 904ms |
带动画时原来剩下的大头是动画自己的实时流启动。现在收起动画也像展开一样先用快速截图起步
(renderer.setImage),实时流在后台就绪后由显示时钟换成实时帧,不再等它。同一进程连续
5 轮,“准备收起”到“盖板出现”:只用 ScreenCaptureKit 冷启动 659ms、其余四轮 199/562/265/277ms;
快速截图起步冷启动 409ms、其余四轮 211/209/157/189ms(中位数约 271 → 199ms,偶发的
500ms 以上长尾消失)。
13. 音效不要在主线程上播。
音频设备闲下来之后,NSSound.play() 会在调用线程上同步把设备拉起来:2026-10-01 在 Mac17,4 /
macOS 27.0 上实测第一次 496ms(同一进程随后立即再播是 0ms)。收起 / 展开的音效原来就在主线播
(AppDelegate.playShadeSound),日志里的主线程卡顿采样有三次停在
CoreAudio AudioDeviceStart ← AQMEIO_HAL ← AudioToolbox——每次折叠都可能冻半秒,而折叠动画自己
才 300ms 左右。现在 prototype/App/ShadeSoundPlayer.swift 把播放固定到一条串行队列上、缓存实例,
ShadeController.shade 与 FoldExit.unshadeReturningElement 在动作开始前用一次静音播放预热设备;
两处 NSSound.beep() 同样走这条队列。tests/run-shade-sound-tests.sh 钉住「调用方立即返回、
真正播放在后台队列、连续两次都不阻塞」。冷启动那一下仍要 ~500ms,但它花在后台队列上,并且会打
一行 perf: sound … play …ms on the sound queue (audio device cold start)。
14. 静止时别再按 12Hz 去问铰链角度。
盖角是一次同步 HID feature 读取:2026-10-01 实测 0.914ms(100 次平均,最大 1.71ms)。
空闲时按 12Hz 问,就是常驻约 1.1% 单核——消融实验里「效果开着 1.16–1.32% / 关掉 0.31%」的差额
基本就是它。加速度计是驱动推上来的(62 份/秒,回调几乎不花钱),所以拿它当「机器在动」的信号:
Core/MotionActivity.swift 的 MotionActivityDetector(相邻读数差 ≥0.02g 算动,之后保持 0.6 秒)判断动静,
LidPollInterval 定采样率——合盖途中 60Hz、机器在动 12Hz(原来的空闲速率)、确实静止 4Hz。
AppleSPUAccelerometer 只在状态变化时发一次 onMotionChange;没有运动数据时(加速度计不可用、
倾斜效果关着、传感器停了)保持 12Hz,降频不靠猜。日志里 lid: poll 4.0Hz (still) 就是省下来的那部分,
tests/run-motion-activity-tests.sh 钉住判定与三档间隔。手感上:手一碰盖子,加速度计在 ~16ms 内
就把采样率拉回 12Hz,所以「开始合盖」的发现速度和以前一样。另有一条自愈兜底:
铰链自己看到角度变化 ≥0.3°(HingeMoveGate)也当作「在动」并保持 3 秒——覆盖「慢慢合盖、
底座几乎不动、加速度计看不出来」的情况,最多晚一个 4Hz 周期(250ms)就把采样率拉回来。
15. 锁屏时不要起传感器(尤其是“启动时就已经锁屏”)。
副作用是一条真的会踩到的路径:DuoController.suspend() 的规则本来就是「锁屏只留锁屏效果要的传感器」,
但那是靠锁屏那一次转换触发的;应用如果在已经锁屏的状态下启动,suspended 一直是 false,
于是 settingsChanged() 照常把铰链传感器起起来,一直 4Hz 问下去。2026-10-01 实测这种状态下
常驻 0.445%(31 个 1 秒样本,效果开着、锁屏但机器醒着),几乎全是铰链。
现在 settingsChanged() 直接看当前锁屏状态(不是只看 suspended):
锁屏且锁屏效果没开就不起传感器,解锁时 com.apple.screenIsUnlocked → resume() → settingsChanged()
照常把它们起回来。改完同一条件下实测 0.055%(峰值 0.20%),日志里也不再有 lid: poll——
省下约 0.39% 单核,而且省的是「人不在跟前」的那段时间。
16. 盖角设备其实会推送——订阅它,把轮询降成看门狗。
tools/lid-report-probe 回答了一个一直没验的问题:盖角设备(usage page 0x20 / usage 0x8A)
会主动推 input report。实测(2026-10-01,机器空闲、没人碰盖子):5 秒 50 条、每条 3 字节、
报告号 1(整度),解出 106.00°。(2026-10-01 复核更正:原文说「与 feature 读的 106.04° 一致」,
但这台机器的 feature 读走的是精细格式(报告号 7,0.01°),和推送不是同一格式;探针复跑时
60 次 feature 读一次也没按整度格式解出来。推送本身可靠,但合盖途中不能和 feature 读混用,见第 21 条。)
而且应用那种「设备已打开再读 feature」的组合下推送照样来(两条都验过)。
于是 LidAngleSource 改成:订阅推送作为主路径(约 10Hz,比原来静止的 4Hz 更跟手、回调几乎不花钱),
定时器退化成 1Hz 看门狗(那一拍不读 HID,只检查推送有没有断);推送断了(>3 秒)才退回
4Hz / 12Hz 的 feature 轮询,合盖途中仍然是 60Hz 轮询保证动画跟手。
间隔判定收进 Core/MotionActivity.swift 的 LidPollInterval.seconds(engaged:moving:pushFresh:),
由 tests/run-motion-activity-tests.sh 覆盖。静止时那一笔从 4Hz×0.914ms≈0.37% 降到
1Hz×0.914ms≈0.09%(合盖那几秒照旧 60Hz,短促、不影响常驻)。
真机日志里会写成 lid: poll 1.0Hz (push);这一行要解锁后才会出现(锁屏不起传感器,见上一条)。
改这一带时顺手抓到的一个真崩溃。 为了验证上面那条,新加的 tests/run-lid-source-tests.sh
直接驱动应用里那个 LidAngleSource(不是另写一份),结果一跑就 abort:
IOHIDManagerClose 之前没有 IOHIDManagerCancel(加速计那边是对的,铰链这边漏了),
IOKit 在 release 时过释放——"Invalid dispatch state" ← IOHIDManagerExtRelease ← IOHIDManagerClose。
订阅推送那条路一上来就要 Activate,正好把这个漏掉的配对暴露出来。补上 Cancel 后测试全绿。
这条测试同时钉住三件事:连得上传感器、3 秒收到 ~31 份读数(推送约 10Hz,1Hz 看门狗不读 HID)、
日志里出现 lid: poll 1.0Hz (push) 而不是退回 4Hz。没有盖角传感器的机器会自动 SKIP。
都是同一个错误:拿几何匹配当身份校验。
windowID(of:) 内部优先用位置 + 尺寸 + 标题去和 CGWindowList 比对
(publicWindowID),_AXUIElementGetWindow 只是它的兜底。于是:
- 在
refreshedWindowElement里写windowID(of: fallback) == id做快速路径, 看起来省掉一次枚举,实际上级联折叠时窗口陆续被隐藏、CG 列表一直在变, 几何匹配会失配——每个窗口先付一次失败匹配、再付一次完整枚举。 元素刷新从 39ms 涨到 612ms。 - 改成
_AXUIElementGetWindow(fallback, &id) == id也不行:传进来的id本身来自windowID(of:)(几何匹配的结果),两者对某些 App 并不一致, 比对系统性失配。元素刷新仍是 236ms。
正确做法是不做身份比对。调用方如果刚刚枚举出这个元素(专注模式在并发
发现阶段几毫秒前才拿到),它知道元素是新鲜的,只需确认还活着——一次
axPosition 属性读,0.1ms。元素真的死了,写入会失败,上层本来就会重新解析
后重写。元素刷新降到 1ms。
恢复路径上的 resolvedWindowElement 犯了同一个错(拿 state.sourceWindowID
比对),已一并改成存活探测。
--duo-ax-bench(只读)打印本文第一节那张表。必须用签名后的
WindowShade.app/Contents/MacOS/WindowShade 运行,未签名二进制没有辅助功能
权限,AX 调用会直接返回而不发 IPC,量不到真实延迟。
卡顿归因。 MainThreadStallSentinel 用主 RunLoop observer 测量两次活动之间
的间隔,超过 0.5s 报一次。注意两点:
- 它量的是两次 runloop 活动之间,不是两条日志之间。主线程卡在一个长调用 里时,app 照样能打日志——所以「日志连续」不等于「没卡」。
- 它在阻塞结束后才拿到控制权,光报时长无法定位。因此
MainThreadActivity记录主线程活动,报告时给出卡顿窗口内累计占用最久的那个标记及其次数与 占比。早期版本报「最后结束的那个」,结果把恢复 15 个窗口的 8 秒记到menu: 重建头上——几秒的连续忙碌通常由几十次短调用组成,末尾那次往往 只是恰好排在最后。占 0%/未标记本身也是信息:说明阻塞落在所有标记 之外(多半在异步回调里)。
foldPhase 分段报告。 shade() 内部各段累计,一次专注结束时一并报出
(单段只有几十毫秒,逐次打日志会淹掉日志)。定位未覆盖的时间用二分比
逐个加探针快:把整个安装闭包罩上,它与已知子项的差额直接指出剩余时间在
安装之内还是之外。
卡顿放在哪一段:采样器一次抓多张。 MainThreadSampler 在卡顿超过 250ms 时
暂停主线程抄栈,一次卡顿最多抓 4 张、每张至少隔 200ms,日志写成
main-thread stall sample 2/4 ≈740ms: …。以前一次只抓一张:一秒多的卡顿里,
音频设备启动那 300ms 被看见,同一段卡顿的后半截完全没记录(2026-10-01 就卡在这里)。
跟踪循环(菜单 / 拖拽)单独记 main-thread tracking,不冒充冻结。
bash tests/run-stall-sampler-tests.sh 在主线程真的阻塞 1.1 秒,验证会留下 ≥2 张带序号的采样。
门限日志的读法。 logIfSlow(label, threshold:) 只在超过阈值时记录,
所以某一行没出现就是结论——说明那一段已经快到阈值以内了。
启动台的无头基准。 bash tests/run-launchpad-perf.sh 跑真实视图的扫描 / 初始排列 /
每个 App 的图标与标签 / 首页、资料库、分类、文件夹、负一屏的布局,只计时不截图、不写用户排列,
锁屏也能跑(GUI 探针在锁屏下会以 posErr=-25205 失败)。2026-10-01 Mac17,4 / macOS 27.0:
目录扫描 248ms(196 个 App,启动时后台做)、196 个图标+标签 113ms、首页 setContent 7.2ms、
资料库布局 0.8ms(13 个分类)、打开分类 2.7ms、打开文件夹 1.4ms、负一屏 0.8ms——
布局这一层不是瓶颈,慢的是取数(扫描 / 图标)与系统调用(见 10、13 两条)。
一条命令做性能复检:bash tests/run-perf-check.sh(要解锁的图形会话)。 它先看锁屏状态
(锁着就直接退出,免得把 posErr=-25205 当成产品问题),停掉 /Applications 那份实例后跑收起计时,
再量 60 秒空闲 CPU 并和基线并排打印,最后从日志里挑出 lid: poll / perf: fold install /
main-thread stall sample / main-thread tracking 这几类行。收尾一定会把 /Applications 的应用拉回来。
两个可以直接从日志里读的基线(2026-10-01)。 启动:=== session start 到
status item visible=true,九次会话 27–189ms(多数 ~100ms)。锁屏空闲:同一构建锁屏 30 秒
top -p 平均 0.04%(峰值 0.1%,RSS 27MB)——锁屏时效果会话确实停了,不是白耗电。
实时活动的音乐来源只在用户显式开启(Notch.activities.musicEnabled)后才每 2 秒起一个
osascript;这台机器没开,日志里也没有这类进程,所以它不是本机现在的瓶颈。
量过、可以不再怀疑的(2026-10-01,Mac17,4 / macOS 27.0)。
- Dock 悬停观察者重建:日志里 19 分钟出现 9 次
dock-element-destroyed重建,看着可疑; 实测建订阅那几个调用(AXObserverCreate0.0ms、AXObserverAddNotification0.8/0.0ms)加AXUIElementCopyAttributeValue(kAXChildren)32.8ms(这一步本来就在workQueue上)—— 重建的主线程代价约 1–2ms,不是瓶颈。 - 收起音效的强冷启动确实存在(一次 496ms),但设备闲下来后通常能热 ≥60 秒才凉;
探针
--single --fold-timing里的shade()用默认选项,本来就播反馈音, 只是设备热着时不打perf: sound那一行——看不到那行不等于没走到播放路径。 - 盖角降频的实测对照(2026-10-01,锁屏但机器醒着,传感器在跑):应用 31 个样本的平均 CPU 0.368%(峰值 0.60%),正好等于 4Hz × 0.914ms/s;换成 12Hz 就是约 1.1%——和 「效果开着 1.16–1.32% / 关掉 0.31%」的消融差额对得上。0.73% 单核的节省是量出来的,不是估的。
盖角降频 A/B(2026-10-01,同一构建、同一台机器、锁屏但机器醒着,各 31 个 1 秒样本):
| 配置 | 平均 CPU | 差值 |
|---|---|---|
| 盒盖 / 倾斜效果关 | 0.087%(峰值 0.20%) | — |
| 盒盖 / 倾斜效果开(改动后,铰链 4Hz) | 0.445%(峰值 0.70%) | 0.36% ≈ 4Hz × 0.914ms ✓ |
| 同一台机器上改动前的对照(铰链 12Hz) | 1.16–1.32% 开 / 0.31% 关 | ~0.9% ≈ 12Hz × 0.914ms ✓ |
也就是说:效果开着时的常驻 CPU 从约 1.2% 降到约 0.45%。日志同步显示 lid: poll 4.0Hz (still)。
2026-10-01 复核更正(Claude 独立复测):上表把两种条件混在了一起。「1.16–1.32% 开 / 0.31% 关」 是当天早上解锁、有人用时量的;0.445% / 0.087% 以及后面的 0.055%、0.080% 都是锁屏时量的。 所以「约 1.2% → 约 0.08%」不是同条件对比。同一份最新构建在解锁空闲下复测(
top30 个 1 秒样本): 平均 0.97%(峰值 3.4%)。20 秒sample里忙的样本分散:加速度计队列最多(约 33,服务的是用户开着的 「倾斜」效果,改动前就在跑)、主线程约 20、铰链队列约 13。也就是说,锁屏/待机那条线的改进是真的, 解锁时只比早上略低;剩下那 ~1% 没有单一大头。
tests/duo-integration-check.py 有两条源码顺序断言,守卫崩溃一致性:
recordShadeRecoveryIntent( 必须出现在 let hide = hideWindow( 之前
let hideVerifiedNow = hideTookEffect( 必须出现在 didVerifyFold( 之前
给这两处加计时时不要用闭包包装——实际执行顺序不变,但守卫匹配的文本会 消失,测试会红。用手工计时(记起始时间、直接累加)保留那段文本原样。
17. AirPods 那段不要每 2 秒枚举一次设备。
实时活动的对账每 2 秒跑一次,其中 kAudioHardwarePropertyDevices 一次枚举实测 1.74ms
(100 次平均,最大 43ms;同一次里的进程列表查询是 0.00ms)。现在设备列表与默认输出挂了
CoreAudio 的属性监听,只有真的变了(或 30 秒兜底)才重新枚举。这条要如实说:锁屏空闲 CPU
量到的是 0.100% → 0.123%,在噪声里——那 0.1% 主要是各种定时器唤醒(诊断采样器每 50ms 醒一次等),
不是这次枚举。 保留这条改动的理由是它减少同步 CoreAudio 调用(解锁态每 2 秒一次),不是量到了 CPU 收益。
18. 诊断采样器不用每 50ms 醒一次。
锁屏空闲那 ~0.1% 在把铰链、AirPods 这些真活儿都拿掉之后仍然在,来源是定时器唤醒:
卡顿采样器的看门狗原本 usleep(50_000)(20 次/秒)。卡顿阈值是 250ms,所以改成 200ms
(5 次/秒)照样抓得到——tests/run-stall-sampler-tests.sh 重跑仍是一次 1.1 秒阻塞抓到 4 张,
第一张在 ≈458ms(250–450ms 区间内),报的仍是真实卡了多久。实测锁屏空闲 0.100–0.123% → 0.080%(41 个样本)。
19. 折叠时那几个 AX 读不要留在主线程上。
折叠的截图分析本来就在 pixelAnalysisQueue 上,但紧接着要用的两组 AX 读——红绿灯三个按钮的命中区
(trafficLightRects)和「这扇窗能不能全屏 / 缩放」(realWindowManagementCapability)——留在主线程上问
目标 App;对方忙的时候一次约 19ms(第一节),七八次就是上百毫秒,而这正卡着安装与菜单重建。
现在它们和像素分析放在同一条后台任务里算(读的是同一扇窗、同一时刻,值不变),
主线程仍然要等结果,但等待期间 runloop 是活的——冻住的是这段等待,不是整个 App。
验收点在解锁后一次真实折叠:perf: fold install … phases: 里那两段(都是主线程上的活)应该看不到了,
而 main-thread stall sample n/4 里的长度应该明显变短。整树 --check 与全量 runner 已通过。
20. 「Swish 在不在跑」不要每次手势都问一遍 LaunchServices。
TrackpadGestureController.conflictingApp() 在每次手势开始、每次甩动时都会被问;它逐个读运行中 App 的
localizedName,那是去 LaunchServices 的同步请求。实测(2026-10-01,180 个运行中的 App):一次
中位 22.7ms(20 次,最大 24.2ms);解锁空闲时对主线程采样,正好抓到一次手势开始在这里等了约 140ms。
结果只在 App 启动 / 退出时会变,所以改成缓存:didLaunchApplication / didTerminateApplication 置脏,
30 秒兜底、缓存的 App 已退出也重算。判断逻辑本身一字没改。
21. 合盖途中不要把整度推送混进精细读数。
订阅推送(第 16 条)之后,合盖途中 60Hz 的 feature 读(精细,0.01°)和约 10Hz 的推送(整度)
会交替送进动画,差出最多 1°(约 2% 进度)来回跳。tests/run-lid-source-tests.sh 在真机上量到:
不修时合盖 1 秒里混进 10 份整度读数,修后 0 份。现在合盖途中只认和 feature 读同格式的推送;
不在合盖时推送照常驱动(那时角度在 95° 以上,进度恒为 0,整度足够)。
同一处还补了两件:找到设备之后才挂推送回调并激活(没找到设备的机器走回原来只 Open/Close 的路,
不会去 Cancel 一个没激活过的管理器);stop() 强引用到 close() 跑完、deinit 也先 Cancel 再 Close,
避免推送回调拿着不持有的指针和释放赛跑。
22. 精细格式的机器上,整度推送只当「角度变了」的信号。
第 21 条只管了合盖途中。真机解锁实测(2026-10-01 13:52)又暴露了另一半:推送是取整的整度(2026-10-01 录序列时对照:feature 103.67° 时推送 104),
盖子停在「触发角 + 8°」(默认 103°)那条线附近时,推送说 102(判「开始合盖」→ 起效果)、feature 读说
103.1(判「盖子开着」→ 停效果),桌面效果 3 秒里每 0.3–0.7 秒起停一次,屏幕上就是一层反复出现的模糊倾斜。
现在精细格式的机器上,推送角度变了才立刻做一次精细读并交出去,交给下游的永远是同一种刻度;
盖子不动时只剩 1Hz 看门狗那一读(约 0.09% 单核)。同格式的机器(推送本身就是整度、feature 读也是整度)照旧直接用推送。
修后同一操作再测:开合几次,效果每次只起停一次,Aaron 目测「不抖了,也没闪」。
另:同一台机器上 LidReport.detect 有时判精细、有时判整度(第一次精细读失败就退回整度),这是老行为,两种情况现在都一致。
23. 定时器间隔没变就别重排。
LidAngleSource.applyIntervalIfChanged() 原来每次都 schedule(deadline: .now()),而盖子在动时
(HingeMoveGate 判「在动」的 3 秒内)每份读数都会调到它——定时器一次次被改成「立刻触发」,
合盖途中本该 60Hz 的轮询变成能读多快读多快:tests/run-lid-source-tests.sh 真机量到 1 秒 760 份,
每份还都排一次主线程。现在间隔真的变了才重排,同一测试是 70 份(60Hz 轮询 + 约 10Hz 推送),并加了上限断言。
观察(未改):内建屏熄灭那一下的主线程等待。 合盖到内建屏熄灭时,三次都有一次 0.3–0.6 秒的主线程卡顿,
每次卡在不同的 WindowServer 调用上:MTKView.currentDrawable(还在往正在熄灭的屏画最后一帧,347/308ms)、
orderOut 里的 _updateWMWindowPresentationMode(444ms)、屏幕参数变化回调里的 isOnActiveSpace(482ms)。
这是 WindowServer 重排显示器期间任何同步调用都会等,不是单点 bug;那一刻内建屏已黑,外接屏上 Aaron 没注意到卡,暂不处理。
测量(Aaron 日常用的 build 16,他正在用电脑,top -l 31 -s 1 -stats cpu,idlew,power):CPU 平均 2.16%,
空闲唤醒约 4549 次/秒,物理占用 140 MB。5 秒 sample:主线程几乎都在等事件;com.windowshade.activities.sources
队列占 86 毫秒(约一个核的 1.7%),全在 NotchActivitySources.poll() 里读 CoreAudio。
没人碰电脑时(空闲 36 秒起量 30 秒)一样:CPU 3.31%、空闲唤醒约 4551 次/秒,所以唤醒不是输入带来的。10 秒 sample
里这个队列忙了 1605 毫秒(约一个核的 16%),几乎全在列音频进程、逐个读 bundle ID(HALC_Object_GetPropertyData_DCFString,
每次一个到 coreaudiod 的 mach 消息)。单独量:这台 Mac 上有 33 个音频进程,列一遍约 27 毫秒(机器负载高时)。
24. 语音备忘录没开着,就不列系统里的音频进程。 认“正在录音”要列出 kAudioHardwarePropertyProcessObjectList 再逐个读
bundle ID(每个都是一次到 coreaudiod 的 IPC),原来每 2 秒做一次。现在先看语音备忘录在不在运行,不在就整段跳过;
它在运行时照旧,行为不变。
25. 音乐来源改成事件驱动。 打开“音乐”后,原来每 2 秒启动一次 osascript 子进程问播放器,并用每 25 毫秒睡一下的循环等它。
现在只在播放器发分布式通知(com.apple.Music.playerInfo、com.spotify.client.PlaybackStateChanged)、播放器开关、点了控制、
或 30 秒兜底时才问一次;其余时候进度条按上次读到的位置加经过的时间推算。等子进程改用结束回调加信号量。
下一步(量过再改):输入事件的扇出。 现在一次指针移动或滚动会分别叫醒多处监听:Dock 悬停的回退检测(全局 mouseMoved, Dock 悬停打开时常驻)、标题栏手势(全局 scrollWheel、左键拖动)、捏合(手势事件 tap 线程)、卷轴边上那一条(有卷轴时)、 Dock 留在一块屏(打开时)。方向是合成一个只听不拦的输入线程,在那里先按位置和状态过滤,只有真正相关的事件才回到主线程。 先量“没人碰”和“正常使用”两种情况下的唤醒次数,再决定改哪几处;改的时候守住已发布的手势手感(帕累托)。
测量脚本:tools/perf/idle-wakeups.sh <pid> [label] [--out FILE] [--build NUM] [--powermetrics]。每次运行等 36 秒后采 30 秒,追加一行 JSON(label、时间、build、cpu、idlew)。--powermetrics 只打印需要 Aaron 手动 sudo 执行的 powermetrics 与 spindump 命令,脚本本身不提权。
AppleSPUAccelerometer 的调用方(S05 的结论): 截至 build 对应的这个提交,prototype/ 里没有任何代码实例化 AppleSPUAccelerometer,只有类型自身与 EffectEnvironment.swift 里的一处注释提到它。它当前没有调用方,因此空闲唤醒里不会来自这个传感器。去留由 F09 之后的决定定,未决前不删类型。
场景 × build 记录表:
| 场景 | build | CPU(%) | IDLEW(次/秒) | wakeups/s | 备注 |
|---|---|---|---|---|---|
| 锁屏 30 秒 | |||||
| 解锁空闲 60 秒 | |||||
| 两扇窗口并排 | |||||
| 隐私页开 | |||||
| 隐私页关 | |||||
| 置顶 3 扇窗口 | |||||
| 开着 Codex 会话 |
逐项关功能的消融表(每次只关一项,其余保持默认,同一台机器、同一场景):
| 关掉的功能 | build | CPU(%) | IDLEW(次/秒) | wakeups/s | 相对基线的变化 |
|---|---|---|---|---|---|
| 无(基线) | |||||
| 活动来源(音频、音乐轮询) | |||||
| Dock 悬停回退检测 | |||||
| 标题栏手势监听 | |||||
| 捏合手势 tap | |||||
| 卷轴边缘条 | |||||
| Dock 留在一块屏 |