Winform+FlaUI 微信桌面自动化:消息发送与监听实践

发布时间:2026/11/2 7:08:44
Winform+FlaUI 微信桌面自动化:消息发送与监听实践

简介:面向希望用 C# 与 Winform 实现微信桌面端自动化的开发者,这套项目源码围绕 FlaUI 的 UI 自动化能力展开,覆盖定时消息发送、关键词自动回复与群聊机器人等典型场景,适合具备一定 C# 基础、想研究桌面 UI 自动化落地方式的中级读者参考。压缩包共 365 个文件、约 47.83MB,以 127 个 dll 依赖库、55 个 cs 源码、18 个 json 配置及 pdb 调试符号、resources 与 resx 界面资源为主,另含解决方案与项目工程文件,能较完整地还原一个可编译的 Winform 自动化项目结构。已有 661 人学习,说明其在微信自动化与 FlaUI 实践方向具有一定关注度。读者可从中了解消息监听、元素定位、任务调度与规则匹配的组织方式,并借鉴配置存储、数据库与多项目分层思路,用于自己的自动化工具原型或技术验证。

1. 从每周几十条手工群发说起:Winform + FlaUI 到底适合做哪类微信自动化

有运营岗位的朋友每周都要给几十个客户挨个发通知,复制、切窗口、粘贴、按回车,一轮下来手腕发酸还容易发错人。这类场景正是桌面端微信自动化的舒适区:不碰协议、不做逆向,直接驱动已经登录的桌面客户端界面。Winform 搭一个操作面板当壳子,FlaUI 把界面上的控件翻译成可编程对象,两者拼起来就是能跑在自己电脑上的小工具。

它适合手里有一批固定联系人、需要定时或批量发消息、又不想去碰灰产接口的独立开发者;也适合把 UI Automation 当练手项目的 .NET 新手,因为微信的界面足够复杂,能练到控件树遍历、等待策略和线程模型。它不适合要大规模并发、要读取本地加密数据的场景,那得换一套完全不同的思路。

2. 项目骨架与技术选型:一个 Winform 壳子怎么把 FlaUI 包成可复用的自动化层

2.1 技术栈定型:Winform 负责交互,FlaUI 负责「看见」界面

这个项目的第一层是 .NET 桌面运行时。常见做法是把目标框架定在net8.0-windows,Winform 在 .NET 6 之后已经跨平台重写,ApplicationConfiguration.Initialize()一行就能把 DPI、字体、视觉样式配好,比老的 .NET Framework 4.8 干净不少。如果你要发给还在用 Win7 的同事,那就得退回 4.8,代价是异步 API 和新的 JSON 序列化都得绕路。

第二层是自动化库。NuGet 上需要装两个包:FlaUI.Core提供 Application、AutomationElement、Keyboard 这些抽象,FlaUI.UIA3是具体实现。FlaUI 同时提供 UIA2 和 UIA3 两套后端,前者基于 MSAA 桥接,后者直接走 COM 的 UIAutomationClient。微信桌面端大量控件是 DirectUI 自绘,UIA3 抓出来的树通常更完整、响应更快,所以默认选它;如果某台机器上 UIA3 返回的树是空的,再切 UIA2 或MixedAutomation兜底。这个切换成本很低,构造函数换个类型就行。

为什么不直接用SendKeys加截图找色?因为那两个方案都强依赖前台焦点。用户鼠标一移、输入法一弹、弹窗一遮,整个流程就断了,而且逐字符发送中文时输入法会插入候选词,消息直接变成乱码。FlaUI 走的是操作系统级的 UI Automation 接口,读的是控件的真实属性,稳定性高一个量级。

为什么不注入 DLL 或者 hook 消息循环?维护成本太高。客户端一次小版本更新,内部消息结构、内存偏移全变,脚本当天就废;而且这类做法在很多公司的安全策略里是明确禁止的。UI Automation 只依赖可见的控件树,界面结构变了顶多改几个定位器,不至于推倒重来。

提示:选型阶段就要明确一件事——这个工具的目标是「模拟人操作界面」,不是「替代客户端」。所有操作都走可见控件,出了问题用户能看见、能中断,这才是可控的自动化。

2.2 目录结构:UI 层、自动化层、业务层怎么切

我一般会按依赖方向切成四块,最里层是被依赖的,最外层是入口。这么做的好处是控件定位逻辑集中在一处,客户端更新时只改一个目录。

WeChatAuto/ ├── WeChatAuto.UI/ # Winform 主程序,只负责界面和事件转发 │ ├── MainForm.cs │ ├── MainForm.Designer.cs │ └── Program.cs # [STAThread] 入口 ├── WeChatAuto.Automation/ # FlaUI 封装,唯一允许出现 FindFirstDescendant 的地方 │ ├── WeChatClient.cs # 附着、窗口还原、生命周期 │ ├── WeChatSession.cs # 搜索、进入会话、发送 │ ├── MessagePoller.cs # 会话列表轮询 │ └── Locators/ │ └── WeChatLocators.cs # 控件定位条件集中管理 ├── WeChatAuto.Core/ # 业务模型与服务,不引用 FlaUI │ ├── Models/Contact.cs │ └── Services/SendTaskService.cs └── WeChatAuto.Tests/ # 针对定位器和解析逻辑的单元测试

依赖方向是UI -> Automation -> Core,Core不反向依赖任何东西。这意味着联系人模型、任务队列、消息模板这些业务代码可以脱离 FlaUI 单独测试,跑 CI 的时候不需要真的开一个客户端。Locators单独成目录是血泪经验:早期我把cf.ByName("发送")散落在十几个方法里,客户端把按钮文案改成「发送(S)」之后,我花了两个小时全局搜索才改完。

Program.cs里的[STAThread]不是可有可无的装饰。UI Automation 的 COM 组件要求调用线程是单线程套间(STA),Winform 主线程天然满足,但你后面开的任何后台轮询线程都得手动SetApartmentState(ApartmentState.STA),否则第一次调FindFirstDescendant就会抛InvalidOperationException。

3. 核心链路拆解:从进程附着到一条消息发出去的完整过程

3.1 附着与窗口还原:先拿到 WeChatMainWndForPC 这个句柄

第一步不是找控件,是找窗口。客户端启动后主窗口类名固定是WeChatMainWndForPC,但有个坑:如果用户点了关闭按钮,窗口会缩到托盘,此时GetMainWindow会一直等到超时。所以我不用它,改成遍历顶层窗口按类名匹配,找到之后强制还原。

public sealed class WeChatClient : IDisposable { private readonly Application _app; private readonly UIA3Automation _automation; private Window? _mainWindow; public WeChatClient(int processId) { // Attach 不会启动新进程,只挂到已登录的客户端上 _app = Application.Attach(processId); _automation = new UIA3Automation(); } public Window AttachMainWindow() { // 托盘状态下 GetMainWindow 会超时,按类名遍历顶层窗口更稳 var result = Retry.WhileNull( () => _app.GetAllTopLevelWindows(_automation) .FirstOrDefault(w => w.ClassName == "WeChatMainWndForPC"), TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(200)); var window = result.Result ?? throw new InvalidOperationException("没找到主窗口,确认客户端已登录且未退出"); // 最小化时控件树是空的,必须先还原再操作 window.Patterns.Window.PatternOrDefault ?.SetWindowVisualState(WindowVisualState.Normal); window.Focus(); window.SetForeground(); Wait.UntilInputIsProcessed(); _mainWindow = window; return window; } public void Dispose() { _automation.Dispose(); } }

Retry.WhileNull是 FlaUI 自带的轮询工具,签名接受一个委托、总超时和轮询间隔,返回RetryResult<T>,取值用.Result。这里给 10 秒是因为客户端刚启动时窗口可能还在初始化,控件树是半成品,太早拿到也没用。SetWindowVisualState需要FlaUI.Core.Definitions命名空间下的WindowVisualState枚举。

SetForeground之后一定要跟一句Wait.UntilInputIsProcessed()。这行代码的作用是等目标进程把输入队列处理完,不加的话紧接着发的快捷键有一半概率打在旧窗口上,表现就是「有时候能搜到人,有时候搜不到」,非常玄学。

3.2 搜索、进会话、发送:三段式链路与等待策略

界面操作的本质是「触发 → 等待 → 校验」的循环。每一步触发之后都必须等到界面出现预期变化,再走下一步,否则就是各种偶发的错发、漏发。

public void SendTo(string contact, string message) { var window = _mainWindow ?? AttachMainWindow(); // 第一步:Ctrl+F 唤出搜索框 window.Focus(); window.SetForeground(); Keyboard.TypeSimultaneously(VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_F); Wait.UntilInputIsProcessed(); // 搜索框通常是顶部那个 Edit,按 BoundingRectangle 的 Y 值排序取最上面的 var searchBox = Retry.WhileNull(() => window.FindAllDescendants(cf => cf.ByControlType(ControlType.Edit)) .OrderBy(e => e.BoundingRectangle.Y) .FirstOrDefault(), TimeSpan.FromSeconds(3)).Result?.AsTextBox(); if (searchBox is null) throw new InvalidOperationException("搜索框没出来"); // 第二步:粘贴联系人名,等搜索结果列表刷新 PasteText(contact); Thread.Sleep(600); // 回车进入第一个搜索结果对应的会话 Keyboard.Press(VirtualKeyShort.ENTER); Wait.UntilInputIsProcessed(); // 第三步:校验当前会话标题确实是目标联系人,防止进错窗口 var title = Retry.WhileNotNull(() => window.FindFirstDescendant(cf => cf.ByControlType(ControlType.Text) .And(cf.ByName(contact))), TimeSpan.FromSeconds(2)).Success; if (!title) throw new InvalidOperationException($"进入会话失败:{contact}"); // 第四步:找到底部输入框,粘贴内容后回车发送 var input = window.FindAllDescendants(cf => cf.ByControlType(ControlType.Edit)) .OrderByDescending(e => e.BoundingRectangle.Y) .First().AsTextBox(); input.Focus(); PasteText(message); Keyboard.Press(VirtualKeyShort.ENTER); }

这段代码里最值得说的是等待策略。Thread.Sleep(600)看起来粗暴,但在「搜索框输入完成后等结果列表渲染」这个环节,比任何条件轮询都可靠,因为结果列表是一个自绘控件,它的子项数量变化不一定能触发 UIA 事件。而真正需要精确等待的地方,比如确认会话已经切换,我用Retry.WhileNotNull去轮询标题文本,最多等 2 秒,等不到就抛异常中断,绝不带着错误状态继续往下发。

PasteText是我自己封装的剪贴板写入,下一节展开。之所以不直接用searchBox.Text = contact,是因为客户端的搜索框对ValuePattern.SetValue的支持时好时坏,某些版本会只写入第一个字符。

3.3 文本输入:为什么剪贴板粘贴比逐字符输入稳

中文输入是这个项目里最容易翻车的一环。Keyboard.Type("你好")走的是模拟键盘消息,遇到输入法激活状态,字符会被拆成拼音送进候选框,最后发出去的内容完全不可控。

private static void PasteText(string text) { // 剪贴板可能被其他进程占用,重试三次 for (var i = 0; i < 3; i++) { try { Clipboard.SetDataObject(text, copy: true); break; } catch (ExternalException) when (i < 2) { Thread.Sleep(120); } } // Ctrl+V 走的是粘贴命令,绕开输入法,中文不会被拆成拼音 Keyboard.TypeSimultaneously(VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_V); Wait.UntilInputIsProcessed(); }

Clipboard.SetDataObject(text, true)的第二个参数表示数据在程序退出后仍然保留,这里其实无关紧要,但设成true能避免某些场景下的剪贴板竞争。重试循环是必须的:剪贴板是全局独占资源,用户正在用别的软件复制东西时会抛ExternalException,不重试就直接挂了。

粘贴方案还有一个额外好处——长文本不会被截断。逐字符发送时,客户端输入框对连续按键的响应有节流,超过几百字就开始丢字;而粘贴是一次性写入,几千字也稳。

注意:粘贴会覆盖用户当前的剪贴板内容。工具正式使用前最好保存并恢复原剪贴板,否则同事会来问你为什么他复制的东西没了。

4. 消息监听模块:轮询会话列表与 UIA 事件两种方案怎么选

4.1 轮询会话列表:从控件 Name 里解析未读数

自动回复和消息收集都需要知道「有没有新消息」。最朴素也最稳的方案是定时遍历左侧会话列表,读每个列表项的 Name 属性。客户端的会话项 Name 通常拼成了「联系人名 + 数字 + 条新消息」这样的形式,不同版本格式略有差异,所以解析逻辑要写得宽容一点。

private static readonly Regex UnreadPattern = new(@"(?<name>.+?)\s*(?<count>\d+)\s*条新消息", RegexOptions.Compiled); public IReadOnlyList<UnreadSession> ScanUnread(Window window) { // 会话列表是一个 List 控件,直接取它下面的所有 ListItem var list = window.FindFirstDescendant(cf => cf.ByControlType(ControlType.List)); if (list is null) return Array.Empty<UnreadSession>(); var items = list.FindAllChildren(cf => cf.ByControlType(ControlType.ListItem)); var result = new List<UnreadSession>(); foreach (var item in items) { var name = item.Name ?? string.Empty; var match = UnreadPattern.Match(name); if (!match.Success) continue; // 没有未读数的项直接跳过 result.Add(new UnreadSession { ContactName = match.Groups["name"].Value.Trim(), UnreadCount = int.Parse(match.Groups["count"].Value), Element = item }); } return result; }

FindAllChildren只扫直接子节点,比FindAllDescendants快一个数量级,因为会话列表可能有几百项,全量深度遍历一次要几百毫秒。正则用了RegexOptions.Compiled,这是在一个长期运行的工具里值得做的优化,扫描频率高的时候能省下可观的 CPU。

轮询间隔我一般设 2 到 3 秒。设得太短(比如 200 毫秒)会让目标进程持续处于 UIA 查询压力下,客户端界面出现明显卡顿;设得太长又失去实时性。2 秒是个平衡点,对普通客服场景够用。

4.2 UIA 事件订阅:StructureChanged 与 PropertyChanged 的取舍

轮询的缺点是延迟和空转。FlaUI 支持直接订阅 UIA 事件,会话列表新增一项时会触发StructureChanged,未读数变化会触发PropertyChanged。理论上更优雅,实际用起来需要挑场景。

public void StartEventWatch(Window window, Action<UnreadSession> onNewMessage) { var list = window.FindFirstDescendant(cf => cf.ByControlType(ControlType.List)); if (list is null) return; // 只监听子树的元素添加事件,避免 Name 属性频繁变化造成风暴 list.RegisterStructureChangedEvent( TreeScope.Subtree, (element, type, id) => { if (type != StructureChangeType.ChildAdded || element is null) return; var match = UnreadPattern.Match(element.Name ?? string.Empty); if (!match.Success) return; onNewMessage(new UnreadSession { ContactName = match.Groups["name"].Value.Trim(), UnreadCount = int.Parse(match.Groups["count"].Value), Element = element }); }); }

RegisterStructureChangedEvent的第一个参数是监听范围,TreeScope.Subtree表示整个子树,范围越大回调越频繁。客户端在切换会话时会把列表项反复重建,如果监听范围设成Descendants并且不过滤StructureChangeType,回调可能在几百毫秒内触发几十次,处理函数里再去做界面操作就卡死了。

我的实践是:事件只用来「唤醒」,真正的读取仍然走一次主动扫描。回调里拿到的 element 是事件瞬间的快照,等你去点它的时候可能已经被回收了,直接操作会抛ElementNotAvailableException。事件加轮询兜底,两套并存反而最省心。

4.3 线程模型与消息去重:STA 后台线程 + 本地状态缓存

UIA 调用必须在 STA 线程上。Winform 主线程是 STA,但你不能在里面跑一个while(true)的轮询,界面会直接假死。正确做法是开一个专用后台线程,手动设成 STA,所有 FlaUI 调用都在这条线程上排队执行。

private BlockingCollection<Action> _queue = new(); private void StartWorker() { var thread = new Thread(() => { foreach (var job in _queue.GetConsumingEnumerable()) { try { job(); } catch (Exception ex) { Log.Error(ex, "自动化任务失败"); } } }) { IsBackground = true, Name = "uia-worker" }; thread.SetApartmentState(ApartmentState.STA); // 不设这一行,UIA 调用必炸 thread.Start(); } // UI 线程投递任务,回调里再 Invoke 回主线程刷新界面 public void Post(Action job) => _queue.Add(job);

把SetApartmentState放在Start之前是硬性要求,线程启动之后再设会抛ThreadStateException。这条线程是串行的,所有自动化操作排成一队,天然避免了两个任务同时操作同一个窗口导致的焦点争抢。

去重靠一张内存字典,键是「联系人名 + 消息内容哈希」,值带一个过期时间。同一个联系人短时间内重复出现相同未读标题时只处理一次,防止事件和轮询双通道同时命中造成重复回复。字典不用很大,保留最近 500 条、超过 10 分钟就清掉,足够应付日常量级。

5. 避坑与排查:五类在真机上翻车的现场记录

5.1 窗口与控件层:找不到、找错、找到是空壳

现象:Attach成功,但遍历顶层窗口始终返回空,日志里一个窗口都没有。原因是客户端已经缩到托盘,主窗口处于隐藏状态,UIA 的顶层窗口枚举会把不可见窗口过滤掉。解决办法是先通过托盘图标或者Process.MainWindowHandle把窗口唤起来,再走类名匹配。我后来在AttachMainWindow前面加了一步:如果连续三次没找到,就用ShowWindow还原进程主句柄,成功率高很多。

现象:FindFirstDescendant返回 null,但用 Inspect 工具看界面上明明有那个按钮。原因是客户端的很多区域是 DirectUI 自绘,UIA 树里是扁平的,按钮没有独立的 Name,只有一个ControlType.Button加一个空 Name。这种时候只能靠位置定位:拿到父容器的BoundingRectangle,按相对坐标筛选。我一般会按 Y 值排序取第 N 个,再断言它的宽度高度在合理范围内,防止误点。这类定位器一定要写在一个文件里并且加注释,不然三个月后自己都看不懂。

现象:控件找到了,Click()也返回成功,但界面毫无反应。原因是元素对象是旧快照,底层的 UI 已经重建过了,点击打在了已销毁的句柄上。FlaUI 这种情况下不一定抛异常,静默失败最坑。解决办法是每次操作前重新查找一次元素,不要跨步骤缓存AutomationElement;如果确实要缓存,操作前用element.IsAvailable探一下,false 就重新查。

5.2 输入与并发层:丢字、抢焦点、权限不对

现象:中文消息发出去变成拼音或者半截乱码。原因就是前面说的输入法干扰,逐字符模拟按键被输入法拦截。改用剪贴板粘贴之后这个问题彻底消失。顺带一提,英文和数字用Keyboard.Type一般没问题,但既然要统一处理,全走粘贴更省事。

现象:本地调试一切正常,打包发给同事后所有查找都返回 null。这是权限完整性问题。同事用管理员身份运行了工具,而客户端是普通权限启动,高完整性级别的进程无法向低级别进程查询 UIA 树,反过来也一样。解决办法是两边权限对齐:要么都以普通用户跑,要么都以管理员跑,并在程序启动时用WindowsIdentity检查当前是否提权,给出明确提示而不是让用户猜。

现象:批量发送跑到一半,消息发到了错误的联系人窗口。原因是两个发送任务之间没有等待会话切换完成,前一个任务的回车落在了新会话的输入框里。这类问题在单条测试时永远复现不了,只有批量才暴露。解决办法是每个任务结束前校验一次输入框所属的会话标题,不匹配就丢弃当前任务并记录,宁可漏发也不能错发。

注意:不要试图用Thread.Sleep大规模堆砌来「保证稳定」。等待时间越长,批量任务总耗时越夸张,而且换台机器配置不同又得重调。所有等待都应该是「带超时的条件轮询」,超时了就当失败处理。

6. 二次开发改造:把一次性脚本收成一个能维护的小框架

脚本能跑通只是起点。真正交付给同事用的时候,你会发现需要改的东西远比想象中多:联系人名单要能从 Excel 导入、消息模板要能配变量、客户端升级后定位器要能快速修。我一般会做两件事来降低后续成本。

6.1 控件定位配置化

把所有ByControlType/ByName的组合抽成一个可序列化的结构,放到独立的 JSON 文件里。运行时不写死,启动时加载。

public sealed record LocatorSpec(string ControlType, string? Name, int Index = 0); public static AutomationElement? Resolve(Window window, LocatorSpec spec) { var type = (ControlType)Enum.Parse(typeof(ControlType), spec.ControlType); var elements = window.FindAllDescendants(cf => cf.ByControlType(type)); // Name 为空时退化为按顺序取第 N 个,这是自绘控件的常见兜底 var matched = string.IsNullOrEmpty(spec.Name) ? elements : elements.Where(e => e.Name == spec.Name); return matched.ElementAtOrDefault(spec.Index); }
{ "searchBox": { "controlType": "Edit", "name": null, "index": 0 }, "sendButton": { "controlType": "Button", "name": "发送", "index": 0 } }

这样一来,客户端升级导致定位失效时,改的是配置文件而不是重新编译发版。Index字段的存在是为了对付那些没有 Name 的自绘控件,配合前面的位置排序使用。配置文件里我还会加一个note字段,写清楚这个控件长什么样、在界面哪个位置,方便接手的人判断。

6.2 页面对象 + 重试 + 黑匣子日志

再把「一个界面」封装成一个类,对外只暴露业务方法,内部处理查找和等待。比如ChatWindow.Send(string text)内部负责找输入框、粘贴、回车、校验,调用方完全不关心控件细节。这样新增一个「发送文件」功能时,只是在这个类里加一个方法,不会污染业务层。

重试策略要分层:查找元素的重试放在页面对象内部,业务层的重试只处理「整个操作失败」的情况,两层都无脑重试会导致一次失败被放大成几十次实际操作。日志方面,我习惯在每次操作前截一张当前窗口的图存到本地,按时间戳命名,保留最近两百张。出问题的时候翻截图,比翻文本日志快十倍,这就是我的黑匣子。

那之后我每次改这类 UI 自动化项目,都会先把「定位器配置 + 操作前截图 + 超时即失败」这三件套搭好再写业务逻辑,宁可前期多花半天,也不想半夜被叫起来排查为什么某条消息发给了错误的人。希望帮到你。

本文还有配套的精品资源,点击获取