Windows UI自动化实战:用句柄和SendMessage操控老程序 📅 发布时间:2026/9/16 21:46:49 👁 浏览次数: 前阵子接手一个老旧的仓库管理客户端每天下午都要做一轮重复操作打开入库单、点刷新、把某个文本框里的数量读出来再填到另一个位置。领导问我能不能写个脚本自动跑。我第一反应是找现成的自动化工具但这类工具大多不会正经对付这种老式 Win32 程序——它们抓不到控件回放也总出乱子。折腾了一下午最后发现还是得回到 Windows 最底层的机制通过句柄 ID 直接操作窗口发送点击消息控制按钮再用 WM_GETTEXT 把文本框数据读回来。这条路看起来土实际上却是跨进程 UI 自动化里最稳定的一条。这篇文章适合三类人需要给老旧桌面软件写自动化脚本的做 Windows 端测试工具、辅助工具的以及想理解为什么某些自动化库能点按钮、能取文本的底层原理的。我会把从找窗口、找控件、点击按钮、读写文本框的完整链路拆开讲清楚包括工具使用、API 原理、完整示例和踩坑记录。这里的示例全部基于 Windows 自带的组件和你有权操作的程序不需要额外安装运行环境照着敲就能看到效果。1. 先搞清楚为什么操作别的程序界面要认句柄1.1 什么场景真的需要跨进程点按钮、读文本框很多人的自动化需求其实来自没有接口只能点界面的老系统。常见的有几类老 ERP / 进销存 / 仓储客户端只提供了人工操作的界面没有开放 API也没有数据库连接权限。第三方配送、打印、客服平台提供的 Windows 客户端需要在登录后自动填写内容、点按钮提交。内部自研但已经没人维护的 MFC/Delphi 工具领导要求先自动化掉一部分重复动作。这类程序最大的特点就是控件是标准 Windows 控件窗口标题、按钮文本、输入框都看得见但没有官方脚本接口。遇到这种情况唯一通用的方式就是走窗口句柄。注意一个前提只建议用在自己有权限、不影响他人使用的程序上。如果是公司内部系统先和相关负责人确认用途再写自动化免得引发不必要的权限问题。1.2 句柄 ID 是什么理解 HWND 和窗口对象模型句柄 ID完整说法是 HWNDHandle to a Window。它是 Windows 内核为每个窗口分配的一个数字标识在整个桌面会话中唯一标识某个窗口。你可以把它理解成窗口的身份证号进程 A 希望操作进程 B 的窗口不是直接去读 B 的内存而是拿这个 ID 向系统发出命令。这里有个关键认知HWND 不是固定不变的。每次程序启动重新创建窗口时系统都会重新分配一个 ID。同一程序这次运行是 0x00040802下次可能变成 0x00090B20。所以脚本里绝对不能把句柄值写死必须每次启动通过窗口标题、类名等条件去动态查找。整个跨进程 UI 操作的基本链路是找到目标进程的主窗口句柄。在主窗口下枚举或直接查找子控件句柄。向按钮、输入框等控件发送对应的窗口消息。从控件读取返回结果文本、状态、数据。核心不是模拟用户鼠标点击的物理动作而是给窗口发消息。这比模拟鼠标稳定得多因为它不要求目标窗口一定在前台也不会因为鼠标误碰而失败。1.3 句柄 vs 模拟鼠标 vs 屏幕坐标区别在哪打个比方模拟鼠标像你隔着玻璃窗给人递纸条别人看不看得到取决于窗户是否打开、纸条是否递到正确位置发窗口消息就像你直接打电话到对方公司总机说出分机号句柄总机帮你转到对应办公室。屏幕坐标方案依赖分辨率、窗口位置、缩放比例窗口一移动就废。而句柄操作和窗口在屏幕上的位置无关只要窗口还活着就能调用。所以当目标程序的按钮和输入框是标准 Windows 控件原生 Win32、MFC、VB6、Delphi 等时句柄方案是优先级比较高的选择。后面会讲到它搞不定的场景再怎么办。2. 侦察阶段用 Spy 和 Inspect 把目标窗口剥开看写代码之前必须先看清目标窗口的内部结构。不然你连该往哪个类名找控件都不知道。2.1 Spy用一个拖拽准星看它的类名、标题、控件 IDSpy 是 Visual Studio 自带工具装过 VS 就能找到。在 VS 的工具菜单里能直接打开它是一个独立进程通常叫 spyxx.exe64 位机器的控件如果抓不到配合 spyxx_amd64.exe 使用。打开后重点做两件事把窗口层级树展开观察目标窗口下面有哪些子窗口、子窗口的类名和标题分别是什么。用工具栏上的查找窗口图标准星拖到目标程序上直接看当前鼠标处的窗口信息。拖拽查找是最快的。把准星拖到某个按钮上Spy 会显示它的窗口句柄、类名、标题、控件 ID。比如一个确定按钮你可能会看到类名 Button标题确定控件 ID 1样式位里有 BS_PUSHBUTTON。这些信息就是写代码的地图。我一般会开一个表格逐个记下要操作的控件主窗口类名和标题、输入框类名和 ID、按钮类名和 ID。后面写 FindWindow、FindWindowEx、GetDlgItem 全用得着。补充一个经验老程序里按钮类名不一定是 Button。Delphi 写的程序的按钮类名通常是 TButton编辑框是 TEditVB6 程序的控件类名则可能是 ThunderButton、ThunderTextBox 这一套。所以不要想当然一定要以 Spy 看到的类名为准。这也是为什么总强调先侦察再动手。2.2 Inspect能看到的日常之外的 UIA 信息Microsoft 提供的 Inspect.exe 在 Windows SDK 里它和 Spy 的区别在于Spy 主要看 Win32 窗口句柄结构Inspect 主要看 UI AutomationUIA树。如果你用句柄方式找不到目标按钮用 Inspect 看看它的 UIA 信息会更清晰。Inspect 能显示每个控件的ControlType控件类型比如 Button、Edit、TextName可访问名称AutomationId自动化 ID支持的 Pattern比如 ValuePattern、InvokePattern这些信息不仅是给 UIA 框架用的也能帮你判断一个控件是不是真的存在、是不是可交互。如果 Inspect 里能看到这个控件但 Spy 的窗口层级里找不到对应子句柄基本可以判断它是自绘/合成控件句柄方案会有难度需要转 UIA。2.3 从层级树判断该走句柄还是走 UI 自动化框架拿到层级树后做个快速判断表现技术路线标准 Win32 控件子窗口能找到类名是 Button/Edit/Static直接用 HWND SendMessage控件存在子窗口也能枚举但 ReadProcessMemory 或 WM_GETTEXT 取不到数据先确认控制权限再考虑自绘或 UIASpy 找不到子窗口整个界面只有一个大窗口类大概率是 Direct UI / 自绘改用 UIA / 坐标方案Inspect 里能找到 Edit 和 Button且有 ValuePattern/InvokePattern优先用 UIA效率虽低但可靠这个判断决定你后面写的是几十行句柄代码还是几百行 UIA 代码。不要盲目选择先用两个工具把目标看透。3. 核心 API 逐个拆解定位、枚举、发消息、读写文本这一节是全文的技术核心。我按实际使用顺序来讲解不按文档顺序。3.1 FindWindow / FindWindowEx / GetDlgItem 定位窗口和控件定位主窗口最常用的是 FindWindowHWND hMain FindWindowW(L#32770, L运行);第一个参数是窗口类名第二个是窗口标题。两个都可以传 NULL表示不限制。但建议同时给除非标题里带了版本号才用类名。FindWindow 只找顶层窗口不找子窗口。要往下找子控件有三个方向FindWindowEx从指定父窗口开始找子控件可以传类名和标题。GetDlgItem直接按控件 ID 找子控件适用于标准对话框。EnumChildWindows递归枚举所有子控件适合不知道 ID、需要过滤的场景。FindWindowEx 的典型用法HWND hEdit FindWindowExW(hMain, NULL, LEdit, 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(Lhwnd%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_GETTEXTwchar_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_SETTEXTSendMessageW(hEdit, WM_SETTEXT, 0, (LPARAM)L需要填入的内容);WM_SETTEXT 会直接替换编辑框全部内容相当于用户先全选再粘贴。注意有些输入框会响应文本变化事件填完之后立刻触发界面联动逻辑这属于正常现象但脚本里要注意下一步操作的时机适当等待界面刷新。这个机制能生效的前提是目标控件是标准 Edit 控件或基于标准消息处理的自绘控件。遇到完全自绘的内容区域WM_GETTEXT 大概率返回空字符串这个我们放到踩坑部分细说。3.5 点击按钮的三种消息BM_CLICK、WM_COMMAND、鼠标消息点击按钮最推荐的消息是 BM_CLICKSendMessageW(hButton, BM_CLICK, 0, 0);BM_CLICK 让按钮按标准被点击语义处理包括按下、抬起、重绘、通知父窗口完成一次点击。这个方式不依赖按钮在屏幕上的坐标也不用把窗口切到前台非常稳。第二种是直接给按钮父窗口发 WM_COMMANDSendMessageW(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 更实用。示例场景用 WinR 打开运行对话框输入 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. 模拟 WinR打开运行对话框 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. 找确定按钮并点击ID1 是 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没反应。推荐两种等待策略固定延时。适合演示但不健壮。窗口慢时容易误判失败。轮询等待循环 FindWindow / IsWindowVisible / IsWindowEnabled直到条件成立或超时。轮询的示例def wait_window(class_name, title, timeout5): 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)同理判断按钮是否可用用 IsWindowEnableduser32.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 Automation6.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而是会先侦察、再选路、最后动手。如果你坚持用窗口标题动态查找句柄而不是写死句柄值这套脚本可以长期稳定运行未来换机器、重启软件都不用重写。希望这次的完整链路和踩坑记录能帮你少走几趟弯路。