

在臨床醫療自動化工作流中,為了隨時掌握放射科資訊系統 (Radiology Information System, RIS) 的待打報告工作清單 (Worklist) 狀態,我設計了一套整合自動化方案:
- 背景自動化推送 (Background Automation):透過 Windows 工作排程器 (Task Scheduler) 定時執行獨立的 AutoHotkey Utility 腳本,自動讀取並抓取 RIS 工作清單的未報告筆數。
- 資料庫儲存與整合 (Database Integration):將抓取到的工作清單狀態推送到 n8n Webhook,並利用 n8n Data Table 建立一個輕量級的小型資料庫來儲存最新數據。
- 隨時手動查詢 (On-demand Query):隨時透過 Telegram Bot 發送指令,手動讀取該 n8n 資料庫中的最新工作清單狀態。
設計此方案的初衷與痛點:
在日常臨床工作中,雖然可以直接透過 VPN 連回醫院主機並開啟 RDP 去查看工作清單,但醫院的 VPN 並非隨時隨地都方便連線;此外,每次開啟電腦、連線 VPN、開 RDP 登入查看的過程也相當耗時且繁瑣。因此,我希望能設計一個更輕量、能快速取得 Worklist 狀態的方案,讓我僅需透過手機 Telegram 發個訊息,就能在幾秒內掌握當前科內報告的累積狀況。
然而,在 Windows 環境下,當試圖在後台自動化 UI 時,遇到一個經典的棘手問題:本機運作完全正常,但只要透過遠端桌面 (Remote Desktop Protocol, RDP) 連線後直接關閉視窗(中斷連線),自動化 UI 便完全卡死或失效。
本文將以完整的時間軸與技術演進紀錄,回顧當初在 RisController 裡開發此功能的過程,深入分析 RDP 斷線後 UI 自動化失效的底層原因,並探討如何透過獨立運作的 AutoWorklistUpdate.v2.ahk 腳本與 UI Automation (UIA) 技術優化,完美突破此限制,實現 24/7 的穩定背景更新。
1. 發展歷程:從內置常駐到獨立腳本 (Development History)
1.1 階段一:RisController 內置常駐 Timer (舊版作法)
在最早的實作中,我在主要的前端 AHK 腳本 RisController 中開發了 GetWorklistJson 函數,並設定了一個每 60 秒觸發一次的背景計時器 (SetTimer)。
- 定位與抓取:完全依賴 UIA 框架,藉由元件的
AutomationId進行樹狀遞迴搜尋 (FindElement),並遍歷表格 (DataGridView) 抓取資料。 - 環境限制與「主動放棄」設計:
在 Timer 檢查邏輯中,加入了User32\OpenInputDesktop的防護檢測。一旦 RDP 斷線或螢幕鎖定,該 API 回傳為假(無活動輸入桌面),程式會主動跳過更新。
當時加入此設計的核心考量在於:- 防止主程式執行緒卡死 (UI Freezing):AutoHotkey 預設為單執行緒 (Single-threaded) 架構。在 RDP 斷線無 GUI 渲染時,常駐 Timer 執行 UIA
FindElement會陷入長達數秒的阻塞式等待(Blocking Timeout),直接造成使用者在前台操作時發生嚴重的鍵盤與畫面卡頓。 - 避免彈出錯誤對話框:無渲染時 UIA 頻繁拋出未處理異常,會彈出 AHK 的錯誤對話框(Runtime Error Dialog),直接中斷常駐程式的運作。
- 防止主程式執行緒卡死 (UI Freezing):AutoHotkey 預設為單執行緒 (Single-threaded) 架構。在 RDP 斷線無 GUI 渲染時,常駐 Timer 執行 UIA
1.2 階段二:獨立腳本與工作排程器重構 (新版作法)
為了解決 RDP 斷線後無法自動更新的痛點,我進行了架構重構:
- 邏輯剝離:將工作清單自動更新的所有邏輯從主程式中徹底移除。
- 獨立腳本:建立
Utilities/AutoWorklistUpdate.v2.ahk,專門供 Windows 工作排程器 (Task Scheduler) 定時調用,即使後台卡死也與主程式完全隔離。 - 多重備援:重新設計控制項定位與資料擷取邏輯,引入「HWND 快取」+「Win32 座標 Fallback」+「剪貼簿 ControlSend 備援」的多重防禦機制。
2. 核心痛點:為何 RDP 斷線後 UI 自動化會失效? (Core Problem)
當 RDP 連線中斷(例如直接關閉遠端桌面視窗)時,Windows 基於安全與效能考量,會將該會話轉為鎖定狀態 (Locked Session)。此時:
- 停止渲染 (No GUI Rendering):OS 停止為該 Session 繪製任何圖形介面,
DllCall("User32\OpenInputDesktop", ...)回傳0(失敗),代表系統不再接受物理的滑鼠與鍵盤輸入。 - 物理事件失效:無畫面渲染下,試圖使用物理點擊(如
elBtn.Click()或是 AHK 的Click)是無效的,因為此時沒有可供滑鼠游標活動的坐標空間。 - UIA 遞迴搜尋崩潰:UI Automation 框架是建立在「操作系統有繪製 GUI」的假設上。當 Windows 停止圖形渲染時,主視窗的 UIA 樹狀階層 (Accessibility Tree) 無法生成或更新。此時呼叫
elWindow.FindElement()遞迴尋找控制項,將會頻繁拋出 “Element not found” 或 “Operation timeout” 的異常。
3. 尋求出路:曾嘗試與放棄的替代方案 (Alternative Approaches Tried)
在探尋當前這套「純 AHK/Win32 底層重構」的方案之前,我也曾研究過其他幾種常見的 Windows RDP 自動化繞過作法,但最終均因醫療工作站特定的環境限制而被迫放棄:
方案 A:使用 tscon 將 Session 歸還給本機實體 Console
- 原理:當 RDP 斷線時,透過批次檔或腳本執行類似以下指令:
tscon %sessionname% /dest:console或是tscon 1 /dest:console
強制 Windows 將遠端連線 Session 切換回本機實體螢幕顯示,從而保持螢幕解鎖與畫面渲染。 - 受限原因:權限不足 (Insufficient Privilege)。 執行
tscon指令需要系統管理員權限 (Administrator privileges)。由於醫療臨床工作站出於資訊安全管理,登入的使用者帳戶均無 Admin 權限,因此此路不通。
方案 B:連接擬真/虛擬顯示器輸出 (Pseudo / Dummy Video Output)
- 原理:在主機接上 HDMI 騙壓插頭 (HDMI Dummy Plug) 或是安裝虛擬顯示卡驅動程式 (Virtual Display Driver),讓顯示卡認為始終有螢幕連接,防止 Windows 關閉 Session 渲染或進入鎖定模式。
- 受限原因:醫療工作站主機通常沒有多餘的實體視訊輸出埠 (Ports) 可供插接 Dummy Plug;且在沒有 Admin 權限的環境下,亦無法自行安裝虛擬顯示卡驅動程式。
結論:
因為上述方案皆被權限與物理安全機制卡死,我才被迫走向 「在不改變 Windows Session 鎖定狀態下,完全靠底層 Win32 API 與記憶體直達來完成自動化」 的純軟體重構之路。
4. 技術突破:獨立腳本的後台自動化設計 (Technical Breakthrough)
全新設計的獨立腳本 AutoWorklistUpdate.v2.ahk 藉由以下底層技術,徹底擺脫了對 GUI 渲染與 UIA 樹狀搜尋的絕對依賴:
4.1 控制項定位的「多重防禦機制」(Multi-layered Resolve)
在 ResolveWorklistControls 函數中,程式設計了三層定位防線:
- 第一防線:HWND 快取驗證 (Fast Path)
- 當用戶有畫面連線時,將工作清單視窗與核心控制項的
HWND寫入快取config/worklist-controls.ini。 - 背景執行時,透過
DllCall("IsWindow", "Ptr", hCtrl)驗證快取中的 HWND 是否有效,確認有效即直接套用,完全不需經過搜尋。
- 當用戶有畫面連線時,將工作清單視窗與核心控制項的
- 第二防線:UIA 動態定位 (UIA Resolve)
- 若快取失效,利用 UIA 物件動態定位以重建快取(初期版本限制僅在 Active Session 下啟用)。
- 第三防線:座標幾何排序 Fallback (最後備援)
- 若 UIA 失效且無快取,退回到 Win32 API,調用
WinGetControls獲取視窗內所有的子控制項,並依據座標位置排序(ER -> ADM -> OPD)。
- 若 UIA 失效且無快取,退回到 Win32 API,調用
4.2 後台非同步訊息點擊 (PostMessage + BM_CLICK)
在定位到重新整理按鈕的 HWND 後,腳本使用 Windows 訊息機制發送點擊指令:
PostMessage(0x00F5, 0, 0, , "ahk_id " . btnHwnd) ; BM_CLICK = 0x00F5
- 原理:直接向按鈕控制項的訊息佇列投遞點擊訊息。不需要視窗被啟動 (Active)、不需要視窗位於最上層,更不需要任何畫面渲染,按鈕即可在後台被觸發更新。
4.3 雙軌資料擷取 (UIA -> ControlSend 雙軌機制)
在讀取 DataGridView 表格內容時,腳本實作了高度容錯的雙軌制:
- 軌道一 (UIA 直讀):優先使用 UIA 的 TreeWalker 直接從 HWND 讀取資料。這種方式免除了剪貼簿的介入,最為乾淨。
- 軌道二 (剪貼簿複製 Fallback):當 UIA 讀取失敗時,退回到剪貼簿複製流程。使用
ControlSend("^a^c", hwnd)向目標 HWND 發送按鍵訊息,強制觸發其內部的 Copy 事件,將表格資料寫入 Windows 剪貼簿,再藉由 AHK 的A_Clipboard進行字串解析。
5. 實戰遭遇戰:2026/05 Locked Session 座標歸零 Bug 與 UIA 動態定位的解放 (Production Bug & Fix)
5.1 問題現象與 Log 診斷 (Symptom & Diagnostic)
在實戰部署後,我們遇到一個奇特現象:在 RDP 鎖定(Locked Session)後,背景排程腳本雖照常觸發,但抓取上傳至 n8n 的統計數據全數為 0(手動執行則正常)。
調閱日誌發現:
2026-05-22 21:58:38 [機制 - Fallback] DataGridView 排序成功 - ER: 0x30B62 (x:0), ADM: 0x10B70 (x:0), OPD: 0x10B72 (x:0)
原因:
當會話進入鎖定狀態時,Windows 視窗管理器 (Window Manager) 會將所有子控制項的幾何位置坐標重設為 0。這導致依賴「X 軸座標由小到大排序」的第三防線(座標 Fallback)發生錯配。系統將 ER、ADM、OPD 的識別綁定到了錯誤的 DataGridView HWND,導致隨後的資料讀取發生錯配,擷取到全為 0 的空數據。
5.2 技術突破:解放 UIA 定位限制 (Session-Agnostic UIA Resolve)
針對此問題,我們對 UIA 定位機制進行了重新驗證,有了重大突破:
- 事實真相:雖然物理點擊與大型 UIA 樹遍歷在無畫面下不可靠,但藉由 HWND 直達 的
UIA.ElementFromHandle(winHwnd),直接去尋找具有特定AutomationId(如dgvClassifyOPDE等)的子控制項 HWND,在鎖定 Session 下依然能夠完美運作且極其快速。 - 修正對策:
移除定位邏輯中對於 Session 是否 Active 的限制,將 UIA 定位調整為無差別優先啟用。只要快取失效,均先嘗試透過 UIA 獲取精確的 AutomationId 綁定,只有在 UIA 徹底拋出異常時,才降級至座標 Fallback 的第三防線。
5.3 修復後實測 (Verification)
當腳本移除 Session 限制後,實測於 Locked Session 背景定時執行,其定位與讀取日誌如下:
2026-05-22 22:11:01 嘗試使用 UI Automation (UIA) 進行定位 (Session: Locked) (第二防線)...
2026-05-22 22:11:01 ✅ [機制 - UIA] UIA 定位成功,快取已更新 (第二防線)
2026-05-22 22:11:01 控制項定位結果 - RefreshButton: 0x10B78, ER: 0x10B8C, ADM: 0x10B8A, OPD: 0x10B88
2026-05-22 22:11:03 正在擷取 [ER] 表格資料 (HWND: 0x10B8C)...
2026-05-22 22:11:03 [ER] 嘗試使用 UIA 從 HWND (0x10B8C) 讀取資料...
2026-05-22 22:11:03 [ER] UIA 尋獲 12 個 Row 元素,UIA 讀取成功
透過此項優化,即使快取失效,排程程式依然能在鎖定狀態下透過 UIA 直接更新快取,徹底杜絕了統計數據被置零的 Bug。
6. 深度技術解析:為什麼在 RDP 斷線後,UIA 直達控制項 HWND 依然能成功讀取資料?
根據實測 Log,在 RDP 鎖定的後台環境下,獨立腳本在資料擷取階段依然能成功透過軌道一 (UIA 讀取) 獲取資料。核心差異在於 「存取視窗控制項的入口方式不同」:
6.1 「UIA 樹遞迴走訪 (FindElement)」的硬傷
在舊版 RisController 中,定位方式是從主視窗節點向下遞迴搜尋。當 RDP 連線關閉、Session 鎖定後,Windows 停止為該會話渲染圖形介面 (GUI),作業系統亦不再更新整個視窗的 UIA 樹狀階層。因此,調用 FindElement 會因為樹狀結構不全而拋出「元件不存在」的錯誤。
6.2 「直達控制項 HWND (ElementFromHandle)」的優勢
新版獨立腳本在定位階段已經直接鎖定了三個 DataGridView 子控制項的具體 HWND(例如 0x181590)。在讀取資料時,程式直接呼叫:
elGrid := UIA.ElementFromHandle(hwnd) ; hwnd 直接傳入 0x181590
- 原理:
UIA.ElementFromHandle(hwnd)是 Windows 提供的直達通道。它不需走訪主視窗的 UIA 階層樹,而是直接向特定的 HWND(由 .NET WinForms 的 DataGridView 元件實作的IRawElementProviderSimple介面)發送查詢。 - 只要該控制項所屬的視窗尚未關閉,雖然「圖形介面沒有被渲染繪製」,但該控制項底層的記憶體模型與資料結構依然完好存在。因此,直接向該子 HWND 請求 UIA 元素時,UIA 依然能成功訪問記憶體中的數據模型並讀出 Row,完全不依賴主視窗 UIA 樹的動態生成與更新。
7. 方案對比總覽 (Comparison Table)
| 特性 / 機制 | 舊版作法 (RisController 常駐 Timer) | 新版獨立腳本 (AutoWorklistUpdate.v2.ahk) |
|---|---|---|
| 執行載體 | AHK 主程式執行緒內以 SetTimer 執行 | 獨立腳本,由 Windows 工作排程器 (Task Scheduler) 定時調用 |
| RDP 斷線檢測 | 偵測到 RDP 斷線即主動退出 | 忽略中斷限制,做為是否啟用 UIA 備援定位的判斷依據 |
| 控制項定位 | 完全依賴 UIA 遞迴搜尋 (FindElement) | 快取 HWND 驗證 + Session-Agnostic UIA 動態定位 (座標 Fallback 保底) |
| 點擊重新整理 | 呼叫 UIA 的 elBtn.Invoke() 或實體點擊 | 使用 PostMessage 發送 BM_CLICK 訊息至 HWND (後台靜默觸發) |
| 表格資料讀取 | 單軌依賴 UIA (TreeWalker) | 雙軌制:優先 UIA 直讀,失敗自動 Fallback 至 ControlSend 剪貼簿複製 |
| 資源與穩定性 | 常駐背景佔用執行緒,若卡死會影響主程式運作 | 每次執行完即關閉,不佔用常駐資源,且有獨立排程防卡死機制 |
8. 關鍵反思:架構隔離與技術重構的雙重奏 (Retrospective)
要實現 RDP 斷線與鎖定 Session 下的完美自動化,必須是 「架構上的執行緒隔離」 與 「技術上的低階機制重構」 雙管齊下,兩者缺一不可:
- 獨立腳本運作 (架構隔離) -> 解決「流暢度與穩定性」:為自動更新建立了一個沙盒化的隔離執行緒環境。即使更新過程在後台卡死、報錯或超時,也完全不影響前台主程式的正常運作,避免前台醫師遇到嚴重的鍵盤與畫面卡頓 (UI Freezing)。
- 低階機制重構 (技術突破) -> 解決「可行性」:引進了 HWND 快取直達、UIA 直讀,以及非實體後台觸發的
PostMessage(BM_CLICK) 與ControlSend(剪貼簿複製備援),完全繞過了作業系統停止渲染圖形介面的物理限制。
9. 總結與部署建議 (Deployment Suggestion)
透過這次的架構重構與最近對 Locked Session 幾何定位 Bug 的修正,我們成功實現了「在 RDP 斷線且螢幕鎖定」的後台環境下,穩定自動化 UI 的目標。這對於需要長期在醫療工作站後台執行監控、上傳數據的場景提供了極其強韌的技術方案。
部署指南:
- 初次執行:在有畫面連線的狀態下,手動執行一次
AutoWorklistUpdate.v2.ahk,或讓主程式RisController運作一次,以便在config/worklist-controls.ini中建立正確的控制項 HWND 快取與座標資訊。 - 設定工作排程器 (Task Scheduler):
- 建立一個新工作,設定觸發程序為每 30 分鐘執行一次。
- 動作:啟動程式,設定為
D:\USER\Documents\ahk-nycuh\Utilities\AutoWorklistUpdate.v2.ahk(或透過 AutoHotkey 執行檔載入此腳本)。 - 關鍵設定:在安全性選項中,勾選 「不管使用者登入與否均執行」 (Run whether user is logged on or not),這能確保排程器在背景以 Non-interactive Session 執行,或者在 RDP 斷線後依然能夠穩定喚醒並執行此腳本。


