Windows 下 UTF-16 的坑
最近幫多個活躍的開源專案改了同一個 bug : 完善 Windows 下的 Unicode 支援。
問題源於 Windows 和其它平臺不同,它有悠久的歷史包袱。和現代大多數作業系統對 Unicode 支援的共識不同,它的 API 不是基於 UTF-8 而是基於 UTF-16 的,且還遺留了一套過去對 ANSI 支援的相容 API 。
最近我接觸的幾個活躍開源專案,都是西方人主導開發的,他們維護者似乎對 UTF-16 均有所誤解,或是懶得做全面的支援。我認為罪魁禍首就是微軟自己把 ANSI 編碼稱為 MBCS 多字元編碼字符集,和 WideChar 寬字符集對應起來,暗示 WideChar 是單字編碼。而事實上 UTF-16 方案應該成為 MWCS 才合理。
Unicode 雖然常用的只有 BMP ,也就是 U+0000 到 U+FFFF 部分的字符集。但是隨著 Emoji 的普及,SMP 也變得非常常見了。其實 Unicode 目前的標準有 17 的 Plane ,需要 21bit 才能表示完全。而很多人直觀的覺得,UTF-16 每一個字 ( 2 位元組 )表示一個碼點。這是不對的,像 Emoji ,一些罕見漢字(SIP)需要用兩個字來表示。這個叫做 surrogate pair ,對於 Unicode ,U+D800 到 U+DFFF 這個區段是不存在的,專門用於實現 UTF-16 中的 surrogate pair 。
U+D800 到 U+DBFF 留出了 1024 個位置,也就是 10 bit ;U+DC00 到 U+DFFF 也是 10bit ,這 20bit 剛好能表示 BMP 外的 16 個 Plane ( 4 + 16 bits )。
現代軟體通常都會統一使用 UTF-8 作為內碼,為了相容 Windows ,則在最後呼叫 API 的時候再和 UTF-16 做轉換。雖然 UTF-8 編碼並不複雜,但這裡最好不要自己實現這個轉換,因為容易犯錯(漏掉 surrogate pair 的處理)。建議儘量使用 MultiByteToWideChar 和 WideCharToMultiByte 。btw, 因為犯錯誤的人/專案實在太多,甚至有人提出了一個叫做WTF-8 的 Unicode 編碼方案,可以正確編碼錯誤的 UTF-16 串。
在 Windows 下比較坑的是,WM_CHAR
這個訊息,在 Unicode 模式下,也是傳遞 UTF-16 編碼的,如果敲出的字元不在 BMP ,那麼會用兩個連續的訊息分兩次傳遞。大多數開源軟體都沒能正確處理這個問題。我最近給 imgui 提了一個PR
,想解決它的這個問題。我同樣在 lua iup 和 bgfx 中發現了類似問題。
btw, 很多人沒有細讀 msdn ,被WM_UNICHAR
這個訊息名誤導,認為只要處理這個訊息,就可以直接拿到 Unicode 的 codepoint ,因為 msdn 上寫的是,這個訊息會按 UTF-32 來發送訊息。但實際上,Windows 的 IME ,也就是會直接產生超出 BMP 的輸入的主要途徑,它並不會向視窗傳送WM_UNICHAR
訊息。這個訊息時專門用於 ANSI 視窗向 Unicode 視窗傳送 Unicode 字元訊息用的,我認為設計這個訊息的動機是,如果你連續向一個視窗傳送兩個WM_CHAR
,很難保證原子性,很可能中間被插入另一個WM_CHAR
。所以必須有這麼一個訊息,可以原子的傳送一個 Unicode 字元。至於 Windows 系統內部通過 DefWindowProc 產生出來的WM_CHAR
訊息,很可能是直接對執行緒的訊息佇列寫,則能保證超過 BMP 的 Unicode 字元可以被分割為兩個訊息,中間不被打斷。換句話說,WM_UNICHAR
只是為了解決程式自己內部傳遞字元用的,你不處理它,系統的 DefWindowProc 也會幫你轉換為WM_CHAR
;而只處理它是無法解決 surrogate pair 問題的。
似乎目前唯一的方法是,當你在WM_CHAR
中發現了 surrogate (U+D800 到 U+DBFF) 時,把它記在和視窗相關的資料結構中,等下一個 surrogate (U+DC00 到 U+DFFF)到來後,再和之前記錄的值拼接起來,算出 code point ,再轉發出去。
對於歷史悠久的開源專案修改,需要注意,很多專案仍舊遺留了 ANSI 版本的 Windows API 封裝,而並非完全是 Unicode 版本的。混用 A 版本和 W 版本(或是 T 版本)的 API 要非常小心,尤其是設計基於 DLL 的外掛處理。Windows 官方的說法是,建議嚴格使用 T 版本的巨集,不要直接呼叫 A 版或 W 版 API 。但大多數現在的開源專案,為了減少不確定因素,都回避了這類 API 的巨集替換。
雖然 Windows 核心中一律使用 Unicode ,A 版本的 API 只是做一個轉換。但不要認為只有涉及字串的 API 才分 A 和 W 版本,設計訊息引數的也會區分。例如 PeekMessage DispatchMessage DefWindowProc 都有兩個版本(但 TranslateMessage 卻沒有)。一個視窗的訊息輸入,其實主要就是最終把支付輸入翻譯成 WM_CHAR 的過程,到底是翻譯成 ANSI 還是 UTF-16 ,只取決於 Windows 的 Class ,也就是你是用 W 版的 RegisterWindowClass 還是 A 版的。如果一個視窗的 Class 是 Unicode 的,那麼你必須使用 DefWindowProcW 函式過濾訊息。你的 WindowProc 不可以直接呼叫,而需要使用 CallWindowProcW 呼叫,它負責對 MSG 做正確的轉換。
記住,Windows 的訊息迴圈是基於執行緒,而不是基於視窗的。一個執行緒只能有一個訊息迴圈分發點。如果你不在意 BMP 之外的 Unicode 支援的話,其實使用 PeekMessage/GetMessage 的 A 版或 W 版都無所謂,Windows 最終都會做正確轉換的。
但是,ANSI 字符集僅僅是 Unicode 的一個子集,如果你的執行緒中混用了 ANSI 類視窗和 Unicode 類視窗,那麼你用 PeekMessageA 獲取訊息,當 DispatchMessageA 去觸發 Unicode 視窗的 WinProc 時,有些字元就會丟失。所以,你必須用 PeekMessageW 和 DispatchMessageW 才能保證資訊不會丟失。
不要覺得不可能出現這種混合的情況,我用 google 就找到了這麼一個古老的案例 。它解答了我起初的一個疑惑:為什麼微軟在設計 API 的時候,不乾脆去掉 ANSI 版的 PeekMessage/GetMessage/DispatchMessage ,反正 Unicode 是過去所有字符集的超集了。這是因為,有不少歷史遺留軟體,因為 MFC 的緣故,還會繞過 WindowProc 直接去訪問 GetMessage 獲取的 MSG 中的訊息。當然,我還是認為這是 Windows API 的設計失誤。
最後再補充一些我在幫 imgui 完善 Unicode 支援,做測試時瞭解到的小知識。
一般中文字型檔案都不會包含 BMP 以外的字元,而擴充套件的罕見漢字,SIP 那些你在常規的 ttf 檔案中是找不到的。但是你在 Windows 下用字型 API 卻取的到。這是因為,在 Windows 下,如果你的字型使用了 SIP 中的字但找不到,Windows 會嘗試登錄檔HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\LanguagePack\SurrogateFallback
中對應的替代字型檔案。比如宋體,在我的系統上,最終會從 C:\Windows\fonts\simsunb.ttf 中查詢那些罕見字。