第二章開始。
今天的主線只有一句:UIA 的一次性後代搜尋會停在 HWND 邊界上,而逐層走訪不會。
Windows 11 的現代應用普遍有這種邊界,所以這不是特例,是預設會遇到的事——後面的量測、解法與我估錯的那段成本,都是在支撐這一句。
Windows 11 的新版記事本。視窗找得到,標題正確,hwnd 有值。然後:
window.re_resolve_element().find_descendant(control_type_id=50030) # Document
# ElementNotFoundError
50030 是記事本的文字編輯區,畫面上佔了視窗九成面積。用 Inspect 打開來看,它確實
在 UIA 樹上。
我在 ARM64 上實測,同一個元素、兩種走訪方式:
| 做法 | 結果 |
|---|---|
單次 FindFirst + TreeScope_Descendants |
找不到,約 200 ms |
RawViewWalker 逐層走訪 |
找到,4–7 ms |
三次連續量測都一致。
而且失敗的方式很有特色:它不是立刻失敗,是等到逾時才失敗。 探索邏輯會重試——它無法區分「元素還沒出現」與「元素永遠找不到」,於是照著逾時值一直試下去。
所以這裡有兩個時間尺度,講的是兩件事:單次呼叫的成本告訴你機制有多貴(200 ms);
使用者實際等到的是整個逾時。
痛的來源是重試,不是那一次呼叫——我一開始把兩者混為一談,差點去優化錯的東西。
記事本的編輯區是 RichEditD2DPT,它住在一個子 HWND 裡。這是 Windows 11 現代應用的普遍結構:外層是 XAML 的殼,裡面嵌著傳統的 Win32 子視窗,或者反過來。從使用者的角度那是一個視窗,從系統的角度那是好幾個 HWND 疊在一起。
要理解為什麼「一次搜尋」會跨不過去,需要一個心智模型:
UIA 不是一組你呼叫的函式,而是一個跨處理程序的物件系統。你手上的每一個元素都是一根指向別人記憶體的遠端指標。
elem = window.re_resolve_element().find_descendant(automation_id="num1Button")
直覺上 elem 是個「按鈕物件」,裡面裝著名字、位置、狀態。實際上它是一根 COM 介面指標,指向另一個處理程序裡的東西;每次讀 elem.name,都是一次跨處理程序呼叫,由被測應用程式那一端的 UIA provider 回答。
關鍵在最後那個詞:provider。UIA 樹在概念上是一棵,實作上卻是由多個 provider 拼起來的——每個 HWND 可能有自己的一個。所以「後代搜尋」跨不跨得過去,取決於這些 provider 之間的銜接,而那是別人的實作,不是你的。
所以在樹上找東西有兩種機制,而它們把這個風險放在不同的人手上:FindFirst / FindAll 給條件和 TreeScope,讓 UIA 一次找完;TreeWalker(GetFirstChildElement、GetNextSiblingElement)則是自己一個節點一個節點爬——RawViewWalker 就是它的一種。
逐層走訪為什麼跨得過去?因為它一步一步問。GetFirstChildElement 每次只跨一層,由該層的 provider 回答,沒有「一次跨越整棵子樹」這個需要被正確實作的語意。
宣告式的查詢把跨越邊界這件事交給了 provider;命令式的走訪把它留在自己手上。
我在 Windows 11 26100 ARM64 上把三個真實應用的結構量過一遍,差異大到值得列出來。
Files(WinUI 3)——WinUIDesktopWin32WindowClass,UIA 後代 101 個,子 HWND 四個:
InputNonClientPointerSource
ReunionWindowingCaptionControls
Microsoft.UI.Content.DesktopChildSiteBridge
Chrome_WidgetWin_0
DesktopChildSiteBridge 是 WinAppSDK 的 content island——XAML 內容真正住的地方;Chrome_WidgetWin_0 是內嵌的 WebView2。也就是說,你想找的東西幾乎一定在某個子 HWND 裡面,而不是在你抓到的那個頂層視窗上。
DB Browser for SQLite(Qt 6.8.3)——Qt683QWindowIcon,零子 HWND。Qt 全部自繪,所有結構都來自 QAccessible→UIA 橋接,所以它從來不會撞到今天這道牆。UIA 覆蓋反而意外地豐富:38 個 Button、11 Tab、12 TreeItem。
WinMerge(Win32/MFC)——另一種極端:編輯窗格對 UIA 完全不透明,class 名稱是Afx:00007FF6C8380000:8 這種含基底位址的東西(每次啟動都不同,不能當識別依據),沒有任何 pattern,WM_GETTEXT 也不回應。
一句話總結這三個:UIA 是一個承諾統一的抽象,而它的實際覆蓋率取決於每個框架的 provider 實作了多少。 今天這道牆只是其中一種失敗方式——有的沒有 pattern、有的 pattern 時有時無、有的連 class name 都不穩定,有的同一個 app 內部就不一致。
Walker 分三種視圖,而視圖會改變你對「元素不存在」的判斷:
| 視圖 | 內容 |
|---|---|
| Raw | 所有元素,包含純排版用的容器 |
| Control | 過濾掉沒有互動意義的裝飾元素 |
| Content | 只留下承載內容的元素 |
一個元素在 Control view 找不到,不代表它不存在——它可能只是被 provider 標成了「裝飾」,而「什麼算裝飾」本來就沒有唯一答案。這是同一個問題的另一面:你查不到,可能是別人替你做了決定。
while time.monotonic() < deadline: # 第一段:照逾時重試 FindFirst
found = self._element.FindFirst(TreeScope_Descendants, full_cond)
if found:
return UiaElement(found)
time.sleep(0.1)
# 第二段:整段逾時之後,RawViewWalker 逐層走訪一次,跨得過子 HWND 邊界
walker = uia.RawViewWalker
def search_walker(node, depth=0):
if depth > 15 or not node: # 唯一的上界:深度 15
return None
...
相關原始碼:
src/wintegrate/element.py#L534-L671與高階查詢引擎src/wintegrate/locators.py#L84-L510
三個設計決定值得說明。
用 Raw view,不用 Control view。 既然已經在處理「provider 行為不如預期」的情況,就不該再讓它幫我們過濾。
走訪必須有上界。 一棵 UI 樹可以很深很寬,深度優先在病態情況下會爆炸。所以有depth > 15 這條線——一個修正逾時問題的機制,本身不可以變成新的逾時來源。
它只跑一次,而且排在逾時之後。 這是取捨,不是最佳解:成功路徑完全不受影響,代價是失敗路徑得先把整個逾時付完,才輪到真正找得到的那個機制。想縮短失敗時間,就該把它移到重試迴圈之前——那是這份程式碼目前還沒做的事。
老實說一段。code review 時我判斷這個 fallback 會帶來明顯的效能退化,理由聽起來很合理:每次搜尋都可能要走整棵樹,而 FindFirst 是 UIA 原生實作,應該快得多。
這個直覺錯在兩個地方。
第一,FindFirst 也是跨處理程序呼叫,它沒有比較「原生」到哪裡去。真正的成本在IPC 往返次數,不在誰實作了那個迴圈——同樣的往返,換誰寫那個迴圈都一樣貴。
第二,它根本不在成功路徑上。走訪排在整個重試視窗之後,第一段命中的搜尋一次都碰不到它。我把「可能走整棵樹」的最壞情況,當成了平均情況。
教訓不是「效能不重要」,而是在跨處理程序的系統裡,你對成本的直覺會系統性失準:它同時錯估了單位成本,也錯估了那條路徑被走到的頻率。
三個徵兆同時出現,八成就是:
FindFirst 找不到,而且要等到逾時才失敗(不是立刻失敗)第 2 點特別有診斷價值。「立刻找不到」通常是查詢條件寫錯;「等到逾時才找不到」是 UIA 真的去找了、找遍了、然後放棄——那代表它找的地方跟你以為的地方不一樣。
FindFirst 的後代搜尋跨不過那道邊界,而且是逾時才失敗,不是立刻失敗RawViewWalker 逐層走訪就跨得過去:4–7 ms 命中 vs FindFirst 約 200 ms 找不到明天換一個方向:不是「找不到」,而是「找到了,但找到的是錯的視窗」——而且它不會拋任何例外。