在 WKWebView 应用里,让键盘焦点跟着标签页和分屏走
Overlay 是在模仿浏览器。侧栏看着像标签页,一个标签页能拆成几格,每一格都是一个 WKWebView,你在里面切来切去的时候,我在底下把它们摘下、挂上。真正的浏览器还会告诉每个网页两件事:用户在不在看它,键盘在不在它这儿。靠的是 visibilitychange、window 上的 focus / blur 和 document.hasFocus()。网页对这几样的依赖比想象中多:视频该不该暂停,聊天工具发不发通知,页面上的快捷键响不响。
我一直没认真查过 Overlay 给网页的到底是什么。有天晚上坐下来查,结论是可见性基本对,焦点一团糟。
一个什么都往回报的页面
读 AppKit 代码只能知道我想让它发生什么。要看实际发生了什么,我写了个探针页:监听 focus、blur、visibilitychange、pagehide、pageshow、keydown,每来一个就立刻 POST 给本机的一个 Python 服务。另外每秒发一次心跳,带上 document.visibilityState、document.hasFocus()、这一秒跑了几次 requestAnimationFrame、一个 100 ms 的 setInterval 实际跑了几次。
然后在 Overlay 里开三份:标签页 C,再加一个拆成 A、B 两格的分屏标签页。用脚本发合成的点击和按键,每一步之前往日志里打一行标记,每一步之后按一下 x,看这个按键最后落到了哪一页。一共十八步:分别点两格、从侧栏和格子右下角的徽标切格、切标签页、⌘L 再 Esc、切到访达再切回来、隐藏和唤出面板、拿别的窗口把面板整个盖住。
同一套步骤我又在一个裸环境里跑了两遍:一个最小的非激活 NSPanel,放两个 web view,别的什么都没有;一遍带真实面板上那个 isKeyWindow 覆写,一遍不带。后来证明这组对照很关键。
可见性没问题
这部分和 Safari 一致,我没动。只有真正显示在屏幕上的格是 visible,分屏里的几格同时都是 visible。后台标签页是 hidden,requestAnimationFrame 完全停掉,100 ms 的定时器降到每秒两次左右。用全局快捷键隐藏面板,大约 100 ms 内页面变成 hidden。拿别的窗口把面板整个盖住也会变,只是慢一点:几次实测在 0.15 到 0.9 秒之间,而且这里面含了启动那个遮挡用的小程序的时间,只能当个大概。pagehide / pageshow 只在真正导航时出现;freeze / resume 从来不会出现,WebKit 就没实现 Page Lifecycle API。
焦点有问题
出来三种坏法。
从侧栏切到一个标签页,或者按 ⌘⇧],或者在新标签页输入网址回车,之后按的 x 哪页都收不到,没有任何页面收到 keydown。想按空格翻页,得先在页面上点一下。
分屏里点 B 格的徽标,蓝色高亮框到了 B,按键却还是进了 A。
还有一种最怪。从分屏标签页切走再切回来,之前有焦点的那一格会发一次 focus,hasFocus() 返回 true,可键盘根本不在它上面。这时候再点另一格,两个页面同时报 hasFocus() === true。
两个原因
前两种其实是同一个 bug。WKWebView 只有被点击时才会成为 first responder,别的时候都不会。活动标签页一变,我的内容区会重建或者重新高亮各格,但从来没碰过 first responder。高亮跟着数据走了,键盘还停在 AppKit 上一次放它的地方,重建之后通常就是窗口本身。
第三种是靠那组对照才看明白的。一个正持有焦点的视图被 removeFromSuperview 时,AppKit 不会对它调 resignFirstResponder。WebKit 不知道自己丢了焦点,一直记着"我有焦点",视图重新挂进窗口时就再宣布一遍。AppKit 那边并不这么认为,所以之后点别处,也不会把焦点从它身上拿走。裸环境里带不带 isKeyWindow 覆写都能复现,跟那个 hack 没关系;任何一个在持有焦点时被摘下的 WKWebView 都会这样。
怎么修的
三处,都不大。
内容区摘任何视图之前,如果 first responder 在内容区里,先 makeFirstResponder(nil),让 web view 按正常流程交出焦点。重建完,如果原来那个视图还在当前格里,把焦点还给它。第二步比我预想的重要:中键在后台打开一个链接,也会让整棵树重建一次,不还回去的话,你正在填的表单就断了。
当前格一变,焦点跟过去。页面上有 JavaScript 对话框时对话框优先,Esc 和回车得先交给它。"该不该移交"是核心包里的一个纯函数,一张很小的真值表:焦点已经在当前格里,不动;在内容区外的输入框里,比如地址栏,不动,因为网页一个 window.open 就能在你输网址的时候切走标签页;压着模态卡片,不动;其他情况移交。不是 web view 的页面(比如新标签页)自己管焦点,只有在焦点卡在别的格里时才会被清掉。
地址栏单独要一个回调。回车和 Esc 都是用 makeFirstResponder(nil) 结束编辑的,回车那条还是异步的,要先把 field editor 卸掉,下一轮 run loop 才执行。我第一版在那句之前就把焦点交回网页,结果马上又被清掉。现在回调放在它后面。
这些都不调 makeKey,也不激活 App。Overlay 的前提就是唤出面板不能抢走你正在用的那个 App 的键盘。在面板内部挪 first responder 不改变这一点:面板不是 key window 的时候,什么都不会被拿走。
修完用新包把那十八步原样跑了一遍。每一步都只有一个页面 hasFocus() === true,每一个 x 都落在高亮的那一格。
留着没修的那一个
面板只要可见,isKeyWindow 就返回 true。这么做是因为窗口不是 key 时,WebKit 会把 mousemove 整个丢掉,而 Overlay 的面板大多数时候都不是 key,:hover 就全死了。之前量过:不覆写时网页收到 0 个 mousemove,覆写后每秒 60 个左右。
代价是面板在屏幕上的时候,网页永远不知道你去了别的 App:没有 blur,hasFocus() 一直是 true。大多数网站无所谓。但有些聊天工具靠窗口焦点决定要不要通知你、要不要把消息标成已读,这就可能意味着你在编辑器里干活、Overlay 开在旁边时漏掉一条通知。我还没有一个站一个站地测过。
裸环境里我找到了一个可行的折中:只在光标位于面板内时报 key;状态翻转时,以面板自己为 object 发一次 didResignKey / didBecomeKey 通知,WebKit 会重新读取,补发 blur 或 focus。换成一个无关的窗口发同样的通知,没有任何效果。这条路依赖 WebKit 没写进文档的行为,Overlay 里还有别的模块在听面板的 resign-key 通知,所以等真有人反馈漏了通知再做。
顺手加的快捷键,和它暴露的一个存储问题
既然焦点会跟着当前格走了,就值得给切格加个键:⌘⌥] 和 ⌘⌥[,跟切标签页的 ⌘⇧] / ⌘⇧[ 排在一起。加的时候踩到一个小坑。以前在设置里清掉一个快捷键,是把它的 key 从配置文件里删掉,所以"没有这个 key"就等于"用户清掉了"。老用户的配置文件里当然没有新动作的 key,新快捷键一升级就是被清掉的状态。现在清掉改成写空串,上线之后才加的那几个动作缺 key 时回落到默认键。最早那几条仍然把缺 key 当成清掉,当年因为和 1Password 冲突删掉 ⌘\ 的人,升级之后不会发现它又回来了。
这并没有让 Overlay 变成什么
底下还是 WebKit,规则还是 Safari 那一套。后台标签页的节流和 Safari 一样,也没有 Page Lifecycle API。如果某个页面依赖 Chrome 的 freeze / resume 语义,Chromium 系的浏览器更适合它。这次的改动只是让 Overlay 自己的标签页和分屏,行为上像浏览器那样,并不会让它变成一个浏览器。