简介:面向希望在 Windows 桌面端实践微信自动化的 C# 开发者,这份项目源码以 Winform 为载体,借助 FlaUI 的 UI 自动化能力模拟人工操作,覆盖定时发送消息、关键词自动回复与群聊机器人三类场景,涉及控件查找、消息监听、规则匹配与任务触发等环节,适合具备一定 C# 基础、想了解桌面自动化落地思路的中级读者。压缩包共 365 个文件、约 47.83MB,以 127 个 dll 依赖库与 55 个 cs 源码文件为主干,配合 3 个 csproj 项目定义、18 个 json 配置、8 个 resx 与 9 个 png 界面资源,另有 4 个可执行程序,完整保留了主程序、工具类与数据库层的分层结构。目前已有 661 人学习。读者可从中获得可运行的工程骨架、FlaUI 定位微信窗口与控件的具体写法、消息处理与定时调度的组织方式,以及配置驱动的规则设计参考,便于按实际需求改造。
1. 为什么我把鼠标模拟换成了 FlaUI 的控件树
做微信自动化的第一版,我用的是最省事的办法:SendKeys 加坐标点击。开发机上是好的,换到同事那台 2K 屏的笔记本上,点下去直接点到了群公告——血泪经验都是这么来的。后来我把整套逻辑重写成 FlaUI 驱动,基于 UIAutomation 读控件树、按控件属性定位,屏幕分辨率、窗口位置、缩放比例全部变成无关变量。这套方案适合两类人:需要把桌面客户端里重复操作做成小工具的开发者,以及手上已经有一堆 WinForm 内部系统、想让它们和客户端联动的工程师。它不适合做批量群发,也不保证跨版本永不失效,但作为个人效率工具的地基,稳定性比坐标点击高一个量级。
2. FlaUI 与 UIA3 选型:先算清读一次控件树的成本
在动手写第一行代码之前,有一件事必须先想明白:FlaUI 本身不是自动化框架,它只是 UIAutomation 的一层薄封装。真正的能力来自 Windows 的 UI Automation 接口,FlaUI 负责把 COM 调用包装成 C# 里能链式写出来的对象。所以选型的核心不是「用不用 FlaUI」,而是「用 UIA2 还是 UIA3」,以及「控件树的读取频率怎么控」。这两个决定会直接影响后面所有代码的稳定性。
2.1 UIA2 与 UIA3 的差别,桌面端怎么挑
FlaUI 同时提供 FlaUI.UIA2 和 FlaUI.UIA3 两个包,它们底层走的是两套不同的实现。UIA2 走的是托管的UIAutomationClient程序集,历史上对 Win32 老控件、部分自绘控件的兼容性更好;UIA3 走的是原生 COM 接口,跨进程调用更直接,读取速度快,对现代应用的支持更完整,但每次属性访问都是一次跨进程调用,代价实打实存在。
| 维度 | UIA2 | UIA3 |
|---|---|---|
| 底层实现 | 托管 UIAutomationClient | 原生 COM UIAutomationCore |
| 单次读取延迟 | 较高,但稳定 | 较低,取决于目标进程响应 |
| 老控件兼容 | 好 | 一般 |
| 自绘/扁平控件树 | 一般 | 较好 |
| 常见适用场景 | 老式 MFC、部分 ERP 客户端 | 现代桌面客户端、WinUI/WPF 混合 |
我一般的做法是:主流程用 UIA3,遇到某个面板死活读不出来,再针对那一小块临时切到 UIA2 单独探测一次。两套 automation 对象可以在同一个进程里共存,互不干扰,只是别在同一个元素上交叉调用——跨 automation 拿到的元素句柄是不通用的。
using FlaUI.Core.AutomationElements; using FlaUI.UIA3; // 主流程统一用 UIA3,生命周期跟窗体一致 using var automation = new UIA3Automation(); // 如果某块控件树在 UIA3 下读不到,单独开一个 UIA2 探测实例 // using var legacyAutomation = new FlaUI.UIA2.UIA2Automation(); // var legacyWindow = legacyApp.GetMainWindow(legacyAutomation);这段代码的重点不在语法,而在生命周期的位置。UIA3Automation内部持有 COM 对象,创建一次、复用到底,不要每次查找都 new 一个。我见过有人在每个FindFirstDescendant外面包一层 new,跑十分钟内存直接起飞,而且 COM 引用释放不及时会拖慢整个 UIA 通道。把它放在窗体级字段或者using作用域里,全流程共用。
2.2 工程骨架:WinForm 项目该长什么样
目标框架建议直接上net8.0-windows,别在 .NET Framework 4.x 上纠缠,后面写异步、写依赖注入都舒服。项目类型选 Windows Forms 应用,Program.cs里的[STAThread]必须保留——这是 WinForm 的硬性要求,而 UIA 的很多调用也偏好 STA 线程。
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net8.0-windows</TargetFramework> <UseWindowsForms>true</UseWindowsForms> <Nullable>enable</Nullable> <Platforms>x64</Platforms> </PropertyGroup> <ItemGroup> <!-- 版本以 NuGet 上的最新稳定版为准,两个包保持同版本 --> <PackageReference Include="FlaUI.Core" /> <PackageReference Include="FlaUI.UIA3" /> </ItemGroup> </Project>FlaUI.Core提供元素、条件、重试这些基础设施,FlaUI.UIA3提供具体实现,两个包版本号必须一致,否则运行时会抛类型加载异常——这个报错信息很难看懂,我第一次遇到时排查了半个下午。Platforms设成 x64 是因为目标客户端基本都是 64 位进程,虽然 FlaUI 支持跨位数访问,但同位数运行能避开一批权限和 COM 序列化的玄学问题。
界面上我一般只放三样东西:一个目标进程下拉框、一个日志 RichTextBox、一个启动/停止按钮。把自动化逻辑全部封装到独立的类里,窗体只负责调度和展示,这样后期换界面框架不用动核心代码。
2.3 第一次探测:把控件树打印出来再谈定位
很多人上手就想写FindFirstDescendant(cf => cf.ByName("发送")),结果返回 null 就开始怀疑人生。正确顺序是先看清楚这棵树长什么样。写一个递归 dump 方法,从主窗口往下打三层,够用了。
using FlaUI.Core.AutomationElements; using FlaUI.Core.Definitions; static void DumpTree(AutomationElement root, int depth = 3, int indent = 0) { if (root is null || depth < 0) return; var pad = new string(' ', indent * 2); // Name 属性在部分元素上会抛异常,取值要兜底 string name = root.Properties.Name.ValueOrDefault ?? string.Empty; string autoId = root.Properties.AutomationId.ValueOrDefault ?? string.Empty; Console.WriteLine( $"{pad}[{root.ControlType}] name='{Trunc(name)}' autoId='{autoId}' " + $"class='{root.ClassName}' rect={root.BoundingRectangle}"); foreach (var child in root.FindAllChildren()) DumpTree(child, depth - 1, indent + 1); } static string Trunc(string s) => s.Length > 24 ? s[..24] + "…" : s;参数说明:depth控制递归层数,桌面客户端的控件树可能有上千个节点,全量 dump 会卡住十几秒,三层足够判断结构。FindAllChildren()只取直接子节点,比FindAllDescendants()快一个数量级,探测阶段用它更合适。BoundingRectangle打出来是为了后面判断控件位置关系,比如区分「消息列表区」和「输入区」,很多时候只能靠 Y 坐标。
探测结果通常会给你两个结论之一:要么树很规整,List/ListItem/Edit 层次分明;要么整棵树被压扁成几个 Pane,什么都读不到。第二种情况在自绘客户端上非常普遍,这时候就别死磕控件树了,直接跳到下一章的「搜索框路径」,用少数几个稳定的输入框做锚点,比硬啃控件树划算得多。
3. 定位控件:从窗口到输入区的路径设计
定位策略决定这套工具能活多久。我见过太多脚本把整条路径写成「第 3 个 List 的第 5 个 ListItem」,客户端一次小版本更新就全废。真正抗变化的写法只有一种:尽量用语义属性定位,减少对层级和索引的依赖,并且把「找不到」当成正常分支处理,而不是异常。
3.1 三层定位法:AutomationId 优先,Name 兜底
给所有查找条件排个优先级。第一优先是AutomationId,这是开发者在代码里写死的标识,最稳定;第二是ControlType加ClassName的组合,适合那些没有 ID 但有明确控件类型的元素;第三才是Name,因为 Name 会跟界面语言、文案调整一起变。
using FlaUI.Core.AutomationElements; using FlaUI.Core.Conditions; using FlaUI.Core.Definitions; using FlaUI.Core.Tools; public static AutomationElement? FindRobust(AutomationElement root, ConditionFactory cf, string name) { // 1. AutomationId 精确匹配 var byId = root.FindFirstDescendant(c => c.ByAutomationId(name)); if (byId is not null) return byId; // 2. ControlType + Name 组合 var byName = root.FindFirstDescendant(c => c.ByName(name)); if (byName is not null) return byName; // 3. 带超时的重试,应对控件延迟渲染 var retry = Retry.WhileNull( () => root.FindFirstDescendant(c => c.ByName(name)), timeout: TimeSpan.FromSeconds(3), interval: TimeSpan.FromMilliseconds(200)); return retry.Success ? retry.Result : null; }Retry.WhileNull是 FlaUI 自带的重试工具,三个参数分别是「要重试的委托」「总超时」「重试间隔」。Success为 false 表示超时仍未拿到结果,Result此时是 null。这里的关键是超时不要设太长,3 秒已经足够覆盖大部分渲染延迟;设成 30 秒只会让你的程序在出错时卡死半分钟,用户以为崩了。
查找还有一个容易忽略的点:FindFirstDescendant默认在整个子树里广度优先搜索,树大的时候开销不小。如果已经知道目标在某个容器下,先把容器拿到手,再在容器里找,能省掉大量跨进程调用。
3.2 会话与输入区:用几何关系代替层级关系
自绘客户端的会话列表经常读不出 ListItem,但输入框通常是标准 Edit 控件。利用这一点,可以把定位问题简化成:先找到所有 Edit,再按位置区分用途。搜索框在顶部,消息输入框在底部,用 Y 坐标排序就能分开。
using FlaUI.Core.AutomationElements; using FlaUI.Core.Definitions; public static (AutomationElement? search, AutomationElement? input) LocateEdits(Window window) { var edits = window.FindAllDescendants(c => c.ByControlType(ControlType.Edit)); if (edits.Length == 0) return (null, null); // 按窗口内相对 Y 坐标排序,越靠上越可能是搜索框 var ordered = edits .Where(e => e.BoundingRectangle.Height > 10) .OrderBy(e => e.BoundingRectangle.Top) .ToArray(); var search = ordered.FirstOrDefault(); var input = ordered.LastOrDefault(); return (search, input); }这段代码的取舍很明确:不追求 100% 准确,追求在布局变化时还能用。BoundingRectangle.Height > 10是为了过滤掉那些被隐藏的零尺寸 Edit(自绘控件经常留一堆不可见的占位元素)。如果搜索结果和输入框恰好是同一个元素,说明当前界面不在会话页,直接返回,让上层决定是否先切回主界面。
实际项目中我会再加一层保险:把识别到的输入框BoundingRectangle缓存下来,下次先验证缓存坐标处是否还是 Edit,是就直接用,省掉一次全树遍历。缓存失效的判定很简单——控件类型对不上,或者尺寸变成 0。
3.3 发送链路的完整实现
把前面几块拼起来,一条完整的发送流程是:激活窗口 → 点搜索框 → 输入联系人 → 等结果 → 回车进入会话 → 定位输入框 → 写入文本 → 回车发送。每一步都要有失败返回,不能一条道走到黑。
using FlaUI.Core; using FlaUI.Core.AutomationElements; using FlaUI.Core.Input; using FlaUI.Core.WindowsAPI; using FlaUI.UIA3; public sealed class ChatDriver : IDisposable { private readonly UIA3Automation _automation = new(); private Application? _app; private Window? _window; public bool Attach(string processName) { var proc = System.Diagnostics.Process.GetProcessesByName(processName) .FirstOrDefault(p => p.MainWindowHandle != IntPtr.Zero); if (proc is null) return false; _app = Application.Attach(proc); _window = _app.GetMainWindow(_automation, TimeSpan.FromSeconds(10)); return _window is not null; } public bool Send(string contact, string text) { if (_window is null) return false; _window.SetForeground(); // 焦点抢不回来,后面全白搭 Thread.Sleep(200); var (search, input) = LocateEdits(_window); if (search is null || input is null) return false; // 搜索联系人:先点一下确保焦点,再整体替换文本 search.Click(); search.AsTextBox().Text = contact; Thread.Sleep(600); // 等搜索结果列表刷新,别省 Keyboard.Press(VirtualKeyShort.RETURN); Thread.Sleep(400); // 等会话切换完成 // 会话切换后输入框可能被重建,必须重新定位 var (_, freshInput) = LocateEdits(_window); if (freshInput is null) return false; freshInput.Click(); freshInput.AsTextBox().Text = text; Thread.Sleep(120); Keyboard.Press(VirtualKeyShort.RETURN); return true; } public void Dispose() { _automation.Dispose(); _app?.Dispose(); } }逻辑说明几个关键点。SetForeground()不能省,客户端在后台时部分控件的 UIA 事件不会触发,写进去的文本可能不生效。AsTextBox().Text = ...走的是 ValuePattern,比逐字Keyboard.Type快得多,也不会被输入法干扰,但它要求控件支持 Value 模式,不支持时得退回键盘输入。两次Thread.Sleep是刻意的等待,搜索结果列表和会话切换都是异步渲染,用Retry.WhileNull等某个确定元素出现更优雅,但需要先知道等什么元素,工程上先用固定延时兜底,后面再逐步替换成条件等待。
4. 消息收取:轮询节流、缓存与去重
发送是主动行为,收消息是被动监听,难度完全不是一个量级。UIAutomation 没有「消息到达」这种事件可以订阅,能做的只有定时读消息列表、比对差异。这套机制的稳定性全看三个参数:轮询间隔、读取范围、去重策略。
4.1 轮询频率与变更检测
轮询间隔设太短,UIA 跨进程调用会把目标进程拖慢,严重时客户端直接卡顿;设太长,消息延迟明显。我的经验值是 800 到 1500 毫秒,具体取决于这台机器的性能。检测变更不要用「列表长度变化」这种朴素办法,因为会话切换、消息撤回都会让长度变化但内容不新增。
比较可靠的做法是取最后若干条消息的文本,拼成一个指纹字符串,和上一次的指纹比对。
using System.Security.Cryptography; using System.Text; using FlaUI.Core.AutomationElements; private string _lastFingerprint = string.Empty; public List<string> PollNewMessages(Window window, int tailCount = 5) { // 消息列表通常是 List 控件,取不到时退化为取所有 Text var list = window.FindFirstDescendant(c => c.ByControlType(FlaUI.Core.Definitions.ControlType.List)); if (list is null) return new List<string>(); var items = list.FindAllChildren() .Select(i => i.Properties.Name.ValueOrDefault ?? string.Empty) .Where(s => !string.IsNullOrWhiteSpace(s)) .ToList(); if (items.Count == 0) return new List<string>(); var tail = items.Skip(Math.Max(0, items.Count - tailCount)).ToList(); var fingerprint = Convert.ToHexString( SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("\u0001", tail)))); if (fingerprint == _lastFingerprint) return new List<string>(); _lastFingerprint = fingerprint; return tail; }用\u0001做分隔符是为了避免消息内容里恰好含有分隔字符导致指纹碰撞。tailCount取 5 就够了,取太多每次都要遍历一大堆元素,取太少又容易漏掉短时间内的连续消息。返回的是尾部消息列表而不是「新增消息」,因为指纹变了不代表全是新消息——可能是历史消息被加载了。真正的增量判断留给下一层去做。
这套指纹法有个天然缺陷:如果两条消息内容完全一样且连续到达,指纹不变,会被漏掉。工程上的解法是把消息列表项的数量也拼进指纹里,两个维度一起比对,误判率就下来了。
4.2 去重与幂等:别让同一条消息处理两遍
指纹只能告诉你「有变化」,不能告诉你「哪条是新的」。真正落地时必须维护一份已处理消息的集合。最简单的实现是HashSet<string>存消息文本的哈希,但同一句话在不同时间说两次是正常业务,不能当成重复。所以键里必须带上时间戳或者消息在列表中的位置。
private readonly HashSet<string> _processed = new(); public IEnumerable<string> ExtractNew(IReadOnlyList<string> tail) { foreach (var msg in tail) { // 位置 + 内容的组合键,避免完全相同的内容被误判为重复 var key = $"{tail.IndexOf(msg)}::{msg}"; if (_processed.Add(key)) // Add 返回 false 表示已存在 yield return msg; } // 集合不能无限增长,超过阈值就丢掉最老的一半 if (_processed.Count > 2000) _processed.Clear(); }HashSet.Add返回 false 表示元素已存在,用它做去重比Contains加Add少一次查找。集合上限那段看着粗糙,但长跑程序里必须要有这么一道闸,否则内存曲线会一路向上。更精细的做法是记录每条消息的首次处理时间,按时间窗口清理,代价是要多维护一个字典。
4.3 把自动化线程和 UI 线程隔开
WinForm 的 UI 线程是 STA,而且被消息循环占着。如果把轮询逻辑直接写在按钮事件里,界面在轮询期间完全无响应,用户点关闭都点不动。正确做法是开一个独立的 STA 线程跑自动化,通过Control.Invoke把日志和状态回传给界面。
private Thread? _worker; private void StartWorker() { _worker = new Thread(() => { while (_running) { try { var msgs = PollNewMessages(_window!, 5); foreach (var m in ExtractNew(msgs)) BeginInvoke(() => AppendLog(m)); } catch (Exception ex) { BeginInvoke(() => AppendLog($"[轮询异常] {ex.Message}")); } Thread.Sleep(1000); } }) { IsBackground = true, Name = "uia-poller" }; _worker.SetApartmentState(ApartmentState.STA); // 关键,别用 Task.Run _worker.Start(); }为什么不用Task.Run:线程池里的线程是 MTA,UIA3 的 COM 对象在 MTA 下访问部分控件时会抛COMException,或者干脆返回空结果。这个现象非常隐蔽,因为有些控件在 MTA 下是能读的,你会在测试阶段以为一切正常。SetApartmentState(STA)必须在Start()之前调用,启动后再设会抛异常。
5. 避坑:五类高频翻车现场与排查顺序
下面这五条,每一条我都在真实项目里踩过,写出来是为了让你少走一遍。排查顺序建议从上往下,因为权限和焦点问题会掩盖掉后面所有问题,先排除前两条再谈定位。
5.1 提权不一致,控件树读出来是空的
现象:GetMainWindow返回了窗口对象,但FindAllChildren()返回空数组,或者只返回一两个 Pane。
原因:目标进程以管理员权限运行,而你的 WinForm 是普通权限。Windows 的 UIPI 机制会阻止低权限进程访问高权限进程的 UI 元素,UIA 拿不到完整树,但不会报错,只会静默返回空。
解决:给程序加app.manifest,把requestedExecutionLevel设为asInvoker之外,还要保证两边权限一致。更稳妥的办法是运行时检测目标进程是否提权,提示用户以相同权限重启。判断方法可以读进程的TokenElevation,也可以简单粗暴地比较:如果控件树为空但你确定窗口存在,先怀疑权限。
5.2 控件树抖动,缓存的元素句柄失效
现象:第一轮循环一切正常,第二轮开始FindFirstDescendant返回 null,或者访问已缓存元素的属性时抛ElementNotAvailableException。
原因:客户端界面重新渲染,旧的 AutomationElement 底层 COM 引用已经失效。自绘控件在切会话、滚动列表时尤其频繁。
解决:永远不要在长时间运行的逻辑里缓存AutomationElement对象本身,只缓存定位条件(AutomationId、Name)或者几何坐标。每次用之前重新查一遍。如果某段逻辑确实需要频繁访问同一元素,就在这个小范围内重新获取并立即用完,用完即弃。
5.3 焦点被抢,文本写一半就断了
现象:AsTextBox().Text = "完整消息",实际发出去只有前几个字,或者内容跑到了别的窗口。
原因:设置 Text 属性会先让控件获得焦点,如果此时有别的窗口抢焦点(弹窗、通知、另一个自动化任务),输入就被截断。输入法处于中文状态时,逐字Keyboard.Type也会出问题。
解决:写入前强制SetForeground()并等待一小段时间,写完立即回车,把窗口交互压缩到最短。文本内容尽量走ValuePattern(即.Text赋值)而不是键盘输入,它对输入法免疫。如果控件不支持 Value 模式,先用Keyboard.Type前把输入法切到英文,或者用剪贴板粘贴替代。
5.4 客户端升级后,控件改了个名字
现象:昨天还跑得好好的脚本,今天全流程失败在搜索框定位这一步。
原因:客户端小版本更新调整了控件属性,Name 从「搜索」变成了空字符串,或者 ControlType 从 Edit 变成了 Pane。
解决:定位条件写成多个候选的组合,用「或」的关系依次尝试,任何一条命中就继续。更根本的办法是降低对单个属性的依赖,比如搜索框的判定改成「位于窗口顶部 1/4 区域内、高度在 20 到 40 之间、可编辑」这种几何加类型的复合条件。同时留一份探测日志,出错时能把当前控件树 dump 出来,方便快速比对改了什么。
5.5 长跑几小时后,内存和句柄一路涨
现象:程序跑两三个小时开始卡顿,任务管理器里句柄数和 GDI 对象数持续上升。
原因:FlaUI 的每个AutomationElement都持有 COM 引用,虽然没有显式释放要求,但大量创建不释放会累积。另外频繁FindAllDescendants会产生大量临时对象,GC 压力大。
解决:把UIA3Automation做成单例复用;轮询里避免每轮都做全树遍历,先定位到稳定的容器再往下找;每次操作完主动把局部变量置空,帮助 GC 回收;日志输出做节流,RichTextBox 追加几千行文本本身就是内存大户。
6. 稳定性加固:重试封装、自检与长跑验证
把功能跑通只是及格线,能连续跑八小时不出错才算交付。我在这一层做的第一件事是把所有裸调用包进统一的重试器,而不是散落在各处的 try-catch。
using FlaUI.Core.Tools; public static class Uia { // 统一重试入口:默认 3 次、间隔 300ms,失败返回 null 而不是抛异常 public static T? Try<T>(Func<T?> action, int attempts = 3, int delayMs = 300) where T : class { for (var i = 0; i < attempts; i++) { try { var result = action(); if (result is not null) return result; } catch (Exception) { /* 单次失败不中断,交给下一轮 */ } Thread.Sleep(delayMs); } return null; } }这个封装的价值不在重试本身,而在于把所有失败都收敛成 null,上层用??和if (x is null)就能处理,不用到处写异常分支。attempts和delayMs的具体值要看目标客户端的响应速度,响应慢的机器上把间隔调到 500 毫秒更稳。
上线前我会固定跑一轮自检,检查项就下面这几条,缺一条都不发版:
| 检查项 | 通过标准 | 失败时的典型原因 |
|---|---|---|
| 进程附加 | 3 秒内拿到主窗口 | 权限不一致、进程名写错 |
| 控件定位 | 搜索框和输入框均非空 | 界面不在主会话页、布局变更 |
| 发送回环 | 发给自己能在列表读到 | 焦点被抢、文本被截断 |
| 异常恢复 | 手动关掉客户端后能重新附加 | 未做重连逻辑 |
| 长跑 2 小时 | 句柄数波动小于 20% | 元素缓存未释放 |
发送回环那条特别值得做:给自己发一条带时间戳的消息,再轮询读回来比对,一条链路把所有环节都验证了,比逐个断言控件存在有意义得多。
从那以后我每次接手 UI 自动化项目,都会先强制走一遍「探测 → 单步 → 回环 → 长跑」这四步,哪怕需求再急也不跳过任何一环。跳过探测的直接后果就是对着返回 null 的代码猜半天,跳过回环的后果是上线第二天被用户投诉发错人。这套东西不难,难的是耐着性子把每一步都做实。希望帮到你。
本文还有配套的精品资源,点击获取