简介:这份压缩包面向希望在 C# WinForm 桌面项目中调用大漠插件(dm.dll)的开发者,重点演示在 .NET Core 5.0 与 Windows 10 环境下免注册加载插件、调用找字、找图、截图、打字等能力的完整工程示例。包内共 204 个文件,以 78 个 dll、40 个 bmp、15 个 cs 及 json、pdb、so 等为主,包含示例源码、界面资源、图片模板和依赖库,整体约 17.47MB,便于对照目录理解项目结构。该资源已有 905 人学习,适合需要减少注册表依赖、简化部署并排查动态加载与资源释放问题的读者参考。借助现成工程,可快速了解 WinForm 界面事件绑定、DllImport 与 LoadLibrary 调用方式、图片素材组织及多线程与错误处理思路,从而在自己的自动化识别场景中复用关键实现。
1. 一个 0x80040154 报错,把这套免注册方案逼了出来
一个 .NET 5 的 C# WinForms 工具,从旧框架迁到 Windows 10 之后界面能起来,一点「开始」就抛 0x80040154「类未注册」。原因不复杂:dm.dll 是 COM 组件,老代码靠 regsvr32 把 CLSID 写进注册表,而新机器没有管理员权限,32 位和 64 位两份 dll 还互相覆盖。所谓免注册调用大漠插件,就是把「注册到系统」改成「进程内自己激活」:用 LoadLibrary 把 dm.dll 载进当前进程,取 DllGetClassObject,手动走一遍 COM 激活拿到 IDispatch,之后所有方法调用都走这条链路。
它适合三类人:要做桌面客户端自动化回归测试的、想把脚本工具打包成绿色版直接发给别人的、以及在 CI 机器上没法改注册表还想跑 UI 自动化的。读完至少能拿到一条能落地的加载链路、一份可抄的参数封装,以及一份让我少熬两个晚上的排查清单。
2. 工程骨架与技术选型:为什么把它拆成四个项目
先看目录。这套结构不是为了好看,是因为 COM 互操作一旦散落在窗体代码里,后面换插件版本、加单元测试、把工具打包发给别人,每一步都会疼。
2.1 目录骨架:把 COM 互操作锁在单独一层
DesktopAuto/ ├─ DesktopAuto.sln ├─ src/ │ ├─ DesktopAuto.App/ # WinForms 宿主,只管界面与生命周期 │ │ ├─ Program.cs │ │ ├─ MainForm.cs │ │ ├─ app.manifest │ │ └─ DesktopAuto.App.csproj │ ├─ DesktopAuto.Automation/ # 与具体插件无关的抽象层 │ │ ├─ IAutomationEngine.cs │ │ ├─ AutomationResult.cs │ │ └─ DesktopAuto.Automation.csproj │ ├─ DesktopAuto.Automation.Dm/ # dm.dll 的加载与调用封装 │ │ ├─ DmLoader.cs │ │ ├─ DmEngine.cs │ │ ├─ DmOptions.cs │ │ └─ Interop/ │ │ ├─ IClassFactory.cs │ │ ├─ DllGetClassObjectFn.cs │ │ └─ PeImage.cs │ └─ DesktopAuto.Automation.Fake/ # 测试替身,CI 里完全不碰真 dll ├─ native/ │ ├─ x86/dm.dll │ └─ x64/dm.dll └─ tests/ └─ DesktopAuto.Tests/Automation这一层只放接口和数据结构,不引用任何 Interop;Automation.Dm是唯一知道 CLSID、唯一调用NativeLibrary.Load的地方;App通过构造函数注入IAutomationEngine,它压根不知道底下是 dm.dll 还是别的实现。这个拆法带来的直接好处是:CI 流水线上跑Automation.Fake就能把业务流程测完,不需要 32 位宿主、不需要 dll、不需要管理员权限。
我见过不少同类工具把所有东西糊在一个 Form 里,dynamic dm = ...从按钮点击一直写到定时器回调。跑是能跑,但换一个插件版本、或者客户机器上 dll 位数不对,报错堆栈会直接指向窗体事件,根本没法定位是哪一步激活失败。
native目录同时留 x86 和 x64 两份占位,是因为发布时你没法预知目标机器上跑的是哪个版本的工具。文件名保持dm.dll不变,只按目录区分,加载器按当前进程位数拼路径,逻辑最简单。
2.2 技术选型:net5.0-windows、x86 与 STA 三件事
目标框架必须是net5.0-windows,不是net5.0。WinForms 的引用挂在 Windows 目标框架标识下,写成net5.0连UseWindowsForms都不生效;另外像Marshal.GetObjectForIUnknown、NativeLibrary这些 API 都带 Windows 平台标注,用net5.0-windows才不会被一堆 CA1416 警告糊脸。
位数是第一优先级。dm.dll 是 32 位,宿主进程就得是 32 位;是 64 位,宿主就得 64 位。位数不匹配时LoadLibrary直接返回BadImageFormatException(HRESULT 0x8007000B),这个错误信息跟「找不到文件」完全是两回事,很多人第一次遇到以为是路径写错了,查半天。
SDK 风格项目有个容易翻车的点:老 csproj 里Prefer32Bit默认是 true,AnyCPU 编译出来会跑成 32 位;到了 SDK 风格项目,AnyCPU 默认跟着操作系统走,64 位系统上就是 64 位进程。同一份代码迁过来,行为悄悄变了。我的做法是干脆不写 AnyCPU,在 csproj 里把PlatformTarget写死成x86,同时在启动时读一次 dll 的 PE 头做校验,位数不对直接给出人话提示,而不是把异常丢给用户。
线程模型是第二个优先级。大漠的 COM 对象不是自由线程的,跨 apartment 调用会被系统 marshal 一层,性能掉得厉害,个别跟窗口、剪贴板相关的接口还会直接失败。WinForms 主线程默认 STA,直接在上面调没问题,但找图找色动辄几百毫秒,全放主线程 UI 就假死了。正确做法是起一条专用的 STA 工作线程,所有插件调用都在这条线程上排队执行,主线程只负责收结果刷界面。
// 专用 STA 工作线程:绝不要把 dm 调用丢进 ThreadPool var worker = new Thread(WorkerLoop) { IsBackground = true, Name = "automation-worker" }; worker.SetApartmentState(ApartmentState.STA); // 线程池线程是 MTA,跨 apartment 会踩坑 worker.Start();SetApartmentState必须在Start()之前调用,启动之后再设会抛InvalidOperationException。线程池线程的 apartment 固定为 MTA,改不了,所以别想着用Task.Run包一层就完事。
至于不用 C++/CLI 中间层包一层、也不用手写ComImport强类型接口,理由很实际:前者发布时要带一堆原生运行时,绿色版就做不成了;后者要把 IDispatch 派生接口的全部方法按 vtable 顺序逐个声明,大漠暴露的方法数量摆在那,维护成本远超收益。统一走 late binding,用InvokeMember派发,是最省心的路子。
2.3 csproj 与 app.manifest 的两处关键配置
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net5.0-windows</TargetFramework> <UseWindowsForms>true</UseWindowsForms> <!-- dm.dll 是 32 位,整个进程就必须跟着 32 位,别留 AnyCPU --> <PlatformTarget>x86</PlatformTarget> <Platforms>x86</Platforms> <Nullable>enable</Nullable> <ApplicationManifest>app.manifest</ApplicationManifest> </PropertyGroup> </Project>PlatformTarget和Platforms两个都要写。前者决定编译进程序集的 PE 头,后者决定 IDE 和 MSBuild 的解决方案配置里能不能选到 x86,只写一个会出现在 VS 里能编译但输出仍是 AnyCPU 的情况。
清单文件里我只关心两件事:执行级别和 DPI。
<?xml version="1.0" encoding="utf-8"?> <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> <assemblyIdentity version="1.0.0.0" name="DesktopAuto.App"/> <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2"> <security> <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3"> <!-- 不提权,避免 UAC 弹窗打断自动化流程;目标程序若以管理员运行则另说 --> <requestedExecutionLevel level="asInvoker" uiAccess="false" /> </requestedPrivileges> </security> </trustInfo> <application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> <!-- DPI 不声明,高缩放屏上截图像素和窗口坐标对不上,找图必然偏 --> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> </assembly>dpiAware这一行不是可选项。系统缩放 125% 或 150% 时,如果不声明 DPI 感知,进程会被系统做位图拉伸,GetWindowRect拿到的是逻辑坐标,Capture截出来的是物理像素,两套坐标混用,找图就永远差那么几十个像素,而且在你自己的开发机(100% 缩放)上完全复现不出来。
3. 免注册调用核心链路:从 LoadLibrary 到 IDispatch
这一章是整套方案的心脏。搞明白这三步,后面所有封装都是锦上添花。
3.1 手动激活到底省掉了什么
正常 COM 调用是CoCreateInstance(clsid, ...),系统拿 CLSID 去注册表HKCR\CLSID\{...}\InprocServer32查 DLL 路径,加载、调用DllGetClassObject、拿类厂、创建实例,一条龙。免注册的意思不是绕过 COM,而是跳过「查注册表」这一步,剩下的活自己干。
dm.dll作为标准 inproc COM 服务器,必然导出这个函数:
HRESULT DllGetClassObject(REFCLSID rclsid, REFIID riid, void **ppv);第一个参数是想要哪个类的 CLSID,第二个是想要类厂暴露哪个接口(一般传IID_IClassFactory),第三个是输出指针。拿到类厂之后调CreateInstance,传IID_IDispatch,就能拿到一个 late binding 可用的 dispatch 指针。
这里有个关键简化:我们不需要知道大漠自定义的任何接口 IID。因为全程只用IDispatch,CreateInstance时riid固定传{00020400-0000-0000-C000-000000000046}就行。省掉了一大块从类型库里挖 IID 的活。
3.2 三个必须手写的 interop 声明
using System; using System.Runtime.InteropServices; namespace DesktopAuto.Automation.Dm.Interop { // COM 类厂,只声明我们真正会调的两个方法,顺序必须是 vtable 顺序 [ComImport] [Guid("00000001-0000-0000-C000-000000000046")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IClassFactory { [PreserveSig] int CreateInstance(IntPtr pUnkOuter, ref Guid riid, out IntPtr ppv); [PreserveSig] int LockServer([MarshalAs(UnmanagedType.Bool)] bool fLock); } // DllGetClassObject 的导出签名,stdcall,返回 HRESULT [UnmanagedFunctionPointer(CallingConvention.StdCall)] internal delegate int DllGetClassObjectFn( ref Guid rclsid, ref Guid riid, out IntPtr ppv); }[PreserveSig]不能省。默认情况下运行时会把这些返回 HRESULT 的方法翻译成「失败就抛异常」,成功时把返回值丢掉。但我们后面要按 HRESULT 分支给出不同提示,保留原始返回值更好排查。
IClassFactory里方法顺序必须和原生 vtable 一致:QueryInterface/AddRef/Release由InterfaceIsIUnknown自动补,接口里声明的第一个方法对应 vtable 第四项,也就是CreateInstance。顺序写反了不会编译报错,但运行时调用会跳到错误的函数地址,表现是进程直接崩或者返回一堆莫名其妙的内存错误。
CallingConvention.StdCall也要对上。COM 在 Windows 上默认就是 stdcall,写成Cdecl会让参数出栈方错位,症状是第一次调用能过、第二次调用栈就歪了。
3.3 DmLoader:加载、激活、缓存
using System; using System.IO; using System.Runtime.InteropServices; using DesktopAuto.Automation.Dm.Interop; namespace DesktopAuto.Automation.Dm { public sealed class DmLoader : IDisposable { // IDispatch 的 IID,全程走 late binding,不需要知道插件的自定义接口 private static readonly Guid IID_IDispatch = new Guid("00020400-0000-0000-C000-000000000046"); private static readonly Guid IID_IUnknown = new Guid("00000000-0000-0000-C000-000000000046"); private IntPtr _hModule = IntPtr.Zero; private object? _instance; public object Instance => _instance ?? throw new InvalidOperationException("尚未加载 dm.dll"); public void Load(string dllPath, Guid clsid) { if (!File.Exists(dllPath)) throw new FileNotFoundException("找不到插件 dll", dllPath); // 位数不匹配时给出人话提示,而不是让 BadImageFormatException 冒到界面上 var is32 = PeImage.Is32Bit(dllPath); if (is32 != (IntPtr.Size == 4)) throw new PlatformNotSupportedException( $"dll 是 {(is32 ? "32" : "64")} 位,当前进程是 {IntPtr.Size * 8} 位"); _hModule = NativeLibrary.Load(dllPath); // 比 LoadLibrary P/Invoke 更干净 try { var export = NativeLibrary.GetExport(_hModule, "DllGetClassObject"); var fn = Marshal.GetDelegateForFunctionPointer<DllGetClassObjectFn>(export); // 第一步:拿类厂 var hr = fn(ref clsid, ref IID_IUnknown, out var pUnk); if (hr != 0) throw new COMException("DllGetClassObject 失败", hr); var factory = (IClassFactory)Marshal.GetObjectForIUnknown(pUnk); Marshal.Release(pUnk); // 第二步:创建实例,rio 固定传 IDispatch hr = factory.CreateInstance(IntPtr.Zero, ref IID_IDispatch, out var pDisp); Marshal.ReleaseComObject(factory); if (hr != 0) throw new COMException("CreateInstance 失败", hr); // 第三步:转成 RCW,后续用 InvokeMember 派发 _instance = Marshal.GetObjectForIUnknown(pDisp); Marshal.Release(pDisp); } catch { NativeLibrary.Free(_hModule); _hModule = IntPtr.Zero; throw; } } public void Dispose() { if (_instance != null) { Marshal.FinalReleaseComObject(_instance); // 先放 COM 对象 _instance = null; } if (_hModule != IntPtr.Zero) { NativeLibrary.Free(_hModule); // 再卸载 dll _hModule = IntPtr.Zero; } } } }参数说明几处。clsid从配置读,不硬编码在代码里,因为不同版本、不同位数的插件可能给出不同的 CLSID,写死之后换版本就得重新编译。Marshal.GetObjectForIUnknown返回的是 RCW,把它 cast 到IClassFactory会走一次QueryInterface,这是合法的,因为接口上标了ComImport和 GUID。
释放顺序不能反。先FinalReleaseComObject把 RCW 的引用计数清零,让插件对象析构,再NativeLibrary.Free卸载模块。反过来会出现 dll 里还有对象活着但模块已被卸载的情况,后续任何一次调用都是野指针,症状是毫无规律的访问冲突。
PeImage.Is32Bit单独写了个小工具读 PE 头,比在运行时撞异常体验好得多:
public static bool Is32Bit(string path) { using var fs = File.OpenRead(path); using var br = new BinaryReader(fs); fs.Position = 0x3C; // DOS 头里 e_lfanew 的位置 fs.Position = br.ReadInt32() + 4; // 跳过 "PE\0\0" var machine = br.ReadUInt16(); // 0x014c = x86,0x8664 = x64 return machine == 0x014c; }3.4 CLSID 从哪来:开发期提取一次,运行期读配置
这是新手最容易卡住的地方:免注册了,可 CLSID 还是得知道。我的做法是在开发机上一次性地注册一次,把 CLSID 抄出来,之后目标机器上再也不需要注册。
# 32 位 dll 必须用 SysWOW64 下的 regsvr32,直接敲 regsvr32 会调 64 位版本而失败 C:\Windows\SysWOW64\regsvr32.exe /s .\native\x86\dm.dll # 从 ProgID 反查 CLSID 并打印 (Get-ItemProperty 'Registry::HKEY_CLASSES_ROOT\dm.dmsoft\CLSID').'(default)' # 抄完立刻反注册,保证开发机也处于「未注册」状态,验证免注册链路真的成立 C:\Windows\SysWOW64\regsvr32.exe /s /u .\native\x86\dm.dll把打印出来的 GUID 写进appsettings.json或者一个dm.config.json,加载器启动时读。反注册这一步别省,很多人开发机上一直留着注册表项,代码里其实有 bug 也测不出来,一发到客户机器上就炸。
提示:ProgID 名字以 dll 里实际注册的为准,不同打包版本可能不一样。提取不到就换个思路,用系统自带的 OLE/COM 查看器按类型库翻一遍,找到那个 coclass 抄 CLSID。
4. 自动化能力落地:绑定、找图与键鼠封装
加载器跑通只算过了一半,真正干活的是后面这些调用。这一章把常用能力按「先能调通、再调稳」的顺序拆开。
4.1 窗口查找与绑定:三个模式参数怎么选
绑定是后续所有操作的前提,绑定失败后面全是白搭。典型写法是先按类名和标题找窗口句柄,再绑:
public sealed class DmEngine : IAutomationEngine { private readonly object _dm; private readonly Type _dmType; public DmEngine(object dm) { _dm = dm; _dmType = dm.GetType(); // RCW 的类型是 __ComObject,InvokeMember 会走 IDispatch } private object Invoke(string method, params object[] args) => _dmType.InvokeMember(method, BindingFlags.InvokeMethod, null, _dm, args); public IntPtr FindWindow(string className, string title) => new IntPtr(Convert.ToInt64(Invoke("FindWindow", className, title))); public bool BindWindow(IntPtr hwnd, DmBindOptions opt) { var ret = Convert.ToInt32(Invoke( "BindWindow", hwnd.ToInt32(), opt.Display, opt.Mouse, opt.Keypad, opt.Mode)); return ret == 1; // 1 成功,0 失败,失败时先 UnBindWindow 再换模式重试 } } public sealed class DmBindOptions { public string Display { get; set; } = "normal"; // 截图方式 public string Mouse { get; set; } = "normal"; // 鼠标模拟方式 public string Keypad { get; set; } = "normal"; // 键盘模拟方式 public int Mode { get; set; } = 0; // 组合模式,不同版本含义有差异 }三个字符串参数决定「用什么方式读屏、用什么方式发消息」。normal是最通用也最容易被目标程序干扰的一种,走常规窗口消息和 GDI 截屏;遇到画面刷不出来的场景,通常要往gdi、windows、dx这一档去试。Mode是个位标志组合,不同插件版本里的取值含义不完全一致,我的习惯是从 0 开始,绑定失败就按候选表逐个试,返回 1 立刻停。
// 绑定模式候选,按「干扰最小」到「兼容性最强」的顺序试 private static readonly int[] ModeCandidates = { 0, 1, 2, 101, 103 }; public bool TryBind(IntPtr hwnd) { foreach (var mode in ModeCandidates) { if (BindWindow(hwnd, new DmBindOptions { Mode = mode })) return true; Invoke("UnBindWindow"); // 上次失败可能留下半绑定状态,必须先解 } return false; }UnBindWindow这一步经常被漏掉。绑定失败后直接换参数再绑,插件内部可能残留上一次的部分状态,导致后面怎么试都失败,重启工具又好了,很容易被误判成「随机失败」。
还有个前提条件容易被忽略:目标程序如果是以管理员权限启动的,而你的自动化工具是普通权限,Windows 的 UIPI 机制会直接拦掉消息发送,绑定表面成功、键鼠完全没反应。这种时候两边权限必须对齐
本文还有配套的精品资源,点击获取