做跨平台输入法最烦微信、Office 和飞书的输入框,全都不按规矩来
很多第一次做输入法或者系统工具的工程师都会觉得,文本替换还能有多难?不就是把用户刚敲进去的那串拼音删掉,再把模型转换好的文字塞回原来的位置吗?
直到我们真正把 UnimeType 装进微信、Office(Word / Excel)、WPS、飞书和 VS Code 这些日常高频软件里才发现,做跨平台输入法最折磨人的地方在于你根本不敢随便相信宿主软件报上来的选区(Selection)和文本长度(Length)。同一个包含 Emoji 和中文的句子,微信、Office/WPS、Excel 单元格、飞书文档、VS Code 和 Ghostty 终端报告回来的光标坐标与长度语义居然全都不一样:有的按 UTF-16 算,有的按字符算,有的在假装替换成功的同时悄悄把文字追加在末尾,还有的干脆在后台慢半拍吞掉你的退格键。
用户眼里看到的现象通常只有一句话:怎么点了 Convert 没反应?怎么变成「拼音 + 中文」拼在一起了?怎么微信能用、切到终端就不行了?这篇文章就把我们在 macOS 输入法、iOS 键盘扩展、Android IME 和 Windows TSF 四个平台上踩过的真坑摊开聊聊,讲清楚为什么输入法不能假设「一个 API 走天下」,以及我们是怎么用一套基于证据链的状态机把写回这件事做稳的。
输入法看不见整篇文档
理解所有兼容性怪象的第一步,是接受一个残酷的物理前提:第三方输入法并不拥有文档本身。无论你在哪个操作系统上打字,输入法都只是站在应用门外的一个访客,只能顺着系统规定的窄窄一条门缝向宿主应用提问:
| 平台 | 典型接口 | 理论上能问宿主拿到的信息 |
|---|---|---|
| macOS | IMKTextInput / NSTextInputClient | selectedRange、length、attributedSubstring、insertText(_:replacementRange:) |
| iOS | UITextDocumentProxy | selectedText、documentContextBefore/AfterInput、adjustTextPosition、删除与插入 |
| Android | InputConnection | getSelectedText、getTextBeforeCursor、deleteSurroundingText、commitText、getExtractedText |
| Windows | TSF ITfContext / ITfRange | GetSelection、GetText、ShiftStart/End、SetText |
纸面上的系统文档看起来都很美好,每个平台似乎都整整齐齐地提供了「读取选区 + 读取上下文 + 原位替换」三件套。可一旦落地到真实的桌面与移动应用里,宿主的交付质量瞬间拉开几个数量级的差距。像 Xcode 和大部分 macOS 原生编辑器属于老实人,完整文档的坐标和替换都按规矩来;iOS 键盘和不少 Android 聊天框只肯给你看光标附近的一小段截断窗口;微信和一部分 Electron 应用明明让你读到了选区,等你调用按范围替换时却直接无视范围参数;部分终端模拟器干脆不管你敲了多少字,length 永远雷打不动地返回 0;而 VS Code(Monaco)在某些状态下甚至会把光标前面的文本镜像复制一份塞到光标后面。
UnimeType 的工作方式又恰好把这些底层裂缝全都暴露在了聚光灯下。传统拼音输入法是边敲字母边在未提交的候选框(marked composition)里折腾;而 UnimeType 是让你痛痛快快把一整句中英混杂的草稿敲进输入框,等你按下显式动作键时,再去捕获范围、送交模型、最后在原位完成替换。只要中间任何一个环节对选区或长度的理解跟宿主差了半个字符,呈现在屏幕上的就是立刻可见的吞字或重影。
光靠按键缓冲不够用
每次聊到这里,都会有工程师朋友提出一个极其直觉的问题:「既然宿主软件报回来的选区和长度这么不靠谱,输入法自己本来就拦截了用户的每一次按键,为什么不干脆在内存里自己维护一个 typing buffer(按键影子缓冲区)当成 Ground Truth 呢?」
这个思路非常自然。对于像 Ghostty 这样完全不向输入法暴露全文长度的终端模拟器,如果你从头到尾顺着敲完一行字、中间没有乱动光标,本地输入会话缓冲确实是唯一能救场的线索。但一旦放到真实的日常打字场景里,输入法自己的 typing buffer 绝对不能无条件当成唯一的 Ground Truth,原因出在三个物理断层上:
第一,大量改变文本和光标的动作,根本不会经过输入法的按键流。你写到一半顺手用鼠标点了一下句子中间改个词、按了 Cmd + V 粘贴一段链接、按了 Cmd + Z 撤销、或者用触控板拖选删掉半句话,在绝大多数操作系统里,鼠标点击、快捷键命令和剪贴板粘贴是由宿主应用自己直接消化的,根本不会逐字通知输入法「刚才用户在第 7 个字符后面粘了 12 个字」。如果你死信自己的 typing buffer,内存里记着的还是五秒钟前按顺序敲的那串字母,而屏幕上的真实光标早就搬家了。
第二,宿主应用自己会背着输入法「偷偷加戏」。你在 VS Code 或 Notion 里敲一个左括号 ( 或反引号 `,编辑器会自动在光标右边补上右括号 );你在飞书或 Slack 里敲 @ 或 / 触发了自动补全弹窗,按一下回车,宿主直接把你敲的三个字母替换成了一整块富文本人名标签;你在终端里敲了半截路径按了一下 Tab,Shell 瞬间帮你补全了后面二十个字符。这些由宿主自己生成的字符从来没走过输入法的键盘输入缓冲,本地 typing buffer 跟屏幕上的真实文本瞬间就会错位。
第三,就算你的 typing buffer 100% 记对了用户刚才敲的每一个字,你最后依然得把旧文字从宿主里删掉、把转换结果写回去。「知道原文是什么」只解决了一半问题;另一半问题是「怎么在宿主里精准抹掉这串原文」。一旦微信忽略你的 replacementRange,或者退格键在宿主队列里异步迟到,光靠本地缓冲区里的字符串长度盲目连发 N 个退格键,只要光标位置或宿主响应速度有一丁点偏差,就会把用户前面写好的正常段落给误删掉。所以,本地 typing buffer 只能作为受严格失效规则约束的辅助证据(一旦发生光标跳转、鼠标点击或上下文失配就必须立刻失效),而不能代替对宿主现场状态的核验。
那么,像 Grammarly、Raycast AI、TextExpander 或者各类桌面语音转写工具,它们在跨应用读写文本时又是怎么绕过这些坑的?拆开来看,业界主流桌面工具其实走了另外三条路,而且每条路都有各自的代价:
| 方案流派 | 代表工具 | 底层实现机制 | 为什么它也不是银弹 |
|---|---|---|---|
| 系统辅助功能树(Accessibility API) | Grammarly Desktop | 绕开输入法通道,改以无障碍服务身份通过 macOS AXUIElement / Windows UIAutomation 强读窗口控件树与坐标,直接改写 AXValue | Electron / Chromium 应用默认关闭无障碍树,强行开启(AXManualAccessibility)会导致 VS Code / Slack 严重掉帧卡顿;遇到 Canvas 自绘界面(如 Google Docs 画布或 GPU 终端)直接失明;且部分宿主 AXValue 只读,写回仍需降级。 |
| 剪贴板劫持(模拟按键 + Pasteboard) | Raycast AI、TextExpander、部分语音输入工具 | 后台模拟发送 Cmd + C(或连发 Alt + Shift + Left 选中后复制),把转换结果塞进系统剪贴板,再模拟 Cmd + V 粘贴,最后尝试恢复原剪贴板 | 严重污染用户的剪贴板历史工具(Paste、Maccy、Raycast 剪贴板记录里全是用了一半的废稿);且还原剪贴板的延迟极难拿捏,恢复早了 50ms,慢半拍的 Electron 宿主就会把旧剪贴板里的东西粘出来;在终端或 Vim 模式下模拟 Cmd + V 更是灾难。 |
| 浏览器 DOM 注入 | Grammarly 浏览器插件 | 直接往网页 <textarea> 和 contenteditable 节点注入脚本读写 DOM | 只能管浏览器标签页,管不了桌面原生与 Electron 客户端;且遇到复杂的富文本框架(ProseMirror、Slate、Monaco)常引发光标跳回行首或撤销栈损坏。 |
更关键的是,在 iOS 和 Android 移动端,系统沙盒从根子上封死了上面前两条路:第三方手机键盘既不可能去调系统的全局无障碍树,也不可能去后台模拟 Cmd + C / Cmd + V。这也是为什么 Grammarly 在桌面端做成了无障碍悬浮窗,到了手机端一旦离开标准输入框也同样束手无策;甚至连早年做跨应用纠错的桌面工具,内部都不得不针对微信、Slack、Notion、VS Code 维护一整套庞大的「按应用分流策略(per-app strategy)」。
理解了全行业的这盘棋,就能明白为什么 UnimeType 作为一个横跨桌面与移动端的原生输入法,既没有走粗暴污染剪贴板的野路子,也没有盲目迷信本地 typing buffer。我们必须在标准输入法协议(IMKTextInput / UITextDocumentProxy / InputConnection / TSF)的基础上,把宿主选区、上下文快照与受控会话状态结合成一条可验证的证据链。
四个系统的底层脾气
在写具体应用的兼容逻辑之前,我们得挨个对付四个操作系统各自完全不同的文档世界观。
macOS 的输入法接口在理论上最接近一个真正的文本编辑器。它的选区是标准的 NSRange(按 UTF-16 码元长度计算,与 NSString.length 一致),支持按任意范围读取子串,也支持通过 insertText(_:replacementRange:) 直接替换文档里的指定区间。但在真实世界里,不少 macOS 宿主根本不按这套规矩出牌:明明屏幕上有闪烁的光标,selectedRange 却报 NSNotFound;明明还能按范围读出文字,length() 却返回 NSNotFound 或 0;最让人头疼的是第三种,宿主欢快地收下了你的 insertText 调用,却默默扔掉了里面的 replacementRange 参数,把转换后的中文直接追加在原拼音屁股后面。如果你贸然用一次试探性的范围替换去测试宿主听不听话,在微信这类聊天框里就会当场造出「原文 + 译文」的拼接车祸。所以在 macOS 上我们立了一条铁律:没有证据就绝不盲写,对范围替换不可靠的宿主,绝不做有损探测,并且只在确认原文状态一致的前提下完成写回。
到了 iOS 上,自定义键盘扩展从根子上就拿不到完整文档的 NSRange。系统只肯通过 UITextDocumentProxy 给你当前选中的 selectedText,以及光标前后的局部上下文 documentContextBeforeInput 和 AfterInput,而且这个上下文窗口是有长度上限的,长一点的段落会被系统直接截断。这也意味着在 iOS 上,任何输入法都不可能做出跟 macOS 原生编辑器一模一样的「整篇长文档无选区转换」。因此我们在 iOS 上坚持「显式选区最高优先、无选区时严格限定在当前局部输入范围」的边界策略,既不会在上下文被系统截断时误删前半段话,也不会在用户切走光标后乱删无辜的文字。
Android 的 InputConnection 乍看之下比 iOS 慷慨得多,既能调 getTextBeforeCursor(n) 和 getSelectedText,又能调 deleteSurroundingText 或 deleteSurroundingTextInCodePoints。可 Android 生态的野生程度也是最高的:大量 App 对 getTextBeforeCursor 返回空字符串或随意截断,不少内嵌 WebView 的自定义输入框里选区 API 跟屏幕上高亮的文字根本对不上。更要命的是字符计数单位,传统的 deleteSurroundingText 是按 Java char(也就是 UTF-16 码元)来数数的,如果句子里刚好有个占两个码元的 Emoji 或生僻合字,算错单位就会把半个 Emoji 留成乱码。因此在 Android 端,我们严格对齐 Unicode 码点边界,并在写回前校验上下文一致性,绝不闭着眼睛硬删字符碰运气。
Windows 的 TSF(Text Services Framework)则又是另一番景象。TSF 允许通过 ITfContext 获取选区并沿着 ITfRange 读写文本,但我们在实测 VS Code 等基于 Monaco 的编辑器时踩到了一个极其诡异的坑:没有选中文字、尝试从光标向两侧读取上下文的时候,某些编辑器会在光标右侧暴露出一串与光标左侧完全对称的「镜像文本」,如果直接拼接就会把 nihao 变成回文式的 nihaooahin。因此我们在 Windows 端严格约束空选区的读取方向,并在写回前核对当前范围里的文本是否仍然与发起请求时一致,防止用户在等待转换的那几百毫秒里又敲了新字而被覆盖。
各家宿主软件的怪癖
搞定了操作系统的共性,接下来才是真正的深水区,也就是具体到每个常用软件的输入框实现。以 macOS 为例,我们在应用兼容性文档里同步说明了不同宿主应用的行为差异:
| 宿主应用 | 常见底层怪癖 | UnimeType 的安全保障 |
|---|---|---|
| 微信(WeChat) | 忽略 replacementRange 直接追加,且退格处理异步慢半拍 | 避开无效范围替换,并在确认旧文本清理完成后再写入新文字 |
| Office / WPS(Word、Excel) | Excel 选中单元格与双击进入编辑框是两套独立焦点;Word 富文本带隐形样式标记会导致坐标偏移 | 区分单元格覆盖态与行内编辑态,对不稳定的富文本范围优先依赖受控输入缓冲或显式选区 |
| 飞书 / 腾讯文档 / Google Docs | 飞书聊天框清空后坐标偶发滞后;网页文档用 Canvas 自绘,光标底下只藏一个 1×1 像素的隐藏输入框 | 结合文本与光标状态消除换字空白等待;在线画布优先读取连续按键缓冲或提示手动选中 |
| VS Code / Terminal / Ghostty | VS Code 空选区向后读取会镜像文本;部分终端不暴露全文长度(始终报 0) | 严格只取光标左侧上下文,结合当前输入会话定位,无法安全定位时提示手动选中文字 |
| Xcode 等标准原生宿主 | 遵循标准 NSTextInputClient 范围契约 | 标准文档 replacementRange 原位替换 |
为什么要针对这些常用软件做专门的兼容处理?看一眼大家平时抱怨最多的几类软件在真实场景里是怎么翻车的就明白了。
先看每天打开频率最高的微信。你敲完一串 nihao 按下转换,如果输入法天真地调用 macOS 标准的 insertText("你好", replacementRange: 原范围),微信的输入框会面不改色地收下文字,却直接无视你传过去的替换范围,把「你好」老老实实贴在 nihao 后面,屏幕上一秒变成 nihao你好。更棘手的是,微信输入框处理退格是异步慢半拍的,如果退格还没消化完就急着把「你好」塞进去,迟到的退格键就会像贪吃蛇一样把刚插进去的「你」字给啃掉。
再看上班族每天都在用的 Microsoft Office 和 WPS(尤其是 Excel 单元格与 Word 文档)。很多人以为在 Excel 里打字跟在记事本里一样,其实 Excel 单元格有两种截然不同的输入态:一种是「只单击选中了单元格直接敲字(覆盖态)」,这时候顶部的公式栏和单元格内还没建立完整的文本范围契约,贸然发退格或范围替换很容易把整格旧数据清空或者焦距跳去公式栏;另一种是「双击单元格(或按 F2)看到闪烁光标(行内编辑态)」。而到了 Word 和 WPS 文字里,段落里往往藏着肉眼看不见的域代码、修订标记或富文本样式锚点,宿主报告给系统的字符偏移量经常跟纯文本长度差着几拍。
还有一类重灾区是飞书(含飞书聊天框与飞书文档)、腾讯文档、Google Docs 这类办公协同软件。在飞书客户端聊天框里,故障现象刚好跟微信反过来:旧文字瞬间清空了,但上报给系统的 selectedRange 光标坐标有时还卡在旧位置,死等光标更新就会让用户盯着空白框干等一秒。而一旦切进飞书文档、腾讯文档或 Google Docs 的网页正文,它们为了多人协同和排版一致,早就不再使用浏览器原生的 <textarea> 了,而是用 Canvas 画布自己画字、自己画假光标,只在光标底下藏一个 1×1 像素的透明 <textarea> 用来接收输入法按键。结果就是,在输入法眼里,那个透明小框里的文档长度永远只有可怜的零星几个字,一旦你用鼠标点到上一行想改半句话,浏览器根本不会把画布上的那行字同步进那个 1×1 像素的输入框里。
至于开发者常用的 VS Code 和 Ghostty 等终端模拟器,怪癖又走向了另一个方向:VS Code 在空选区向后读取时会镜像光标前的文本,而 Ghostty 终端在没用鼠标选中文字时,报告给输入法的文档长度 length() 永远是 0。针对这些五花八门的宿主脾气,UnimeType 在底层建立了一套基于运行时证据的写回门禁:既防止微信异步退格吞字、区分 Office/WPS 单元格与在线画布的焦点边界,也消除飞书的空屏等待;对于彻底拒绝透露上下文的终端或画布,我们会在界面上诚实地给出提示(此编辑器需要先选择文字),并且绝对不会为了凑合兼容去强开系统的拼音候选预编辑下划线(marked composition),免得把 Excel 公式输入、终端 Shell 补全和代码编辑器的 @ 选择器搅得一团糟。
写回文字的三条底线
经历了上面这些五花八门的宿主怪癖之后,我们在设计文本范围规则时给自己定下了几条绝不妥协的工程红线。
第一条红线是:只要用户手动选中了非空文本,选区就是最高圣旨,任何时候都绝不擅自扩大或调包。 不管你接下来点的是 Convert、Polish 还是 Explain,只要屏幕上有一块高亮选区,我们就只处理这块选区里的字,绝不偷偷把它扩成整段,也绝不拿其他上下文去替换它。因为用户拉出选区的那一刻,就是在亲手划定信任边界,擅自往外多抓半句话,不仅可能把旁边的英文代码或引用格式一起卷进模型改坏,更是在越过用户的隐私预期。
第二条红线是:严格保护 Markdown 结构前缀与已转换段落。 写 Markdown 笔记、光标停在一行 - ming tian kan metrics 末尾触发自动转换的时候,UnimeType 会自动保护开头的列表符号 - 、标题井号 ## 、引用 > 或任务框 [ ] ,确保你的排版缩进与列表格式永远不会被模型顺手吃掉。同时,一段文字已经转换完成之后,如果你紧接着在同一段末尾继续敲了几句新拼音再按一次 Convert,UnimeType 只会增量替换你新敲进去的那截后缀,彻底告别整段文字反复重刷闪烁的尴尬。
第三条红线是:严格区分不同层面的「长度」计量单位,保护 Emoji 与多字节字符边界。 为什么输入法代码里最容易写出差一错误(off-by-one)?因为同样叫 length,在不同层面的含义完全不同:
| 长度概念 | 实际计量单位 | 常见翻车风险 |
|---|---|---|
| 宿主文档坐标(macOS / Windows) | UTF-16 码元(NSRange / TSF 范围) | 把人类眼里的「1 个 Emoji」当成长度 1,导致替换坐标整体偏移 |
| 编程语言字符计数 | 扩展字素簇(Grapheme Cluster)vs UTF-16 | 混用字符串长度 API 会把组合 Emoji 或生僻汉字从中间劈成乱码 |
| 移动端删除接口(Android) | UTF-16 码元 vs Unicode 码点 | 删错单位会在输入框末尾留下半个损坏的代理对(Surrogate Pair) |
通过把所有宿主范围统一校验到精确的编码边界,只要宿主返回的子串长度出现异常截断,我们就立即中止写回,绝不把截断了一半的代理对当成合法原文去覆盖你的草稿;同时在诊断日志里只记录脱敏状态码,从根源上保证用户的正文内容绝不进遥测。
宁可停手也不乱删字
把上面所有的故障链收拢到一起,就能整理出一张日常排查问题时最直观的「症状与根因对照表」:
| 你在屏幕上看到的现象 | 底层真正发生的根因 | UnimeType 的安全保护 |
|---|---|---|
| 按了 Convert 没有任何变化 | 无选区且宿主读不到段落,或模型判定无需修改 | 明确提示「没有可转换内容」或「文字没有变化」 |
| 屏幕变成「拼音 + 中文」连在一起 | 宿主收下 insertText 却忽略了 replacementRange | 对该类宿主避开无效范围替换,改走受控写回路径 |
| 转换结果的第一个字被削掉了 | 宿主异步退格慢半拍,新文字插入得太早 | 确认旧文本清理完毕后再写入新文字 |
| 输入框空白一秒才蹦出转换结果 | 宿主已清空文本,但光标坐标更新滞后 | 结合文本与光标状态及时完成写入 |
出现 nihaooahin 这样的回文重复 | 部分编辑器在光标右侧暴露了镜像文本 | 空选区读取时严格只取光标左侧有效内容 |
| 等待转换的那一秒继续打字,新字没被冲掉 | 请求飞行期间用户改了选区、切了窗口或继续敲字 | 写回前二次校验原文状态,发现变化立即中止并提示 |
所以,如果你在使用过程中偶尔看到状态栏弹出「此编辑器需要先选择文字」「文字已发生变化」或者「无法替换文字」(具体含义可以随时查状态与故障排查),请不要以为是输入法突然抽风崩了。那恰恰是我们的替换守卫在最后一刻踩下了刹车,宁可让你看到一条诚实的失败提示、手动选中再试一次,也绝不在证据不足的时候闭着眼睛删改你辛辛苦苦写好的半段文字。
输入法的跨平台兼容性从来是一门一点都不性感的苦活累活,每天打交道的全是 NSNotFound、UTF-16 代理对、镜像光标和迟到的退格键。但对于一个鼓励你「一口气把整句话痛快敲完,再按一次键原地转换」的工具来说,这些枯燥的边界守卫就是全部的安全感来源。无论在哪个平台上,用鼠标或快捷键选中那句话再触发动作,永远是最稳妥、100% 不受宿主怪癖影响的黄金路径;而在你不做选择直接盲按的每一秒背后,我们的状态机都在替你跟各式各样的输入框斗智斗勇。
下载 UnimeType支持 iOS、Android、macOS、Windows、Linux 与 ChromeOS