Windows UI自动化实战:用句柄和SendMessage操控老程序
2026/9/16 21:46:46 网站建设 项目流程

前阵子接手一个老旧的仓库管理客户端,每天下午都要做一轮重复操作:打开入库单、点刷新、把某个文本框里的数量读出来,再填到另一个位置。领导问我能不能写个脚本自动跑。我第一反应是找现成的自动化工具,但这类工具大多不会正经对付这种老式 Win32 程序——它们抓不到控件,回放也总出乱子。折腾了一下午,最后发现还是得回到 Windows 最底层的机制:通过句柄 ID 直接操作窗口,发送点击消息控制按钮,再用 WM_GETTEXT 把文本框数据读回来。这条路看起来土,实际上却是跨进程 UI 自动化里最稳定的一条。

这篇文章适合三类人:需要给老旧桌面软件写自动化脚本的;做 Windows 端测试工具、辅助工具的;以及想理解"为什么某些自动化库能点按钮、能取文本"的底层原理的。我会把从找窗口、找控件、点击按钮、读写文本框的完整链路拆开讲清楚,包括工具使用、API 原理、完整示例和踩坑记录。这里的示例全部基于 Windows 自带的组件和你有权操作的程序,不需要额外安装运行环境,照着敲就能看到效果。

1. 先搞清楚:为什么操作别的程序界面要认句柄

1.1 什么场景真的需要跨进程点按钮、读文本框

很多人的自动化需求其实来自"没有接口只能点界面"的老系统。常见的有几类:

  • 老 ERP / 进销存 / 仓储客户端,只提供了人工操作的界面,没有开放 API,也没有数据库连接权限。
  • 第三方配送、打印、客服平台提供的 Windows 客户端,需要在登录后自动填写内容、点按钮提交。
  • 内部自研但已经没人维护的 MFC/Delphi 工具,领导要求"先自动化掉一部分重复动作"。

这类程序最大的特点就是:控件是标准 Windows 控件,窗口标题、按钮文本、输入框都看得见,但没有官方脚本接口。遇到这种情况,唯一通用的方式就是走窗口句柄。

注意一个前提:只建议用在自己有权限、不影响他人使用的程序上。如果是公司内部系统,先和相关负责人确认用途再写自动化,免得引发不必要的权限问题。

1.2 句柄 ID 是什么:理解 HWND 和窗口对象模型

句柄 ID,完整说法是 HWND(Handle to a Window)。它是 Windows 内核为每个窗口分配的一个数字标识,在整个桌面会话中唯一标识某个窗口。你可以把它理解成"窗口的身份证号":进程 A 希望操作进程 B 的窗口,不是直接去读 B 的内存,而是拿这个 ID 向系统发出命令。

这里有个关键认知:HWND 不是固定不变的。每次程序启动重新创建窗口时,系统都会重新分配一个 ID。同一程序这次运行是 0x00040802,下次可能变成 0x00090B20。所以脚本里绝对不能把句柄值写死,必须每次启动通过窗口标题、类名等条件去动态查找。

整个跨进程 UI 操作的基本链路是:

  1. 找到目标进程的主窗口句柄。
  2. 在主窗口下枚举或直接查找子控件句柄。
  3. 向按钮、输入框等控件发送对应的窗口消息。
  4. 从控件读取返回结果(文本、状态、数据)。

核心不是"模拟用户鼠标点击"的物理动作,而是"给窗口发消息"。这比模拟鼠标稳定得多,因为它不要求目标窗口一定在前台,也不会因为鼠标误碰而失败。

1.3 句柄 vs 模拟鼠标 vs 屏幕坐标,区别在哪

打个比方:模拟鼠标像你隔着玻璃窗给人递纸条,别人看不看得到取决于窗户是否打开、纸条是否递到正确位置;发窗口消息就像你直接打电话到对方公司总机,说出分机号(句柄),总机帮你转到对应办公室。

屏幕坐标方案依赖分辨率、窗口位置、缩放比例,窗口一移动就废。而句柄操作和窗口在屏幕上的位置无关,只要窗口还活着,就能调用。

所以,当目标程序的按钮和输入框是标准 Windows 控件(原生 Win32、MFC、VB6、Delphi 等)时,句柄方案是优先级比较高的选择。后面会讲到它搞不定的场景再怎么办。

2. 侦察阶段:用 Spy++ 和 Inspect 把目标窗口剥开看

写代码之前,必须先看清目标窗口的内部结构。不然你连"该往哪个类名找控件"都不知道。

2.1 Spy++:用一个拖拽准星看它的类名、标题、控件 ID

Spy++ 是 Visual Studio 自带工具,装过 VS 就能找到。在 VS 的"工具"菜单里能直接打开,它是一个独立进程,通常叫 spyxx.exe,64 位机器的控件如果抓不到配合 spyxx_amd64.exe 使用。

打开后重点做两件事:

  • 把窗口层级树展开,观察目标窗口下面有哪些子窗口、子窗口的类名和标题分别是什么。
  • 用工具栏上的"查找窗口"图标(准星),拖到目标程序上,直接看当前鼠标处的窗口信息。

拖拽查找是最快的。把准星拖到某个按钮上,Spy++ 会显示它的窗口句柄、类名、标题、控件 ID。比如一个"确定"按钮,你可能会看到:类名 Button,标题"确定",控件 ID 1,样式位里有 BS_PUSHBUTTON。

这些信息就是写代码的"地图"。我一般会开一个表格,逐个记下要操作的控件:主窗口类名和标题、输入框类名和 ID、按钮类名和 ID。后面写 FindWindow、FindWindowEx、GetDlgItem 全用得着。

补充一个经验:老程序里按钮类名不一定是 "Button"。Delphi 写的程序的按钮类名通常是 TButton,编辑框是 TEdit;VB6 程序的控件类名则可能是 ThunderButton、ThunderTextBox 这一套。所以不要想当然,一定要以 Spy++ 看到的类名为准。这也是为什么总强调"先侦察再动手"。

2.2 Inspect:能看到的日常之外的 UIA 信息

Microsoft 提供的 Inspect.exe 在 Windows SDK 里,它和 Spy++ 的区别在于:Spy++ 主要看 Win32 窗口句柄结构,Inspect 主要看 UI Automation(UIA)树。

如果你用句柄方式找不到目标按钮,用 Inspect 看看它的 UIA 信息会更清晰。Inspect 能显示每个控件的:

  • ControlType(控件类型,比如 Button、Edit、Text)
  • Name(可访问名称)
  • AutomationId(自动化 ID)
  • 支持的 Pattern(比如 ValuePattern、InvokePattern)

这些信息不仅是给 UIA 框架用的,也能帮你判断一个控件是不是真的存在、是不是可交互。如果 Inspect 里能看到这个控件,但 Spy++ 的窗口层级里找不到对应子句柄,基本可以判断它是自绘/合成控件,句柄方案会有难度,需要转 UIA。

2.3 从层级树判断该走句柄还是走 UI 自动化框架

拿到层级树后,做个快速判断:

表现技术路线
标准 Win32 控件,子窗口能找到,类名是 Button/Edit/Static直接用 HWND + SendMessage
控件存在,子窗口也能枚举,但 ReadProcessMemory 或 WM_GETTEXT 取不到数据先确认控制权限,再考虑自绘或 UIA
Spy++ 找不到子窗口,整个界面只有一个大窗口类大概率是 Direct UI / 自绘,改用 UIA / 坐标方案
Inspect 里能找到 Edit 和 Button,且有 ValuePattern/InvokePattern优先用 UIA,效率虽低但可靠

这个判断决定你后面写的是几十行句柄代码,还是几百行 UIA 代码。不要盲目选择,先用两个工具把目标看透。

3. 核心 API 逐个拆解:定位、枚举、发消息、读写文本

这一节是全文的技术核心。我按实际使用顺序来讲解,不按文档顺序。

3.1 FindWindow / FindWindowEx / GetDlgItem 定位窗口和控件

定位主窗口,最常用的是 FindWindow:

HWND hMain = FindWindowW(L"#32770", L"运行");

第一个参数是窗口类名,第二个是窗口标题。两个都可以传 NULL,表示不限制。但建议同时给,除非标题里带了版本号才用类名。

FindWindow 只找顶层窗口,不找子窗口。要往下找子控件,有三个方向:

  • FindWindowEx:从指定父窗口开始找子控件,可以传类名和标题。
  • GetDlgItem:直接按控件 ID 找子控件,适用于标准对话框。
  • EnumChildWindows:递归枚举所有子控件,适合不知道 ID、需要过滤的场景。

FindWindowEx 的典型用法:

HWND hEdit = FindWindowExW(hMain, NULL, L"Edit", NULL);

第二个参数是"从哪个同级窗口之后开始找",常用来遍历同一父窗口下的多个同类型控件。第一次传 NULL,第二次传上一次的结果,就能一个个往下找。

GetDlgItem 则非常直接:

HWND hOk = GetDlgItem(hMain, 1); // 1 通常是 IDOK 确定按钮

前提是你能从 Spy++ 看到控件 ID。大多数标准对话框的"确定"按钮 ID 就是 1,但不要依赖这个记忆,去 Spy++ 里对一眼。

3.2 EnumChildWindows 枚举窗口树,筛选目标控件

有些程序不按标准 ID 来,比如多个输入框都没有固定标题。这时用 EnumChildWindows 把所有子窗口捞出来,看一遍类名和可见性,再定位。

C++ 回调写法:

BOOL CALLBACK EnumProc(HWND hChild, LPARAM lParam) { WCHAR cls[256]; GetClassNameW(hChild, cls, 256); wprintf(L"hwnd=%p class=%s\n", hChild, cls); return TRUE; // 返回 TRUE 继续枚举,FALSE 停止 } EnumChildWindows(hMain, EnumProc, NULL);

Python ctypes 也可以写一样的回调:

EnumChildWindowsProc = ctypes.WINFUNCTYPE( wintypes.BOOL, wintypes.HWND, wintypes.LPARAM ) def enum_callback(hwnd, lparam): cls = ctypes.create_unicode_buffer(256) user32.GetClassNameW(hwnd, cls, 256) children.append((hwnd, cls.value)) return True children = [] user32.EnumChildWindows(hMain, EnumChildWindowsProc(enum_callback), 0) for h, c in children: print(hex(h), c)

跑一遍通常能看到十几到几十个窗口项。过滤时建议用IsWindowVisible(hwnd)排除隐藏控件,只保留可见的 Edit、Button 类。Windows 本身有很多隐藏辅助窗口,不过滤容易被坑到。

3.3 SendMessage 与 PostMessage,以及发送结果怎么看

这两种方式本质都是向窗口发送消息,但区别很大:

  • SendMessage:同步。消息发送给目标控件所在线程,等目标处理完才返回。可以拿到处理结果(比如 WM_GETTEXT 后结果放到缓冲区里)。如果目标程序卡死,SendMessage 会一直等。
  • PostMessage:异步。把消息丢进目标线程的消息队列就返回,不关心处理结果,也不会有返回值。适合"只管发、不管结果"的通知消息。

点击按钮、读取文本这类需要结果的操作,必须用 SendMessage。PostMessage 发 BM_CLICK 虽然也能触发,但你不确定对方处理了没有,出了问题很难排查。

另外要说一个返回值细节:SendMessage 返回的是 LRESULT,是一个ssize_t类型。判断按钮点击是否成功、文本是否读取成功,主要看返回值和 GetLastError。在 Python ctypes 里不声明 restype 很容易把 64 位返回值截成 32 位,这个后面示例部分会写。

3.4 读写文本:WM_GETTEXT、WM_GETTEXTLENGTH、WM_SETTEXT

读取输入框文本,发送 WM_GETTEXT:

wchar_t buf[4096] = {0}; SendMessageW(hEdit, WM_GETTEXT, 4096, (LPARAM)buf);

WM_GETTEXT 的作用是把窗口文本复制到调用方提供的缓冲区。返回值是实际复制的字符数,如果为 0,说明窗口没内容,或者消息被拒绝。

为了不浪费缓冲区,先读长度:

int len = SendMessageW(hEdit, WM_GETTEXTLENGTH, 0, 0); wchar_t* buf = new wchar_t[len + 1]; SendMessageW(hEdit, WM_GETTEXT, len + 1, (LPARAM)buf);

写入文本则发 WM_SETTEXT:

SendMessageW(hEdit, WM_SETTEXT, 0, (LPARAM)L"需要填入的内容");

WM_SETTEXT 会直接替换编辑框全部内容,相当于用户先全选再粘贴。注意,有些输入框会响应文本变化事件,填完之后立刻触发界面联动逻辑,这属于正常现象,但脚本里要注意下一步操作的时机,适当等待界面刷新。

这个机制能生效的前提是:目标控件是标准 Edit 控件或基于标准消息处理的自绘控件。遇到完全自绘的内容区域,WM_GETTEXT 大概率返回空字符串,这个我们放到踩坑部分细说。

3.5 点击按钮的三种消息:BM_CLICK、WM_COMMAND、鼠标消息

点击按钮最推荐的消息是 BM_CLICK:

SendMessageW(hButton, BM_CLICK, 0, 0);

BM_CLICK 让按钮按标准"被点击"语义处理,包括按下、抬起、重绘、通知父窗口完成一次点击。这个方式不依赖按钮在屏幕上的坐标,也不用把窗口切到前台,非常稳。

第二种是直接给按钮父窗口发 WM_COMMAND:

SendMessageW(hParent, WM_COMMAND, MAKEWPARAM(btnId, BN_CLICKED), (LPARAM)hButton);

命令格式是(控件ID << 16) | BN_CLICKED。这种方式不经过按钮控件,直接通知父窗口"按钮被点了"。有些程序对 BM_CLICK 的响应不好,改用 WM_COMMAND 反而有效。但要小心,如果父窗口没有正确处理这个通知,可能无效。

第三种是模拟鼠标物理点击:

SendMessageW(hButton, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(x, y)); SendMessageW(hButton, WM_LBUTTONUP, 0, MAKELPARAM(x, y));

只建议在遇到自绘控件但按钮本身能响应鼠标消息时使用。因为它要传坐标,而且要求按钮能处理鼠标消息,不如 BM_CLICK 通用。

这里放一个关键消息常量表,方便查阅:

消息用途
WM_SETTEXT0x000C设置窗口文本
WM_GETTEXT0x000D读取窗口文本
WM_GETTEXTLENGTH0x000E获取文本长度
WM_COMMAND0x0111向父窗口发送命令/通知
WM_LBUTTONDOWN0x0201鼠标左键按下
WM_LBUTTONUP0x0202鼠标左键抬起
BM_CLICK0x00F5模拟按钮点击

4. 一个能跑的完整示例:驱动"运行"对话框,启动记事本并读写文字

4.1 用 Python ctypes 还是 C++,为什么脚本场景我建议 Python

如果只是写一次性自动化脚本,我建议用 Python ctypes,理由是:不用编译、不用装 VC 运行库、可以快速加判断逻辑,而且同一套 API 在 C++ 里的命名几乎一致。缺点是要手动处理类型声明,否则会踩 64 位句柄截断的坑。

C++ 的优势是类型安全、原生、适合封装成长期维护的工具。但脚本场景,尤其是一次性或半自动场景,Python 更实用。

示例场景:用 Win+R 打开"运行"对话框,输入 notepad 并按确定按钮打开记事本,然后往记事本的编辑区写一行文字,再把内容读回来打印。这个流程覆盖了找窗口、找子控件、设文本、点按钮、读文本全部环节。

4.2 从打开运行框到点确定:完整脚本步骤

完整 Python 示例:

import ctypes import time from ctypes import wintypes user32 = ctypes.windll.user32 # 64 位 Python 下不声明函数签名,句柄会被当成 32 位整数截断 user32.FindWindowW.restype = wintypes.HWND user32.FindWindowW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR] user32.FindWindowExW.restype = wintypes.HWND user32.FindWindowExW.argtypes = [ wintypes.HWND, wintypes.HWND, wintypes.LPCWSTR, wintypes.LPCWSTR ] user32.SendMessageW.restype = ctypes.c_ssize_t user32.SendMessageW.argtypes = [ wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM ] user32.GetDlgItem.restype = wintypes.HWND user32.GetDlgItem.argtypes = [wintypes.HWND, ctypes.c_int] WM_SETTEXT = 0x000C WM_GETTEXT = 0x000D BM_CLICK = 0x00F5 VK_LWIN = 0x5B VK_R = 0x52 KEYEVENTF_KEYUP = 0x0002 def press_key(vk): user32.keybd_event(vk, 0, 0, 0) user32.keybd_event(vk, 0, KEYEVENTF_KEYUP, 0) # 1. 模拟 Win+R,打开运行对话框 press_key(VK_LWIN) press_key(VK_R) time.sleep(0.8) # 2. 找运行对话框,类名 #32770 是标准对话框窗口类 hwnd_run = user32.FindWindowW("#32770", "运行") if not hwnd_run: raise RuntimeError("找不到运行对话框") # 3. 找输入框,并写入 notepad h_edit = user32.FindWindowExW(hwnd_run, 0, "Edit", None) if not h_edit: raise RuntimeError("找不到运行输入框") addr = ctypes.addressof(ctypes.c_wchar_p("notepad")) user32.SendMessageW(h_edit, WM_SETTEXT, 0, addr) # 4. 找确定按钮并点击,ID=1 是 IDOK h_ok = user32.GetDlgItem(hwnd_run, 1) if not h_ok: raise RuntimeError("找不到确定按钮") user32.SendMessageW(h_ok, BM_CLICK, 0, 0) # 5. 等待记事本窗口出现,按类名 Notepad 找 time.sleep(1.2) hwnd_notepad = user32.FindWindowW("Notepad", None) if not hwnd_notepad: raise RuntimeError("记事本没有打开") print("记事本窗口句柄:", hex(hwnd_notepad)) # 6. 在记事本编辑区写入一行文字 h_main_edit = user32.FindWindowExW(hwnd_notepad, 0, "Edit", None) if not h_main_edit: raise RuntimeError("找不到记事本编辑区") addr2 = ctypes.addressof(ctypes.c_wchar_p("hello from hwnd")) user32.SendMessageW(h_main_edit, WM_SETTEXT, 0, addr2) # 7. 读回编辑区内容 buf = ctypes.create_unicode_buffer(4096) buf_addr = ctypes.addressof(buf) n = user32.SendMessageW(h_main_edit, WM_GETTEXT, 4096, buf_addr) print("读取到字符数:", n) print("内容:", buf.value)

运行前确认几点:脚本要在桌面会话里跑,不要用服务方式跑;系统是中文 Windows 的话"运行"标题不变,英文系统要把标题改成 "Run"。如果 FindWindow 找不到,多半是标题或类名不对,先用 Spy++ 核对。

4.3 在记事本编辑区里写入并读回内容:WM_SETTEXT/WM_GETTEXT 实战

示例第 6、7 步就是完整的"写文本 + 读文本"实践。

写入时我把字符串指针地址转成了 LPARAM 传给 WM_SETTEXT,系统会从指针地址读取字符串。必须保证指针在 SendMessage 调用期间一直有效。这里用ctypes.c_wchar_p("notepad")临时创建,并在同一行取地址,理论上调用结束前对象仍存活。如果觉得不保险,可以改成:

text_obj = ctypes.c_wchar_p("notepad") user32.SendMessageW(h_edit, WM_SETTEXT, 0, ctypes.addressof(text_obj))

读取时缓冲区要足够大。我用 4096 字符,一般编辑框不会超。如果内容可能很长,先发 WM_GETTEXTLENGTH 获取长度再建缓冲区。

n 的值是关键判断点:如果 n 等于 0,说明没读到内容或消息被拒绝。记事本编辑区正常情况下一定能读到,读不到就检查这个编辑句柄是不是正确的子控件。

4.4 等窗口出现和等按钮可用的等待策略

脚本中最容易翻车的就是时序。窗口还没创建完,就 FindWindow 去找,返回 0;按钮还没初始化,就发 BM_CLICK,没反应。

推荐两种等待策略:

  1. 固定延时。适合演示,但不健壮。窗口慢时容易误判失败。
  2. 轮询等待:循环 FindWindow / IsWindowVisible / IsWindowEnabled,直到条件成立或超时。

轮询的示例:

def wait_window(class_name, title, timeout=5): start = time.time() while time.time() - start < timeout: hwnd = user32.FindWindowW(class_name, title) if hwnd: return hwnd time.sleep(0.1) return 0 hwnd_notepad = wait_window("Notepad", None, 8)

同理,判断按钮是否可用用 IsWindowEnabled:

user32.IsWindowEnabled.argtypes = [wintypes.HWND] user32.IsWindowEnabled.restype = wintypes.BOOL if not user32.IsWindowEnabled(h_ok): raise RuntimeError("按钮还不可用")

写完代码自己加几条判断,后面排错会轻松很多。

5. 踩坑记录:按钮没反应、文本读不到、权限被拒

5.1 UIPI 权限隔离:为什么消息发过去"没反应"

Windows Vista 以后,系统引入用户界面特权隔离(UIPI)。低完整性级别的进程不能向高完整性级别进程发送消息。具体到我们的场景:如果目标程序已是管理员权限运行,而你的脚本只是普通权限打开,那么 SendMessage 发出的 BM_CLICK、WM_GETTEXT 很可能被系统静默拦截,表现就是:代码执行了,SendMessage 返回 0 或某个垃圾值,目标界面没有任何反应。

解决办法最简单的是:目标程序以管理员运行,脚本也以管理员运行,让二者处于同一完整性级别。也可以用"以管理员身份运行"打开命令行再启动 Python,确保整条链同权限。

碰到按钮死活没反应时,先检查这个。不要一上来怀疑消息参数,先确认双方权限一致。

5.2 标准控件与自绘控件的分水岭

标准 Win32 控件(Button、Edit、Static、ComboBox 等)都支持 WM_GETTEXT/WM_SETTEXT/BM_CLICK 这套消息,因为它们本质是用公共控件库实现的。MFC、VB6、Delphi 老程序大多数也沿用这些标准消息,区别只是类名不同。

但自绘控件是另一回事。很多现代界面框架(WPF、Qt 自绘、CEF、Electron、游戏 UI)会在窗口客户区自己画所有内容,子窗口里没有对应的 Button/Edit 句柄。这时:

  • FindWindowEx 找不到"按钮"。
  • 找到的可能是唯一的"窗口外壳",向它发 WM_GETTEXT 拿到的是空字符串或整个窗口标题。
  • WM_SETTEXT 也不会真正改变界面内容。

遇到这种情况,先用 Inspect 看有没有 UIA 信息。如果能通过 UI Automation 看到控件节点,就改用 UIA;如果连 UIA 都看不到,那就只能用图像识别 + 坐标模拟点击的兜底方案了。

5.3 位数不一致引发的查找异常

64 位 Python 脚本操作 64 位目标程序,没问题;32 位脚本操作 32 位目标,也没问题。最容易出问题在跨位宽场景。

Windows 的窗口句柄本身是 64 位系统上的 64 位数字。在 Python ctypes 里,如果不给 FindWindowW / FindWindowExW 声明 restype,默认返回类型是 c_int,就会把 64 位句柄截断成 32 位,导致拿到的句柄根本不对。这在 64 位 Python 上特别常见。

解决办法:统一声明 restype 和 argtypes,这是第 4 节代码里已经做的事情。另外,用 EnumChildWindows 跨位宽枚举时,枚举回调里最好也不要做 GetWindowLongPtr、SetWindowLongPtr 这类依赖进程位宽的取地址操作,那个很容易崩或拿错值。只用 GetClassName、GetWindowText 这类消息机制,基本安全。

5.4 误用 wParam/lParam 的典型错误

我见过不少把 WM_COMMAND 参数写反的例子。WM_COMMAND 的 wParam 高 16 位是通知码(如 BN_CLICKED),低 16 位是控件 ID。lParam 才是按钮句柄。很多人一开始会写MAKEWPARAM(BN_CLICKED, btnId),把顺序搞反,结果按钮确实收到了命令,但程序觉得通知来源不对,什么都不做。

正确的写法是:

SendMessageW(hParent, WM_COMMAND, MAKEWPARAM(btnId, BN_CLICKED), (LPARAM)hButton);

同理,BM_CLICK 比较简单,wParam 和 lParam 都传 0 就行。WM_GETTEXT 的 wParam 是缓冲区大小,lParam 是缓冲区地址,写反了会直接内存问题。消息参数不复杂,但每个参数含义都要对表确认。

6. 边界在哪:什么时候应该换用 UI Automation

6.1 哪些程序用句柄搞不定

不是所有 Windows 程序都适合句柄操作。除了上面说的自绘界面,还有两类情况:

  • UWP / Microsoft Store 应用。这类应用窗口模型特殊,普通 Win32 枚举拿不到控件树,需要用 UIA 才能访问。
  • 嵌入网页的界面(CEF、WebView)。界面内容在渲染进程里,Win32 窗口只是一个容器,句柄操作基本无效。

如果你发现目标程序的窗口能枚举到,但控件结构非常扁平、内容取不到,那基本可以认定是这类情况。

6.2 UIA 的 InvokePattern 和 ValuePattern 怎么操作

UI Automation 是 Windows 为辅助功能和自动化测试提供的框架。它把界面抽象成一棵 Automation Tree,每个节点有 ControlType、Name、AutomationId 等属性,并支持各种 Pattern(模式)。最常用的两个:

  • ValuePattern:读写输入框文本。
  • InvokePattern:触发按钮点击。

用 C# 写一小段示例:

using System.Windows.Automation; var root = AutomationElement.RootElement; var condition = new PropertyCondition( AutomationElement.NameProperty, "目标窗口标题"); var window = root.FindFirst(TreeScope.Children, condition); var editCondition = new PropertyCondition( AutomationElement.ControlTypeProperty, ControlType.Edit); var edit = window.FindFirst(TreeScope.Descendants, editCondition); var valuePattern = (ValuePattern)edit.GetCurrentPattern(ValuePattern.Pattern); string currentText = valuePattern.Current.Value; valuePattern.SetValue("新的内容"); var buttonCondition = new PropertyCondition( AutomationElement.NameProperty, "确定"); var button = window.FindFirst(TreeScope.Descendants, buttonCondition); var invokePattern = (InvokePattern)button.GetCurrentPattern(InvokePattern.Pattern); invokePattern.Invoke();

UIA 的优点是能覆盖很多句柄方案搞不定的框架,缺点也明显:性能明显比 SendMessage 低,查找过程可能耗时几百毫秒甚至更久;而且不是所有程序都暴露完整的 UIA 树。

6.3 句柄和 UIA 混用的兜底策略

我的习惯是:优先句柄方案,因为它轻量、精准、速度快。当句柄方案失败时,再查 UIA 信息,能走 UIA 就走 UIA。如果 UIA 也没有,才考虑屏幕坐标 + 图像识别。

举个例子,某个程序的登录窗口是标准 Edit 和 Button,先发 WM_SETTEXT 填账号。但登录后的主界面是自绘 Dashboard,某个搜索框正文 WM_GETTEXT 取不到,就用 Inspect 看它的 AutomationId,再用 UIA 的 ValuePattern 去写。这样混合方案能覆盖同一个程序里不同区域的控件。

UIA 也不是万能钥匙。在 Win10 以前的老系统里,部分老控件的 UIA 支持不完整,反而句柄方案更可靠。所以不要迷信某个框架,实际目标程序是什么形态,就选择对应的技术。

最后分享我个人的一个经验:句柄自动化看起来"低级",但在大量历史遗留系统上反而是最省事、最不容易失效的方案。关键不是会用某一个 API,而是会先侦察、再选路、最后动手。如果你坚持用窗口标题动态查找句柄,而不是写死句柄值,这套脚本可以长期稳定运行,未来换机器、重启软件都不用重写。希望这次的完整链路和踩坑记录能帮你少走几趟弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询