Winform+FlaUI微信PC端自动化:UIA控件定位与发送避坑
简介:面向希望用 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> <OutputType>WinExe</OutputType> <TargetFramework>net8.0-windows</TargetFramework> <UseWindowsForms>true</UseWindowsForms> <PlatformTarget>x64</PlatformTarget> <!-- 和客户端位数对齐 --> <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> <PackageReference Include="FlaUI.Core" Version="4.0.0" /> <PackageReference Include="FlaUI.UIA3" Version="4.0.0" /> </ItemGroup>Program.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、Button),Name通常是可见文本,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发Ctrl+V而不是 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(Action<string> 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支持中断,日志通过IProgress<string>回传。
public async Task RunAsync(IReadOnlyList<SendTask> tasks, IProgress<string> 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包住单个任务而不是整个循环——一条失败不能拖垮整批。IProgress<T>在 Winform 里用Progress<string>构造,内部自动SynchronizationContext.Post回 UI 线程,不用自己写Invoke。
5. 排查与避坑:5 个让自动化脚本半夜挂掉的现场
这套东西我在自己机器上跑了三个月,中间翻车的场景基本能归成五类。每一条都按"现象 → 原因 → 解决"记下来,你遇到问题时可以照着对。
5.1 现象:FindFirstDescendant突然返回 null,昨天还好好的
原因:客户端小版本升级,控件的AutomationId或Name变了,或者节点层级多包了一层Pane。还有一种情况是客户端还没渲染完就被查询了,树是空的。
解决:先在Selectors.cs里把条件改成Or组合,同时兼容新旧两套属性;查询侧统一用Retry.WhileNull加 8 秒超时,不要裸调FindFirstDescendant。长远做法是把"控件指纹"落盘,启动时打一次日志,升级后再打一次,两次 diff 就能定位到具体变了哪个属性,比重装一遍客户端试错快得多。
5.2 现象:消息发出去了,对方收到的是一段空白,或者只发出去半句
原因:剪贴板粘帖和点击发送之间没有等待。富文本编辑器接收Ctrl+V之后要异步处理粘贴内容,文本还没渲染完就触发了发送,发出去的是空内容。半句的情况则是长文本被截断,粘贴处理到一半就被发了。
解决:粘帖后读一次输入框的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里,用固定的一份小配置驱动,三分钟跑完。
从那以后我每次动选择器或者客户端升级之后,都强制先走一遍这四条冒烟用例,再碰真实任务——因为发错消息的后悔药,从来都买不到。希望帮到你。
本文还有配套的精品资源,点击获取