本文面向需要做 Windows 桌面自动化的读者:RPA、医疗/工业软件对接、自动化测试、辅助工具。
示例全部是 C#,目标框架为 .NET Framework 4.8(.NET 6/8 同样适用)。
文中经验来自一个真实的 PACS 桌面自动化检查工具:默认只读,动作必须显式触发。
先给结论:控件到底怎么"拿"
很多人一开始会问:
我知道屏幕上有这个按钮,代码怎么拿到它?
Windows 没有一棵统一的"控件树"。同一个控件,通常有下面几种观察方式:
| 方式 | 你拿到的东西 | 读取成本 | 适合做什么 | 最大问题 |
|---|---|---|---|---|
| Win32 | HWND(窗口句柄) | 本地调用,几乎不阻塞 | 枚举窗口、拿类名/标题/矩形、点击坐标 | 自绘控件没有子 HWND,读不到文字 |
| UIA(UI Automation) | AutomationElement |
跨进程 RPC,可能卡住 | 拿控件类型、名称、模式、列表、焦点 | Provider 不实现就什么都读不到 |
| MSAA(IAccessible) | IAccessible + childId |
跨进程 COM,可能卡住 | 老 WinForms、ActiveX、部分自绘控件 | 信息有限,COM 生命周期容易出错 |
| Hook(消息/事件/API/注入) | 消息、绘制参数、文本、事件 | 取决于实现,可能影响目标 | 补足前三种读不到的信息 | 侵入性强、易受版本/安全软件影响 |
Hook 是最后手段,不是第一种手段。 能用标准接口读到的,不要 Hook;能用进程外事件拿到的,不要注入。原因和具体做法见第十一节。
前三种方式的关系不是 1:1:
- 一个 UIA 元素可能有 HWND,也可能没有(
NativeWindowHandle = 0); - 一个 HWND 可能对应整棵 UIA 子树(浏览器、UWP);
- MSAA 的叶子可能是一个
childId整数,而不是独立对象; - 同一个控件在不同来源里的"名字"和"层级"可能完全不同。
所以工程上不要迷信某一种来源,而是:
能拿到什么就先拿什么,能读多细就读多细,
读不到就明确说"读不到",绝不猜一个看起来像的控件。
本项目的自动读取顺序是:
UIA → MSAA → Win32
理由:UIA 信息最丰富;MSAA 覆盖老控件;Win32 是永远能返回结果的"地板",而且本地调用不会因为目标进程卡死而挂住。
一、准备工作
1.1 引用
在 .NET Framework 项目中添加引用:
UIAutomationClientUIAutomationTypesAccessibility(MSAA 用)System.Windows.Forms(鼠标/屏幕坐标)
使用 .NET 6/8 时,通过 NuGet 安装 UIAutomationClient / UIAutomationTypes 即可,命名空间仍然是 System.Windows.Automation。
1.2 DPI:坐标错位的第一大来源
在 app.manifest 中声明 DPI 感知,否则系统会对你的窗口/坐标做虚拟化:
xml
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
</windowsSettings>
</application>
注意:
dpiAware=true是"系统 DPI 感知",多显示器不同缩放时仍可能不准;- 更严谨的做法是用 Per-Monitor V2;
- 取鼠标坐标优先用
GetPhysicalCursorPos+WindowFromPhysicalPoint,读 UIA 矩形则用物理像素的BoundingRectangle。
一句话:同一个程序里,屏幕坐标、窗口矩形、UIA 矩形、截图坐标必须是同一套坐标系。
1.3 权限
如果目标程序以管理员身份运行,而你的工具不是管理员:
- 能枚举到窗口;
- 但 UIA/MSAA 读取可能返回空、超时或
AccessDenied; - 发送消息也可能失败。
这不是代码 bug,是 Windows 的完整性级别隔离(UIPI)。工具要么与目标同级别运行,要么明确提示用户。
二、Win32:最底层、最可靠的一层
2.1 枚举顶层窗口
csharp
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
internal static class Win32
{
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")]
public static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam);
[DllImport("user32.dll")]
public static extern bool EnumChildWindows(IntPtr parent, EnumWindowsProc callback, IntPtr lParam);
[DllImport("user32.dll")]
public static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int GetClassName(IntPtr hWnd, StringBuilder buffer, int maxCount);
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
[DllImport("user32.dll")]
public static extern IntPtr GetAncestor(IntPtr hWnd, uint flags);
public const uint GA_PARENT = 1;
public const uint GA_ROOT = 2;
public static string ClassName(IntPtr hWnd)
{
var buffer = new StringBuilder(256);
GetClassName(hWnd, buffer, buffer.Capacity);
return buffer.ToString();
}
public static string ProcessName(IntPtr hWnd)
{
uint pid;
GetWindowThreadProcessId(hWnd, out pid);
try { return Process.GetProcessById((int)pid).ProcessName; }
catch { return ""; }
}
}
枚举:
csharp
var windows = new List<IntPtr>();
Win32.EnumWindows((hWnd, lParam) =>
{
if (Win32.IsWindowVisible(hWnd))
{
Console.WriteLine(
$"{hWnd:X8} {Win32.ProcessName(hWnd),-20} {Win32.ClassName(hWnd),-30}");
}
return true; // 必须返回 true,否则枚举会提前停止
}, IntPtr.Zero);
2.2 读窗口标题:不要直接 GetWindowText
GetWindowText 在跨进程读取控件文本时经常返回空;更糟的是,目标窗口无响应时它可能永久阻塞调用线程。
安全写法是 SendMessageTimeout:
csharp
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern IntPtr SendMessageTimeout(
IntPtr hWnd, uint msg, IntPtr wParam, StringBuilder lParam,
uint flags, uint timeout, out IntPtr result);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int GetWindowTextLength(IntPtr hWnd);
private const uint WM_GETTEXT = 0x000D;
private const uint WM_GETTEXTLENGTH = 0x000E;
private const uint SMTO_ABORTIFHUNG = 0x0002;
private const int MaxText = 32 * 1024;
public static string WindowText(IntPtr hWnd, uint timeoutMs = 200)
{
// 不要用 GetWindowTextLength:跨进程读 Edit 控件时它经常返回 0。
// 用 WM_GETTEXTLENGTH 走 SendMessageTimeout,卡死时能超时返回。
IntPtr lengthResult;
SendMessageTimeout(
hWnd, WM_GETTEXTLENGTH, IntPtr.Zero, null,
SMTO_ABORTIFHUNG, timeoutMs, out lengthResult);
int length = lengthResult.ToInt32();
if (length <= 0) length = Math.Min(GetWindowTextLength(hWnd), MaxText);
if (length <= 0) return "";
length = Math.Min(length, MaxText);
var buffer = new StringBuilder(length + 1);
IntPtr result;
SendMessageTimeout(
hWnd, WM_GETTEXT, (IntPtr)(length + 1), buffer,
SMTO_ABORTIFHUNG, timeoutMs, out result);
return buffer.ToString();
}
要点:
SMTO_ABORTIFHUNG让目标卡死时按超时返回;- 长度要有上限,不能信任目标返回的长度;
- 超时不要太长,窗口列表刷新是高频操作;
- 只对可见窗口读标题,能省掉大量无意义的消息。
2.3 按坐标找窗口,再下钻到最深的子窗口
csharp
[StructLayout(LayoutKind.Sequential)]
public struct POINT { public int X, Y; }
[DllImport("user32.dll")]
public static extern IntPtr WindowFromPoint(POINT point);
[DllImport("user32.dll")]
public static extern IntPtr ChildWindowFromPointEx(IntPtr parent, POINT point, uint flags);
[DllImport("user32.dll")]
public static extern bool ScreenToClient(IntPtr hWnd, ref POINT point);
public const uint CWP_SKIPINVISIBLE = 0x0001;
public static IntPtr DeepestWindowAt(POINT screenPoint)
{
IntPtr hWnd = WindowFromPoint(screenPoint);
// WindowFromPoint 可能返回子窗口;先统一到顶层窗口
IntPtr root = GetAncestor(hWnd, GA_ROOT);
if (root != IntPtr.Zero) hWnd = root;
// 逐层下钻:浏览器外壳的渲染窗口就是真正的子 HWND
for (int depth = 0; depth < 16 && hWnd != IntPtr.Zero; depth++)
{
POINT clientPoint = screenPoint;
if (!ScreenToClient(hWnd, ref clientPoint)) break;
IntPtr child = ChildWindowFromPointEx(
hWnd, clientPoint, CWP_SKIPINVISIBLE);
if (child == IntPtr.Zero || child == hWnd) break;
hWnd = child;
}
return hWnd;
}
这里有两个容易踩的点:
ChildWindowFromPointEx的坐标是相对父窗口客户区 的,不是屏幕坐标,所以每一层都要ScreenToClient;WS_EX_TRANSPARENT的子窗口仍可能是真正的内容宿主(DirectUI、浏览器渲染窗口),只跳过不可见窗口,不要跳过透明窗口。
2.4 枚举子控件
EnumChildWindows 会返回所有后代(包括孙辈),不需要自己递归:
csharp
public static List<IntPtr> AllChildren(IntPtr root)
{
var result = new List<IntPtr>();
EnumChildWindows(root, (hWnd, lParam) =>
{
result.Add(hWnd);
return true;
}, IntPtr.Zero);
return result;
}
拿到扁平的句柄列表后,用 GetAncestor(hWnd, GA_PARENT) 重建层级:
csharp
var byParent = new Dictionary<IntPtr, List<IntPtr>>();
foreach (var child in AllChildren(root))
{
var parent = GetAncestor(child, GA_PARENT);
if (!byParent.TryGetValue(parent, out var list))
byParent[parent] = list = new List<IntPtr>();
list.Add(child);
}
2.5 能读到哪些窗口属性
| 属性 | API | 说明 |
|---|---|---|
| 类名 | GetClassName |
Chrome_WidgetWin_1 = Chromium/CEF;WindowsForms10.* = WinForms;HwndWrapper* = WPF |
| 标题 | SendMessageTimeout(WM_GETTEXT) |
跨进程安全 |
| 进程 | GetWindowThreadProcessId |
再查进程名 |
| 窗口矩形 | GetWindowRect |
屏幕坐标 |
| 客户区 | GetClientRect + ClientToScreen |
去掉边框/标题栏 |
| 可见 | IsWindowVisible |
|
| 最小化 | IsIconic |
|
| 可用 | IsWindowEnabled |
|
| 样式 | GetWindowLong(GWL_STYLE/GWL_EXSTYLE) |
判断是否置顶、透明、工具窗口 |
| 父子/根 | GetAncestor(GA_PARENT/GA_ROOT) |
重建树、找顶层窗口 |
三、UIA:信息最丰富的一层
3.1 获取元素
UIA 的入口只有几个:
csharp
using System.Windows.Automation;
using System.Windows;
AutomationElement root = AutomationElement.FromHandle(hWnd); // 由窗口句柄
AutomationElement atPoint = AutomationElement.FromPoint(new Point(x, y)); // 由屏幕坐标
AutomationElement focused = AutomationElement.FocusedElement; // 当前键盘焦点
AutomationElement desktop = AutomationElement.RootElement; // 桌面根
3.2 读取属性
csharp
AutomationElement.AutomationElementInformation c = element.Current;
Console.WriteLine(c.Name);
Console.WriteLine(c.AutomationId);
Console.WriteLine(c.ControlType.ProgrammaticName); // ControlType.Button
Console.WriteLine(c.ClassName);
Console.WriteLine(c.FrameworkId); // Win32 / WinForm / WPF / XAML / UIA
Console.WriteLine(c.NativeWindowHandle);
Console.WriteLine(c.ProcessId);
Console.WriteLine(c.IsEnabled);
Console.WriteLine(c.IsOffscreen);
Console.WriteLine(c.HasKeyboardFocus);
Console.WriteLine(c.BoundingRectangle); // System.Windows.Rect,物理像素
只读快照 很重要:AutomationElement 是活的 COM 对象,元素销毁后再读会抛 ElementNotAvailableException。工程上应尽快把它转换成普通 DTO:
csharp
public sealed class ElementSnapshot
{
public string Name, AutomationId, ControlType, ClassName, FrameworkId;
public int ProcessId;
public long Hwnd;
public bool? IsEnabled, IsOffscreen, HasFocus;
public double X, Y, Width, Height;
}
3.3 遍历控件树
UIA 提供三个 TreeWalker:
| Walker | 包含什么 | 用途 |
|---|---|---|
RawViewWalker |
所有元素,包括不可见、非控件元素 | 和"控件树"显示一致,读取最完整 |
ControlViewWalker |
只包含控件元素 | 只看可交互控件 |
ContentViewWalker |
只包含内容元素 | 读文本内容 |
本项目统一用 RawViewWalker,否则会出现"读到的树和屏幕上看到的不一样"。
csharp
static void Dump(AutomationElement element, int depth)
{
var c = element.Current;
Console.WriteLine(
new string(' ', depth * 2) +
$"{c.ControlType.ProgrammaticName} " +
$"Name='{c.Name}' Id='{c.AutomationId}' Class='{c.ClassName}'");
TreeWalker walker = TreeWalker.RawViewWalker;
for (AutomationElement child = walker.GetFirstChild(element);
child != null;
child = walker.GetNextSibling(child))
{
Dump(child, depth + 1);
}
}
遍历一定要有深度、节点数、超时预算。真实的大型 Qt/Electron/UWP 界面可能有几万个节点,而且每个属性读取都是一次跨进程调用。
3.4 FindAll / 条件查找
csharp
var condition = new AndCondition(
new PropertyCondition(
AutomationElement.ControlTypeProperty, ControlType.Button),
new PropertyCondition(
AutomationElement.AutomationIdProperty, "save"));
AutomationElementCollection matches =
root.FindAll(TreeScope.Descendants, condition);
if (matches.Count != 1)
throw new InvalidOperationException(
$"要求唯一命中,实际 {matches.Count} 个。");
TreeScope 的取值:
Element:自己;Children:直接子级;Descendants:所有后代;Subtree:自己和所有后代。
经验:查找可以宽,保存/定位必须唯一。 多命中时不要"取第一个",否则今天能跑,明天患者数据一变就点错控件。
3.5 模式(Pattern):控件真正能做什么
UIA 的属性只是"长什么样",Pattern 才是"能做什么":
| Pattern | 用途 |
|---|---|
ValuePattern |
读写输入框的值 |
TextPattern |
读富文本/文档文本 |
InvokePattern |
点击按钮 |
SelectionPattern |
读容器里被选中的项 |
SelectionItemPattern |
选中/取消选中某一项 |
ExpandCollapsePattern |
展开/收起菜单、树节点 |
TogglePattern |
勾选/取消勾选 |
RangeValuePattern |
滑块、进度值 |
GridPattern |
表格:行列数 + GetItem(row, col) |
TablePattern |
语义表格:行列 + 表头 |
ScrollPattern / ScrollItemPattern |
滚动、把项滚进视图 |
ItemContainerPattern |
按属性查找虚拟化容器里的项 |
VirtualizedItemPattern |
把虚拟化项实例化 |
WindowPattern |
最大化/最小化/关闭窗口 |
TransformPattern |
移动/缩放 |
读取模式:
csharp
static bool TryPattern<T>(
AutomationElement element,
AutomationPattern pattern,
out T value) where T : class
{
value = null;
object obj;
if (!element.TryGetCurrentPattern(pattern, out obj)) return false;
value = obj as T;
return value != null;
}
示例:
csharp
if (TryPattern(element, ValuePattern.Pattern, out ValuePattern value))
Console.WriteLine(value.Current.Value);
if (TryPattern(element, TextPattern.Pattern, out TextPattern text))
Console.WriteLine(text.DocumentRange.GetText(4096));
注意:
GetCurrentPattern失败会抛ElementNotAvailableException或InvalidOperationException,要先TryGetCurrentPattern;TextPattern.GetText(-1)可能把整个文档读进来,一定要设上限;- 读和写要分开:本项目默认只读,
Invoke/SetValue必须由用户显式触发。
3.6 缓存请求:把几十次跨进程调用压成一次
这是 UIA 性能优化里最重要的一招。
默认情况下,每读一个属性都是一次跨进程 RPC。一棵 2000 节点的树,读 20 个属性就是 4 万次调用,慢到不可用。
CacheRequest 可以让遍历时一次性把属性和模式带回来:
csharp
var request = new CacheRequest
{
TreeFilter = Automation.RawViewCondition
};
request.Add(AutomationElement.NameProperty);
request.Add(AutomationElement.AutomationIdProperty);
request.Add(AutomationElement.ControlTypeProperty);
request.Add(AutomationElement.ClassNameProperty);
request.Add(AutomationElement.BoundingRectangleProperty);
request.Add(AutomationElement.IsOffscreenProperty);
request.Add(AutomationElement.HasKeyboardFocusProperty);
request.Add(ValuePattern.Pattern);
request.Add(TextPattern.Pattern);
using (request.Activate())
{
// 必须在同一个线程内:Activate → 遍历/读取 → Pop
AutomationElement root = AutomationElement.FromHandle(hWnd);
var name = root.Cached.Name;
var rect = root.Cached.BoundingRectangle;
object pattern;
if (root.TryGetCachedPattern(ValuePattern.Pattern, out pattern))
Console.WriteLine(((ValuePattern)pattern).Cached.Value);
}
坑:
CacheRequest.Activate()作用于当前线程,遍历必须在同一线程完成;- 缓存值会过期,适合做"快照",不适合做"点击前最后确认";
- 缓存出来的 Pattern 只用来读;要执行动作时重新
GetCurrentPattern; - 缓存未命中会抛异常,不要假设所有属性都能缓存。
3.7 浏览器 / UWP:真正的控件树可能在内层窗口
对浏览器、Electron、WebView2、UWP 应用,AutomationElement.FromHandle(顶层窗口) 有时只能读到外壳(标题栏、边框),真正的控件树挂在一个内层窗口元素上。
识别方法:
- 控件类型是
ControlType.Window; NativeWindowHandle != 0;FrameworkId是XAML/UIA,或类名包含CoreWindow/Chrome_RenderWidgetHost。
csharp
using System.Collections.Generic;
static IntPtr FindContentWindow(IntPtr topLevel)
{
AutomationElement root = AutomationElement.FromHandle(topLevel);
var queue = new Queue<KeyValuePair<AutomationElement, int>>();
queue.Enqueue(new KeyValuePair<AutomationElement, int>(root, 0));
int visited = 0;
while (queue.Count > 0 && visited++ < 100)
{
var item = queue.Dequeue();
if (item.Value >= 3) continue;
foreach (AutomationElement child in
item.Key.FindAll(TreeScope.Children, Automation.RawViewCondition))
{
var c = child.Current;
if (c.ControlType == ControlType.Window && c.NativeWindowHandle != 0)
{
string cls = c.ClassName ?? "";
bool isContentHost =
c.FrameworkId == "XAML" ||
c.FrameworkId == "UIA" ||
cls.IndexOf("CoreWindow", StringComparison.OrdinalIgnoreCase) >= 0 ||
cls.IndexOf("Chrome_RenderWidgetHost", StringComparison.OrdinalIgnoreCase) >= 0;
if (isContentHost)
return new IntPtr(c.NativeWindowHandle);
}
queue.Enqueue(new KeyValuePair<AutomationElement, int>(
child, item.Value + 1));
}
}
return IntPtr.Zero;
}
拿到内层 HWND 后,再 AutomationElement.FromHandle 或直接使用那个子元素读取。
注意:不要用进程 ID 判断归属。UWP/Chromium 的内容 Provider 很可能在另一个进程;用 HWND 祖先链或引用判断。
四、MSAA:老控件和自绘控件的补充
MSAA 的核心接口是 IAccessible。很多老 WinForms、ActiveX、部分自绘控件只实现 MSAA,不实现 UIA。
4.1 获取根对象
csharp
using Accessibility;
using System.Runtime.InteropServices;
[DllImport("oleacc.dll")]
static extern int AccessibleObjectFromWindow(
IntPtr hWnd, uint objectId, ref Guid iid,
[MarshalAs(UnmanagedType.Interface)] out object accessible);
static readonly Guid IID_IAccessible =
new Guid("618736E0-3C3D-11CF-810C-00AA00389B71");
const uint OBJID_WINDOW = 0;
const uint OBJID_CLIENT = 0xFFFFFFFC; // unchecked((uint)-4)
static IAccessible GetAccessible(IntPtr hWnd)
{
object obj;
Guid iid = IID_IAccessible;
// 先拿"客户区内容",失败再退回"窗口本身"
int hr = AccessibleObjectFromWindow(hWnd, OBJID_CLIENT, ref iid, out obj);
if (hr != 0 || obj == null)
hr = AccessibleObjectFromWindow(hWnd, OBJID_WINDOW, ref iid, out obj);
return obj as IAccessible;
}
OBJID_CLIENT 是真正的窗口内容;OBJID_WINDOW 任何 HWND 都能返回,但信息通常很粗。先 CLIENT,失败再 WINDOW。
4.2 读属性
csharp
object childId = 0; // CHILDID_SELF
Console.WriteLine(accessible.get_accName(childId));
Console.WriteLine(accessible.get_accValue(childId));
Console.WriteLine(accessible.get_accDescription(childId));
Console.WriteLine(accessible.get_accDefaultAction(childId));
object role = accessible.get_accRole(childId);
object state = accessible.get_accState(childId);
get_accRole / get_accState 返回的是 VARIANT,通常可以转成 int。角色如 ROLE_SYSTEM_PUSHBUTTON、ROLE_SYSTEM_TEXT,状态如 STATE_SYSTEM_FOCUSED、STATE_SYSTEM_SELECTED、STATE_SYSTEM_INVISIBLE。
4.3 遍历子节点
MSAA 有两种"子":
childId整数:叶子,不能继续递归;IAccessible对象:可以继续递归。
用 AccessibleChildren 批量取:
csharp
[DllImport("oleacc.dll")]
static extern int AccessibleChildren(
IAccessible parent, int start, int count,
[Out] object[] children, out int obtained);
object[] buffer = new object[128];
int obtained;
AccessibleChildren(accessible, 0, buffer.Length, buffer, out obtained);
for (int i = 0; i < obtained; i++)
{
if (buffer[i] is IAccessible child)
{
// 对象子节点,可递归
}
else
{
int childId = Convert.ToInt32(buffer[i]);
// 简单子节点,只能读属性,不能递归
}
}
4.4 释放 COM 对象
MSAA 是 COM。遍历时不要长时间持有所有 IAccessible,否则会把目标进程的对象拖活,造成泄漏。
本项目的做法是:
每个节点读完立即释放,只保留可序列化的原始值。
csharp
try
{
// 读取
}
finally
{
if (Marshal.IsComObject(accessible))
Marshal.ReleaseComObject(accessible);
}
注意:不要对同一个对象释放两次 ;子对象和父对象可能是同一个引用,释放前先判断 ReferenceEquals。
五、鼠标定位:从屏幕坐标到控件
5.1 为什么不能只用 AutomationElement.FromPoint
AutomationElement.FromPoint 看起来最直接,但实际经常不符合预期:
- Provider 只返回容器 。例如 Windows 11 记事本正文,命中测试返回的是
Pane[NotepadTextBox],不是里面的Document。 - 浏览器/Explorer 只返回整窗 。UIA 命中测试给出
ControlType.Window,高亮框会盖住整个窗口。 - 自绘控件没有 UIA 节点。只能退到 MSAA 或 Win32。
- 坐标 DPI 不一致。你以为传的是物理像素,系统按逻辑像素解释,命中的是旁边控件。
所以工业级拾取器通常是三路探测 + 合并:
Win32:WindowFromPoint → 最深子 HWND(不会阻塞,永远有结果)
↓
UIA:从顶层窗口 FromPoint,校验祖先关系,命中后继续向深处收敛
↓
MSAA:在已解析的最深 HWND 上 accHitTest,失败再 AccessibleObjectFromPoint
↓
取"更小"的矩形作为高亮框(控件而不是整窗)
5.2 UIA 命中后为什么要"向下收敛"
FromPoint 返回的往往不是光标下最深的控件,而是"包含该点的某个容器"。用户想要的是按钮/输入框本身,不是它外面的 Pane。
做法:从命中元素开始,沿 RawView 往下找同时满足下面条件的子元素:
- 子元素矩形包含该点;
- 面积比当前最优更小;
- 深度更深。
csharp
static AutomationElement Refine(
AutomationElement hit, Point p, int maxDepth = 16)
{
AutomationElement best = hit;
double bestArea = Area(hit.Current.BoundingRectangle);
void Walk(AutomationElement parent, int depth)
{
if (depth >= maxDepth || parent == null) return;
TreeWalker walker = TreeWalker.RawViewWalker;
for (AutomationElement child = walker.GetFirstChild(parent);
child != null;
child = walker.GetNextSibling(child))
{
try
{
var r = child.Current.BoundingRectangle;
if (!r.IsEmpty && r.Contains(p))
{
double area = r.Width * r.Height;
if (area > 0 && area < bestArea)
{
best = child;
bestArea = area;
}
Walk(child, depth + 1);
}
}
catch (ElementNotAvailableException) { }
}
}
Walk(hit, 0);
return best;
}
static double Area(Rect r) => r.IsEmpty ? double.MaxValue : r.Width * r.Height;
注意:不要因为"父容器矩形不包含点"就剪枝。很多虚拟化容器自己没有矩形,但子元素有。
5.3 祖先校验:不要用进程 ID
UIA 命中到的元素,可能来自另一个进程:
- UWP:外壳窗口是一个进程,
CoreWindow内容可能是另一个进程; - Chromium/CEF:浏览器进程和渲染进程分离;
- 某些 UIA 代理会把子元素暴露成跨进程对象。
所以判断"这个元素是不是属于目标窗口",要看引用或 HWND 祖先链 ,不要看 ProcessId:
csharp
static bool BelongsToWindow(
AutomationElement hit, AutomationElement root, IntPtr rootHwnd)
{
TreeWalker walker = TreeWalker.RawViewWalker;
for (int depth = 0; hit != null && depth < 64; depth++)
{
if (ReferenceEquals(hit, root)) return true;
if (new IntPtr(hit.Current.NativeWindowHandle) == rootHwnd)
return true;
hit = walker.GetParent(hit);
}
return false;
}
5.4 拾取器不能抢焦点、不能点到目标
一个可用的拾取器需要:
- 全屏透明覆盖层,鼠标穿透(
WS_EX_TRANSPARENT),不激活(WS_EX_NOACTIVATE); - 用
WH_MOUSE_LL低级鼠标钩子接管左键,确认时吞掉这次点击,否则用户每拾取一次就会顺带按下目标按钮; - 右键 / ESC 取消;
- 探测在后台线程做,UI 线程只画高亮;
- 单个探测超时(例如 2 秒)就放弃,允许下次鼠标移动重试;
- 点击确认后如果 UIA 1.2 秒还没返回,用纯 Win32 兜底,绝不让覆盖层一直挂着。
覆盖层的关键样式:
csharp
protected override CreateParams CreateParams
{
get
{
const int WS_EX_TRANSPARENT = 0x20;
const int WS_EX_LAYERED = 0x80000;
const int WS_EX_TOOLWINDOW = 0x80;
const int WS_EX_NOACTIVATE = 0x08000000;
var cp = base.CreateParams;
cp.ExStyle |= WS_EX_TRANSPARENT | WS_EX_LAYERED |
WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE;
return cp;
}
}
低级鼠标钩子只做一件事:把坐标入队。绝对不要在钩子回调里调用 UIA/MSAA,否则目标无响应时会拖死整个输入链,甚至被系统摘掉钩子。
csharp
static IntPtr MouseHook(int code, IntPtr wParam, IntPtr lParam)
{
if (code >= 0)
{
int message = wParam.ToInt32();
if (message == WM_LBUTTONDOWN)
{
var data = Marshal.PtrToStructure<MSLLHOOKSTRUCT>(lParam);
queue.Add(new Sample(data.pt.X, data.pt.Y)); // 只入队
}
}
return CallNextHookEx(hook, code, wParam, lParam);
}
SetWindowsHookEx(WH_MOUSE_LL, ...) 需要线程有消息循环,所以钩子要跑在专用线程上,并 Application.Run() 起来。
六、点击与控件的关联:不只"坐标在哪"
很多需求不是"鼠标下面是什么",而是:
用户点了哪一行/哪个按钮?这次点击对应哪个业务控件?
这比按点拾取难,因为一次点击是按下 + 抬起两个事件,中间可能发生:
- 窗口切换;
- 列表滚动;
- 控件重建;
- 用户拖拽;
- 双击。
6.1 正确的事件模型
按下(down):记录坐标、时间、窗口,读取一次命中控件
抬起(up):重新读取命中控件,校验:
同一个窗口
同一个控件身份
抬起点仍在控件矩形内
不是拖拽
→ 才算一次 click
为什么要重新读?因为按下时读到的元素,在抬起前可能已经销毁或滚走。只信按下时的快照,会把点击算到错误的行上。
6.2 控件身份怎么判断
优先级:
RuntimeId(同一进程生命周期内有效);- 没有 RuntimeId 时,退化为
ControlType + AutomationId + NativeWindowHandle; - 不要用
Name做身份。列表行文本会随患者/文件/语言/状态变化。
csharp
static bool SameElement(AutomationElement a, AutomationElement b)
{
if (a == null || b == null) return false;
int[] ra = a.GetRuntimeId();
int[] rb = b.GetRuntimeId();
if (ra != null && ra.Length > 0 && rb != null && rb.Length > 0)
{
if (ra.Length != rb.Length) return false;
for (int i = 0; i < ra.Length; i++)
if (ra[i] != rb[i]) return false;
return true;
}
var ca = a.Current;
var cb = b.Current;
return ca.ControlType == cb.ControlType
&& ca.AutomationId == cb.AutomationId
&& ca.NativeWindowHandle == cb.NativeWindowHandle;
}
注意:两侧都有 RuntimeId 但不同,就是不同元素,不能因为名字一样"救回来"。
6.3 单击 / 双击 / 拖拽
系统双击判定有两个参数:
SystemInformation.DoubleClickTime(默认 500ms 左右);SystemInformation.DoubleClickSize(允许的位移)。
不能收到 MouseDown 就立刻发 click,否则一次双击会变成两次单击。正确做法是延迟发单击:
csharp
// 收到一次完整点击
if (pending != null
&& now - pending.Time <= SystemInformation.DoubleClickTime
&& SameElement(pending.Element, current.Element)
&& Distance(pending.Point, current.Point) <=
SystemInformation.DoubleClickSize.Width / 2)
{
Emit("double-click", current);
pending = null;
}
else
{
if (pending != null) Emit("click", pending); // 上一个确定是单击
pending = current;
}
还要有一个定时器:超过双击时间还没有第二次点击,就把 pending 作为单击发出去。
拖拽则判断按下到抬起的位移是否超过 SystemInformation.DragSize。拖拽不应该算点击。
6.4 点在哪一行?向上找 DataItem / ListItem
命中测试通常返回单元格、文本、图标,而不是整行。要拿到"行",应该从命中元素沿父链向上找:
csharp
static AutomationElement FindRow(
AutomationElement hit, AutomationElement listContainer)
{
AutomationElement row = null;
for (int depth = 0; hit != null && depth < 64; depth++)
{
if (SameElement(hit, listContainer))
return row; // 到容器了,最后一次见到的行就是答案
ControlType type = hit.Current.ControlType;
// 表头、滚动条不是数据行
if (type == ControlType.Header ||
type == ControlType.HeaderItem ||
type == ControlType.ScrollBar)
return null;
// 记录最靠近命中点的行
if (type == ControlType.DataItem ||
type == ControlType.ListItem)
row = hit;
hit = TreeWalker.RawViewWalker.GetParent(hit);
}
return null;
}
拿到行之后,再读单元格:
- 优先
GridItemPattern.Current.Row/Column; - 没有 GridItem 时,按行内子元素的顺序做列索引;
- 列名优先
TablePattern.GetColumnHeaders(),其次找Header/HeaderItem子元素。
6.5 输入过期要丢弃
低级钩子回调很快,但 UIA 读取很慢。如果事件队列里已经积压了 750ms 以上的输入,不要再用"现在的控件树"去解释"很久以前的点击":
csharp
if (DateTime.UtcNow - sample.AtUtc > TimeSpan.FromMilliseconds(750))
{
// 输入过期,放弃归属,避免算到后来才出现的控件上
return;
}
这是很多"偶尔点错行"问题的根因:不是定位算法错,而是把旧点击读到了新界面上。
6.6 三种"当前项"不要混
| 概念 | 获取方式 | 含义 |
|---|---|---|
| 鼠标下 | AutomationElement.FromPoint |
光标位置的控件 |
| 键盘焦点 | AutomationElement.FocusedElement / HasKeyboardFocus |
键盘输入会进哪个控件 |
| 选中项 | SelectionPattern.GetSelection() / SelectionItemPattern.IsSelected |
列表当前选中了哪一行 |
它们可能完全不同。比如鼠标悬停在 A 行,键盘焦点在搜索框,选中项是 B 行。需求说"当前行"时,一定要先问清楚是哪一种。
七、属性与取值:怎么读"内容"
7.1 不同来源的取值键
| 来源 | 常用键 |
|---|---|
| UIA | Name、AutomationId、ControlType、ClassName、FrameworkId、RuntimeId、IsEnabled、IsOffscreen、BoundingRectangle、Value、Text、选中状态、焦点状态 |
| MSAA | accName、accValue、accRole、accState、accDescription、accDefaultAction |
| Win32 | 窗口标题、类名、HWND、窗口矩形、客户区矩形、可见、最小化、进程名、PID |
7.2 输入框/富文本:Name 不是内容
这是最常见的误判:
- WinForms 普通
TextBox的Name可能就是内容; - WinForms
RichTextBox、Chromium/CEF、UWP 文本框的Name可能只是RichEdit Control之类的通用标题; - 真正的内容在
ValuePattern或TextPattern里。
所以本项目定义了一个跨来源的键:文本内容,读取顺序是:
UIA:Value → Text → Name
MSAA:accValue → accName
Win32:窗口标题
csharp
static string ReadText(AutomationElement e)
{
object pattern;
if (e.TryGetCurrentPattern(ValuePattern.Pattern, out pattern))
{
string value = ((ValuePattern)pattern).Current.Value;
if (!string.IsNullOrEmpty(value)) return value;
}
if (e.TryGetCurrentPattern(TextPattern.Pattern, out pattern))
{
string text = ((TextPattern)pattern).DocumentRange.GetText(4096);
if (!string.IsNullOrEmpty(text)) return text;
}
return e.Current.Name ?? "";
}
7.3 "没有值"和"控件不存在"要分开
工程上必须区分三种结果:
- 控件找不到 →
NotFound; - 控件找到了,但不支持该属性 →
Unsupported; - 控件支持该属性,但当前值是空 →
Success + ""。
如果全都返回空字符串,调用方就无法判断该重试、换路径,还是接受空值。
7.4 自绘控件:OCR 只能作为降级
有些控件(Canvas、游戏引擎、部分医疗影像界面)完全不暴露文本。这时只能截图 + OCR。
但必须明确:
- OCR 结果是派生的,不是控件自身的值;
- OCR 失败不等于控件为空;
- OCR 不应用于"必须精确"的业务字段,除非有人工复核或其它校验;
- 截图要截控件矩形,且要考虑遮挡:先截整个窗口再裁剪,比直接截屏幕区域更可靠。
八、获取列表:Table → Grid → 遍历降级
列表读取是最容易"看起来成功、实际全错"的部分。
8.1 读取顺序
本项目的顺序:
1. TablePattern 有语义行列/表头,最可靠
2. GridPattern 行列计数 + GetItem
3. 在被拾取元素内部向下找列表(BFS)
4. 沿被拾取元素及祖先向上找列表
5. 子元素遍历降级
6. 读不到就返回 Unsupported/Failed,绝不返回"0 行成功"
为什么模式探测必须放在遍历之前?
因为遍历几乎总能"找到"东西 。窗口里的布局 Panel 看起来也像一行,盲目遍历会把整个界面当成列表。只有确认没有 GridPattern/TablePattern,才允许降级。
8.2 GridPattern
csharp
if (element.TryGetCurrentPattern(GridPattern.Pattern, out object obj))
{
var grid = (GridPattern)obj;
int rows = grid.Current.RowCount;
int cols = grid.Current.ColumnCount;
for (int r = 0; r < rows; r++)
{
for (int c = 0; c < cols; c++)
{
try
{
AutomationElement cell = grid.GetItem(r, c);
Console.WriteLine($"[{r},{c}] {ReadText(cell)}");
}
catch (Exception ex)
{
Console.WriteLine($"[{r},{c}] 读取失败: {ex.Message}");
}
}
}
}
8.3 TablePattern
TablePattern 比 GridPattern 多语义信息:
csharp
if (element.TryGetCurrentPattern(TablePattern.Pattern, out object obj))
{
var table = (TablePattern)obj;
int rows = table.Current.RowCount;
int cols = table.Current.ColumnCount;
AutomationElement[] headers = table.Current.GetColumnHeaders();
// GetItem(row, col) 用法相同
}
真实表格里优先 TablePattern:它的行/列标识在排序、插入列之后仍然有意义。
8.4 虚拟化列表
很多表格只"实现"当前可见的几十行。读不到的行不是不存在,而是还没被创建。
处理方式:
ItemContainerPattern.FindItemByProperty按属性找项;VirtualizedItemPattern.Realize()让项实例化;ScrollItemPattern.ScrollIntoView()把项滚进视图;ScrollPattern.Scroll()翻页。
但要注意:滚动会改变目标界面状态。如果工具默认只读,滚动应该是显式动作,或者至少明确告知用户。
8.5 单元格读不到怎么办
有些 Provider 能返回 RowCount/ColumnCount,但每个 GetItem 都失败。这时可以:
- 用
SelectionPattern读选中项(至少拿到当前行); - 用子元素遍历读已实现的行;
- 标记
Partial,把失败的单元格和原因一起返回。
不要因为"读到了行数"就宣称读取成功。
8.6 预算
列表读取必须有上限:
csharp
public sealed class ListReadOptions
{
public int MaxRows = 1000;
public int MaxColumns = 200;
public TimeSpan Timeout = TimeSpan.FromSeconds(5);
public int MaxTraversalNodes = 5000;
public int MaxTraversalDepth = 8;
public int MaxAncestorDepth = 8;
}
超时是软截止 :同步的跨进程调用无法在进程内强制中断,只能在两次调用之间检查。所以真正卡死的 Provider 只能靠独立线程 + 放弃结果来兜底,不能靠 CancellationToken 立刻停。
九、焦点:当前键盘在哪
9.1 读焦点
csharp
bool hasFocus = element.Current.HasKeyboardFocus;
bool canFocus = element.Current.IsKeyboardFocusable;
AutomationElement focused = AutomationElement.FocusedElement;
注意:
FocusedElement是全局焦点,可能属于别的窗口;- 用之前先确认前台窗口:
csharp
[DllImport("user32.dll")]
static extern IntPtr GetForegroundWindow();
if (GetForegroundWindow() != targetHwnd)
return; // 焦点不在目标窗口,读到的不是你要的
HasKeyboardFocus只表示"这个元素有键盘焦点",不表示"被选中";- 有些 Provider 不实现该属性,读出来是
false或抛异常,要区分"没有焦点"和"读不到"。
9.2 焦点变化事件
csharp
AutomationFocusChangedEventHandler handler = (sender, e) =>
{
// 这里在 UIA 的事件线程上,不要做重活,也不要再调用 UIA
queue.Add(sender as AutomationElement);
};
Automation.AddAutomationFocusChangedEventHandler(handler);
// 退出时务必 Remove
焦点事件回调里再同步调用 UIA 很容易死锁。正确做法是入队,交给专用工作线程处理。
9.3 选中 vs 焦点
- 焦点:键盘输入去哪;
- 选中:列表/树里被标记的项。
读选中:
csharp
if (container.TryGetCurrentPattern(SelectionPattern.Pattern, out object obj))
{
foreach (AutomationElement item in
((SelectionPattern)obj).Current.GetSelection())
{
Console.WriteLine(item.Current.Name);
}
}
单个项是否选中:
csharp
if (item.TryGetCurrentPattern(
SelectionItemPattern.Pattern, out object obj))
{
bool selected = ((SelectionItemPattern)obj).Current.IsSelected;
}
9.4 Win32 侧怎么拿焦点
GetFocus() 只能拿到当前线程 的焦点,对别的进程返回 IntPtr.Zero。要读另一个线程的焦点,用 GetGUIThreadInfo:
csharp
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Sequential)]
struct RECT { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)]
struct GUITHREADINFO
{
public int cbSize;
public uint flags;
public IntPtr hwndActive;
public IntPtr hwndFocus;
public IntPtr hwndCapture;
public IntPtr hwndMenuOwner;
public IntPtr hwndMoveSize;
public IntPtr hwndCaret;
public RECT rcCaret;
}
[DllImport("user32.dll")]
static extern bool GetGUIThreadInfo(uint threadId, ref GUITHREADINFO info);
static IntPtr GetFocusedHwnd(IntPtr topLevel)
{
uint pid;
uint threadId = GetWindowThreadProcessId(topLevel, out pid);
var info = new GUITHREADINFO();
info.cbSize = Marshal.SizeOf(typeof(GUITHREADINFO));
return GetGUIThreadInfo(threadId, ref info) ? info.hwndFocus : IntPtr.Zero;
}
但要注意:Win32 的 hwndFocus 只告诉你"哪个 HWND 有焦点",不告诉你"哪一个列表行被选中"。
HWND 级焦点、UIA 元素焦点、列表选中项,仍然是三个不同的东西。
十、生成稳定的定位路径
拿到控件只是第一步,真正难的是:下次程序重启、界面刷新后,还能找到同一个控件。
10.1 把控件树投影成 XML
把每个控件节点映射成一个 XML 元素:
xml
<Window Name="PACS 工作站" ClassName="PacsMainWindow" Source="Uia">
<Pane AutomationId="PatientPanel" ClassName="Panel">
<Edit AutomationId="PatientId" ClassName="TextBox" Name="患者编号" />
<Button AutomationId="QueryButton" Name="查询" />
</Pane>
</Window>
然后就可以用标准 XPath 1.0 定位:
xpath
/Window[1]//*[@AutomationId='PatientPanel']//Edit[@AutomationId='PatientId']
10.2 自动生成路径的优先级
1. AutomationId
2. ClassName + 控件类型
3. 控件类型 + 同级序号
不要自动使用 Name。 按钮文字、列表行文本、患者姓名、文件路径都会变化,把 Name 写进长期路径,今天能用,明天就失效。
路径示例:
xpath
/Window[1]/Pane[@ClassName='Panel']/Edit[@AutomationId='PatientId']
/Window[1]/Pane[@ClassName='Panel']/Button[@ClassName='ActionButton'][2]
10.3 序号:最后手段
xpath
//Button[2]
序号是 XPath 结果中的位置,不是永久 ID。页面插入一个同类型控件后,[2] 就可能指向别的按钮。
所以:
- 能用
AutomationId就不用ClassName; - 能用稳定祖先锚定,就不用全局
//; - 能用结构关系,就不用序号;
- 序号必须配合唯一性测试。
10.4 回读时必须唯一
csharp
var matches = root.SelectNodes(xpath);
if (matches.Count != 1)
throw new InvalidOperationException(
$"路径匹配到 {matches.Count} 个控件,要求唯一。");
多命中时不要"取第一个"。正确做法是失败,并提示用户补充条件或重新拾取。
10.5 窗口定位和控件定位是两层
- 窗口层:进程名 + 窗口类名 + 标题关键词;
- 控件层:XPath。
窗口条件用于"去哪棵树",XPath 用于"树里的哪个节点"。
窗口匹配可以分级放宽:
4 = 进程 + 类名 + 标题
3 = 进程 + 类名
2 = 进程 + 标题
1 = 只有进程
因为程序升级后 WinForms 的类名可能带 build 哈希,标题也可能随数据变化。但放宽后必须标记"需要人工核对",不能静默当成精确匹配。
十一、Hook 指导思想:标准接口读不到时怎么办
这一节讨论的是"补足读取能力"的 Hook,不是键盘记录器,也不是绕过安全软件。
所有内容都应以自有程序、测试夹具或获得书面授权的目标 为前提。
医疗、金融等场景还涉及患者/客户隐私,捕获到的文字必须最小化、可审计、可关闭。
11.1 十条指导思想
在写第一行 Hook 代码之前,先把这十条贴在墙上:
- 合法授权:只 Hook 自己开发的进程,或客户明确授权、且你知道后果的目标。不要注入安全软件、浏览器密码框、支付/游戏反作弊进程。
- 标准优先:UIA、MSAA、Win32、OCR 能解决的,不 Hook。Hook 是对实现细节的依赖,目标一升级就可能失效。
- 进程外优先 :能用
SetWinEventHook(WINEVENT_OUTOFCONTEXT)、低级鼠标/键盘钩子拿到的,不注入 DLL。 - 最小侵入:只 Hook 必需的函数/消息;只读优先;不做不必要的全局钩子。
- 热路径极简:Hook 回调里不分配内存、不加锁、不写日志、不做 I/O、不调用 UIA/MSAA。
- 必须调用原函数 :除非你明确要拦截,否则一定要
CallNextHookEx或调用原始 API,并保留返回值和GetLastError。 - 坐标/身份归一:Hook 拿到的是 DC 坐标、客户区坐标、线程 ID、HWND;统一转换成屏幕物理像素和稳定身份,再进入业务层。
- 可降级:Hook 安装失败、目标升级、被安全软件拦截时,自动退回 UIA/MSAA/OCR,并如实告诉用户。
- 可关闭、可审计:提供总开关;记录"哪个进程、哪个 Hook、捕获了多少条、丢弃了多少条",但不记录敏感原文。
- 不碰安全边界:不绕过 UIPI、不绕过受保护进程、不关闭杀毒/EDR、不隐藏注入痕迹。
一句话:
Hook 是"借来看一眼",不是"接管目标"。
11.2 Hook 的分类与选择
| 机制 | 作用范围 | 是否注入 | 典型用途 | 风险/限制 |
|---|---|---|---|---|
WH_MOUSE_LL / WH_KEYBOARD_LL |
全局输入 | 否(回调在本进程) | 拾取、点击关联、快捷键 | 回调必须极快;系统可能因超时摘钩 |
WH_CALLWNDPROC / WH_CALLWNDPROCRET |
指定线程或全局窗口消息 | 是(需要 DLL) | 观察发送给窗口的消息 | 全局钩子需要 DLL;不能做重活 |
WH_GETMESSAGE |
消息队列 | 是(需要 DLL) | 观察投递消息 | 同上;消息量大 |
WH_CBT |
窗口创建/激活/焦点等 | 是(需要 DLL) | 跟踪窗口生命周期 | 回调频繁;易影响目标 |
SetWinEventHook |
无障碍事件 | 可选(OUTOFCONTEXT 不注入) |
焦点、前台、值变化、位置变化 | 事件可能延迟/丢失;需要消息循环 |
| IAT / Inline Hook | 目标进程内的 API 调用 | 是 | 拦截 TextOut、ExtTextOut、GdipDrawString 等 |
与版本/架构强相关;容易被安全软件拦截 |
子类化 SetWindowLongPtr(GWLP_WNDPROC) |
单个窗口 | 否(同进程)或注入 | 观察/修改自己的窗口消息 | 只能处理自己的窗口;跨进程要注入 |
选择顺序建议:
标准读取(UIA/MSAA/Win32)
↓ 不够
进程外事件(SetWinEventHook OUTOFCONTEXT)
↓ 不够
低级输入钩子(WH_MOUSE_LL / WH_KEYBOARD_LL)
↓ 不够
消息钩子(WH_CALLWNDPROC / WH_GETMESSAGE,需要 DLL)
↓ 还不够
API Hook(TextOut / ExtTextOut / GdipDrawString,需要注入)
↓
OCR / 图形捕获 / 让厂商提供接口
11.3 SetWindowsHookEx 的规则
SetWindowsHookEx 是 Windows 最经典的 Hook API:
csharp
[DllImport("user32.dll", SetLastError = true)]
static extern IntPtr SetWindowsHookEx(
int idHook, HookProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll", SetLastError = true)]
static extern bool UnhookWindowsHookEx(IntPtr hhk);
[DllImport("user32.dll")]
static extern IntPtr CallNextHookEx(
IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
几个必须知道的点:
dwThreadId = 0表示全局;非 0 表示只监听指定线程;WH_MOUSE_LL/WH_KEYBOARD_LL比较特殊:它们是低级钩子,回调在安装进程里执行,不需要把 DLL 注入到每个进程;WH_CALLWNDPROC、WH_GETMESSAGE、WH_CBT这类消息钩子,如果要跨进程,必须传入一个原生 DLL 的模块句柄,系统会把 DLL 注入目标线程;- 托管委托不能可靠地用于全局消息钩子,必须用 C/C++ 写 DLL;
- 安装钩子的线程必须有消息循环,否则回调收不到;
- 32 位进程不能 Hook 64 位进程(低级钩子除外,因为回调在本进程);反之亦然;
- 目标以管理员运行而你的工具不是管理员时,钩子通常装不上或收不到事件;
- 回调里绝对不能做耗时操作。低级钩子超时会被系统摘掉;消息钩子会拖慢目标。
低级鼠标钩子的骨架(第 5.4 节已经给过完整思路):
csharp
static IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam)
{
if (nCode >= 0)
{
// 只做一件事:把原始数据复制到无锁队列
Enqueue(wParam.ToInt32(), lParam);
}
// 必须继续传递,否则会破坏系统输入链
return CallNextHookEx(_hook, nCode, wParam, lParam);
}
全局消息钩子需要原生 DLL,示意:
cpp
// HookDll.cpp ------ 仅示意,完整工程还要处理导出、卸载、共享内存等
extern "C" __declspec(dllexport)
LRESULT CALLBACK CallWndProc(int nCode, WPARAM wParam, LPARAM lParam)
{
if (nCode == HC_ACTION)
{
CWPSTRUCT* msg = reinterpret_cast<CWPSTRUCT*>(lParam);
// 只把 msg->hwnd / msg->message / msg->wParam / msg->lParam
// 写入预分配的无锁环形队列,不做任何重活
CaptureMessage(msg);
}
return CallNextHookEx(g_hook, nCode, wParam, lParam);
}
安装:
cpp
HMODULE dll = LoadLibraryW(L"HookDll.dll");
HOOKPROC proc = (HOOKPROC)GetProcAddress(dll, "CallWndProc");
HHOOK hook = SetWindowsHookExW(WH_CALLWNDPROC, proc, dll, threadId);
注意事项:
- DLL 必须与目标进程同架构;
- DLL 的加载/卸载、全局变量、共享内存都要考虑多进程并发;
- 不要在
DllMain里做复杂工作; - 卸载时先
UnhookWindowsHookEx,再等待在途回调结束,最后释放 DLL; - 全局消息钩子会影响整个桌面,测试时要在虚拟机/隔离环境里做。
11.4 SetWinEventHook:优先选择的"进程外事件"
如果你只是想知道"焦点变了、值变了、位置变了",SetWinEventHook 通常比注入 DLL 更安全。
前台窗口变化是另一个事件(EVENT_SYSTEM_FOREGROUND),需要单独注册。
csharp
using System;
using System.Runtime.InteropServices;
delegate void WinEventDelegate(
IntPtr hWinEventHook, uint eventType, IntPtr hwnd,
int idObject, int idChild, uint dwEventThread, uint dwmsEventTime);
[DllImport("user32.dll")]
static extern IntPtr SetWinEventHook(
uint eventMin, uint eventMax, IntPtr hmod,
WinEventDelegate proc, uint pid, uint tid, uint flags);
[DllImport("user32.dll")]
static extern bool UnhookWinEvent(IntPtr hWinEventHook);
const uint EVENT_SYSTEM_FOREGROUND = 0x0003;
const uint EVENT_OBJECT_FOCUS = 0x8005;
const uint EVENT_OBJECT_LOCATIONCHANGE = 0x800B;
const uint EVENT_OBJECT_VALUECHANGE = 0x800E;
const uint WINEVENT_OUTOFCONTEXT = 0x0000;
const uint WINEVENT_SKIPOWNPROCESS = 0x0002;
static WinEventDelegate _handler; // 必须保持引用,否则会被 GC
static IntPtr _hook;
static void StartWinEventHook()
{
_handler = OnWinEvent;
// 这个区间覆盖 FOCUS(0x8005) 到 VALUECHANGE(0x800E),
// 中间还会收到一些其它对象事件;生产代码建议按需拆成多个窄区间。
_hook = SetWinEventHook(
EVENT_OBJECT_FOCUS, EVENT_OBJECT_VALUECHANGE,
IntPtr.Zero, _handler,
0, 0,
WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS);
if (_hook == IntPtr.Zero)
throw new System.ComponentModel.Win32Exception(
Marshal.GetLastWin32Error());
}
static void OnWinEvent(
IntPtr hook, uint evt, IntPtr hwnd,
int idObject, int idChild, uint thread, uint time)
{
// 只入队;不要在这里调用 UIA/MSAA
Enqueue(new WinEvent(evt, hwnd, idObject, idChild, time));
}
关键点:
WINEVENT_OUTOFCONTEXT表示回调在你的进程里执行,不需要注入目标;- 回调在安装钩子的线程上派发,所以该线程要有消息循环(WinForms 的 UI 线程即可);
- 事件可能延迟、合并或丢失,不能把它当"每一次变化都必然到达";
WINEVENT_SKIPOWNPROCESS可以过滤自己的进程,避免自激;- 事件范围不要一上来就
EVENT_MIN..EVENT_MAX,那会产生大量无关事件;按需选择区间; - 如果确实需要
WINEVENT_INCONTEXT,那就要写原生 DLL 并注入目标,回调里同样不能做重活。
11.5 文本绘制 Hook:TextOut / ExtTextOut / GdipDrawString
有些控件是"自绘"的:
- Delphi/VCL、Qt、部分医疗影像工作站;
- 自己用 GDI/GDI+ 画表格、按钮、标签;
- UIA/MSAA 只能读到整块 Canvas,读不到里面的文字。
这时可以 Hook 文本绘制 API,把"画了什么字、画在哪个矩形"截下来。
常见 API
| API | 所在库 | 典型来源 |
|---|---|---|
TextOutA / TextOutW |
gdi32.dll | 老式 GDI 程序 |
ExtTextOutA / ExtTextOutW |
gdi32.dll | 最常见的 GDI 文本输出 |
DrawTextA/W、DrawTextExA/W |
user32.dll | 按钮、标签、表格 |
GdipDrawString |
gdiplus.dll | C# Graphics.DrawString、GDI+ 程序 |
IDWriteTextLayout::Draw |
dwrite.dll | DirectWrite |
ID2D1RenderTarget::DrawTextLayout |
d2d1.dll | Direct2D |
A是 ANSI 版本,W是 Unicode 版本。Unicode 程序通常调用W,老程序可能调用A,也可能两者都调用。Hook 时不要假设"只装
W就能覆盖所有文本":先确认目标实际调用的是哪一个,必要时两个都装。
签名(只列关键参数):
cpp
BOOL TextOutW(HDC hdc, int x, int y, LPCWSTR text, int count);
BOOL ExtTextOutW(
HDC hdc, int x, int y, UINT options,
const RECT* rect, LPCWSTR text, UINT count, const INT* dx);
int DrawTextW(HDC hdc, LPCWSTR text, int count, LPRECT rect, UINT format);
Status GdipDrawString(
GpGraphics* graphics, const WCHAR* text, INT length,
const GpFont* font, const RectF* layoutRect,
const GpStringFormat* format, const GpBrush* brush);
一次 Hook 要采集什么
不要只存字符串。没有坐标和字体,文字无法和控件对应:
cpp
struct TextRun
{
HWND hwnd; // 由 HDC / 当前线程窗口推导
DWORD processId;
DWORD threadId;
RECT screenRect; // 统一转成屏幕物理像素
std::wstring text;
std::wstring fontFace;
int fontSize;
COLORREF color;
ULONGLONG timestamp;
};
至少要采集:
- 文本内容;
- 绘制起点 / 裁剪矩形;
- 字体名、字号、粗体/斜体;
- 文本颜色、背景模式;
HDC对应的 HWND;- 时间戳、线程 ID、进程 ID;
- 滚动偏移、裁剪区域、世界变换(如果有)。
坐标转换
TextOut / ExtTextOut 的 x, y 通常是逻辑坐标,不是屏幕坐标:
cpp
int mapMode = GetMapMode(hdc); // 常见是 MM_TEXT
POINT viewportOrg, windowOrg;
GetViewportOrgEx(hdc, &viewportOrg);
GetWindowOrgEx(hdc, &windowOrg);
XFORM world;
GetWorldTransform(hdc, &world);
RECT clip;
GetClipBox(hdc, &clip);
关联窗口:
cpp
HWND hwnd = WindowFromDC(hdc);
注意:
WindowFromDC对窗口 DC 有效;对内存 DC(CreateCompatibleDC)通常返回NULL;- 如果目标双缓冲,文字可能先画到内存 DC,再
BitBlt到窗口;这时要额外 HookCreateCompatibleDC/BitBlt,或者用绘制顺序和控件矩形做关联; - 坐标要统一成屏幕物理像素:客户区坐标用
ClientToScreen,窗口坐标用GetWindowRect,再考虑滚动条偏移; - DPI 缩放、镜像、旋转、世界变换都要处理,否则文字框会整体偏移。
Hook 函数骨架
cpp
using ExtTextOutW_t = BOOL(WINAPI*)(
HDC, int, int, UINT, const RECT*, LPCWSTR, UINT, const INT*);
static ExtTextOutW_t g_realExtTextOutW = nullptr;
BOOL WINAPI Hook_ExtTextOutW(
HDC hdc, int x, int y, UINT options,
const RECT* rect, LPCWSTR text, UINT count, const INT* dx)
{
// 1. 只做轻量复制:预分配环形缓冲区,不 new/malloc,不加锁
CaptureText(hdc, x, y, options, rect, text, count, dx);
// 2. 必须调用原始函数,保留返回值
return g_realExtTextOutW(hdc, x, y, options, rect, text, count, dx);
}
安装方式有两种:
- IAT Hook:改目标模块导入表中的函数地址。简单,但只能拦截通过 IAT 调用的函数;
- Inline Hook / Detour:改函数开头几个字节,跳到你的函数。能拦截直接调用,但要处理指令长度、x86/x64、并发、热补丁。
无论哪种,都必须:
- 保存原始函数地址/trampoline;
- 用
VirtualProtect改页保护,改完恢复; - 暂停相关线程或用原子写,避免目标正在执行时指令被撕裂;
- 处理重入:你的 Hook 里再次调用被 Hook 的 API,会无限递归;
- 卸载时恢复原始字节,等待在途调用结束;
- 只 Hook 已知导出,不要硬编码偏移(除非你按版本严格校验)。
GdipDrawString 的特殊性
GDI+ 的 GdipDrawString 参数是浮点矩形和 GpGraphics*,不是 HDC:
cpp
Status Hook_GdipDrawString(
GpGraphics* graphics, const WCHAR* text, INT length,
const GpFont* font, const RectF* layoutRect,
const GpStringFormat* format, const GpBrush* brush)
{
// 可以通过 GdipGetDC 拿到底层 HDC(可能是 NULL)
HDC hdc = nullptr;
GdipGetDC(graphics, &hdc);
// 通过 GdipGetWorldTransform / GdipGetPageUnit 处理坐标
// 通过 GdipGetFontSize / GdipGetFontStyle 读字体信息
if (hdc) CaptureGdiPlusText(hdc, text, length, layoutRect, font);
if (hdc) GdipReleaseDC(graphics, hdc);
return g_realGdipDrawString(graphics, text, length, font,
layoutRect, format, brush);
}
坑:
GdipGetDC不一定拿得到 HDC(硬件加速/离屏渲染时可能为NULL);- GDI+ 的坐标可能受世界变换、页面单位、DPI 影响;
- C# 的
Graphics.DrawString最终会走到GdipDrawString,但中间可能有多个重载; - GDI+ 文本可能被裁剪/旋转,
layoutRect只是布局矩形,不等于最终可见区域。
DirectWrite / Direct2D
DirectWrite 更复杂:
IDWriteTextLayout::Draw接收的是IDWriteTextRenderer,回调里拿到的是 glyph run(字形索引),不是原始 Unicode;- 需要同时 Hook
IDWriteFactory::CreateTextLayout,保存"文本 → layout 对象"的映射; - 或者实现一个包装
IDWriteTextRenderer,在DrawGlyphRun里结合 layout 的 cluster metrics 还原文本; - Direct2D 的
DrawTextLayout可能走 GPU,GDI Hook 完全看不到; - 这类 Hook 通常要按 COM vtable 做 inline hook,稳定性和兼容性都更差。
经验:如果 DirectWrite/Direct2D 很难搞,优先考虑 OCR 或让厂商提供接口,不要硬啃 vtable。
文本 Hook 的边界
- 只能看到经过这个 API 的文本;OpenGL/DirectX/GPU 自绘看不到;
- 文本可能被拆分、合并、重复绘制;
- 滚动、重绘、双缓冲会产生大量重复记录,必须做去重和帧管理;
- 密码框、聊天、患者信息等敏感内容不能记录;要有字段级屏蔽;
- Hook 失败要降级,不能因为读不到文本就崩溃;
- 文本 Hook 的性能开销通常远高于 UIA,能不用就不用。
11.6 DLL 注入:最后的选择
API Hook 需要你的代码运行在目标进程里,所以才有 DLL 注入。
常见方式:
| 方式 | 原理 | 评价 |
|---|---|---|
SetWindowsHookEx + 原生 DLL |
系统把 DLL 注入到目标线程 | 文档化,但只适合消息钩子 |
CreateRemoteThread + LoadLibraryW |
在目标进程里加载 DLL | 经典,但常被 AV/EDR 关注 |
| IAT / Inline Hook | 注入后修改导入表/函数入口 | 需要注入,稳定性取决于实现 |
AppInit_DLLs / IFEO / shim |
系统级加载 | 影响面大、脆弱,不建议 |
| 手动映射(Manual Map) | 自己实现加载器 | 高风险,不建议在正常产品中使用 |
最小注入流程(仅用于自有测试进程):
cpp
bool InjectDll(DWORD pid, const std::wstring& dllPath)
{
HANDLE process = OpenProcess(
PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION |
PROCESS_VM_OPERATION | PROCESS_VM_WRITE,
FALSE, pid);
if (!process) return false;
SIZE_T size = (dllPath.size() + 1) * sizeof(wchar_t);
LPVOID remote = VirtualAllocEx(
process, nullptr, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!remote) { CloseHandle(process); return false; }
if (!WriteProcessMemory(process, remote, dllPath.c_str(), size, nullptr))
{
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
CloseHandle(process);
return false;
}
HMODULE kernel32 = GetModuleHandleW(L"kernel32.dll");
FARPROC loadLibrary = GetProcAddress(kernel32, "LoadLibraryW");
HANDLE thread = CreateRemoteThread(
process, nullptr, 0,
reinterpret_cast<LPTHREAD_START_ROUTINE>(loadLibrary),
remote, 0, nullptr);
bool started = thread != nullptr;
if (thread)
{
DWORD wait = WaitForSingleObject(thread, 5000);
CloseHandle(thread);
// 只有线程确实结束后才能释放远程路径;超时释放可能让目标
// 仍在 LoadLibrary 时读到已释放的内存。
if (wait == WAIT_OBJECT_0)
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
}
CloseHandle(process);
return started;
}
必须考虑:
- 架构匹配:32 位注入器不能给 64 位进程加载 32 位 DLL;
- 权限匹配:普通进程通常无法注入管理员/系统进程;UIPI 会拦截;
- 受保护进程:PPL、反作弊、安全软件通常禁止注入;不要尝试绕过;
- 会话隔离:服务进程(Session 0)和用户桌面不在同一会话;
- AppContainer/UWP:普通注入方式通常无效;
- 稳定性:注入的 DLL 崩溃会拖垮目标;必须有异常隔离和卸载逻辑;
- 合规:只对自有/授权目标使用;记录操作审计;提供关闭开关。
注入后的 DLL 只做"采集",不要把业务逻辑、网络请求、数据库访问放进目标进程。采集到的数据通过共享内存/命名管道/窗口消息送回宿主,由宿主统一处理。
11.7 Hook 的工程规范
热路径规则
Hook 回调可能每秒执行几万次,必须按"中断处理程序"的标准写:
- 不
new/malloc/ 字符串拼接; - 不加锁(用无锁环形队列或线程局部缓冲);
- 不写文件、不发网络请求;
- 不调用 UIA/MSAA/COM;
- 不打日志(最多原子计数);
- 不做复杂坐标变换(先存原始参数,交给工作线程)。
调用链规则
- 永远调用原始函数 /
CallNextHookEx,除非你明确要拦截; - 保留原始返回值;如果修改了
GetLastError,要恢复; - 注意重入:Hook 里调用同一个 API 会递归;
- 不要吞掉目标正常输入,除非这是拾取器明确要做的(并且只吞一次)。
生命周期规则
- 安装/卸载放在专用线程,保证有消息循环;
- 保持委托/函数指针引用,防止 GC 或模块卸载后回调悬空;
- 卸载时先停钩子,再等在途回调,再释放 DLL;
DllMain(DLL_PROCESS_DETACH)里只做最小清理,不要调用可能加载 DLL 的 API;- 目标退出、崩溃、重启时,钩子要能自动失效并让宿主感知。
数据规则
- 每条记录带
HWND + PID + TID + 时间戳; - 坐标统一成屏幕物理像素;
- 文本去重:同一窗口、同一矩形、同一文本在短时间内重复出现,只保留一条;
- 滚动/重绘时清理旧记录;
- 敏感字段(密码、身份证、患者姓名)按策略屏蔽或哈希;
- 数据只保留必要时间,落盘要加密/脱敏。
观测规则
至少暴露这些指标:
hook_installed 是否安装成功
hook_calls 回调次数
hook_queue_depth 队列深度
hook_dropped 丢弃条数
hook_last_error 最近错误
target_pid/hwnd 目标
没有这些指标,Hook 出问题时你只能猜。
11.8 常用工具与库
| 工具/库 | 用途 | 备注 |
|---|---|---|
| Spy++ | 查看窗口层级、消息、类名 | 调试 Win32 层的第一站 |
| Accessibility Insights | 查看 UIA 树、属性、Pattern | 比自写探针更快 |
| Inspect(Windows SDK) | 查看 UIA/MSAA 元素 | 经典工具 |
| API Monitor | 观察目标调用了哪些 API | 定位该 Hook 哪个函数 |
| Process Monitor | 文件/注册表/进程活动 | 排查注入/加载失败 |
| Microsoft Detours | 成熟的 API Hook 库 | 注意许可证 |
| MinHook | 轻量 x86/x64 inline hook | 适合自研 DLL |
| EasyHook | .NET 侧的 Hook 框架 | 较老,注意维护状态 |
| Frida | 动态插桩/调试 | 适合研究,不建议直接进生产 |
| FlaUI | .NET 的 UIA 封装 | 做 UIA 自动化比裸 API 省事 |
建议的排查顺序:
- 先用 Spy++ / Accessibility Insights 确认"到底有没有标准接口";
- 用 API Monitor 看目标实际调用了
TextOutW还是GdipDrawString; - 再决定 Hook 哪个函数、是否需要注入;
- 最后才写 Hook 代码,并准备降级方案。
11.9 决策树:到底该不该 Hook
需要读取控件/文字/事件
│
├─ UIA 能读到?
│ └─ 是 → 用 UIA(首选)
│
├─ MSAA 能读到?
│ └─ 是 → 用 MSAA
│
├─ Win32 能拿到窗口/矩形/标题?
│ └─ 是 → 用 Win32 补足
│
├─ 只需要焦点/前台/值变化事件?
│ └─ 是 → SetWinEventHook(WINEVENT_OUTOFCONTEXT)
│
├─ 只需要鼠标/键盘原始输入?
│ └─ 是 → WH_MOUSE_LL / WH_KEYBOARD_LL
│
├─ 需要窗口消息?
│ └─ 是 → WH_CALLWNDPROC / WH_GETMESSAGE(原生 DLL)
│
├─ 文字是自绘的,且必须拿到原文/坐标?
│ ├─ 能接受 OCR → 截图 + OCR(优先)
│ └─ 必须精确 → API Hook(TextOut/ExtTextOut/GdipDrawString)
│ + DLL 注入(最后手段)
│
└─ 以上都不行 → 让厂商提供 API / 导出 / 无障碍支持
最后再强调一次:
Hook 不是"更高级的控件获取",而是"在没有接口时借实现细节看一眼"。
它应该被封装成可替换的采集器,和 UIA/MSAA/OCR 并列,而不是渗进整个业务代码。
十二、踩坑清单(按出现频率排序)
跨进程与线程
-
在 UI 线程做 UIA/MSAA 调用
目标 Provider 一挂,界面直接冻结。所有跨进程读取都要放到后台线程,并设预算/超时。
-
在本进程里用 UIA 读自己的窗口
某些环境下会原生崩溃(不是托管异常,
try/catch接不住)。验证 UIA 逻辑要用独立进程的夹具窗口。 -
以为
CancellationToken能中断正在执行的 UIA 调用不能。同步 Provider 调用在途时无法取消,只能放弃结果、换线程或结束进程。
-
在低级鼠标钩子里调用 UIA
钩子回调必须尽快返回。调用 UIA 会拖慢整个系统输入,目标卡死时还会被系统摘钩。
-
在 UIA 焦点事件回调里再调用 UIA
容易死锁。回调只入队,工作线程处理。
定位与身份
-
用
Name作为控件身份名称会随数据、语言、状态变化。身份用
AutomationId、ClassName、RuntimeId、HWND。 -
把
RuntimeId写进配置文件它只在当前进程生命周期内有效,程序重启后变化。只用于同一次运行中的重新绑定。
-
多命中时取第一个
今天能跑,明天就点错。要求唯一,否则报错。
-
Provider 返回容器而不是控件
命中后要向深处收敛,选择"包含该点、面积最小"的后代。
-
浏览器/Explorer 只返回整窗
UIA 返回
ControlType.Window时不要结束搜索,继续走 MSAA / 子 HWND / 向下收敛。 -
UWP/Chromium 用进程 ID 判断归属
子 Provider 可能在另一个进程。用引用或 HWND 祖先链判断。
-
把窗口标题当成控件路径的一部分
窗口条件用于找窗口;控件路径里不要写会变的标题。
坐标与 DPI
-
DPI 虚拟化导致坐标偏移
声明 DPI 感知;统一使用物理像素;优先
GetPhysicalCursorPos/WindowFromPhysicalPoint。 -
多显示器负坐标
副屏在主屏左边时 X 是负数,别用
uint或无符号转换。 -
ChildWindowFromPointEx传了屏幕坐标它要的是相对父窗口客户区的坐标,每层都要
ScreenToClient。 -
WindowFromPoint返回子窗口,却当成顶层窗口用需要
GetAncestor(GA_ROOT)时别忘了。
读取内容
-
把
Name当输入框内容富文本、CEF、UWP 的内容在
Value/Text里。用Value → Text → Name的降级顺序。 -
TextPattern.GetText(-1)读整个文档设长度上限,否则一个误拾取的文档能把内存和日志撑爆。
-
把"读不到"当成"空值"
要区分 NotFound / Unsupported / 空值三种状态。
-
把"0 行"当成空列表
Provider 不支持列表模式时,应该返回 Unsupported,而不是成功返回 0 行。
-
虚拟化列表只读可见行
未实现的行不是不存在。需要
ItemContainerPattern/VirtualizedItemPattern/ 滚动,或者明确说明只读到了可见部分。
Win32 / MSAA
-
直接
GetWindowText读跨进程控件可能返回空,也可能永久阻塞。用
SendMessageTimeout(SMTO_ABORTIFHUNG)。 -
EnumWindows结果不过滤隐藏窗口、工具窗口、UWP 框架窗口都会进来。按可见性、进程、类名过滤。
-
MSAA COM 对象不释放 / 重复释放
每节点读完立即释放;释放前判断引用,避免二次释放。
-
MSAA 的
childId当成对象递归childId是整数,不能递归;只有IAccessible对象才能继续取子。
拾取与点击
-
覆盖层抢焦点或吃掉正常点击
用
WS_EX_NOACTIVATE | WS_EX_TRANSPARENT;确认点击用钩子吞掉,右键/ESC 取消。 -
拾取时顺带按下了目标按钮
同上:不吞左键,就会把拾取变成真实点击。
-
双击被算成两次单击
延迟发单击,等过
DoubleClickTime再确认。 -
按下和抬起之间控件变了
抬起时重新读取并校验身份/矩形,不能只信按下时的快照。
-
输入队列积压后用新界面解释旧点击
超过 750ms 的输入直接丢弃,避免"点错行"。
-
把鼠标下、焦点、选中混为一谈
三个概念分开建模,需求里明确是哪一种。
权限与环境
-
工具和目标权限级别不同
管理员程序对普通程序通常不暴露 UIA。要么同级别运行,要么明确提示。
-
目标无响应时整个工具卡死
所有 Provider 调用都要有"放弃"机制:独立线程、预算、看门狗、纯 Win32 兜底。
-
浏览器/CEF 读到空树
它可能还没开启无障碍。可以请求目标开启(MSAA 查询 + 注册 UIA 事件监听),等约 1 秒重读;这是实验性行为,要如实报告成功/失败。
十三、一个最小可运行示例
下面这个控制台程序做三件事:
- 枚举可见顶层窗口;
- 打印指定窗口的 UIA 控件树;
- 打印当前鼠标位置下的 UIA 元素。
csharp
using System;
using System.Text;
using System.Windows.Automation;
using System.Windows.Forms;
internal static class Program
{
private static void Main()
{
Console.OutputEncoding = Encoding.UTF8;
Console.WriteLine("=== 可见顶层窗口 ===");
Win32.EnumWindows((hWnd, lParam) =>
{
if (Win32.IsWindowVisible(hWnd))
{
Console.WriteLine(
$"{hWnd:X8} {Win32.ProcessName(hWnd),-20} " +
$"{Win32.ClassName(hWnd),-28} {Win32.WindowText(hWnd)}");
}
return true;
}, IntPtr.Zero);
Console.Write("输入窗口句柄(十六进制,可跳过):");
string input = Console.ReadLine();
if (!string.IsNullOrWhiteSpace(input))
{
var hWnd = new IntPtr(Convert.ToInt64(input, 16));
Console.WriteLine("=== UIA 控件树 ===");
try
{
var root = AutomationElement.FromHandle(hWnd);
Dump(root, 0);
}
catch (Exception ex)
{
Console.WriteLine("读取失败:" + ex.Message);
}
}
Console.WriteLine("=== 当前鼠标下的元素 ===");
var point = Cursor.Position;
try
{
var hit = AutomationElement.FromPoint(
new System.Windows.Point(point.X, point.Y));
var c = hit.Current;
Console.WriteLine(
$"{c.ControlType.ProgrammaticName} " +
$"Name='{c.Name}' Id='{c.AutomationId}' " +
$"Class='{c.ClassName}' Rect={c.BoundingRectangle}");
}
catch (Exception ex)
{
Console.WriteLine("没有命中元素:" + ex.Message);
}
Console.WriteLine("按回车退出。");
Console.ReadLine();
}
private static void Dump(AutomationElement element, int depth)
{
var c = element.Current;
Console.WriteLine(
new string(' ', depth * 2) +
$"{c.ControlType.ProgrammaticName} " +
$"Name='{c.Name}' Id='{c.AutomationId}'");
TreeWalker walker = TreeWalker.RawViewWalker;
for (var child = walker.GetFirstChild(element);
child != null;
child = walker.GetNextSibling(child))
{
Dump(child, depth + 1);
}
}
}
窗口枚举部分用前面的 Win32 帮助类即可。
十四、最后:一张检查表
实现一个"能长期用"的控件获取工具,至少要做到:
- 三种来源(UIA / MSAA / Win32)都有,且自动降级;
- 所有跨进程调用不在 UI 线程;
- 所有遍历有深度、节点数、时间预算;
- 命中后向深处收敛,不把容器当控件;
- 定位必须唯一,多命中就报错;
- 长期配置只存 AutomationId / ClassName / 结构,不存 Name / RuntimeId / 实时值;
- 读取结果区分"找不到""不支持""空值";
- 列表优先 Table/Grid,读不到再降级,绝不返回假成功;
- 鼠标拾取不抢焦点、不触发目标、可取消;
- 点击关联重新校验按下/抬起,处理双击和拖拽;
- 坐标统一 DPI,权限问题能诊断;
- 读不到时明确告诉用户原因,而不是猜一个;
- Hook 只在标准接口确实不够时使用,并有明确授权;
- Hook 回调无分配、无锁、无 I/O,且始终调用原函数/
CallNextHookEx; - Hook 失败能降级到 UIA/MSAA/OCR,且有开关、指标和审计。
做到这些,你的控件获取代码才从"演示能跑"变成"现场能用"。
本文示例来自一个实际项目:默认只读、动作显式、定位必须唯一。
如果你正在做 PACS/医疗、工业、金融等不能点错控件的场景,建议把"宁可找不到,也不要找错"作为第一条设计原则。