昨天確認了注音輸入法會攔截實體掃描碼。今天的問題很自然: 那我能不能在測試裡先問一下「現在有沒有輸入法在運作」,或者乾脆把它換掉?
這一篇的結論我改過兩次,兩次都是被實測打回來的。過程比結論有價值,所以照順序寫。
IMM32 是 Windows 傳統的輸入法介面。標準流程是跟視窗要一個 Input Context:
himc = imm32.ImmGetContext(hwnd)
if himc:
is_open = imm32.ImmGetOpenStatus(himc)
imm32.ImmReleaseContext(hwnd, himc)
直覺上, ImmGetContext 回傳 0(NULL)就是「這個視窗沒有 Input Context,也就是沒有輸入法」。
然後我在一個明明開著輸入法的環境下拿到了 0。 繁體中文的視窗裡,注音正在把按鍵吃得一乾二淨——攔截行為千真萬確,但 ImmGetContext 卻堅稱這裡空無一物。
原因有兩個,而且都踩在現代 Windows 的核心邊界上:
ImmGetContext(hwnd) 只能取得呼叫端自己執行緒所屬視窗的 Context。當測試腳本跑在 Process A,而被測的目標視窗跑在 Process B 時,直接跨處理程序索取,作業系統本來就不會給你。所以 ImmGetContext 回傳 0 是誠實的: 就 IMM32 而言,這裡確實沒有東西 。
問題在於, False 或 0 這個值同時涵蓋了兩種完全相反的情況:
has_context |
可能的實際情況 |
|---|---|
False |
這個環境真的沒有輸入法 |
False |
有輸入法,而且正在攔截你的按鍵,只是它走 TSF 或在另一個處理程序 |
一個無法區分「沒有」還是「拿不到」的讀數,根本不能拿來做測試的條件分支。
既然 has_context 答不出來,那就退一步問:「這條執行緒目前掛載的鍵盤佈局(HKL),本身是不是一個輸入法?」
Win32 裡有一個 API 看名字就像是為此而生的:
def layout_has_ime(hkl: int) -> bool:
return bool(imm32.ImmIsIME(wintypes.HKL(hkl)))
我曾把它加進 get_ime_status(),發了新版本,甚至寫好了這篇文章的第一版。 然後我在做全面環境核對時實測了它:
| HKL | 已載入 | ImmIsIME 讀數 |
|---|---|---|
0x04040404 zh-TW 注音 |
是 | 1 |
0x04090409 en-US 美式英文 |
是 | 1 |
0x08090809 en-GB 英式英文 |
否 → 接著手動載入 | 0 → 1 |
0x04110411 ja-JP 日文 |
否 | 0 |
看第三列。我把純英文的 en-GB 載入之後, ImmIsIME 對它從 0 變成了 1。
ImmIsIME 回報的根本不是「它是不是輸入法」,而是「這個 HKL 有沒有被載入進系統」!
在任何裝了輸入法的機器上,這個欄位對每一個已載入的鍵盤佈局都會回傳 True。一個永遠回傳 True 的欄位比沒有這個欄位更糟,因為呼叫端會誤信它並拿它來做邏輯判斷。
所以我沒有修它,而是把它整個從 API 裡拔掉了。
拿掉之後我卡住了好一陣子:如果 IMM32 答不出來、 ImmIsIME 也在騙人,那自動化測試到底要怎麼判斷輸入法狀態?
答案是: 這個問題本身問錯了 。
我一開始總想著去「偵測」環境,但測試真正需要的是 「控制」 。
測試程式不需要知道目標視窗現在處於什麼狀態,它需要的是 把目標視窗變成它要的樣子 。這跟前幾天處理焦點時「不要管預設焦點落在哪,自己主動奪取焦點建立確定性」是完全同一個原則。
既然不需要 Context,那我們就直接去找 IME 視窗 說話:
tid = user32.GetWindowThreadProcessId(hwnd, None)
user32.GetGUIThreadInfo(tid, ctypes.byref(gti)) # 1. 找到真正有焦點的子控制項
ime_wnd = imm32.ImmGetDefaultIMEWnd(gti.hwndFocus) # 2. 取得它的 IME 視窗
user32.SendMessageW(ime_wnd, WM_IME_CONTROL, IMC_SETCONVERSIONMODE, 0) # 3. 設成英數模式
這段程式碼有三個關鍵細節:
GetGUIThreadInfo 先找焦點子視窗:輸入法是跟著焦點跑的。在一個對話框裡,鍵盤焦點在子控制項(如 EDIT)上,而不是你手上拿著的頂層視窗。ImmGetDefaultIMEWnd:每個有輸入需求的執行緒,Windows 都會替它建立一個隱藏的 IME 視窗——也就是視窗探索時常被當作雜訊過濾掉的那個 Default IME。它平常是背景雜訊,但此時卻是穿透邊界的入口。SendMessageW 跨得過處理程序邊界: ImmGetContext 會被跨處理程序擋下,但對著這個 Default IME 視窗發送 WM_IME_CONTROL,作業系統會乖乖替你轉發過去。實測,同一個控制項,從外部切換模式:
| 轉換模式 | send_physical_keys("hello") 結果 |
|---|---|
ALPHANUMERIC(英數) |
'hello' |
NATIVE(中文) |
''(被攔截進組字) |
再切回 ALPHANUMERIC |
'hello' |
可逆、可重複,而且乾淨俐落地跨過了處理程序邊界。
必須老實承認我是怎麼找到這個解法的: 我根本沒有找到,我是被提醒才去看的 。
ImeModePersistence——我自己寫的那個跨視窗記憶輸入法狀態的工具箱——它的核心邏輯早就這樣寫了,而且已經在無數視窗上運作了許久。它必須這麼做,因為它就是專門跨處理程序管理輸入法狀態的工具。
然而當我轉過頭來寫 wintegrate 時,我卻在 ImmGetContext 撞牆後下了「外部無法控制」的草率結論。
這給了我一個深刻的教訓: 當你判定某件事在 Windows 上做不到時,先冷靜確認你有沒有在別的專案裡早就做出來過 。答案往往就在自己手邊,只是被專案邊界阻斷了聯想。
在摸索出轉換模式之前,我嘗試的第一個暴力解法其實是:切換 鍵盤佈局(Keyboard Layout / HKL) 。
這條路徹底走不通,但它展示了 Windows 訊息迴圈中「請求 vs 指令」的經典架構,非常值得講透。
HKL 是一個 32 位元的值:
0x0404 0404
│ └── 低 16 位:語言識別碼(LANGID),0x0404 = zh-TW
└──────── 高 16 位:裝置識別碼,標示是哪一種佈局或輸入法
鍵盤佈局是「執行緒」層級的狀態,不是系統全域的 。你的 Python 測試行程和被測的應用程式是兩個獨立的處理程序、不同的執行緒,各自擁有自己的作用中佈局。
為了把另一個處理程序的視窗鍵盤佈局強切成英文(0x04090409),我把所有能查到的 Win32 API 組合全部測了一遍:
| 嘗試做法 | 結果 |
|---|---|
PostMessage + WM_INPUTLANGCHANGEREQUEST + INPUTLANGCHANGE_SYSCHARSET |
失敗(無反應) |
PostMessage + WM_INPUTLANGCHANGEREQUEST + wParam=0 |
失敗(無反應) |
PostMessage + WM_INPUTLANGCHANGEREQUEST + INPUTLANGCHANGE_FORWARD |
失敗(無反應) |
SendMessage + WM_INPUTLANGCHANGEREQUEST + wParam=0 |
失敗(無反應) |
AttachThreadInput + ActivateKeyboardLayout |
只改到呼叫端自己的執行緒 |
PostMessage 到廣播控制代碼 HWND_BROADCAST |
失敗(無反應) |
目標視窗的執行緒紋絲不動,始終停留在 0x04040404。
根本原因:鍵盤佈局在 Windows 中是 per-process 載入 的。
當你呼叫 LoadKeyboardLayoutW 時,它只把佈局載入到 呼叫端(你的 Python 行程) 內部。目標處理程序如果從來沒有載入過那個 HKL,它根本不認識那個代碼,自然會直接拒絕這個切換動作。
「切換模式」是叫輸入法讓路,「切換佈局」是把輸入法換掉——而你沒有權限替別人的處理程序換掉輸入法。
再看一眼這個訊息的名稱:
user32.PostMessageW(hwnd, WM_INPUTLANGCHANGEREQUEST, INPUTLANGCHANGE_SYSCHARSET, hkl)
注意字尾是 REQUEST(請求) ,而不是 Command(指令)。
而且我們用的是 PostMessageW—— 把訊息貼進目標執行緒的訊息佇列後立刻返回 。 PostMessage 回傳 True,僅僅代表「訊息成功放進對方的郵筒裡了」。
至於對方什麼時候讀郵件、讀到之後願不願意處理、有沒有載入那個佈局,呼叫端一無所知。目標執行緒如果發現那是個陌生或不合法的 HKL,訊息迴圈內部會靜默忽略,直接已讀不回。
這跟我們前面談過的驗證原則完全一致: 送出成功,不等於操作已經完成 。
既然跨處理程序切換鍵盤佈局是一條走不通的死路,那如果你在框架裡提供了這個方法,就必須保證它在失敗時能明確告訴下一位工程師為什麼:
def set_keyboard_layout_verified(self, layout_id: str, timeout: float = 3.0) -> int:
hkl = load_keyboard_layout(layout_id)
if not hkl:
raise ActionVerificationError(f"Could not load keyboard layout {layout_id!r}")
user32.PostMessageW(self.hwnd, WM_INPUTLANGCHANGEREQUEST,
INPUTLANGCHANGE_SYSCHARSET, hkl)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self.keyboard_layout == hkl:
return hkl
time.sleep(0.1)
raise ActionVerificationError(
f"Window did not switch to layout {layout_id!r} within {timeout}s "
f"(still 0x{self.keyboard_layout:x}). The window belongs to another process, "
"and keyboard layouts are loaded per process — that process has most likely "
"never loaded this layout, so it silently rejects the request. "
"Switch the conversion mode instead of the layout."
)
一個註定失敗的操作,至少要讓下一個維護者不用再去重複踩那六種做法。
注意驗證後置條件時,一定要問對執行緒: self.keyboard_layout 查詢的是 GetKeyboardLayout(GetWindowThreadProcessId(hwnd)),問的是目標視窗的執行緒狀態。如果誤查成呼叫端自己的執行緒,這個驗證就會淪為毫無意義的自我安慰。
在 Day 13 的 ime_mode Context Manager 中,有一個小而關鍵的防禦性細節:
original = get_ime_conversion(dialog.hwnd)
dialog.set_ime_conversion(ImeConversion.ALPHANUMERIC)
try:
yield dialog
finally:
if original is not None:
dialog.set_ime_conversion(original)
注意那行 if original is not None:。
如果在進入測試前,目標視窗根本沒有回應 IME 轉換模式(讀到 None),那麼離開時就 絕對不能憑空猜測一個數值去還原 。把狀態還原成一個未經證實的猜測值,往往比什麼都不動還要危險。只還原你真正讀到過的現場狀態,是寫桌面測試 teardown 的基本修養。
ImmGetContext 跨處理程序索取時只會拿到 0;走 TSF 的現代控制項在 IMM32 介面下也沒有 Context。ImmIsIME 回報的是「HKL 是否已載入」,而不是「它是不是輸入法」;不要依賴它做邏輯判斷。WM_IME_CONTROL 搭配 ImmGetDefaultIMEWnd 能夠安全穿透處理程序邊界,精確控制中英轉換。明天我們將結束第三章,探討一件讓這一整章看似混亂的 Win32 程式碼回歸優雅純粹的技術: 把純函式從 Win32 泥淖中剝離出來 ,並用 Property-Based Testing(Hypothesis)對它進行邊界轟炸。