Windows 控件获取全攻略:Win32 / UIA / MSAA + 鼠标拾取与点击关联(C# 实战)

本文面向需要做 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 项目中添加引用:

  • UIAutomationClient
  • UIAutomationTypes
  • Accessibility(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;
}

这里有两个容易踩的点:

  1. ChildWindowFromPointEx 的坐标是相对父窗口客户区 的,不是屏幕坐标,所以每一层都要 ScreenToClient;
  2. 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 看起来最直接,但实际经常不符合预期:

  1. Provider 只返回容器 。例如 Windows 11 记事本正文,命中测试返回的是 Pane[NotepadTextBox],不是里面的 Document。
  2. 浏览器/Explorer 只返回整窗 。UIA 命中测试给出 ControlType.Window,高亮框会盖住整个窗口。
  3. 自绘控件没有 UIA 节点。只能退到 MSAA 或 Win32。
  4. 坐标 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 拾取器不能抢焦点、不能点到目标

一个可用的拾取器需要:

  1. 全屏透明覆盖层,鼠标穿透(WS_EX_TRANSPARENT),不激活(WS_EX_NOACTIVATE);
  2. 用 WH_MOUSE_LL 低级鼠标钩子接管左键,确认时吞掉这次点击,否则用户每拾取一次就会顺带按下目标按钮;
  3. 右键 / ESC 取消;
  4. 探测在后台线程做,UI 线程只画高亮;
  5. 单个探测超时(例如 2 秒)就放弃,允许下次鼠标移动重试;
  6. 点击确认后如果 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 控件身份怎么判断

优先级:

  1. RuntimeId(同一进程生命周期内有效);
  2. 没有 RuntimeId 时,退化为 ControlType + AutomationId + NativeWindowHandle;
  3. 不要用 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 "没有值"和"控件不存在"要分开

工程上必须区分三种结果:

  1. 控件找不到 → NotFound;
  2. 控件找到了,但不支持该属性 → Unsupported;
  3. 控件支持该属性,但当前值是空 → 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 都失败。这时可以:

  1. 用 SelectionPattern 读选中项(至少拿到当前行);
  2. 用子元素遍历读已实现的行;
  3. 标记 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 代码之前,先把这十条贴在墙上:

  1. 合法授权:只 Hook 自己开发的进程,或客户明确授权、且你知道后果的目标。不要注入安全软件、浏览器密码框、支付/游戏反作弊进程。
  2. 标准优先:UIA、MSAA、Win32、OCR 能解决的,不 Hook。Hook 是对实现细节的依赖,目标一升级就可能失效。
  3. 进程外优先 :能用 SetWinEventHook(WINEVENT_OUTOFCONTEXT)、低级鼠标/键盘钩子拿到的,不注入 DLL。
  4. 最小侵入:只 Hook 必需的函数/消息;只读优先;不做不必要的全局钩子。
  5. 热路径极简:Hook 回调里不分配内存、不加锁、不写日志、不做 I/O、不调用 UIA/MSAA。
  6. 必须调用原函数 :除非你明确要拦截,否则一定要 CallNextHookEx 或调用原始 API,并保留返回值和 GetLastError。
  7. 坐标/身份归一:Hook 拿到的是 DC 坐标、客户区坐标、线程 ID、HWND;统一转换成屏幕物理像素和稳定身份,再进入业务层。
  8. 可降级:Hook 安装失败、目标升级、被安全软件拦截时,自动退回 UIA/MSAA/OCR,并如实告诉用户。
  9. 可关闭、可审计:提供总开关;记录"哪个进程、哪个 Hook、捕获了多少条、丢弃了多少条",但不记录敏感原文。
  10. 不碰安全边界:不绕过 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 到窗口;这时要额外 Hook CreateCompatibleDC / 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 省事

建议的排查顺序:

  1. 先用 Spy++ / Accessibility Insights 确认"到底有没有标准接口";
  2. 用 API Monitor 看目标实际调用了 TextOutW 还是 GdipDrawString;
  3. 再决定 Hook 哪个函数、是否需要注入;
  4. 最后才写 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 并列,而不是渗进整个业务代码。


十二、踩坑清单(按出现频率排序)

跨进程与线程

  1. 在 UI 线程做 UIA/MSAA 调用

    目标 Provider 一挂,界面直接冻结。所有跨进程读取都要放到后台线程,并设预算/超时。

  2. 在本进程里用 UIA 读自己的窗口

    某些环境下会原生崩溃(不是托管异常,try/catch 接不住)。验证 UIA 逻辑要用独立进程的夹具窗口。

  3. 以为 CancellationToken 能中断正在执行的 UIA 调用

    不能。同步 Provider 调用在途时无法取消,只能放弃结果、换线程或结束进程。

  4. 在低级鼠标钩子里调用 UIA

    钩子回调必须尽快返回。调用 UIA 会拖慢整个系统输入,目标卡死时还会被系统摘钩。

  5. 在 UIA 焦点事件回调里再调用 UIA

    容易死锁。回调只入队,工作线程处理。

定位与身份

  1. 用 Name 作为控件身份

    名称会随数据、语言、状态变化。身份用 AutomationId、ClassName、RuntimeId、HWND。

  2. 把 RuntimeId 写进配置文件

    它只在当前进程生命周期内有效,程序重启后变化。只用于同一次运行中的重新绑定。

  3. 多命中时取第一个

    今天能跑,明天就点错。要求唯一,否则报错。

  4. Provider 返回容器而不是控件

    命中后要向深处收敛,选择"包含该点、面积最小"的后代。

  5. 浏览器/Explorer 只返回整窗

    UIA 返回 ControlType.Window 时不要结束搜索,继续走 MSAA / 子 HWND / 向下收敛。

  6. UWP/Chromium 用进程 ID 判断归属

    子 Provider 可能在另一个进程。用引用或 HWND 祖先链判断。

  7. 把窗口标题当成控件路径的一部分

    窗口条件用于找窗口;控件路径里不要写会变的标题。

坐标与 DPI

  1. DPI 虚拟化导致坐标偏移

    声明 DPI 感知;统一使用物理像素;优先 GetPhysicalCursorPos / WindowFromPhysicalPoint。

  2. 多显示器负坐标

    副屏在主屏左边时 X 是负数,别用 uint 或无符号转换。

  3. ChildWindowFromPointEx 传了屏幕坐标

    它要的是相对父窗口客户区的坐标,每层都要 ScreenToClient。

  4. WindowFromPoint 返回子窗口,却当成顶层窗口用

    需要 GetAncestor(GA_ROOT) 时别忘了。

读取内容

  1. 把 Name 当输入框内容

    富文本、CEF、UWP 的内容在 Value/Text 里。用 Value → Text → Name 的降级顺序。

  2. TextPattern.GetText(-1) 读整个文档

    设长度上限,否则一个误拾取的文档能把内存和日志撑爆。

  3. 把"读不到"当成"空值"

    要区分 NotFound / Unsupported / 空值三种状态。

  4. 把"0 行"当成空列表

    Provider 不支持列表模式时,应该返回 Unsupported,而不是成功返回 0 行。

  5. 虚拟化列表只读可见行

    未实现的行不是不存在。需要 ItemContainerPattern / VirtualizedItemPattern / 滚动,或者明确说明只读到了可见部分。

Win32 / MSAA

  1. 直接 GetWindowText 读跨进程控件

    可能返回空,也可能永久阻塞。用 SendMessageTimeout(SMTO_ABORTIFHUNG)。

  2. EnumWindows 结果不过滤

    隐藏窗口、工具窗口、UWP 框架窗口都会进来。按可见性、进程、类名过滤。

  3. MSAA COM 对象不释放 / 重复释放

    每节点读完立即释放;释放前判断引用,避免二次释放。

  4. MSAA 的 childId 当成对象递归

    childId 是整数,不能递归;只有 IAccessible 对象才能继续取子。

拾取与点击

  1. 覆盖层抢焦点或吃掉正常点击

    用 WS_EX_NOACTIVATE | WS_EX_TRANSPARENT;确认点击用钩子吞掉,右键/ESC 取消。

  2. 拾取时顺带按下了目标按钮

    同上:不吞左键,就会把拾取变成真实点击。

  3. 双击被算成两次单击

    延迟发单击,等过 DoubleClickTime 再确认。

  4. 按下和抬起之间控件变了

    抬起时重新读取并校验身份/矩形,不能只信按下时的快照。

  5. 输入队列积压后用新界面解释旧点击

    超过 750ms 的输入直接丢弃,避免"点错行"。

  6. 把鼠标下、焦点、选中混为一谈

    三个概念分开建模,需求里明确是哪一种。

权限与环境

  1. 工具和目标权限级别不同

    管理员程序对普通程序通常不暴露 UIA。要么同级别运行,要么明确提示。

  2. 目标无响应时整个工具卡死

    所有 Provider 调用都要有"放弃"机制:独立线程、预算、看门狗、纯 Win32 兜底。

  3. 浏览器/CEF 读到空树

    它可能还没开启无障碍。可以请求目标开启(MSAA 查询 + 注册 UIA 事件监听),等约 1 秒重读;这是实验性行为,要如实报告成功/失败。


十三、一个最小可运行示例

下面这个控制台程序做三件事:

  1. 枚举可见顶层窗口;
  2. 打印指定窗口的 UIA 控件树;
  3. 打印当前鼠标位置下的 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/医疗、工业、金融等不能点错控件的场景,建议把"宁可找不到,也不要找错"作为第一条设计原则。

相关推荐
工业传感笔记1 小时前
工业传感器国产替代的工程评估框架:从规格覆盖到工况验证的四个维度
其他·自动化
乐橙开放平台1 小时前
门店监控想集中看?乐橙含光支持多品牌摄像头跨店管理
网络·笔记·物联网·自动化·音视频·智能家居
dk_tjy10 小时前
AI驱动的自动化攻击背景下:企业端点安全体系的技术架构与建设路径
网络·人工智能·安全·自动化·终端安全管理·数据防泄漏
小羊没烦恼!11 小时前
铁道部新客票系统设计(二)
开发语言·前端·数据库·python·oracle·c#·php
昂好的day12 小时前
VisionMaster 通信模块实战(上):Modbus、TCP 收发、协议组装解析
开发语言·网络·网络协议·tcp/ip·c#
郝学胜-神的一滴14 小时前
AI 编程智能体 09:零门槛调用教程
人工智能·数据分析·自动化·numpy·pandas·量化投资
波力海苔夹心脆67515 小时前
C# 正则表达式零基础入门:从“看得懂“到“会用“
经验分享·后端·mysql·正则表达式·c#
戴西软件16 小时前
制造研发中的AI视觉与仿真:戴西工程图纸智能审图智能体、戴西3DViz_CAE AI集成与戴西3DViz_CAD AI集成
大数据·运维·人工智能·3d·自动化·制造·rpa
工业传感笔记16 小时前
烘道高温检测失效频发:耐高温接近开关的选型逻辑与国产化落地
其他·自动化