简介:面向C# WinForm开发者的.NET Core 5.0免注册调用大漠插件(dm.dll)完整示例包。实现在Windows 10下通过动态加载DLL和DllImport方式调用大漠插件的找图、找字、截图、模拟键盘打字等功能,规避系统注册权限问题,方便部署与集成。压缩包内共204个文件,以78个dll、40个bmp、15个cs源码、13个json配置、6个txt说明等为主,附带sln/csproj工程与pdb调试符号,整体约17.47MB。其中bmp模板覆盖“进入游戏”“江湖历练”等界面场景,dll包含大漠插件及运行依赖库。目前已有902人学习下载。通过这套资料可掌握免注册调用封装思路、完整WinForm工程结构、图像识别与自动化操作的接口实现,并参考其中配置与排错细节,适用于自动化测试、游戏辅助及桌面流程自动化等开发场景。
1. 免注册调用大漠插件:给 .NET 5 WinForms 的 COM 黑匣子开个口
我手里有一个编辑器自动化工具,原本跑在 .NET Framework 4.8 上,用 regsvr32 把大漠插件注册到系统里,再 CreateObject 一把拉起。迁到 netcore5.0 + WinForms 后,第一件事就翻车:运行直接报“COM 类未注册”。问题是目标机器不止一台,我不想每台都手动注册一遍,也不想把 Dm.dll 写进系统目录。这份工程做的事就是绕开注册表,在 Windows 10 上用 .NET Core 5.0 的 WinForms 程序直接加载大漠插件,把 dm.dll 放在 exe 目录就能跑。它适合做窗口自动化、模拟器辅助、UI 自动化测试脚本的人,也适合那些被“必现注册 COM 才能用”卡住的老项目迁移。
2. 技术选型与工程结构:RegFree COM 在 netcore5.0 下的落地方式
这一章先把选型讲清楚,后面看代码才不会一头雾水。
2.1 为什么 netcore 不能直接引用大漠
大漠插件的本质是一个原生 COM 服务端,Dm.dll 导出 DllGetClassObject、DllRegisterServer、DllUnregisterServer 这几个标准入口,调用方通过 ProgID 或 CLSID 拿对象。旧项目里用的是“注册式”调用:regsvr32 把 CLSID 写进 HKEY_CLASSES_ROOT,CLR 从注册表读到路径,然后加载 dll 并创建实例。
到了 netcore5.0 问题就来了。.NET Core 的工程模型里没有“添加 COM 引用”的常规入口,想通过 Type.GetTypeFromProgID 走老路,CLR 解析 ProgID 时依然会去注册表找,找不到就抛 REGDB_E_CLASSNOTREG。直接给 csproj 加 COMReference 也行不通,SDK 风格项目对 ActiveX 和 TLB 导入的支持非常有限,生成出来一堆警告还不一定能用。
更关键的是设计预期。自动化工具交付出去,客户机上不应该残留任何注册表痕迹,卸载就是删文件夹。所以免注册调用不是“偷懒方案”,而是可交付性的硬要求。常见做法是两条:一条走 SxS Activation Context(应用清单),另一条是跳过注册表,直接用 DllGetClassObject 导出函数拿类工厂。下面是我对这两条路的选择过程。
2.2 两条路线:SxS App manifest 与 DllGetClassObject
第一反应通常是写一个 app.manifest 声明 comClass,然后在进程启动时创建 Activation Context,让 Windows 把 dm.dll 按“并行程序集”方式装载。这套在 C++/MFC 下很成熟,但 winform 的 exe 本身已经有自己的 manifest(DPI awareness、requestedExecutionLevel),再塞 comClass 配置,经常出现其中一个失效。而且 .NET Core 宿主进程对这个 activation context 的传导时机很敏感,manifest 声明的时机稍不对,CLR 在 create object 时还是看不到这层 context,翻车率不低,排错也费劲。
我在这份工程里选的是更直接的第二条:用 P/Invoke 调 Dm.dll 的 DllGetClassObject,拿到 IClassFactory 后再创建 COM 对象。整个过程不查注册表,不依赖 manifest,加载路径完全由代码控制。对比是这样的:
| 对比项 | SxS manifest | DllGetClassObject 直接调 |
|---|---|---|
| 是否写注册表 | 否 | 否 |
| 是否需要 exe/actx | 是 | 否 |
| .NET Core 宿主兼容性 | 有踩坑风险 | 无特殊依赖 |
| 排错难度 | 中(会静默失败) | 低(HRESULT 可见) |
| 位数匹配要求 | 严格 | 严格 |
| 代码量 | 少但隐蔽 | 略多但透明 |
这套方案的代价是,得自己处理 COM 接口定义和调用细节。好在也就三个步骤:拉类工厂、创建实例、后续调用。下面先把这份工程的目录结构铺开,再进核心代码。
2.3 工程目录结构
整个工程是单项目结构,没有拆多个类库,跑通优先。
Demo.DmRegFree/ ├─ Demo.DmRegFree.sln ├─ src/ │ └─ Demo.DmRegFree/ │ ├─ Demo.DmRegFree.csproj │ ├─ Program.cs │ ├─ MainForm.cs │ ├─ MainForm.Designer.cs │ ├─ DmFactory.cs // 核心:DllGetClassObject 创建实例 │ ├─ NativeMethods.cs // P/Invoke 声明 │ ├─ ComInterfaces.cs // IClassFactory / IDmSoft 接口定义 │ ├─ app.manifest // 只保留 DPI 声明,不碰 comClass │ └─ libs/ │ ├─ dm.dll │ └─ dm.ini ├─ tools/ │ └─ check-arch.ps1 // 检查 dm.dll 是 x86 还是 x64 └─ README.mdDmFactory.cs 是核心,ComInterfaces.cs 是支撑,MainForm 只是用来验证调用效果的壳。这里有个设计细节:我没把原生调用逻辑散在窗体里,而是收敛到工厂类,这样后续要接日志、接异常处理都有固定位置。
3. 核心模块拆解:免注册调用从加载到完成一次点击的完整链路
这章是这份工程最值得读的部分。我会按调用顺序一步步拆,每一段代码后面说明我为什么这么写,以及常见的替换写法。
3.1 用 DllGetClassObject 拉出类工厂
COM 组件创建对象的经典路径是 CoCreateInstance,它内部会先查注册表找到 dll,再调 DllGetClassObject。免注册调用的思路就是把最后这一步自己做了:直接拿到 dll 里的导出函数,绕过注册表。
internal static class NativeMethods { // DllGetClassObject 是 COM 服务端必须实现的导出函数 [DllImport("dm.dll", EntryPoint = "DllGetClassObject", PreserveSig = true)] internal static extern int DllGetClassObject( ref Guid clsid, ref Guid riid, out IntPtr ppv); }这里两个关键点。第一是 PreserveSig 必须为 true,否则运行时默认把 HRESULT 转换成异常抛出,我们自己拿不到错误码,排错会少一条关键信息。第二是 EntryPoint 的字符串必须和 dll 导出表完全一致,大漠插件的导出名就是 DllGetClassObject,用 dumpbin 或者 CFF Explorer 都能确认。
有了这个声明,创建实例就是三步:准备 CLSID、调用导出函数、解包 IClassFactory。
public static object CreateDmInstance() { // 这个值取自你本机 dm.desc 里的 coclass DmSoft // 每台环境不同,必须按实际替换 Guid clsidDmSoft = new Guid("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"); // IID_IClassFactory 是 COM 标准常量,不能改 Guid iidClassFactory = new Guid("00000001-0000-0000-C000-000000000046"); // IID_IDispatch,我们要通过后期绑定调大漠方法 Guid iidIDispatch = new Guid("00020400-0000-0000-C000-000000000046"); int hr = NativeMethods.DllGetClassObject( ref clsidDmSoft, ref iidClassFactory, out IntPtr pFactory); if (hr < 0) { throw new COMException( $"DllGetClassObject failed, HRESULT=0x{hr:X8}, " + "确认 dm.dll 在应用目录且位数匹配", hr); } var factory = (IClassFactory)Marshal.GetObjectForIUnknown(pFactory); hr = factory.CreateInstance(IntPtr.Zero, ref iidIDispatch, out IntPtr pDm); if (hr < 0) { throw new COMException( $"CreateInstance failed, HRESULT=0x{hr:X8}", hr); } return Marshal.GetObjectForIUnknown(pDm); }参数说明:clsidDmSoft 是大漠组件类型库里的 CLSID,不是随便猜的,在 dm.desc 描述文件里搜索 coclass 就能找到,或者先在干净的机器上 regsvr32 注册一次,再从注册表里把 CLSID 抄出来。iidClassFactory 和 iidIDispatch 是 COM 规范定义的常量,任何组件都一样。
这个设计的核心收益,是把“这个组件没注册”的失败点变成显式异常而非隐式崩溃。HRESULT 是负数,直接打印成 hex 一眼就能看出是哪一步失败,不用像以前那样靠事件日志猜。
3.2 定义 COM 接口并拿到 IDispatch
拿到工厂后,需要定义 IClassFactory 接口来解包。这个接口是标准接口,Guid 固定,每个字段的意义也固定。
[ComImport] [Guid("00000001-0000-0000-C000-000000000046")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IClassFactory { [PreserveSig] int CreateInstance( IntPtr pUnkOuter, ref Guid riid, out IntPtr ppvObject); [PreserveSig] int LockServer([MarshalAs(UnmanagedType.Bool)] bool fLock); }这里有个容易翻车的细节:IClassFactory 的 CreateInstance 第一参数 pUnkOuter,聚合场景下传外层 IUnknown,不聚合就是 IntPtr.Zero。大漠组件不支持聚合,所以上面传了 IntPtr.Zero,这是符合 COM 规范的写法。
riid 我们传的是 IID_IDispatch,意思是“我要的是后期绑定接口”。大漠组件暴露的接口是 IDispatch 派生的,拿它做动态调用足够,不需要早期绑定。最后 Marshal.GetObjectForIUnknown 返回的托管对象,在 .NET 5 里是一个运行时生成的 COM Callable Wrapper,后续可以直接用 dynamic 语法调用。
3.3 用 InvokeMember 调大漠方法
拿到 IDispatch 后有两种调用姿势。第一种是 C# 4.0 开始的 dynamic 语法,代码最省:
dynamic dm = DmFactory.CreateDmInstance(); dm.SetPath(@"D:\script\res"); int hwnd = dm.FindWindow("", "记事本");前提是工程目标必须是 windows TFM(net5.0-windows),IDispatch 后期绑定在 .NET Core 3.0 之后的 Windows 平台原生支持,不用额外引包。
第二种是反射 InvokeMember,好处是能精确控制参数和异常:
Type dmType = dm.GetType(); object result = dmType.InvokeMember( "FindWindow", BindingFlags.InvokeMethod, null, dm, new object[] { "", "记事本" }); int hwnd = Convert.ToInt32(result);我在这份工程的实际代码里用的是 InvokeMember 封装一层,因为调用日志在 InvokeMember 上最好埋点。所有方法名都集中在一个静态类里定义成常量,比如:
internal static class DmMethod { public const string SetPath = "SetPath"; public const string FindWindow = "FindWindow"; public const string MoveTo = "MoveTo"; public const string LeftClick = "LeftClick"; }这样做的好处是把“大漠方法名”当成和 DllImport 入口一样的契约来管理,IDE 重命名、排查拼写错误都方便很多。
3.4 一次完整的“找窗口并点击”链路
把上面几段拼起来,就是这份工程里跑通的核心链路:初始化实例 → 设置资源路径 → 查找窗口句柄 → 移动鼠标 → 点击。
public static int FindAndClick(string windowClass, string windowTitle, int x, int y) { dynamic dm = DmFactory.CreateDmInstance(); // 资源路径指向 exe 目录,避免写死绝对路径 dm.SetPath(AppContext.BaseDirectory); // 第一个参数是窗口类名,空串表示不限定 int hwnd = (int)dm.FindWindow(windowClass, windowTitle); if (hwnd <= 0) { return hwnd; // 找不到窗口,返回 0 或负数 } dm.MoveTo(x, y); dm.LeftClick(); return hwnd; }逻辑并不复杂,但有三处值得说明。SetPath 是所有操作前的前置调用,大漠的找图找字资源目录都依赖这个路径,换成 AppContext.BaseDirectory 是为了 xcopy 部署时不用改代码。FindWindow 返回的 hwnd 是整数窗口句柄,大漠的相等判断在找不到窗口时返回 0 或者负值,所以判断用 <= 0 而不是 == 0。MoveTo 和 LeftClick 是同步阻塞调用,执行完再返回,不需要额外等待。
这个链路跑通后,后面接 FindStr 找字、Capture 截图、SendString 发送文本都只是新增方法名常量的事,核心对象的创建过程不需要再动。
4. 运行步骤:构建、复制 dll 与首跑验证
很多人拿到工程第一件事是直接 F5,然后被各种环境问题劝退。这里写清楚我实际跑通这套工程的步骤。
4.1 环境准备
你需要三样东西:Windows 10(1903 以后都可以);对应版本的 .NET 5 SDK;一份与你系统位数匹配的 Dm.dll。
需要注意的是位数。大漠插件常见发布包是 32 位动态库,也有 64 位版本。如果你手里的 dll 和工程的目标平台不一致,运行期会在 DllGetClassObject 或 CreateInstance 阶段报错。先检查 dll 的 PE 头,用系统自带 PowerShell 就能做:
# 从 PE 头读取 Machine 字段判断位数 $path = ".\libs\dm.dll" $bytes = [System.IO.File]::ReadAllBytes($path) $machine = [BitConverter]::ToUInt16($bytes, 0) # PE 偏移 0 处是 Machine if ($machine -eq 0x14c) { "x86 (32-bit)" } elseif ($machine -eq 0x8664) { "x64 (64-bit)" } else { "unknown architecture" }注意 PE 头的 Machine 字段在文件偏移 0 处,PE32 是 0x14c,PE32+ 是 0x8664。拿到结果后,工程平台目标必须和它一致。工程默认设置为 x86,如果手里的 dll 是 x64,把 csproj 里的 PlatformTarget 改成 x64 即可。
4.2 构建配置
csproj 是 SDK 风格,关键配置都在这里,全部贴出来:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net5.0-windows</TargetFramework> <UseWindowsForms>true</UseWindowsForms> <PlatformTarget>x86</PlatformTarget> <RuntimeIdentifier>win-x86</RuntimeIdentifier> <ApplicationManifest>app.manifest</ApplicationManifest> <Nullable>disable</Nullable> </PropertyGroup> <ItemGroup> <None Include="libs\dm.dll"> <Link>dm.dll</Link> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Include="libs\dm.ini"> <Link>dm.ini</Link> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup> </Project>三个设置是关键。TargetFramework 写死 net5.0-windows,而不是 net5.0,这样 WinForms 和 IDispatch 动态调用同时生效。RuntimeIdentifier 和 PlatformTarget 都锁 x86,避免 AnyCPU 在 64 位系统里自动变成 x64 进程,导致加载 32 位 dll 失败。None Include 的 Link 让 dm.dll 和 dm.ini 在输出目录里和 exe 平级,DllImport 的默认搜索路径会优先找 exe 所在目录,这样发布会方便。
4.3 首跑验证清单
构建成功不代表调用成功。我按三个层次验证,每一层都对应一类常见失败:
| 验证项 | 预期结果 | 失败时看什么 |
|---|---|---|
| 程序启动 | 窗体出现,无未注册异常 | 看 DmFactory 抛的 HRESULT |
| 点击绑定按钮 | 返回的 hwnd 大于 0 | 窗口标题是否匹配,管理员权限 |
| 执行移动+点击 | 目标窗口内鼠标动作生效 | 检查 SetPath 路径存在性 |
启动异常时,错误信息里是 0x80040154 就是还没走到 DllGetClassObject,大概率 dll 没复制;如果是负数且按 hex 翻译后是 MODLOAD 类错误,基本就是位数不匹配。用上面表格能快速定位。
5. 避坑记录:免注册调用大漠最常见的五个坑
这类项目翻车点集中在加载和调用边界上,我把实际踩过的坑按“现象 → 原因 → 解决”整理一遍。
5.1 运行就报 FileNotFoundException:dm.dll 没被复制
现象:程序启动第一行代码就抛 DllNotFoundException,异常信息写的是“无法加载 DLL‘dm.dll’”。
原因:DllImport 的解析顺序是:exe 目录 → 系统目录 → PATH。如果你只是在 Visual Studio 里打开工程直接 F5,而工程文件里没配 CopyToOutputDirectory,输出目录里根本没有 dm.dll。很多人会把 dll 放在项目根目录,以为调试时会自动复制。
解决:csproj 里加 None Include 并设 CopyToOutputDirectory,或者把 dm.dll 手工复制到 bin\Debug\net5.0-windows\ 下。从这之后我设置任何原生依赖,都会在工程里用 Link 方式统一管理,不再依赖手工复制。
5.2 报 REGDB_E_CLASSNOTREG:说明没走对加载路径
现象:按网上老教程写 Type.GetTypeFromProgID("dm.dmsoft") 或 CreateObject,运行时报“没有可用的分类对象”,也就是 0x80040154。
原因:GetTypeFromProgID 走的是注册表查询,机器上没注册,CLR 找不到 CLSID。免注册这条路不是让 ProgID 能用,而是整个调用体系就不该再依赖 ProgID。
解决:改成 DmFactory.CreateDmInstance 这套 DllGetClassObject 路径,CLSID 直接硬编码在代码里。从那以后我再没在代码里写过 ProgID 字符串。
5.3 一调用就 AccessViolation:位数不匹配
现象:CreateInstance 能成功,但第一次调 FindWindow 或者 SetPath 时进程直接崩溃,事件查看器里是访问冲突。
原因:工程编译成了 x64,而你手上的 dm.dll 是 32 位。CLR 加载了 32 位原生模块,但进程本身是 64 位,COM 调用时接口指针传参位数不一致,触发非法内存访问,而且这种崩溃不一定报托管异常。
解决:用 check-arch.ps1 先确认 dll 位数,再检查工程属性。只要原生依赖是 32 位,PlatformTarget 和 RuntimeIdentifier 必须同步变成 x86,不能只改一个。
5.4 FindWindow 返回 0:大漠初始化顺序问题
现象:第一次调用 FindWindow,返回 0 或负数;程序里多试几次又能找到窗口,看起来像“时好时坏”。
原因:大漠的窗口查找依赖系统枚举窗口的状态,而窗口标题变化需要一个微小的时间窗口。比如目标程序还在启动中,窗口标题还没设置完。另一个常见原因是 SetPath 没在 FindWindow 之前执行,导致内部状态未初始化。
解决:调用前先 SetPath,然后对查找结果做轮询等待,而不是单次断言。实际代码里我写成“最多尝试五次,每次间隔 200ms”的循环,找到即返回。这个模式也可以推广到找图找色,所有大漠查询类接口都值得加一层重试。
5.5 发布后不能用:RuntimeIdentifier 与 xcopy 部署不匹配
现象:本机调试一切正常,dotnet publish 之后换一台干净机器双击 exe 报找不到模块。
原因:调试时 dll 由工程输出目录复制;发布时如果选的是 self-contained,dotnet publish 输出的是按 RID 分发的独立目录,如果 dm.dll 保留了 libs 子目录结构、没 Link 到根目录,运行时 DllImport 找不到。
解决:publish 后手动确认 dm.dll 和 exe 在同一目录。更稳的做法是把 None Include 的 Link 写成 dm.dll,发布的输出会直接落在根目录下。从那以后我每次发布完都会打开 publish 目录看一眼原生依赖在不在,不依赖“应该没问题”的直觉。
6. 二次开发:把免注册调用封装成可复用的 DmService
核心链路跑通之后,剩下的问题是如何把它放进生产级代码。直接在每个窗体里 new 一个 DmFactory 肯定不行,大漠插件的全局路径是有状态的,多处实例会导致路径互相覆盖。我会在工程里再加一层 DmService,把实例生命周期和异常处理收拢到一起。
6.1 封装成 DmService:懒加载与异常日志
大漠实例有两点适合做单例:SetPath 的全局路径,以及窗口句柄重复查询的缓存价值。用 Lazy 初始化天然线程安全,第一次访问时才创建 COM 对象。
public sealed class DmService : IDisposable { private static readonly Lazy<DmService> _lazy = new Lazy<DmService>(() => new DmService()); public static DmService Instance => _lazy.Value; private readonly object _dm; private DmService() { _dm = DmFactory.CreateDmInstance(); } public T SafeInvoke<T>(Func<object, T> call, T fallback = default) { try { return call(_dm); } catch (Exception ex) { Console.WriteLine($"[dm] {ex.GetType().Name}: {ex.Message}"); return fallback; } } public void Dispose() { Marshal.FinalReleaseComObject(_dm); } }SafeInvoke 的意义在于把 COM 异常和业务异常统一处理:调用方不用每个地方都包 try/catch,只需要提供一个默认值兜底。比如 FindWindow 找不到窗口返回 -1 就是合理兜底,不需要抛异常打断流程。
6.2 调用方写法
改造后的窗体代码变得很干净:
int hwnd = DmService.Instance.SafeInvoke( dm => (int)dm.FindWindow("", "目标程序"), -1); if (hwnd > 0) { DmService.Instance.SafeInvoke(dm => dm.MoveTo(300, 200)); DmService.Instance.SafeInvoke(dm => dm.LeftClick()); }每个调用都是两行以内,日志、兜底、实例管理都在 DmService 内部。
6.3 验证方式:内嵌自测命令
工程入口里我加了一个自测参数,作为交付前验证手段:
if (args.Length == 1 && args[0] == "--selftest") { var dm = DmService.Instance; string ver = dm.SafeInvoke(q => q.Ver()?.ToString(), "unknown"); Console.WriteLine($"dm core ver: {ver}"); }Ver 是大漠插件自带的版本查询接口。新环境部署完,命令行跑一次 exe --selftest,能打出版本号就说明 DllGetClassObject、位数、文件复制三件事全对。这套自测脚本后来我接到每个自动化项目里,比人工开界面点按钮快得多。
从那以后,我每次把 dm.dll 从一个环境复制到另一个环境,都会先跑一遍自测再往下写业务逻辑,这个习惯帮我过滤掉了至少一半的环境类报错。希望帮到你。
本文还有配套的精品资源,点击获取