简介:资源以 Winform 结合 FlaUI 的方式,演示对微信桌面客户端的 UI 自动化改造,面向有 C# 基础、希望用桌面自动化减少重复操作的开发者。压缩包共 365 个文件、约 47.83MB,以 127 个 dll 依赖与 55 个 cs 源码为主体,配合 json 配置、resx/resources 界面资源、exe 可执行文件及 csproj 工程文件,并附带 so、dylib 等运行时库与编译缓存,可还原完整解决方案。项目按主程序、FlaUI 操作封装、定时任务、消息处理与数据库分层,覆盖定时向好友或群聊发送消息、关键词匹配自动回复、群聊机器人应答等场景,便于理解元素查找、事件监听与模拟交互的实际写法。已有 661 人学习下载,适合作为 Windows UI 自动化的实练参考。
1. 微信自动化不是「模拟点击」:Winform + FlaUI 这条路的真实定位
很多人第一次听到微信自动化,脑子里浮现的是屏幕坐标点击、找图找色那一套。这条路能跑通,但极脆——换个分辨率、改个主题、窗口挪个位置,脚本当场翻车。FlaUI 走的是另一条路:它把 Windows 的 UI Automation 接口包了一层,让程序能像读 DOM 一样读到窗口里的控件树,按 AutomationId、Name、ControlType 去定位元素,再调用控件自身暴露的 Invoke、Value、Selection 模式完成操作。换句话说,它操作的是「控件的语义」,不是「像素的位置」。
这套方案适合三类人:需要把桌面端重复劳动(批量发消息、群发通知、消息归档、客服话术回复)做成内部工具的开发;想用 Winform 快速搭一个可视化操作台的工程师;以及已经在用 UIAutomation 原生 API、被 AutomationElement 那套冗长写法折磨到想换库的人。前提是你的目标程序跑在 Windows 桌面端、控件树暴露得还算完整——这两条不成立,后面所有章节都不用看了。
2. 选型与运行原理:UIA3、FlaUI、Winform 各站哪一层
动手之前先把三层关系捋清楚,不然后面遇到「找不到控件」根本不知道该往哪查。最底层是 Windows 的 UI Automation,它是操作系统提供的辅助功能接口,任何能跑 UIA 客户端的程序都能读取其他进程的控件树;中间层是 FlaUI,它对 UIA 的 COM 接口做了托管封装,提供强类型控件、条件工厂、重试工具、鼠标键盘模拟;最上层是 Winform,它只负责界面和调度,不参与定位逻辑。
2.1 UIA2 与 UIA3 的差别,以及为什么这个场景选 UIA3
FlaUI 同时提供UIA2Automation和UIA3Automation两个入口,名字像版本号,实际是两套不同的底层通道。UIA2 走的是托管封装 + MSAA 桥接,对老式 Win32 控件和部分自绘控件的兼容性更好;UIA3 直接调原生 COM 接口,对现代 UI 框架支持更完整,TextPattern、ValuePattern、SelectionPattern这些模式暴露得更全。
即时通讯类桌面客户端的界面大量使用自绘控件,控件树的「伪控件」特征明显:一个看起来像输入框的东西,在 UIA 里可能既不是 Edit 也不是 Document,而是挂了 TextPattern 的 Pane。这种时候 UIA3 的模式探测更靠谱。我一般会先写一个探测方法,把两种 automation 都跑一遍,比对返回的控件数量:
// 用两种通道分别扫描同一个窗口,比对控件树规模 using (var uia3 = new UIA3Automation()) using (var uia2 = new UIA2Automation()) { var app = FlaUI.Core.Application.Attach(processId); var win3 = app.GetMainWindow(uia3, TimeSpan.FromSeconds(5)); var win2 = app.GetMainWindow(uia2, TimeSpan.FromSeconds(5)); // FindAllDescendants 会遍历整棵子树,大窗口下耗时可观,探测时限定深度 int count3 = win3?.FindAllDescendants().Length ?? -1; int count2 = win2?.FindAllDescendants().Length ?? -1; Console.WriteLine($"UIA3={count3}, UIA2={count2}"); }Attach(processId)是附加到已运行的进程,不负责启动,进程号需要自己用Process.GetProcessesByName拿。GetMainWindow的第二个参数是等待主窗口出现的超时,程序刚启动时窗口句柄还没建立,不传超时会直接拿到 null。两个 count 差得越多,说明两套通道对这个界面的解析差异越大,通常选数字大的那一侧——控件暴露得越多,可用的定位锚点越多。
提示:
FindAllDescendants()不带条件时会遍历整棵树,在会话列表很长的情况下单次调用可能几百毫秒。探测阶段可以接受,正式代码里一律带条件或限定TreeScope.Children。
2.2 Winform 宿主与线程模型:别在主线程上跑 UIA
Winform 的 UI 线程是 STA 的,所有控件操作必须在这个线程上完成。UIA 的 COM 调用本身也倾向 STA,但它慢——一次跨进程的FindFirstDescendant可能几十毫秒,遍历整棵子树能到秒级。把这种东西直接写在按钮点击事件里,界面必然假死,用户点一下窗口就白屏。
常见做法是:把自动化逻辑整体丢到一个专用线程上跑,Winform 主线程只负责收集参数、启动任务、接收日志。日志回显必须切回 UI 线程,否则跨线程访问控件会抛InvalidOperationException。这里用一个IProgress<string>把两边解耦:
// 自动化工作线程:所有 UIA 调用都在这个线程内完成 private async Task RunAutomationAsync(string target, IProgress<string> log, CancellationToken ct) { await Task.Run(() => { // automation 实例在这个线程内创建,不跨线程共享 using var automation = new UIA3Automation(); var app = FlaUI.Core.Application.Attach(GetTargetProcessId()); log.Report("已附加到目标进程"); var window = app.GetMainWindow(automation, TimeSpan.FromSeconds(10)); if (window == null) { log.Report("主窗口未出现,检查是否最小化到托盘"); return; } // 后续定位与操作全部基于 window 展开 // ... }, ct); }Task.Run把整段逻辑推到线程池,ct用于中途取消——长流程里每个关键节点前插一次ct.ThrowIfCancellationRequested(),否则用户点了「停止」也只能干等。automation用using包住,UIA3 的 COM 对象不释放会累积句柄,跑几百轮之后目标进程会变得卡顿。log.Report是线程安全的,Progress<T>内部会把回调 marshal 回创建它的 SynchronizationContext,所以 UI 那边直接写label.Text = msg不会炸。
有一个细节值得单独说:不要在多个线程里共享同一个UIA3Automation实例。COM 对象的 apartment 归属是固定的,跨线程调用轻则报RPC_E_WRONG_THREAD,重则整个进程挂死。要么每个工作线程自建实例,要么把所有 UIA 操作收敛到一个专用 STA 线程上串行执行。
2.3 工程骨架与环境参数
新建一个 Winform 项目,目标框架选 .NET Framework 4.8 或 .NET 6/8 的-windows变体都可以,FlaUI 两边都支持。NuGet 只需要装一个包:
# 只装 UIA3 包,FlaUI.Core 会被作为依赖自动带入 dotnet add package FlaUI.UIA3平台目标必须设成 x64。这不是可选项:目标进程是 64 位,自动化程序编成 x86 之后,UIA 的跨位数通信虽然名义上支持,但部分 Pattern 会返回空值,症状是「控件找得到、值读不出来」,极难排查。在 csproj 里直接锁死:
<PropertyGroup> <PlatformTarget>x64</PlatformTarget> <!-- Winform 项目关掉隐式 usings,避免和 FlaUI 的类型名冲突 --> <ImplicitUsings>disable</ImplicitUsings> </PropertyGroup>还有两个容易漏的配置。第一是 DPI 感知:在 app.manifest 里加<dpiAware>true/pm</dpiAware>,否则在高分屏上BoundingRectangle拿到的坐标和实际屏幕坐标对不上,凡是走鼠标模拟的兜底路径全会点偏。第二是权限对齐:如果目标程序是以管理员身份启动的,自动化程序也必须以管理员身份运行,否则 UIA 调用返回的控件树是残缺的——不是空,是残缺,只拿得到顶层窗口,拿不到子控件。这个现象特别迷惑人,因为代码逻辑完全正确。
3. 控件树定位实战:从进程句柄到一条消息发出去
环境配好之后,真正花时间的部分开始了。定位的本质是:在目标窗口的控件树里,找到那个唯一的、稳定的锚点。锚点选得好,客户端小版本升级不用改代码;锚点选得烂,每周都得修一次。
3.1 启动与附加:把进程和窗口拿到手
第一件事是决定「启动」还是「附加」。启动适用于冷流程,程序自己拉起目标进程,能控制启动参数;附加适用于目标进程已经在跑、可能已经登录了账号的场景。生产工具里我倾向附加,因为登录态是最麻烦的部分,让用户自己登录好再点开始,比什么都稳。
// 按进程名找到目标,多个实例时优先取有主窗口的那个 private static int GetTargetProcessId() { var candidates = Process.GetProcessesByName("WeChat"); // 进程名按实际目标填写 if (candidates.Length == 0) throw new InvalidOperationException("目标进程未启动"); // 没有主窗口句柄的通常是残留的后台进程,直接排除 var alive = candidates.Where(p => p.MainWindowHandle != IntPtr.Zero).ToArray(); if (alive.Length == 0) throw new InvalidOperationException("进程存在但没有可见主窗口"); if (alive.Length > 1) throw new InvalidOperationException($"检测到 {alive.Length} 个实例,请先关闭多余窗口"); return alive[0].Id; }MainWindowHandle为零的进程必须过滤掉,否则你会附加到一个已经退出的壳上,后面所有定位返回 null,而错误信息完全指不到根因。多实例直接抛异常而不是「取第一个」,这是血泪经验:取第一个意味着用户开两个号的时候,脚本可能把消息发给错的人,这类事故比报错难处理一百倍。
如果确实要走启动路径,用Application.Launch(exePath)而不是Process.Start,前者会把启动参数、工作目录、等待逻辑都包好,返回值直接可以传给GetMainWindow:
// Launch 返回的是 FlaUI 的 Application 包装,内置了进程等待 var app = FlaUI.Core.Application.Launch(@"<目标程序安装路径>\WeChat.exe"); // 冷启动时窗口出现得慢,超时给足 var window = app.GetMainWindow(automation, TimeSpan.FromSeconds(20));3.2 读控件树:AutomationId、Name、ControlType 三件套
定位之前必须先把树打出来看。FlaUI 生态里有个 FlaUInspect 工具,界面和 Windows SDK 自带的 Inspect.exe 类似,但展示的信息更贴合 FlaUI 的 API。如果没有这个工具,用自己的代码 dump 一份也行:
// 递归打印控件树,深度可控,避免在复杂界面上刷屏 private static void DumpTree(AutomationElement element, int depth = 0, int maxDepth = 6) { if (element == null || depth > maxDepth) return; // 缩进反映层级,输出五个定位关键属性 Console.WriteLine($"{new string(' ', depth * 2)}" + $"[{element.ControlType}] " + $"Name='{element.Name}' " + $"AutoId='{element.AutomationId}' " + $"Class='{element.ClassName}' " + $"Rect={element.BoundingRectangle}"); foreach (var child in element.FindAllChildren()) DumpTree(child, depth + 1, maxDepth); }五个属性各有用途。ControlType是控件的类型枚举,Button、Edit、List、ListItem 这些,最稳定但区分度最低;Name是控件的显示文本,可读性好,但会随语言和版本变化;AutomationId是开发者显式设置的标识,最稳定,可惜自绘控件经常不给;ClassName是底层窗口类名,对自绘控件往往是唯一的线索;BoundingRectangle是屏幕坐标矩形,只在兜底方案里用。
maxDepth别设太大,社交类客户端的控件树动辄上千节点,全打出来根本没法看。先打到 4 层,找到大致区域,再针对性地下钻。
定位条件用ConditionFactory组合。FlaUI 的写法比原生PropertyCondition链清爽很多:
var cf = automation.ConditionFactory; // 单个条件:按控件类型找输入框 var editOnly = cf.ByControlType(ControlType.Edit); // 组合条件:类型 + 名称,用 And 连接 var searchBox = cf.ByControlType(ControlType.Edit) .And(cf.ByName("搜索")); // 或者关系:多个可能的名称都接受 var sendButton = cf.ByName("发送(S)") .Or(cf.ByName("发送"));ConditionFactory从automation实例上取,不要自己 new——不同 automation 通道的 property library 不一样,混用会导致条件永远匹配不上。.And()和.Or()返回的仍是 Condition,可以继续链式叠加,但超过三个条件就该反思了:说明你没找到真正的锚点。
3.3 搜索、输入、发送:三个动作的完整链路
一个完整的发消息流程分四步:激活窗口、定位搜索框、输入联系人、定位输入框写内容、点发送。逐步写:
// 第一步:激活窗口。UIA 的某些操作需要窗口在前台才生效 window.SetForeground(); window.WaitUntilClickable(TimeSpan.FromSeconds(3)); // 第二步:定位搜索框。这里用类型 + 名称双条件 var searchBox = window.FindFirstDescendant(cf.ByControlType(ControlType.Edit) .And(cf.ByName("搜索"))); if (searchBox == null) throw new InvalidOperationException("搜索框定位失败"); // 第三步:写入联系人名。优先走 ValuePattern searchBox.AsTextBox().Text = "张三"; // 部分自绘控件不支持 ValuePattern,会抛异常,需要降级到键盘输入 // 降级路径:searchBox.Focus(); Keyboard.Type("张三");AsTextBox()是 FlaUI 的强类型包装,内部调的是ValuePattern.SetValue。它比直接设element.Name靠谱得多——后者在某些控件上是只读的,赋值静默失败,你还以为写进去了。降级路径里的Keyboard.Type走的是SendInput,前提是窗口在前台,所以第一步的SetForeground不能省。WaitUntilClickable是 FlaUI 的扩展方法,判断元素存在且未禁用,比死等固定毫秒数靠谱。
搜索结果出来后,列表项需要选中并回车:
// 搜索结果列表:先定位 List,再取第一个 ListItem var resultList = window.FindFirstDescendant(cf.ByControlType(ControlType.List)); var firstResult = resultList?.FindFirstChild(cf.ByControlType(ControlType.ListItem)); if (firstResult == null) { // 结果为空说明联系人名写错了,或者列表还在加载 // 常见做法是重试等待,不要直接抛 throw new InvalidOperationException("搜索结果为空"); } // 双击进入会话,比单击 + 回车更稳,后者在部分版本上只选中不进入 firstResult.DoubleClick();DoubleClick()是 FlaUI 的鼠标模拟扩展,内部基于元素中心点坐标。这里有个隐含风险:如果窗口被遮挡,双击会打到遮挡物上。所以前面SetForeground之后最好再加一次window.Focus()。
写消息正文和点发送:
// 输入框:社交客户端的消息输入区通常是个挂了 TextPattern 的 Document 或 Edit var inputBox = window.FindFirstDescendant(cf.ByControlType(ControlType.Edit) .And(cf.ByName("输入"))); inputBox.AsTextBox().Text = "这是一条自动化测试消息"; // 发送按钮:优先用 Invoke,比鼠标点击稳 var sendBtn = window.FindFirstDescendant(cf.ByControlType(ControlType.Button) .And(cf.ByName("发送(S)"))); sendBtn.AsButton().Invoke(); // 兜底:Invoke 不生效时用键盘 Enter // inputBox.Focus(); Keyboard.Press(VirtualKeyShort.ENTER);AsButton().Invoke()调用的是 UIA 的 InvokePattern,这是「语义点击」,不依赖坐标,窗口被部分遮挡也能生效,比鼠标模拟稳一个量级。缺点是有些自绘按钮压根没实现 InvokePattern,调用会抛NotSupportedException,这时候才退回Click()或键盘 Enter。
3.4 读回消息列表:把界面数据变成结构化文本
光会发不够,很多场景要读。消息列表的读取逻辑和定位反过来:先找到承载消息的 List 控件,再遍历子项,从每个 ListItem 的 Name 里拆出昵称、时间、内容。
// 消息列表:ControlType 是 List,且子项数量随时间增长的那个才是 var msgList = window.FindFirstDescendant(cf.ByControlType(ControlType.List)); var items = msgList.FindAllChildren(cf.ByControlType(ControlType.ListItem)); var messages = new List<string>(); foreach (var item in items) { // ListItem 的 Name 通常是「昵称+时间+内容」的拼接串 var raw = item.Name; if (string.IsNullOrWhiteSpace(raw)) continue; messages.Add(raw); }FindAllChildren只取直接子节点,比FindAllDescendants快得多,在长列表上差距明显。List 控件的筛选要小心:一个会话窗口里往往有多个 List(联系人列表、消息列表、表情面板),先 dump 一遍看哪个 List 的子项数量随消息增长,那个才是目标。取Name之后通常还要用正则拆字段,拆不出来就说明客户端用了自绘渲染、把文本挂在了更深层的 Text 子节点上,需要往下再走一层。
4. 把定位结果配置化:改版本不改代码
定位逻辑写死在代码里,客户端一升级就得重新编译发版。这是内部工具最常见的死法。解决办法是把「怎么找」从「找到之后干什么」里剥出来,定位规则放配置文件,业务逻辑只认 key。
4.1 定位规则抽成 JSON
配置文件里每个条目描述一个元素,字段和ConditionFactory的构造参数一一对应:
{ "SearchBox": { "ControlType": "Edit", "Name": "搜索", "AutomationId": null, "TimeoutMs": 5000 }, "SendButton": { "ControlType": "Button", "Name": "发送(S)", "AutomationId": null, "TimeoutMs": 3000 }, "MessageList": { "ControlType": "List", "Name": null, "AutomationId": null, "TimeoutMs": 5000 } }Name和AutomationId允许为 null,表示这个维度不参与匹配。允许 null 是刻意设计:不同版本的客户端暴露的属性不同,某个版本突然给了 AutomationId,你只要填空就行,代码一行不动。TimeoutMs单独放配置而不是写死,因为不同元素出现的时间差很多——按钮基本秒出,列表要等网络。
对应的 C# 模型和条件构建:
public class Locator { public string ControlType { get; set; } public string Name { get; set; } public string AutomationId { get; set; } public int TimeoutMs { get; set; } = 3000; } // 把配置条目翻译成 FlaUI 的 Condition public static Condition BuildCondition(Locator loc, ConditionFactory cf) { Condition cond = null; if (!string.IsNullOrEmpty(loc.ControlType)) { // 字符串转枚举,解析失败说明配置写错了,早报早改 var ct = (ControlType)Enum.Parse(typeof(ControlType), loc.ControlType, true); cond = cf.ByControlType(ct); } if (!string.IsNullOrEmpty(loc.Name)) { var nameCond = cf.ByName(loc.Name); cond = cond == null ? nameCond : cond.And(nameCond); } if (!string.IsNullOrEmpty(loc.AutomationId)) { var idCond = cf.ByAutomationId(loc.AutomationId); cond = cond == null ? idCond : cond.And(idCond); } if (cond == null) throw new ArgumentException("定位规则至少要有一个有效字段"); return cond; }Enum.Parse的ignoreCase传 true,配置里写edit和Edit都能过。cond == null的分支必须处理,否则第一个条件没配、直接.And()会抛空引用——这是我自己踩过的,配置文件里手滑删了一行就炸了。
注意:配置文件加载失败要给出明确的行号和字段名。Json.NET 的默认异常信息够用,但
Enum.Parse抛的异常只说「值无效」,不带字段名,包一层 try 转成自定义异常会省很多排查时间。
4.2 显式等待与重试:Retry.WhileNull的正确姿势
FindFirstDescendant找不到就返回 null,不会等。界面上刚点完搜索、结果还没渲染出来,直接查就是 null,然后代码抛异常,用户看到「定位失败」,其实只是慢了 200 毫秒。所有定位都要包一层带超时的重试。
FlaUI 自带Retry工具类:
// 在超时窗口内反复执行查找,直到返回非 null var sendBtn = Retry.WhileNull( () => window.FindFirstDescendant(cf.ByName("发送(S)")), timeout: TimeSpan.FromSeconds(5), interval: TimeSpan.FromMilliseconds(200) ).Result; if (sendBtn == null) throw new TimeoutException("发送按钮 5 秒内未出现");参数逐个说:第一个是Func<T>,每次重试都会重新执行,所以里面必须是「重新查找」而不是「用缓存的引用」;timeout是总时长,到点就停;interval是两次尝试之间的间隔,200ms 是个折中值,再小会疯狂占用 CPU 和 UIA 通道,再大会让响应变钝。返回值是RetryResult<T>,.Result拿实际结果,.Success判断是否成功。
新手最容易犯的错是把已经拿到的元素传给重试。比如先var btn = Find(...),然后Retry.WhileNull(() => btn, ...)——这毫无意义,btn要么是 null 要么不是,重试一万次结果一样。重试的函数体里必须包含「查找」这个动作。
还有一类坑是「元素存在但不可交互」。刚渲染出来的按钮,IsEnabled可能还是 false,这时候Invoke()会静默失败。稳妥写法是等IsEnabled:
// 等元素出现,再等它可用,两步走 var btn = Retry.WhileNull( () => window.FindFirstDescendant(cf.ByName("发送(S)")), TimeSpan.FromSeconds(5)).Result; var ready = Retry.WhileFalse( () => btn != null && btn.IsEnabled, TimeSpan.FromSeconds(3)).Success; if (!ready) throw new TimeoutException("发送按钮未进入可用状态");Retry.WhileFalse的语义是「条件为 false 时持续重试」,和WhileNull一个思路,只是判定谓词不同。
4.3 界面与自动化线程的分工
工作线程跑自动化、主线程渲染,中间用IProgress<string>和CancellationToken连接,这个结构前面提过。真正要补的是指令传递:用户可能连续点五次「发送」,不能让它们并发跑,必须排队串行执行。
常见做法是用Channel或BlockingCollection做单消费者队列:
// 单消费者队列,保证同一时刻只有一个自动化任务在跑 private readonly BlockingCollection<SendTask> _queue = new(); // 生产端:UI 线程调用,只入队不阻塞 public void Enqueue(SendTask task) => _queue.Add(task); // 消费端:独立长驻线程 private void WorkerLoop(CancellationToken ct) { foreach (var task in _queue.GetConsumingEnumerable(ct)) { try { Execute(task); _log.Report($"[OK] {task.Target}"); } catch (Exception ex) { // 单条失败不能拖垮整个队列,记录后继续 _log.Report($"[FAIL] {task.Target}: {ex.Message}"); } } }GetConsumingEnumerable(ct)在取消时会退出循环,foreach自然结束。try/catch放在循环内部,一条任务失败不影响后续,这个位置很关键——放在外面整个工作线程就死了。
5. 避坑与排查:五条现场记录
这一章全是踩过的坑,按「现象 → 原因 → 解决」记录,遇到问题可以先在这里对号入座。
5.1 定位类问题
现象:代码逻辑完全正确,GetMainWindow能拿到窗口,但FindFirstDescendant无论怎么试都返回 null,dump 出来的控件树只有顶层几个节点。
原因:目标程序以管理员权限运行,而自动化程序是普通权限。Windows 的 UIPI(用户界面特权隔离)会阻止低完整性级别进程访问高完整性级别进程的 UI 对象,UIA 调用不会报错,只是返回一个被裁剪过的树。
解决:两个程序的权限级别必须对齐。给自动化程序加 app.manifest,设置requestedExecutionLevel level="requireAdministrator";或者在启动时检测目标进程是否提权,提权了就提示用户重启自己。检测方式可以尝试打开目标进程句柄,Win32Exception里带「拒绝访问」就说明权限不够。
现象:控件树 dump 出来一切正常,ByName("发送(S)")就是找不到,但眼睛看着按钮明明在。
原因:按钮的 Name 属性里带了快捷键标记,实际值是「发送(S)」没错,但括号可能是全角也可能是半角,不同语言包下还会变成「Send(S)」。
解决:不要死磕精确匹配。cf.ByName()是精确匹配,改用cf.ByName("发送").Or(cf.ByName("Send")),或者干脆退回ByControlType(ControlType.Button)加索引。更好的办法是把 Name 写成配置项,出问题改配置不改代码——这就是上一章配置化的意义。
5.2 时序与状态问题
现象:批量发消息,前几条正常,后面开始错乱——消息发出去了,但顺序和预期不一致,或者偶尔漏发。
原因:发送动作是异步的。Invoke()返回只代表按钮被点了,不代表消息已经上屏。紧接着发下一条,两条消息可能在客户端的发送队列里竞争。
解决:每条消息发完做一次「确认」。发送前记录消息列表的子项数量 N,发送后轮询等待数量变成 N+1,或者等最后一项的文本包含刚发的内容。轮询用Retry.WhileFalse,超时设 5 秒,超时就标记这条失败并继续下一条。
// 发送前取样,发送后比对,确认上屏再发下一条 int before = msgList.FindAllChildren().Length; sendBtn.AsButton().Invoke(); bool ok = Retry.WhileFalse( () => msgList.FindAllChildren().Length > before, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(300)).Success; if (!ok) _log.Report("本条消息未确认上屏,跳过");FindAllChildren()不带条件时要遍历全部子项,长列表下有开销,但每 300ms 一次在可接受范围内。TimeSpan.FromSeconds(5)是经验值,网络差的时候可能不够,做成配置更好。
现象:脚本在开发机上跑得好好的,到同事电脑上第一屏就失败,报「主窗口未出现」。
原因:目标程序最小化到了系统托盘,MainWindowHandle变成 0,GetMainWindow自然拿不到。还有一种情况是程序的启动画面先出现,主窗口延迟几秒才建。
解决:GetMainWindow的超时给足(15~20 秒),失败后先尝试用Process.MainWindowHandle判断是否真的无窗口,再考虑从托盘恢复。托盘恢复没有通用 UIA 方案,通常得靠全局热键或者直接让用户手动点开——把这条写进使用说明,比在代码里硬刚划算。
5.3 环境与性能问题
现象:跑了几十轮之后,界面开始卡顿,任务管理器里目标进程的内存持续上涨,最后自动化程序自己抛 COM 异常。
原因:UIA3Automation实例和AutomationElement都是 COM 对象,不显式释放会累积。尤其是每次定位都新建 automation、用完不 dispose 的写法,几百轮下来句柄泄漏非常明显。
解决:UIA3Automation用using包住,生命周期和一次完整任务对齐;AutomationElement不强制手动释放,但要在任务结束时清掉所有字段引用,别让它挂在静态变量上。更彻底的做法是整个进程只维护一个 automation 实例,通过锁串行访问。
提示:如果发现异常信息里出现
0x8001010E(应用程序调用了一个为不同线程封送的接口),八成是 automation 实例跨线程了。回到 2.2 节,检查实例的创建位置和调用位置是不是同一个线程。
6. 进阶:把脚本变成能自检的回归套件
单点脚本能跑通只是起点。真正省心的是让工具自己知道「我还能不能用」——每次客户端升级后,跑一遍自检,十秒钟告诉你哪些锚点失效了,而不是等用户发消息发错人再回头查。
做法是维护一张元素指纹表,把每个关键元素的五个属性都记下来,自检时逐个比对:
| 元素 | ControlType | Name | AutomationId | ClassName | 失效影响 |
|---|---|---|---|---|---|
| 搜索框 | Edit | 搜索 | 空 | Edit | 无法定位联系人 |
| 发送按钮 | Button | 发送(S) | 空 | Button | 无法发送 |
| 消息列表 | List | 空 | 空 | List | 无法确认上屏 |
| 输入框 | Edit | 输入 | 空 | Edit | 无法写入内容 |
自检代码很短,核心就是逐个走一遍定位,把结果汇总成报告:
// 逐项验证定位规则,返回一份可读的体检报告 public static List<string> SelfCheck(AutomationElement window, ConditionFactory cf, Dictionary<string, Locator> locators) { var report = new List<string>(); foreach (var kv in locators) { var cond = BuildCondition(kv.Value, cf); // 自检用短超时,避免整体跑太久 var el = Retry.WhileNull( () => window.FindFirstDescendant(cond), TimeSpan.FromSeconds(2), TimeSpan.FromMilliseconds(150)).Result; report.Add(el == null ? $"[MISS] {kv.Key} (Name={kv.Value.Name}, Type={kv.Value.ControlType})" : $"[ OK ] {kv.Key} Rect={el.BoundingRectangle}"); } return report; }BuildCondition复用第 4 章的方法,自检和正式流程走同一套规则——这点很重要,如果自检用的是另一套硬编码条件,那自检通过也说明不了什么。超时压到 2 秒,是为了让整份报告在十秒内出结果;正式流程该用 5 秒还是 5 秒,两套超时分开配。
再加一封「失败现场」的存档:任何一次定位失败,自动截一张窗口图,同时把当前控件树 dump 到文本文件,文件名带时间戳。客户端改版之后,这些存档就是你反推新锚点的第一手材料,比事后凭记忆复现强太多。
// 失败时留证:截图 + 控件树快照,一并落盘 private static void CaptureFailure(AutomationElement window, string tag) { var stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "failures"); Directory.CreateDirectory(dir); // 截图走 FlaUI 的 Capture,返回 Bitmap,直接存 png var bmp = window.Capture(); bmp.Save(Path.Combine(dir, $"{stamp}_{tag}.png")); // 控件树快照,方便事后对比属性变化 using var writer = new StreamWriter(Path.Combine(dir, $"{stamp}_{tag}.tree.txt")); Console.SetOut(writer); DumpTree(window); }window.Capture()只截目标窗口区域,不截整个屏幕,避免把无关内容带进去。Console.SetOut是个取巧写法,正式代码里应该让DumpTree接收一个TextWriter参数,别去改全局的 stdout——这个坑我踩过,改完之后所有日志都跑到文件里去了,控制台一片空白。
这套自检 + 存档的组合,跑顺了之后维护成本会降一个量级。客户端升级当天跑一遍,报告里标红的条目就是需要重新找锚点的地方,通常十分钟能修完。从那以后我每次给这类工具加新元素,第一件事不是写业务逻辑,而是先把它塞进指纹表、让它进自检清单,否则等到线上出问题才发现这个元素从来没有被验证过。希望帮到你。
本文还有配套的精品资源,点击获取