做跨平台輸入法最怕通訊 App、Office 和雲端文件的輸入框,全都不按規矩來

Aijie ShuFigurich 創辦人 · UnimeType 開發者已更新19 分鐘閱讀

很多第一次開發輸入法或者系統工具的工程師都會覺得,文字替換還能有多難?不就是把使用者剛敲進去的那串拼音刪掉,再把模型轉換好的文字塞回原來的位置嗎?

直到我們真正把 UnimeType 裝進 LINE、Slack、WeChat(com.tencent.xinWeChat)、Office(Word / Excel)、Google Docs、Notion 和 VS Code 這些日常高頻應用程式裡才發現,開發跨平台輸入法最折磨人的地方在於你根本不敢隨便相信宿主應用程式回報上來的選取範圍(Selection)和文字長度(Length)。同一個包含 Emoji 和中文的句子,桌面通訊客戶端、Word、Excel 儲存格、線上協作文件、VS Code 和 Ghostty 終端機回報回來的游標座標與長度語意居然全都不一樣:有的按 UTF-16 算,有的按字元算,有的在假裝替換成功的同時悄悄把文字附加在句尾,還有的乾脆在背景慢半拍吞掉你的 Backspace 退格鍵。

使用者眼裡看到的現象通常只有一句話:怎麼點了 Convert 沒反應?怎麼變成「拼音 + 中文」拼在一起了?怎麼在聊天視窗能用、切到終端機就不行了?這篇文章就把我們在 macOS 輸入法、iOS 鍵盤擴充功能、Android IME 和 Windows TSF 四個平台上踩過的真坑攤開聊聊,講清楚為什麼輸入法不能假設「一個 API 走天下」,以及我們是怎麼用一套基於證據鏈的狀態機把寫回這件事做穩的。

輸入法看不見整份文件

理解所有相容性怪象的第一步,是接受一個殘酷的物理前提:第三方輸入法並不擁有文件本身。無論你在哪個作業系統上打字,輸入法都只是站在應用程式門外的一個訪客,只能順著系統規定的窄窄一條門縫向宿主應用程式提問:

平台典型介面理論上能向宿主取得的資訊
macOSIMKTextInput / NSTextInputClientselectedRange、length、attributedSubstring、insertText(_:replacementRange:)
iOSUITextDocumentProxyselectedText、documentContextBefore/AfterInput、adjustTextPosition、刪除與插入
AndroidInputConnectiongetSelectedText、getTextBeforeCursor、deleteSurroundingText、commitText、getExtractedText
WindowsTSF ITfContext / ITfRangeGetSelection、GetText、ShiftStart/End、SetText

紙面上的系統文件看起來都很美好,每個平台似乎都整整齊齊地提供了「讀取選取範圍 + 讀取上下文 + 原位替換」三件套。可一旦落地到真實的桌面與行動裝置應用程式裡,宿主的交付品質瞬間拉開幾個數量級的差距。像 Xcode 和大部分 macOS 原生編輯器屬於老實人,完整文件的座標和替換都按規矩來;iOS 鍵盤和不少 Android 聊天框只肯給你看游標附近的一小段截斷視窗;com.tencent.xinWeChat 和一部分跨平台桌面框架明明讓你讀到了選取範圍,等你呼叫按範圍替換時卻直接無視範圍參數;部分終端機模擬器乾脆不管你敲了多少字,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、LINE 或 Notion 裡敲 @ 或 / 觸發了自動補全選單,按一下 Enter,宿主直接把你敲的三個字母替換成了一整塊富文本人名標籤;你在終端機裡敲了半截路徑按了一下 Tab,Shell 瞬間幫你補全了後面二十個字元。這些由宿主自己產生的字元從來沒走過輸入法的鍵盤輸入緩衝區,本機 typing buffer 跟畫面上的真實文字瞬間就會錯位。

第三,就算你的 typing buffer 100% 記對了使用者剛才敲的每一個字,你最後依然得把舊文字從宿主裡刪掉、把轉換結果寫回去。「知道原文是什麼」只解決了一半問題;另一半問題是「怎麼在宿主裡精準抹掉這串原文」。一旦宿主忽略你的 replacementRange,或者 Backspace 退格鍵在宿主佇列裡非同步遲到,光靠本機緩衝區裡的字串長度盲目連發 N 個退格鍵,只要游標位置或宿主回應速度有一丁點偏差,就會把使用者前面寫好的正常段落給誤刪掉。所以,本機 typing buffer 只能作為受嚴格失效規則約束的輔助證據(一旦發生游標跳轉、滑鼠點擊或上下文不符就必須立刻失效),而不能代替對宿主現場狀態的核驗。

那麼,像 Grammarly、Raycast AI、TextExpander 或者各類桌面語音轉寫工具,它們在跨應用程式讀寫文字時又是怎麼繞過這些坑的?拆開來看,業界主流桌面工具其實走了另外三條路,而且每條路都有各自的代價:

方案流派代表工具底層實作機制為什麼它也不是銀彈
系統輔助使用樹(Accessibility API)Grammarly Desktop繞開輸入法通道,改以輔助使用服務身分透過 macOS AXUIElement / Windows UIAutomation 強讀視窗控制項樹與座標,直接改寫 AXValueElectron / 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(com.tencent.xinWeChat)與部分自訂聊天框忽略 replacementRange 直接附加,且 Backspace 退格處理非同步慢半拍避開無效範圍替換,並在確認舊文字清理完成後再寫入新文字
Office(Word、Excel)Excel 選取儲存格與雙擊進入編輯框是兩套獨立焦點;Word 富文本帶隱形樣式標記會導致座標偏移區分儲存格覆蓋態與行內編輯態,對不穩定的富文本範圍優先依賴受控輸入緩衝區或明確選取範圍
協作聊天客戶端 / Google Docs / 線上畫布文件部分聊天框清空後座標偶發滯後;網頁文件用 Canvas 自繪,游標底下只藏一個 1×1 像素的隱藏輸入框結合文字與游標狀態消除換字空白等待;線上畫布優先讀取連續按鍵緩衝區或提示手動選取
VS Code / Terminal / GhosttyVS Code 空選取範圍向後讀取會鏡像文字;部分終端機不暴露全文長度(始終回報 0)嚴格只取游標左側上下文,結合目前輸入工作階段定位,無法安全定位時提示手動選取文字
Xcode、LINE、Slack 等遵循標準的宿主遵循標準 NSTextInputClient 或富文本範圍契約標準文件 replacementRange 或驗證後原位替換

為什麼要針對這些常用應用程式做專門的相容處理?看一眼大家平時抱怨最多的幾類應用程式在真實場景裡是怎麼翻車的就明白了。

先看跨平台桌面通訊客戶端(例如 com.tencent.xinWeChat)。你敲完一串 nihao 按下轉換,如果輸入法天真地呼叫 macOS 標準的 insertText("你好", replacementRange: 原範圍),它的輸入框會面不改色地收下文字,卻直接無視你傳過去的替換範圍,把「你好」老老實實貼在 nihao 後面,畫面上一秒變成 nihao你好。更棘手的是,它的輸入框處理退格是非同步慢半拍的,如果退格還沒消化完就急著把「你好」塞進去,遲到的退格鍵就會像貪食蛇一樣把剛插進去的「你」字給啃掉。

再看上班族每天都在用的 Microsoft Office(尤其是 Excel 儲存格與 Word 文件)。很多人以為在 Excel 裡打字跟在記事本裡一樣,其實 Excel 儲存格有兩種截然不同的輸入態:一種是「只單擊選取了儲存格直接敲字(覆蓋態)」,這時候頂部的資料編輯列和儲存格內還沒建立完整的文字範圍契約,貿然發退格或範圍替換很容易把整格舊資料清空或者焦點跳去資料編輯列;另一種是「雙擊儲存格(或按 F2)看到閃爍游標(行內編輯態)」。而到了 Word 文字裡,段落裡往往藏著肉眼看不見的欄位代碼、修訂標記或富文本樣式錨點,宿主回報給系統的字元偏移量經常跟純文字長度差著幾拍。

還有一類重災區是 Google Docs 與各類雲端協作文件。在部分協作聊天框裡,故障現象剛好反過來:舊文字瞬間清空了,但回報給系統的 selectedRange 游標座標有時還卡在舊位置,死等游標更新就會讓使用者盯著空白框乾等一秒。而一旦切進 Google Docs 等雲端協作文件的網頁內文,它們為了多人協同和排版一致,早就不再使用瀏覽器原生的 <textarea> 了,而是用 Canvas 畫布自己畫字、自己畫假游標,只在游標底下藏一個 1×1 像素的透明 <textarea> 用來接收輸入法按鍵。結果就是,在輸入法眼裡,那個透明小框裡的文件長度永遠只有可憐的零星幾個字,一旦你用滑鼠點到上一行想改半句話,瀏覽器根本不會把畫布上的那行字同步進那個 1×1 像素的輸入框裡。

至於開發者常用的 VS Code 和 Ghostty 等終端機模擬器,怪癖又走向了另一個方向:VS Code 在空選取範圍向後讀取時會鏡像游標前的文字,而 Ghostty 終端機在沒用滑鼠選取文字時,回報給輸入法的文件長度 length() 永遠是 0。針對這些五花八門的宿主脾氣,UnimeType 在底層建立了一套基於執行期證據的寫回門禁:既防止非同步退格吞字、區分 Office 儲存格與線上畫布的焦點邊界,也消除空畫面等待;對於徹底拒絕透露上下文的終端機或畫布,我們會在介面上誠實地給出提示(此編輯器需要先選取文字),並且絕對不會為了湊合相容去強開系統的候選組字底線(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

查看下載下一篇: 說話時中英混著來很順口,一到打字就得不停切輸入法