给 WKWebView 应用做地址栏自动完成:拼音、常用度,还有一份自己的撤销栈
Overlay 是一个装着网页应用的悬浮面板,每个标签底下是一个 WKWebView。WKWebView 只负责显示网页,地址栏、历史、补全这些浏览器外壳一概没有,
得自己写。前一段时间 Overlay 的地址栏就是一个普通输入框,打什么就开什么。我自己最常遇到的情况是:几天前看过一个知乎问题或者飞书文档,
记得标题里的几个中文字,网址一个字母都想不起来。
所以这次的目标说得很具体:打中文名能找到,打拼音也能找到,常去的站排前面。下面按做的顺序记一下,包括后来审查和实机测试时翻出来的问题。
先把历史记对
动手才发现,原有的访问历史根本撑不起排序。
历史是给新标签页的"最近访问"卡片准备的:只留 200 条,按时间淘汰。更糟的是计数。页面网址变化时记一次访问,标题变化时又调了一次同一个函数, 于是一次导航算两次,单页应用每改一次标题再加一次。还有一处:启动时恢复上次的标签页,每个标签重新加载一次,也各算一次访问。 我的历史文件里有几个 GitHub Actions 的运行页面,访问次数高得不合常理。
改法很直接。标题回调只更新标题,不加次数。恢复会话的那次重新加载不算。上限提到 3000 条,满了先丢常用度最低的,不再丢最老的。 另外单独记一个"手动打开次数":从地址栏或新标签页打开的页面,比点链接进去的更能说明"我想回到这里"。
加字段的时候差点踩坑。Visit 原来是编译器合成的 Codable,直接加一个非可选字段,老用户的 history.json 就会整份解码失败,
被挪成 .corrupt,历史清零。最后写成容错解码:新字段缺了给默认值,单条坏掉只丢那一条。设置文件早就是这么做的,历史这份当初漏了。
旧数据里已经虚高的次数我没有去修。排序时次数是取对数的,翻一倍只差一点,再加上时间衰减,慢慢会被冲淡。
匹配:先把字变成能比的样子
输入和候选走同一套规范化:NFKC(全角变半角)、小写、繁体转简体。这样 git 能找到 github,飛書 能找到"飞书云文档"。
繁转简用的是 Foundation 的 applyingTransform,ICU 的 Hant-Hans。有个细节:高亮要知道命中的字在原文第几个位置,
所以规范化之后每个字符都得记得自己从哪来。我一开始整串做规范化,只要总字数没变就认为位置对得上。审查时有人构造出一个反例:
一处变长、一处变短,正好抵消,高亮就落到了别的字上。后来改成逐字规范化(ASCII 走快路),繁转简仍然整串做,只在 Unicode scalar 数量不变时采用。
拼音用的是系统自带的 mandarinToLatin 再去掉声调,没有引第三方库。转换不便宜:3000 条中文标题冷建索引,Release 构建下大约 0.4 秒。
所以索引在后台线程建,启动两秒后先预热一次,之后历史变了只增量重建,网址和标题都没变的条目直接复用,大约 20 毫秒。
每敲一个键的查询在 3 到 5 毫秒之间。
拼音还有几个小问题。多音字取系统给的读音,"银行"会变成 yin xing,我没处理。一段汉字里只要有一个字转不出拼音(比如生僻字),
早期整段都作废,现在在那个字那里断开,前后各自保留。ü 去声调后会变成 u,我改成了 v,打 nv 能找到"女",和拼音输入法的习惯一致。
打分
输入按空格切成几个词,每个词都要命中某处才算匹配,命中方式各有分数:域名开头 100,标题开头 80,中文标题包含 60, 拼音从标题开头匹配 50,首字母 45,往下还有路径、标题中间的拼音等几档。几个词的分数相加,再乘一个常用度:
常用度 = 时间权重 × (1 + ln(1+访问) + 1.5·ln(1+手动打开) + 快捷入口 1.5 + 已打开 0.5)
时间权重从一天内的 1.0 降到三个月以上的 0.15。历史淘汰也用同一个分数,两边各算各的话,会出现"补全排第一的页面恰好被淘汰"这种事。
单个英文字母只认开头类的命中,不然打一个 g 会把所有域名里带 g 的站都拉进来。单个汉字不受这个限制,一个字在中文里就有意义。
还有一条是审查时补的:域名的最后一段不算命中。之前打 com、ai,甚至单个 c,常去的 .com、.ai 站点会被当成"子域名开头"顶到第一行,
回车直接进去。现在最后一段和 com.cn、co.uk 这类常见二级后缀都排除在外。我没有打包整份 Public Suffix List,内置了一张小表,
漏掉冷门后缀的代价只是那一段被当成普通子域名。
第一行是回车会做的事
这里有个取舍。Chrome 的做法偏保守,除了行内补全,回车基本就是搜索;Safari 有"首选结果",强匹配的历史页面可以直接顶到第一行。 我选了后者,因为我最在意的场景就是打"知乎"回车进知乎,而不是去 Google 搜"知乎"。
代价是门槛必须严:每个词都要强命中,而且这个页面去过三次以上或手动打开过两次以上。达不到就退回搜索。 回车把人带到他没想去的地方,比多按一次 ↓ 糟糕得多。后面实测时这条规则又被补了两次,下面会讲。
行内补全(打 git 框里补出 github.com)只对 ASCII 输入做,候选至少去过两次。补到哪也有讲究:
首页去过就补到首页;没去过的话,站内只有一个常去页面就补那一页(fei 补成 feishu.cn/docs);有好几个就只补域名。
最早我写的是"补到最常去的那一页",在自己机器上一试,git 补出来一条几十个字符的 CI 运行页网址。
下拉不能是 SwiftUI 的 overlay
Overlay 的顶栏是一个 NSHostingView,下面的网页区是另一个视图,两棵树。SwiftUI 的 overlay 画不出顶栏的边界,下拉会被裁掉。
所以下拉是一个挂在面板上的子窗口:不激活的 NSPanel,becomesKeyOnlyIfNeeded,addChildWindow(_:ordered: .above)。
之前做密码填充下拉时已经走过一遍,这次照着做。
焦点必须一直留在地址栏里,键盘到不了下拉。↑↓、⇥、⏎、⎋ 都在 NSTextField 的 control(_:textView:doCommandBy:) 里截下来,
转给控制器。输入法组字期间(框里有带下划线的拼音)什么都不做:不报编辑、不转按键、不往框里写补全。
组字期间改框里的文字会打断输入法,这条在笔记编辑器那边已经吃过亏。
还有一个小坑:每敲一个字列表都重排,静止的鼠标下面换上来一行,会收到一次 mouseEntered,高亮被它抢走,回车打开的就不是你选的那一行。
现在只有鼠标真的移动过,hover 才算数。
审查和实机测试翻出来的
功能做完后我让三个审查并行过了一遍代码,又在自己的真实历史上实机跑了几轮。没有崩溃,但"回车去错地方"的问题有好几个。
最严重的一个:回车时我把高亮那一行的网址交给导航,而原来的导航入口第一步是看输入是不是扩展的地址栏关键字(Chrome 扩展的 omnibox,
"关键字 + 空格 + 内容")。现在列表总有一行"搜索",回车总被列表截走,传下去的是一个 Google 搜索网址,关键字永远匹配不上。
改成搜索行和"打开网址"行回车时交用户打的原文。
第二个:搜索也被记成了手动打开。搜过一次 "swift concurrency",这个结果页就够格当首选,以后打 swift 回车打开的是旧搜索。
现在搜索不记手动打开,搜索结果页也永远不当首选、不参与补全。
第三个是我自己没想到的:粘贴。粘贴 github.com/apple 回车,会被补成更常去的 github.com/apple/swift。
现在只有"在末尾多打了一个字"才补全,粘贴、剪切、在中间插字都不补。判断方式是拿这次的内容和上次报上来的比,不靠按键名——
⌘X、粘贴、拖入、撤销都绕得过按键名。补全关掉以后,首选结果还是会把更长的那个网址顶上来,实机又补了一条:输入本身像网址时,首选只认正好是它的那条。
然后是删除。我一开始用 ⇧⌫ 从历史里删掉高亮那一条,审查指出按住 Shift 打大写时顺手退格,就会把第一行的历史永久删掉。
改成 Chrome 那样的 ⇧⌦(笔记本上是 fn+⇧+⌫)。单测都过了,实机一按,什么都没发生。
原来系统默认的按键绑定里没有"Shift + 向前删除"这一项,它在文本系统里被当成插入一个功能键字符,随即丢掉,永远不会变成命令,
doCommandBy 根本等不到。最后在输入框编辑期间挂了一个本窗口的按键监听,在按键进文本系统之前截下来。
⌘Z 也是实机才发现的。补全是在敲键的同一个事件里写进去的,系统的撤销把"打的字 + 补全"合成了一步,于是每按一次 ⌘Z,
框里就退回到之前出现过的某个补全:exa 之后是 ex 加 ample.com,再之后是 e 加一个完全不相干的域名。
控制器记的内容也和框里对不上了。现在这个输入框编辑期间关掉 field editor 自带的撤销,自己记"用户每次实际打的内容",⌘Z 退回上一份。
什么时候算一次访问
记账原先挂在 webView.url 的 KVO 上。这个属性在临时导航阶段就会变,所以下载链接、跳转经过的中间网址都被记成了访问。
我写了一个裸的 WKWebView 小程序,把各种导航的回调顺序打出来看。新导航、服务器跳转、下载、DNS 失败,url 变化的那一刻 isLoading 都已经是 true;
跳转的中间网址从来不会提交(didCommit);锚点和 pushState 改网址时 isLoading 是 false;下载被取消或加载失败后,url 会退回原来的页面,而且这时也不在加载。
于是访问改在主框架提交时记;url 变化时如果不在加载,说明是网页内部换了网址,当场记。每个标签再记住上一次记账的是哪个页面,
同一个页面再出现(锚点、刷新、www. 互跳、下载和失败后的回退)只更新标题,不加次数。新文档以空标题落账,标题只写给当前显示着的那个文档——
之前拿的是标签上一页的标题,在 B 站页面上打开纽约时报,那条记录的标题就会是"Bilibili"。
同一个站刷屏
用了几天,我发现打 git 会列出六七条几乎一样的 GitHub 网址。Chrome 很少这样,据我读 Chromium 代码的印象,它给历史结果的名额很少,
大部分位置留给了搜索联想,而且只索引手动打开过、去过多次或最近几天去过的网址。Overlay 不做搜索联想(那要把你打的每个字发给搜索引擎),
历史结果就成了列表的主体,问题也就更明显。
现在同一个站点最多 3 条,输入里带 / 时不限,那是在找具体页面。历史结果最多 7 条。只去过一次、没手动打开过、最后一次在三天以前的页面,
常用度打三折,而且只靠域名或路径命中时直接不出。靠标题或拼音命中的照出,打中文名找回只看过一次的页面,正是这个功能的初衷。
还没做的
搜索联想不做,理由上面说了。也没有完整的历史管理页,不能从 Safari 或 Chrome 导入历史,搜索引擎还是写死的 Google。 如果你多年的浏览记录都在 Chrome 里,Chrome 的地址栏手里的数据比 Overlay 多得多。 Overlay 的地址栏管的是你每天在悬浮面板里反复打开的那几个网页工具,让它们打两三个字就能回去。