
简介这份源码面向具备一定C#基础的WPF开发者聚焦插件式架构中DLL动态加载这一常见难点提供可直接套用的模板工程。它演示了如何通过反射在运行时发现并加载外部程序集涵盖插件目录扫描、Assembly.LoadFrom加载、类型筛选与实例化、插件注册调用以及异常处理等关键环节帮助读者理解主程序与插件解耦的设计思路并规避加载失败导致崩溃的风险。压缩包共135个文件以cs源码、dll程序集、pdb调试符号、csproj工程文件及config配置为主另含xaml界面与sln解决方案整体约477KB结构完整便于对照阅读。目前已有227人学习下载。借助这份示例读者可快速掌握插件接口定义、反射创建实例与插件容器管理的写法并将其迁移到自己的桌面应用中提升软件的可扩展性与灵活性。1. 插件式 DLL 动态加载为什么你的 WPF 主程序不该在编译期认识任何插件做过 WPF 桌面项目的同学大概率遇到过这种需求主程序上线之后客户突然说想加一个报表导出模块或者某个业务线要单独换一套界面。如果所有功能都硬编码在主程序里每次加功能都得重新编译、重新发版用户还得卸载重装。插件式 DLL 动态加载就是解决这个问题的主程序只定义接口具体功能由外部 DLL 实现运行时通过反射把 DLL 加载进来、找到实现类、创建实例、调用方法。整个过程主程序在编译期完全不需要引用那些 DLL。这套方案适合谁适合正在做 WPF 桌面工具、上位机、内部管理系统且功能模块需要按客户或按场景灵活增减的开发者。它不适合那种功能固定、永远不扩展的小工具因为引入插件机制本身有维护成本。但只要你预感到「以后还要加模块」提前把插件框架搭好后面每加一个功能就是新建一个类库项目、实现接口、丢到插件目录主程序一行不用改。这篇就按「接口怎么定 → DLL 怎么加载 → 反射怎么用 → 坑在哪」的顺序把一套可以直接当模板用的 WPF 插件式 DLL 动态加载方案讲透。2. 接口契约与项目结构插件方案能不能长期维护这一步就定了2.1 为什么必须把接口抽到独立程序集很多人第一次做插件习惯把接口定义在主程序 exe 里插件项目引用主程序。这样做编译能过但会带来一个致命问题插件 DLL 间接依赖了主程序 exe一旦主程序更新所有插件都得重新编译。更麻烦的是如果主程序和插件互相引用Visual Studio 会直接报循环依赖。正确做法是新建一个独立的类库项目通常叫Plugin.Contracts或MyApp.SDK里面只放接口和共享的数据模型不引用任何 WPF 程序集。主程序引用它每个插件项目也引用它。这样主程序和插件之间只通过这个契约程序集耦合双方都可以独立编译、独立发版。项目结构大致是这样Solution ├── MyApp.Host (WPF 主程序输出 exe) ├── MyApp.Contracts (类库只放接口和模型) ├── Plugin.ExportReport (类库实现导出报表插件) ├── Plugin.DataMonitor (类库实现数据监控插件) └── Plugins/ (运行时插件 DLL 存放目录)主程序编译输出到bin\Debug插件项目编译后把 DLL 拷贝到主程序目录下的Plugins文件夹。这个拷贝动作可以手动做也可以在插件项目的生成后事件里写xcopy命令自动完成。2.2 接口怎么定义才够用又不臃肿接口定义是整个插件框架的核心。定义得太少插件拿不到主程序的能力定义得太多每个插件都被迫实现一堆用不到的方法。我一般会拆成两个接口一个描述插件本身的元信息一个描述插件能做什么。// MyApp.Contracts/IPlugin.cs namespace MyApp.Contracts { /// summary /// 所有插件必须实现的基础接口提供插件元信息 /// /summary public interface IPlugin { // 插件显示名称用于菜单或列表展示 string Name { get; } // 插件版本号便于排查问题时确认加载的是哪个版本 string Version { get; } // 插件作者或团队标识 string Author { get; } // 插件初始化入口主程序加载后立即调用 // host 参数提供主程序暴露给插件的服务 void Initialize(IHostContext host); // 插件卸载前的清理入口用于释放资源 void Shutdown(); } /// summary /// 主程序暴露给插件的上下文插件通过它访问主程序能力 /// /summary public interface IHostContext { // 主窗口句柄插件需要弹窗时可以作为 Owner object MainWindow { get; } // 主程序日志接口插件统一走这里输出日志 void Log(string message); // 插件之间或插件与主程序之间传递消息的通道 void Publish(string topic, object payload); } }这段代码里IPlugin的Initialize方法接收一个IHostContext这是关键设计。插件不应该直接引用主程序的窗口类或服务类而是通过这个上下文接口拿到它需要的东西。MainWindow用object类型而不是Window类型是为了让Contracts项目不依赖 WPF 程序集保持纯净。插件项目自己引用 WPF需要弹窗时把object转成Window即可。Shutdown方法很多人会忽略但实际项目中插件可能持有文件句柄、数据库连接、定时器不提供卸载入口就会导致主程序退出时资源泄漏。哪怕当前用不到接口里先留着后面加插件时不会因为改接口导致所有插件重新编译。2.3 插件项目怎么引用契约并输出到统一目录新建一个类库项目Plugin.ExportReport引用MyApp.Contracts然后实现IPlugin// Plugin.ExportReport/ExportReportPlugin.cs using MyApp.Contracts; namespace Plugin.ExportReport { public class ExportReportPlugin : IPlugin { private IHostContext _host; public string Name 报表导出插件; public string Version 1.0.0; public string Author 内部工具组; public void Initialize(IHostContext host) { _host host; _host.Log(${Name} 初始化完成版本 {Version}); } public void Shutdown() { _host?.Log(${Name} 正在卸载); _host null; } // 插件自己的业务方法主程序通过反射调用 public string ExportToCsv(string tableName) { // 实际导出逻辑省略这里返回模拟结果 return $已导出表 {tableName} 到 CSV; } } }编译这个项目把生成的Plugin.ExportReport.dll拷贝到主程序的Plugins目录。注意MyApp.Contracts.dll不需要拷贝到Plugins因为主程序目录下已经有了插件运行时会从主程序目录加载契约程序集。如果插件还依赖了第三方库那些库需要一起拷贝到Plugins或者主程序能探测到的路径否则反射创建实例时会抛FileNotFoundException。提示插件项目的目标框架要和主程序一致。主程序是 .NET 6 就都用 .NET 6主程序是 .NET Framework 4.8 就都用 4.8。混用框架版本是反射加载失败最常见的原因之一。3. 反射加载 DLL 的完整实现从扫描目录到调用方法3.1 用 Assembly.LoadFrom 加载插件程序集主程序启动时需要扫描Plugins目录下所有 DLL逐个加载并检查里面有没有实现IPlugin的类型。核心 API 是Assembly.LoadFrom它接受一个文件路径返回Assembly对象。// MyApp.Host/PluginLoader.cs using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using MyApp.Contracts; namespace MyApp.Host { public class PluginLoader { private readonly ListIPlugin _plugins new ListIPlugin(); private readonly string _pluginDir; public PluginLoader(string pluginDir) { _pluginDir pluginDir; } public IReadOnlyListIPlugin Plugins _plugins; public void LoadAll(IHostContext host) { if (!Directory.Exists(_pluginDir)) { Directory.CreateDirectory(_pluginDir); return; } // 只扫描 dll 文件忽略 pdb、xml 等附属文件 var dllFiles Directory.GetFiles(_pluginDir, *.dll); foreach (var dllPath in dllFiles) { try { // LoadFrom 会加载程序集及其依赖 var assembly Assembly.LoadFrom(dllPath); // 查找所有实现 IPlugin 且非抽象类的类型 var pluginTypes assembly.GetTypes() .Where(t typeof(IPlugin).IsAssignableFrom(t) !t.IsAbstract !t.IsInterface); foreach (var type in pluginTypes) { // 创建实例并初始化 var plugin (IPlugin)Activator.CreateInstance(type); plugin.Initialize(host); _plugins.Add(plugin); } } catch (ReflectionTypeLoadException ex) { // 部分类型加载失败时记录具体是哪些类型出了问题 var loaderMessages ex.LoaderExceptions .Select(e e?.Message) .Where(m m ! null); host.Log($加载 {Path.GetFileName(dllPath)} 时部分类型失败{string.Join(; , loaderMessages)}); } catch (Exception ex) { // 单个插件加载失败不应影响其他插件 host.Log($加载 {Path.GetFileName(dllPath)} 失败{ex.Message}); } } } public void UnloadAll() { foreach (var plugin in _plugins) { try { plugin.Shutdown(); } catch (Exception ex) { // 卸载失败只记录不抛出 System.Diagnostics.Debug.WriteLine($插件卸载异常{ex.Message}); } } _plugins.Clear(); } } }这段代码有几个关键点。第一Assembly.LoadFrom会加载目标程序集及其依赖项如果插件依赖的 DLL 不在同目录或主程序目录下会抛异常。第二assembly.GetTypes()在某些情况下会抛ReflectionTypeLoadException这个异常本身包含部分成功加载的类型但更稳妥的做法是捕获它并记录LoaderExceptions方便定位是哪个依赖缺失。第三每个插件的加载都包在 try-catch 里一个插件坏了不能影响其他插件这是插件框架的基本素养。3.2 通过反射调用插件方法插件加载进来之后主程序怎么调用插件里的业务方法如果主程序在编译期就知道方法名那说明主程序和插件耦合了失去了插件化的意义。实际做法是主程序通过反射按方法名调用或者插件在Initialize时把自己的功能注册到主程序的某个命令表里。先看反射调用的写法// 在主程序某个按钮事件里调用插件的 ExportToCsv 方法 public string InvokePluginMethod(IPlugin plugin, string methodName, params object[] args) { // 获取插件实例的实际类型 var type plugin.GetType(); // 按名称查找公共实例方法忽略大小写 var method type.GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); if (method null) { return $插件 {plugin.Name} 上找不到方法 {methodName}; } try { // 反射调用args 是方法参数数组 var result method.Invoke(plugin, args); return result?.ToString() ?? 方法执行完成无返回值; } catch (TargetInvocationException ex) { // 反射调用时插件内部异常会被包装成 TargetInvocationException // 真正有用的信息在 InnerException 里 return $插件方法执行异常{ex.InnerException?.Message ?? ex.Message}; } }这里有个血泪经验反射调用时如果插件方法内部抛异常外层捕获到的是TargetInvocationException真正的异常在InnerException里。很多新手只打印ex.Message看到的是「Exception has been thrown by the target of an invocation」这种毫无信息量的提示排查半天找不到原因。一定要取InnerException。另一种更优雅的做法是让插件在初始化时注册命令。在IHostContext里加一个RegisterCommand(string name, Actionobject[] handler)方法插件把自己的功能注册进去主程序通过命令名调用完全不需要反射查找方法。这种方式性能更好类型也更安全但灵活性稍低适合功能相对固定的场景。两种方式可以混用核心功能走命令注册扩展功能走反射调用。3.3 插件目录扫描与热加载的边界有些场景要求主程序运行期间不重启就能加载新插件。理论上可以用AppDomain或 .NET Core 的AssemblyLoadContext实现卸载和重新加载但实际做起来限制很多。AssemblyLoadContext卸载要求程序集没有任何存活的引用插件里只要有一个静态变量、一个事件订阅没取消卸载就会失败。我一般会告诉团队热加载可以做但不要把它当成核心需求。更务实的做法是主程序启动时加载全部插件运行期间不卸载需要更新插件时提示用户重启主程序。如果确实要做热加载用AssemblyLoadContext的isCollectible: true参数创建可回收上下文卸载时调用Unload()然后强制 GC 等待回收。但要做好心理准备调试阶段可能会遇到「卸载了但内存没释放」的玄学问题大部分情况是插件里的事件订阅或定时器没清理干净。注意Assembly.LoadFrom加载的程序集会锁定 DLL 文件运行期间无法覆盖或删除。如果插件需要更新必须先卸载或重启主程序。用Assembly.Load(File.ReadAllBytes(path))可以避免文件锁定但会失去依赖自动解析的能力需要自己处理依赖加载。4. 避坑与排查插件加载失败时先看这五个地方4.1 现象插件 DLL 明明在目录里但主程序一个都没加载到原因通常有三种。第一种是Directory.GetFiles的路径不对比如主程序工作目录和 exe 所在目录不一致Plugins相对路径解析到了别的地方。第二种是 DLL 里没有实现IPlugin的公共非抽象类比如实现类被写成了internal或者接口引用的是另一个版本的Contracts程序集。第三种是Assembly.LoadFrom抛了异常但被 catch 吞掉了日志没输出。解决先把_pluginDir打印出来确认绝对路径再用assembly.GetTypes()遍历时把每个类型名都打日志确认实现类是否被识别。如果是接口版本不一致检查主程序和插件引用的MyApp.Contracts.dll是不是同一个文件比较两者的文件版本或哈希值。4.2 现象抛 FileNotFoundException提示找不到某个依赖程序集原因插件项目引用了第三方库但那个库的 DLL 没有拷贝到主程序能探测到的目录。Assembly.LoadFrom加载插件时会在插件所在目录和主程序目录查找依赖找不到就抛异常。解决把插件依赖的所有非框架 DLL 都拷贝到Plugins目录或者统一放到主程序目录。可以在插件项目的.csproj里设置CopyLocalLockFileAssemblies为true确保依赖被复制到输出目录。更省事的做法是写一个生成后事件把插件输出目录的所有 DLL 拷贝到主程序的Plugins文件夹。4.3 现象反射调用方法时抛 TargetInvocationException看不到真实错误原因反射调用包装了插件内部的异常直接看ex.Message只能看到「调用的目标发生了异常」。解决始终取ex.InnerException来获取真实异常信息。在日志里同时打印InnerException.StackTrace否则只能知道出错不知道哪一行出错。如果InnerException为 null再回退到ex.ToString()打印完整堆栈。4.4 现象插件初始化时弹窗报错提示「调用线程必须为 STA」原因WPF 的 UI 元素要求 STA 线程。如果插件在Initialize里直接创建Window或调用MessageBox.Show而Initialize是在主程序启动的某个非 UI 线程上调用的就会抛这个异常。解决确保插件加载和初始化都在 UI 线程上执行。如果主程序在Application.Startup事件里加载插件那本身就是 UI 线程没问题。如果是在后台线程加载需要用Dispatcher.Invoke切回 UI 线程再调用插件的Initialize。4.5 现象插件卸载后重新加载报「程序集已加载」或行为异常原因Assembly.LoadFrom加载的程序集一旦进入当前应用程序域就无法真正卸载。即使调用了Shutdown程序集仍然驻留内存。再次加载同路径 DLL 时运行时可能返回缓存的程序集导致新版本不生效。解决如果不需要热更新忽略这个问题重启主程序即可。如果需要热更新改用AssemblyLoadContext并设置isCollectible: true每次加载创建新的上下文卸载时调用Unload()并等待 GC。但要注意清理插件里的静态引用和事件订阅否则上下文无法回收。5. 进阶技巧用特性标记插件元数据让加载器更聪明前面插件元数据是写在IPlugin属性里的每次加载都要创建实例才能读到名称和版本。如果插件很多或者需要在加载前做筛选这种方式效率不高。更优雅的做法是定义一个PluginAttribute让插件类用特性标注元数据加载器先读特性再决定是否实例化。// MyApp.Contracts/PluginAttribute.cs using System; namespace MyApp.Contracts { [AttributeUsage(AttributeTargets.Class, AllowMultiple false)] public class PluginAttribute : Attribute { public string Name { get; } public string Version { get; } public string Author { get; } public PluginAttribute(string name, string version, string author) { Name name; Version version; Author author; } } }插件类上标注[Plugin(报表导出插件, 1.0.0, 内部工具组)] public class ExportReportPlugin : IPlugin { // ... }加载器里先读特性再决定是否创建实例var attr type.GetCustomAttributePluginAttribute(); if (attr null) { // 没有标记特性的类型跳过不实例化 continue; } // 可以在这里按名称或版本做筛选 host.Log($发现插件{attr.Name} v{attr.Version} by {attr.Author}); var plugin (IPlugin)Activator.CreateInstance(type); plugin.Initialize(host);这样做的好处是加载器可以在不创建实例的情况下获取插件信息用于展示插件列表、做版本兼容检查、按需加载。比如主程序可以只加载版本号大于某个值的插件或者根据用户权限只加载特定插件。另一个技巧是在IPlugin里加一个bool CanLoad(IHostContext host)方法插件自己判断当前环境是否满足运行条件返回 false 就跳过初始化。这样插件可以把「需要管理员权限」「需要特定数据库连接」这类条件写在自身主程序不需要硬编码判断逻辑。我自己的习惯是契约程序集里只放接口、特性和数据模型不放任何实现代码主程序里插件加载器单独一个类不跟业务逻辑混在一起每个插件项目独立解决方案文件夹编译输出统一到Plugins目录。这套结构跑了三年多加插件就是新建项目、实现接口、拷贝 DLL主程序从来没因为加插件改过一行代码。希望帮到你。本文还有配套的精品资源点击获取