iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Software Development

Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影系列 第 14

Day 14:為什麼切換鍵盤佈局永遠失敗?——跨處理程序的輸入法控制

  • 分享至 

  • xImage
  •  

昨天確認了注音輸入法會攔截實體掃描碼。今天的問題很自然: 那我能不能在測試裡先問一下「現在有沒有輸入法在運作」,或者乾脆把它換掉?

這一篇的結論我改過兩次,兩次都是被實測打回來的。過程比結論有價值,所以照順序寫。


試著問 IMM32:為什麼有輸入法卻拿到 0?

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 的核心邊界上:

  1. 跨處理程序隔離(Cross-process Isolation)ImmGetContext(hwnd) 只能取得呼叫端自己執行緒所屬視窗的 Context。當測試腳本跑在 Process A,而被測的目標視窗跑在 Process B 時,直接跨處理程序索取,作業系統本來就不會給你。
  2. TSF(Text Services Framework)的現代架構:從 Windows XP 起引入的 TSF 是現代輸入服務的標準,許多控制項直接走 TSF。走 TSF 的視窗在 IMM32 那條老路上根本沒有 Context。

所以 ImmGetContext 回傳 0 是誠實的: 就 IMM32 而言,這裡確實沒有東西

問題在於, False0 這個值同時涵蓋了兩種完全相反的情況:

has_context 可能的實際情況
False 這個環境真的沒有輸入法
False 有輸入法,而且正在攔截你的按鍵,只是它走 TSF 或在另一個處理程序

一個無法區分「沒有」還是「拿不到」的讀數,根本不能拿來做測試的條件分支。


試著問 ImmIsIME:它說的是「已載入」,不是「是輸入法」

既然 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 英式英文 否 → 接著手動載入 01
0x04110411 ja-JP 日文 0

看第三列。我把純英文的 en-GB 載入之後, ImmIsIME 對它從 0 變成了 1

ImmIsIME 回報的根本不是「它是不是輸入法」,而是「這個 HKL 有沒有被載入進系統」!

在任何裝了輸入法的機器上,這個欄位對每一個已載入的鍵盤佈局都會回傳 True。一個永遠回傳 True 的欄位比沒有這個欄位更糟,因為呼叫端會誤信它並拿它來做邏輯判斷。

所以我沒有修它,而是把它整個從 API 裡拔掉了。


問錯了問題:測試要的是控制,不是偵測

拿掉之後我卡住了好一陣子:如果 IMM32 答不出來、 ImmIsIME 也在騙人,那自動化測試到底要怎麼判斷輸入法狀態?

答案是: 這個問題本身問錯了

我一開始總想著去「偵測」環境,但測試真正需要的是 「控制」

測試程式不需要知道目標視窗現在處於什麼狀態,它需要的是 把目標視窗變成它要的樣子 。這跟前幾天處理焦點時「不要管預設焦點落在哪,自己主動奪取焦點建立確定性」是完全同一個原則。


正解:繞過 Context,找 IME 視窗送 WM_IME_CONTROL

既然不需要 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. 設成英數模式

這段程式碼有三個關鍵細節:

  1. GetGUIThreadInfo 先找焦點子視窗:輸入法是跟著焦點跑的。在一個對話框裡,鍵盤焦點在子控制項(如 EDIT)上,而不是你手上拿著的頂層視窗。
  2. ImmGetDefaultIMEWnd:每個有輸入需求的執行緒,Windows 都會替它建立一個隱藏的 IME 視窗——也就是視窗探索時常被當作雜訊過濾掉的那個 Default IME。它平常是背景雜訊,但此時卻是穿透邊界的入口。
  3. 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 能夠安全穿透處理程序邊界,精確控制中英轉換。
  • 鍵盤佈局是執行緒狀態、但載入屬於處理程序——這個錯位導致跨處理程序切換佈局幾乎注定失敗。
  • 六種切換佈局的做法全部失敗;如果某個操作註定受到作業系統限制,錯誤訊息必須清楚交代根因,阻止後人重蹈覆轍。
  • 認定某件事在 Windows 上做不到之前,先翻翻自己以前的專案有沒有成功過

明天我們將結束第三章,探討一件讓這一整章看似混亂的 Win32 程式碼回歸優雅純粹的技術: 把純函式從 Win32 泥淖中剝離出來 ,並用 Property-Based Testing(Hypothesis)對它進行邊界轟炸。


上一篇
Day 13:在 CI 全綠,到中文環境全滅——實體按鍵與組字緩衝區
下一篇
Day 15:在一個「全都是整合測試」的領域裡,把純函式挖出來
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言