首页 / 资讯中心 / 文章详情

Winform+FlaUI微信PC端自动化:UIA控件定位与发送避坑

Winform+FlaUI微信PC端自动化:UIA控件定位与发送避坑 ★ FEATURED ARTICLE
简介面向希望用 Winform 与 FlaUI 实现微信自动化操作的中高级 C# 开发者这份实战项目包围绕定时任务、自动回复和群聊机器人三类场景提供可参考的桌面端 UI 自动化实现思路。压缩包共 365 个文件约 47.83MB以 127 个 dll、55 个 cs 源码、18 个 json 配置、8 个 resx 资源及若干缓存和依赖文件为主便于查看项目依赖、界面资源与业务代码的组织方式。已有 661 人学习下载。包内包含主程序入口、FlaUI 操作封装、定时任务、消息监听处理、配置管理等模块可帮助读者理解元素查找、模拟点击输入、消息匹配与规则响应等关键环节也能作为搭建个人自动化小工具、排查 Winform 调用 UI 自动化问题时的结构参考。1. 从一次点 200 次开始Winform FlaUI 把微信 PC 端自动化跑起来每周一早上要给几十个会话发同一份排期通知复制、切窗口、粘贴、回车一个错手就发进了错的会话发完还得翻聊天记录核对哪几个没回。后来我把这套动作搬进了一个 Winform 小工具基于 FlaUI 挂到微信 PC 客户端上按 UIA 树找会话项、找输入框、找发送按钮一个会话两三秒全程日志打在界面右侧的 RichTextBox 里。它不改客户端、不做协议、不注入进程走的是 Windows UI Automation——本质上是替人点鼠标。适合两类人一类是天天做重复消息操作的运营或客服一类是想拿桌面 UIA 自动化练手、顺便搞懂跨进程控件定位的 .NET 开发者。门槛不高但坑不少下面把选型、定位、输入、监听和翻车现场一条条拆开。2. 技术选型与工程骨架为什么是 FlaUI 而不是键盘模拟和图像识别动手写第一行代码之前先把路线选对。桌面端自动化的常见路子有四条选错了后面全是返工。我最初用的是最土的办法——把窗口置顶然后SendKeys盲打。跑了一周就放弃了窗口一旦被别的程序抢焦点消息就发到别人聊天框里这种事故一次就够喝一壶。2.1 四条路线横向对比路线原理优点致命问题键盘鼠标模拟SendInput / SendKeys 发全局输入零依赖五行代码能跑抢焦点、分辨率敏感、没法读界面内容图像识别截屏 模板匹配找按钮不依赖控件树界面改了也能蒙对缩放/主题/DPI 一变全废还得配 OpenCV协议或注入Hook 网络层或进程内存快、稳、能拿结构化数据侵入性强版本一升级全崩风险不可控UI Automation走系统 UIA 接口读写控件能读能写、不抢焦点、可事件订阅控件树依赖客户端实现版本间会漂移FlaUI 属于第四条它把UIAutomationCore的 COM 接口包了一层强类型 API比原生System.Windows.Automation好用得多ConditionFactory能链式拼条件Retry.WhileNull内置了轮询等待还能拿到BoundingRectangle做坐标兜底。缺点也明显——微信 PC 端是自绘界面UIA 树浅、AutomationId 覆盖率低大部分控件只能靠ControlType加Name的组合去认。这个后面第 3 章专门讲。提示FlaUI 有UIA2和UIA3两套后端前者走老版 MSAA 兼容层后者走 UIAutomationCore。目标程序是 WPF/自绘的现代客户端一律用 UIA3只有当 UIA3 摸不到控件时才把 UIA2 作为兜底再挂一次。2.2 解决方案分层把能跑和能维护分开单文件 Form 里写满定位逻辑是我见过的翻车率最高的写法——腾讯一更新你根本不知道该改哪一行。我的做法是拆三层UI 层只管界面和日志Core 层封装所有 UIA 操作Infrastructure 层放重试、剪贴板、等待这些通用件。WeChatAuto/ ├── WeChatAuto.UI/ Winform 主程序 │ ├── Program.cs STAThread 入口 │ ├── MainForm.cs 窗体逻辑、日志面板、任务开关 │ └── appsettings.json 进程名、超时、节流间隔 ├── WeChatAuto.Core/ │ ├── Automation/ │ │ ├── ClientSession.cs 附着/启动客户端持有 Automation 实例 │ │ ├── ChatOperator.cs 切会话、输入、发送 │ │ ├── MessageWatcher.cs 新消息事件订阅 │ │ └── Selectors.cs 全部控件定位条件集中在此 │ ├── Infrastructure/ │ │ ├── UiaWait.cs 统一超时与重试 │ │ └── ClipboardHelper.cs 带重试的剪贴板写入 │ └── Models/SendTask.cs 待发送任务的数据结构 └── WeChatAuto.Tests/ 定位规则回归用例分层最直接的好处是定位规则只有一处。第 5 章会看到微信小版本升级后Name属性变化的概率极高把条件抽到Selectors.cs改一处、全项目生效比重装一次依赖省事得多。2.3 依赖、目标框架与第一次跑通新建一个net8.0-windows的 Winform 项目PlatformTarget必须和微信进程的位数保持一致现在客户端都是 64 位就设 x64。位数不一致时 COM 编组会绕一大圈表现出来就是偶发超时——这叫玄学其实是踩坑。!-- WeChatAuto.UI.csproj 关键片段 -- PropertyGroup OutputTypeWinExe/OutputType TargetFrameworknet8.0-windows/TargetFramework UseWindowsFormstrue/UseWindowsForms PlatformTargetx64/PlatformTarget !-- 和客户端位数对齐 -- Nullableenable/Nullable /PropertyGroup ItemGroup PackageReference IncludeFlaUI.Core Version4.0.0 / PackageReference IncludeFlaUI.UIA3 Version4.0.0 / /ItemGroupProgram.cs需要标[STAThread]Winform 默认就有但从控制台工程改过来的人经常忘忘了之后 UIA 调用会在某些系统上直接抛COMException错误信息还特别含糊。[STAThread] static void Main() { ApplicationConfiguration.Initialize(); Application.Run(new MainForm()); }第一次跑通的最小验证不用写业务代码窗体上一个按钮点了之后打印当前前台的控件树。using FlaUI.Core.AutomationElements; using FlaUI.UIA3; private void btnProbe_Click(object sender, EventArgs e) { using var automation new UIA3Automation(); // 桌面根节点微信是它的子窗口 var desktop automation.GetDesktop(); var wechat desktop.FindFirstChild(cf cf.ByName(微信)); if (wechat is null) { txtLog.AppendText(没找到微信主窗口确认客户端已登录\r\n); return; } // 只打印两层整棵树打出来会卡界面 foreach (var child in wechat.FindAllChildren()) { txtLog.AppendText(${child.ControlType} | {child.Name} | {child.AutomationId}\r\n); } }FindFirstChild的参数是条件工厂委托ByName匹配的是窗口标题如果你的客户端标题是微信就写微信是别的就照抄过来。ControlType、Name、AutomationId这三个属性是后面所有定位的基础AutomationId为空是常态能用的通常只有Name。这一段跑通说明 FlaUI 到客户端的链路没问题剩下的都是定位精度问题。3. 控件定位核心把客户端的 UIA 树摸清楚再写代码自动化做得好不好八成取决于你把控件树读得多细。用猜的写选择器跑三天必崩用 Inspect 工具一个节点一个节点看过去能省掉后面无数次调试。3.1 用 Inspect 读控件树重点看四个属性Windows SDK 自带的 Inspect.exe 或者 Accessibility Insights 都行把鼠标悬到目标控件上右侧会显示 UIA 属性。我关注四个字段ControlType决定这个节点的角色ListItem、Document、ButtonName通常是可见文本AutomationId有就是宝藏没有就拉倒ClassName是底层类名比Name稳定一些但也可能变。以主界面为例典型的树结构长这样这是我自己机器上抓的示意值你那边因版本不同大概率不一样Window 微信 ├── Pane │ └── List (会话列表) │ ├── ListItem 文件传输助手 │ ├── ListItem 产品讨论群(12) │ └── ListItem 某客户A ├── Pane │ ├── List (消息列表随会话滚动加载) │ └── Document (输入框注意不是 Edit) └── ToolBar └── Button 发送(S)两个反直觉的点输入框是Document而不是Edit这是富文本编辑器的典型特征用ByControlType(ControlType.Edit)永远找不到群聊的Name会带人数后缀(12)你按群名精确匹配必然失败。这两条踩过的人都记一辈子。3.2 Selectors把所有定位规则收进一个文件不要在主流程里写FindFirstDescendant(cf cf.ByName(xxx))写成Selectors.Xxx条件工厂用静态字段复用一个实例避免每次新建。using FlaUI.Core; using FlaUI.Core.AutomationElements; using FlaUI.Core.Conditions; using FlaUI.UIA3; public static class Selectors { // 条件工厂要复用内部维护 COM 对象反复 new 会有开销 private static readonly ConditionFactory CF new(new UIA3PropertyLibrary()); // 主窗口标题进程名在 3.x 和 4.x 之间变过两个都试 public static readonly string[] ProcessNames { WeChat, Weixin }; // 会话列表优先 AutomationId拿不到再退回 ControlType public static Condition SessionList CF.ByAutomationId(session_list).Or(CF.ByControlType(ControlType.List)); // 会话项Name 精确匹配 必须是 ListItem 防止误中消息列表里的同名节点 public static Condition SessionItem(string name) CF.ByName(name).And(CF.ByControlType(ControlType.ListItem)); // 群聊名称带人数后缀用模糊匹配兜住 public static Condition GroupItem(string prefix) CF.ByName(prefix, PropertyConditionFlags.IgnoreCase) .And(CF.ByControlType(ControlType.ListItem)); // 输入框是 Document不是 Edit public static Condition InputBox CF.ByControlType(ControlType.Document); // 发送按钮有 AutomationId 就用没有就靠 Name 前缀 public static Condition SendButton CF.ByAutomationId(send_button).Or(CF.ByName(发送).Or(CF.ByName(Send))); }ByAutomationId(...).Or(...)这种优先精确、退化模糊的写法是为了在小版本更新时多一层容错。PropertyConditionFlags.IgnoreCase让名称匹配不区分大小写对多语言包友好。要强调的是上面所有 ID 和名称都是示意值落到你自己机器上必须用 Inspect 重新抓一遍再替换——这是这个项目里最容易偷懒也最容易翻车的一步。3.3 切会话、输入、发送的完整链路链路本身不复杂定位会话项 → 点击或双击进入会话 → 定位输入框 → 写入文本 → 点发送。但每一步都得有超时和判空否则一个节点没出来整个线程就卡死在 COM 调用里。using FlaUI.Core.AutomationElements; using FlaUI.Core.Tools; public sealed class ChatOperator { private readonly Window _mainWindow; private readonly TimeSpan _timeout TimeSpan.FromSeconds(8); public ChatOperator(Window mainWindow) _mainWindow mainWindow; public bool SendTo(string contact, string text, out string error) { error string.Empty; // 第一步会话列表可能在左侧滚动区里先拿容器再拿项 var sessionList _mainWindow.FindFirstDescendant(Selectors.SessionList); if (sessionList is null) { error 会话列表未找到; return false; } // Retry.WhileNull 会按间隔轮询直到超时比 Thread.Sleep 靠谱 var item Retry.WhileNull( () sessionList.FindFirstDescendant(Selectors.SessionItem(contact)), _timeout, TimeSpan.FromMilliseconds(200)).Result; if (item is null) { error $未找到会话{contact}; return false; } item.Click(); Thread.Sleep(600); // 等右侧面板完成切换见第 5 章第 2 条 // 第二步输入框 var input Retry.WhileNull( () _mainWindow.FindFirstDescendant(Selectors.InputBox), _timeout).Result; if (input is null) { error 输入框未找到; return false; } input.Focus(); ClipboardHelper.SetText(text); // 见第 4 章 input.SendKeys(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL, FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_V); // 第三步发送。点按钮失败就退回回车按键前确认焦点还在输入框 var sendBtn _mainWindow.FindFirstDescendant(Selectors.SendButton); if (sendBtn is not null) { sendBtn.Click(); } else { input.Focus(); input.SendKeys(FlaUI.Core.WindowsAPI.VirtualKeyShort.ENTER); } return true; } }Retry.WhileNull的三个参数分别是取值委托、总超时、轮询间隔返回值里带Result超时后是 null 而不是抛异常所以后面必须判空。item.Click()默认走的是 UIA 的 Invoke/SelectionItem 模式不会真的移动鼠标指针——这正是不抢焦点的好处。Thread.Sleep(600)这种硬等待我不推荐在别处用但切换会话这一步没有可靠的可等待信号只能靠时间换稳定后面可以用消息列表的 Name 发生变化来替代见第 4 章。4. 输入与监听中文消息怎么发、新消息怎么收发送和接收是两套完全不同的机制。发送是写主要难点在中文接收是读难点在何时触发读、怎么避免重复读。4.1 中文输入的三条路我最后只留了一条UIA 提供的标准写入方式是ValuePattern.SetValue一行代码搞定var valuePattern input.Patterns.Value.PatternOrDefault; valuePattern?.SetValue(你好排期已更新);这条路对普通Edit控件非常稳但对微信这类富文本输入框经常静默失败——不报错文本也没进去或者进去了但发送按钮仍是禁用态。第二种是WM_SETTEXT消息直投需要拿窗口句柄跨进程发消息容易把编辑器的内部状态搞乱。我最后用的是剪贴板粘帖虽然多一步但成功率接近 100%using System.Windows.Forms; public static class ClipboardHelper { // 剪贴板被别的程序占用时会抛异常重试三次基本够用 public static bool SetText(string text, int retry 3) { for (var i 0; i retry; i) { try { Clipboard.SetDataObject(text, true, 5, 100); return true; } catch (System.Runtime.InteropServices.ExternalException) { Thread.Sleep(150); } } return false; } }SetDataObject的四个参数是数据对象、复制后是否保留、重试次数、重试间隔毫秒。用这个重载而不是Clipboard.SetText就是为了让系统自己处理剪贴板正被另一个进程占用的竞争问题。粘帖的触发必须用SendKeys发CtrlV而不是 UIA 的 Value 模式因为只有走真实的键盘消息富文本编辑器才会正确触发内部的输入事件。注意剪贴板是全局资源粘帖完成后最好把原内容还原否则用户的剪贴板会被你的自动化脚本覆盖体验很差。4.2 消息监听事件优先轮询兜底UIA 支持订阅结构变化事件新消息插入消息列表时会触发ElementAddedEvent。FlaUI 把这个能力包成了RegisterAutomationEvent注册之后回调可能在任意线程执行所以回主线程刷新界面必须Invoke。using FlaUI.Core.AutomationElements; using FlaUI.Core.Definitions; using FlaUI.Core.EventHandlers; using FlaUI.UIA3; public sealed class MessageWatcher : IDisposable { private readonly UIA3Automation _automation; private readonly AutomationElement _messageList; private AutomationEventHandlerBase? _handler; public MessageWatcher(UIA3Automation automation, AutomationElement messageList) { _automation automation; _messageList messageList; } public void Start(Actionstring onNewMessage) { // TreeScope.Children 只监听直接子节点范围开大会严重拖慢客户端 _handler _messageList.RegisterAutomationEvent( _automation.EventLibrary.Element.ElementAddedEvent, TreeScope.Children, (sender, e) { var item sender.AsListBoxItem(); var text item?.Name; // 一条消息的纯文本摘要 if (!string.IsNullOrWhiteSpace(text)) onNewMessage(text!); }); } public void Dispose() { if (_handler is not null) _messageList.UnregisterAutomationEventHandler(_handler); } }重点在TreeScope.Children。设成Descendants会把整棵子树都纳入监听客户端每刷新一次界面就产生大量事件回调跑十几分钟电脑就卡住了——这是血泪经验。另外订阅一定要能反注册窗体关闭时调用Dispose否则 COM 对象泄漏进程退不掉。事件不是 100% 可靠客户端用虚拟列表渲染时滚动出去的消息会被回收事件可能漏。所以我常配一个 30 秒的轮询兜底读消息列表最后一个子项的Name和上次记录的对比变了就认为有新消息。两套机制并存漏报率能压到很低。4.3 把自动化包成可取消的异步任务所有 UIA 调用都是跨进程同步调用放在 Winform 的按钮事件里直接跑界面会假死。用Task.Run放到后台线程CancellationToken支持中断日志通过IProgressstring回传。public async Task RunAsync(IReadOnlyListSendTask tasks, IProgressstring log, CancellationToken ct) { foreach (var task in tasks) { ct.ThrowIfCancellationRequested(); // 用户点了停止立刻退出 try { if (SendTo(task.Contact, task.Content, out var error)) log.Report($[OK] {task.Contact}); else log.Report($[FAIL] {task.Contact} - {error}); } catch (Exception ex) { log.Report($[ERR] {task.Contact} - {ex.Message}); } // 节流别把对方界面刷爆也给自己留排查窗口 await Task.Delay(800, ct); } }ThrowIfCancellationRequested要放在每轮循环最前面Task.Delay传入同一个 token 才能被及时唤醒。try-catch包住单个任务而不是整个循环——一条失败不能拖垮整批。IProgressT在 Winform 里用Progressstring构造内部自动SynchronizationContext.Post回 UI 线程不用自己写Invoke。5. 排查与避坑5 个让自动化脚本半夜挂掉的现场这套东西我在自己机器上跑了三个月中间翻车的场景基本能归成五类。每一条都按现象 → 原因 → 解决记下来你遇到问题时可以照着对。5.1 现象FindFirstDescendant突然返回 null昨天还好好的原因客户端小版本升级控件的AutomationId或Name变了或者节点层级多包了一层Pane。还有一种情况是客户端还没渲染完就被查询了树是空的。解决先在Selectors.cs里把条件改成Or组合同时兼容新旧两套属性查询侧统一用Retry.WhileNull加 8 秒超时不要裸调FindFirstDescendant。长远做法是把控件指纹落盘启动时打一次日志升级后再打一次两次 diff 就能定位到具体变了哪个属性比重装一遍客户端试错快得多。5.2 现象消息发出去了对方收到的是一段空白或者只发出去半句原因剪贴板粘帖和点击发送之间没有等待。富文本编辑器接收CtrlV之后要异步处理粘贴内容文本还没渲染完就触发了发送发出去的是空内容。半句的情况则是长文本被截断粘贴处理到一半就被发了。解决粘帖后读一次输入框的Value或者TextPattern内容做校验确认和目标文本一致再点发送不一致就重试一次。校验不通过比发错消息强得多private bool WaitForInputReady(AutomationElement input, string expected, TimeSpan timeout) { var ok Retry.WhileFalse(() { var actual input.Patterns.Text.PatternOrDefault?.DocumentRange.GetText(-1); return actual?.Contains(expected) true; }, timeout, TimeSpan.FromMilliseconds(150)).Result; return ok; }DocumentRange.GetText(-1)里的 -1 表示取全部内容返回空字符串就意味着编辑器还没渲染完。5.3 现象脚本跑十几分钟后整个客户端卡住鼠标能点但界面不刷新原因UIA 事件订阅范围开得太大或者事件回调里做了耗时操作比如回调里又去查询控件树。UIA 的事件回调是同步派发的回调一慢客户端的 UI 线程就跟着堵。解决TreeScope降到Children回调里只做一件事——把数据塞进ConcurrentQueue然后立刻返回真正的处理放到另一个消费线程。另外不要忘了UnregisterAutomationEventHandler注册了不注销COM 引用计数一直涨。5.4 现象手动切换账号或重新登录后之前拿到的所有元素全部失效原因重新登录会导致窗口销毁重建之前缓存的Window对象指向的是已经死掉的句柄。UIA 对失效元素的操作会抛ElementNotAvailableException而且错误堆栈里看不出是账号切换导致的。解决不要长期缓存Window和AutomationElement每次任务开始前重新GetMainWindow捕获ElementNotAvailableException时统一当作会话失效处理重建一次 session 再重试当前任务。把重建和重试写成同一个方法比在每处 try-catch 里手写一遍强。5.5 现象本机跑得好好的拷到同事机器上直接崩在启动那一行原因典型的是位数不一致——你的工程是 AnyCPU在 64 位系统上跑成 64 位没问题但对方装了 32 位的客户端或者是对方系统 DPI 缩放是 150%坐标兜底逻辑全部偏移再或者是FlaUI.UIA3的依赖没跟着拷贝。解决PlatformTarget显式写 x64 或 x86别留 AnyCPU需要坐标兜底的地方一律用BoundingRectangle换算成相对窗口的比例而不是写死像素发布时用dotnet publish -r win-x64 --self-contained别指望目标机器装了对的运行时。这套检查我固化成了一个启动自检方法跑主流程前先过一遍。6. 二次开发把写死的流程改成配置驱动这套代码最容易僵化的地方是把发给谁、发什么、什么时间发硬编码在 C# 里。一旦要加十个人就得改代码重新编译这在业务侧完全不可接受。我的改法是把任务流抽成 JSON运行时反序列化成SendTask列表界面只负责选择配置文件和开始/停止。{ throttleMs: 800, tasks: [ { contact: 某客户A, content: 本周排期已更新请查收, delayAfterMs: 1200 }, { contact: 产品讨论群, content: 会议改到周四下午三点, delayAfterMs: 500 } ] }delayAfterMs让每条任务可以单独控制节奏——给客户发要慢一点给自己发可以快一点。反序列化用System.Text.Json就行配上PropertyNameCaseInsensitive true前端写小驼峰也不用改。真正值得说的一步是校验加载配置时先检查contact是否都能在会话列表里找到找不到的直接在界面上标红不要等到跑到一半才发现。改配置还有一个隐性收益——排查变简单了。以前出了问题得翻代码看这行是给谁发的现在直接把 JSON 给业务方看一眼就能对出来是配置写错了还是脚本有问题。再说验证方法。每次改完Selectors.cs或者升级客户端之后我不会直接跑全量任务而是跑一组冒烟用例找会话列表、找一个指定会话、往文件传输助手发一句测试文本、检查输入框内容是否为空。这四条过了才敢放开跑正式任务。这套用例放在WeChatAuto.Tests里用固定的一份小配置驱动三分钟跑完。从那以后我每次动选择器或者客户端升级之后都强制先走一遍这四条冒烟用例再碰真实任务——因为发错消息的后悔药从来都买不到。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站