
简介基于WPF的插件式DLL动态加载示例面向有一定C#基础、希望掌握反射与插件架构的桌面应用开发者。源码通过Assembly.LoadFrom、GetTypes、Activator.CreateInstance等API演示了运行时发现并加载外部DLL、匹配插件接口并调用实例的完整过程让主程序保持精简同时可按需扩展功能。压缩包共135个文件包含34个C#源文件、XAML界面文件、工程文件与配置文件另有编译生成的DLL、PDB调试符号及缓存文件整体仅477KB结构紧凑适合作为模板直接改造。已有227人学习下载。源码还覆盖了插件注册容器、异常处理与AppDomain卸载等关键细节并附带可运行的演示项目可在Visual Studio中打开对照调试将其中思路迁移到自有工具或业务系统即可快速获得插件化扩展能力无需重写核心框架。对于需要热插拔模块或第三方扩展的桌面应用这套代码提供了清晰参考。1. 把插件式 DLL 动态加载弄明白用反射在 WPF 里搭一套可插拔架构插件式 DLL 动态加载核心是让 WPF 主程序在运行时通过反射扫描插件目录、识别契约接口并实例化外部组件从而做到「主程序一直不动新功能由 DLL 带进来」。这个资源把整条链路收在一个模板工程里契约接口、目录扫描、反射装载、实例注册、WPF 菜单绑定一应俱全适合想给桌面应用做模块化拆分又不想引入 Prism 这类重框架的 C# 开发者。新手能照模板跑通第一个插件熟手可以直接拿它当项目骨架继续改。需要说明的是资源里插件装载用的还是 System.Reflection 这一套原生能力没有引入第三方容器这意味着你拿到的是一条没有黑匣子的完整链路每一步都能跟代码对上。2. 插件契约先行先定 IPlugin 接口再写功能代码插件架构立得住第一件事不是写 Assembly.LoadFrom而是定契约。契约是主程序和插件之间的「共同语言」通常用一个接口或抽象基类表达。这个资源里 PluginsTest 工程之所以能在一堆 DLL 之间互相认前提是所有插件 DLL 都在编译期引用了同一个契约程序集运行时再用反射核查类型。如果一开始没定好接口后面每个插件的函数签名都各写各的主程序根本没办法统一调度。2.1 契约接口把插件边界收敛在三个成员里契约接口是整个插件系统的最小公约数。我把这份资源里的插件交互方式提炼成一个精简版本用 Name 标识插件、用 Version 做加载过滤、用 GetControl 返回 WPF 界面元素。这样做的好处是主程序不关心插件内部怎么实现只需要知道「它叫什么、版本多少、界面怎么取」。// PluginContract.cs — 契约程序集主程序和插件都引用它 namespace PluginMeter.Contract { /// summary所有插件必须实现的最小契约/summary public interface IPlugin { /// summary插件显示在 WPF 菜单上的名字/summary string Name { get; } /// summary插件版本号用于加载时的兼容性过滤/summary string Version { get; } /// summary主程序调用入口返回承载插件 UI 的元素/summary System.Windows.UIElement GetControl(); } }Name 和 Version 在这里不是摆设。Name 会直接绑定到 WPF 菜单项Version 用于加载时比对主程序期望的版本区间避免老旧插件在升级后被硬塞进来。GetControl 返回 UIElement意味着插件不只是后台逻辑它能直接产出一段 WPF 界面这正好贴合资源标题里「WPF 应用」的场景。如果插件不需要界面返回 null 也行但模板工程里通常约定每个插件必须能给出自己的界面否则菜单点击后没有内容可展示。2.2 扫描插件目录用 Directory 枚举候选 DLLDLL 通常集中在主程序旁边的 Plugins 子目录。扫描用 System.IO 里的 Directory.GetFiles 就够不需要引入第三方库。这里要留意的细节是搜索模式用 *.dll、SearchOption 用 TopDirectoryOnly因为子目录里的 DLL 往往是插件自身的依赖不该被当作插件候选否则会把一堆第三方类库误判成插件。string pluginDir Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Plugins); if (!Directory.Exists(pluginDir)) Directory.CreateDirectory(pluginDir); // 只枚举顶层 DLL避免把插件自身的依赖也当成插件 string[] dllFiles Directory.GetFiles(pluginDir, *.dll, SearchOption.TopDirectoryOnly); foreach (string dll in dllFiles) { // 这里只拿到文件路径真正的装载交给下一步 TryLoadPlugin(dll); }TopDirectoryOnly 这个参数是第一个容易出错的地方很多新手直接把搜索层级写成 AllDirectories结果插件导入成功后反射判断阶段全是「某类型不是有效插件」的噪音日志。扫描阶段只负责把候选 DLL 路径收集出来判断交给反射阶段做两者职责分开更清晰。另外在正式项目里我不会把插件目录写死在代码里而是放在 app.config 或用 WPF 的 Settings 体系管理这样部署时可以灵活改路径不必重新编译主程序。2.3 反射装载LoadFrom 与 LoadFile 的选型差异扫描拿到路径后要用 Assembly.LoadFrom 或 Assembly.LoadFile 把程序集装进当前 AppDomain。两者的差异在路径解析上LoadFrom 会结合调用方的基目录解析相对路径并且会触发 LoadFrom 上下文探测遇到插件依赖的附加 DLL 时能顺藤摸瓜找到LoadFile 只认绝对路径不会走上下文探测依赖解析时容易因找不到相邻 DLL 而抛异常。日常插件加载我习惯用 LoadFrom因为它在依赖解析上更宽容。Assembly asm Assembly.LoadFrom(fullPath); foreach (Type type in asm.GetTypes()) { // 判断类型是否实现契约接口且不是抽象类或接口本身 if (typeof(IPlugin).IsAssignableFrom(type) !type.IsAbstract !type.IsInterface) { IPlugin plugin (IPlugin)Activator.CreateInstance(type); if (plugin ! null) { pluginDict[plugin.Name] plugin; } } }GetTypes 返回程序集里所有类型IsAssignableFrom 用来检查「这个 type 是不是 IPlugin 的实现」。要同时排除抽象类和接口否则 Activator.CreateInstance 会抛 MissingMethodException。Activator.CreateInstance 默认调用无参构造函数所以插件类必须提供公开的无参构造哪怕里面只做初始化这个约束要写进插件开发规范里。另外值得注意asm.GetTypes() 在程序集损坏或元数据不一致时可能抛 ReflectionTypeLoadException实际项目中我会单独封装这个调用并做容错避免单个坏 DLL 导致主程序崩溃。到这一步反射装载链路已经完整扫描目录 → LoadFrom 装程序集 → GetTypes 取类型集合 → IsAssignableFrom 过滤 → CreateInstance 出实例。下一步是把这些实例接进 WPF 界面这才是插件架构里真正体现设计功底的地方。3. 把插件接进 WPF 界面容器管理、菜单绑定与通信方式插件实例化之后不能散落在内存里得有个集中管理者。资源里的 Core.PluginMeterManager 大概就承担这个角色。管理器的核心是一个 Dictionarystring, IPlugin键是插件名值是实例。主程序后面想调哪个插件按名字取即可不需要再次走反射。这一层做厚了主程序的其他模块才能稳定地访问插件。3.1 插件管理器用 Dictionary 登记插件实例管理器要做三件事扫描目录并加载插件、以名称索引保存实例、对外提供按名查找的接口。我在封装这类管理器时会把单个插件的加载过程包进独立方法并用 try-catch 隔离开。插件是第三方写的谁也不能保证它的构造函数不抛异常更别说 DLL 本身可能损坏。让一个坏插件拖垮整个加载流程这个责任最后还是要主程序开发者来背。public class PluginManager { // 键插件 Name值实例化后的插件对象 private readonly Dictionarystring, IPlugin _plugins new(); public void LoadPlugins(string pluginDir) { foreach (string dll in Directory.GetFiles(pluginDir, *.dll, SearchOption.TopDirectoryOnly)) { try { var asm Assembly.LoadFrom(dll); foreach (var type in asm.GetTypes()) { if (typeof(IPlugin).IsAssignableFrom(type) !type.IsAbstract !type.IsInterface) { var plugin (IPlugin)Activator.CreateInstance(type); _plugins[plugin.Name] plugin; } } } catch (Exception ex) { // 单个插件失败不能拖垮整个主程序记录后继续 Debug.WriteLine($[PluginManager] 加载 {dll} 失败: {ex.Message}); } } } public IPlugin GetPlugin(string name) _plugins.TryGetValue(name, out var p) ? p : null; }这里的关键设计是 try-catch 要包住整个循环体内的逻辑而不是只包住 CreateInstance。因为 GetTypes 也可能抛异常LoadFrom 也可能因为依赖缺失而失败任何一个环节出问题都应该跳过当前 DLL 继续下一个。Debug.WriteLine 在开发时能输出到输出窗口但在正式部署里要换成日志组件后面我会专门讲日志埋点。这个容器不涉及 UI 线程加载过程是同步的插件数量少时不会卡界面如果插件数量上了几十个再考虑用 Task.Run 配合 Dispatcher 切回 UI 线程。3.2 WPF 菜单动态生成从插件名到 UIElement插件加载完毕下一步是把入口暴露给用户。WPF 里最直接的做法是用 Menu 控件动态生成 MenuItem资源里的 MainWindow.baml 应该就是配合这个机制做界面承载。如果项目用了 MVVM则更顺手把插件名集合放进 ObservableCollection 通过 ItemsSource 绑定到菜单点击时再到 PluginManager 取实例。// MainWindow.xaml.cs 中加载后刷新菜单 ObservableCollectionstring pluginNames new(manager.GetAllNames()); // 菜单项点击事件内部取出插件实例并挂到内容区 private void OnPluginMenuClick(object sender, RoutedEventArgs e) { if (sender is not MenuItem item || item.Header is not string name) return; var plugin manager.GetPlugin(name); if (plugin null) return; // 把插件返回的 UIElement 塞进主内容区 ContentArea.Children.Clear(); ContentArea.Children.Add(plugin.GetControl()); }ContentArea.Children.Clear 先清空再添加保证同一时刻只有一个插件界面在显示。如果让多个插件界面叠在一起WPF 不会报错但用户看到的是控件堆叠视觉上直接翻车。这里还有个细节MenuItem.Header 拿到的对象类型是 object直接 ToString 在某些控件上可能拿到的是空的所以稳妥的写法是绑定一个插件名对象点击时再做显式转换。另外如果插件里做了耗时操作比如连接仪表读取数据GetControl 里不应该阻塞 UI 线程否则菜单点下去界面会卡住这个约束要在插件开发文档中写明。3.3 插件与主程序通信Initialize 注入优于全局单例插件要读写主程序的公共数据时不能直接引用主程序 EXE那会让插件和主程序强耦合。常见的做法是把共享数据包装成服务接口由主程序实现后通过构造函数或属性注入传给插件另一种是事件驱动插件抛事件、主程序订阅。模板工程里如果插件只是独立模块直接调 IPlugin 接口就够了不需要引入消息总线。但当插件需要读主程序配置或写日志时Initialize 注入是更干净的方式。public interface IPlugin { string Name { get; } // 主程序在加载后调用把共享服务交给插件 void Initialize(ISharedServices services); } public interface ISharedServices { string GetConfig(string key); void WriteLog(string message); }Initialize 的语义是「插件刚被实例化、主程序把外部依赖交给它」比插件在构造函数里找全局单例要清晰得多。构造函数获取主程序服务的问题是构造时机太早此时主程序的服务容器可能还没准备好。实际的坑还有另一个插件千万别自己 new 配置读取器因为插件目录里往往没有配置文件容易读到空值。正确做法是约定主程序把公共配置统一注入给插件插件只依赖 ISharedServices 暴露的方法不关心配置存在哪。这套注入机制对熟手来说浅显但它决定了插件系统的扩展上限——以后要加日志、加权限、加数据库访问都走 ISharedServices 扩展即可。4. 插件卸载与程序集隔离AppDomain 的取舍和实现插件要卸载绕不开 AppDomain 这个话题。默认情况下Assembly.LoadFrom 把程序集装进当前 AppDomain而当前 AppDomain 在进程结束前无法卸载结果就是插件 DLL 文件被进程锁住Windows 上删不掉、也覆盖不了。开发时改一次插件 DLL 就要重启一次主程序这是插件系统最容易劝退的点。4.1 默认 AppDomain 的文件锁困境为什么会锁文件因为 LoadFrom 加载的 DLL 被映射进进程地址空间只要 AppDomain 不卸载CLR 就认为程序集还在使用因此文件句柄不释放。这是 .NET 程序集加载的默认行为不是 bug。我在自己项目里第一次踩到这个问题时开发流程是「改插件 → 编译 → 提示文件被占 → 重启主程序 → 继续改」一个上午能反复十几次相当折磨。// 演示默认加载方式下DLL 文件会被占用 var asm Assembly.LoadFrom(D:\Plugins\MyPlugin.dll); File.Delete(D:\Plugins\MyPlugin.dll); // IOException: 文件被另一进程使用解决办法是给插件开独立 AppDomain。独立 AppDomain 可以整体卸载卸载时该域内加载的所有程序集一并释放文件锁随之解除。但代价是跨域通信插件实例不能直接在主域里用因为不同域的托管对象无法直接互相引用需要继承 MarshalByRefObject 才能跨域代理调用。这里有个关键判断如果你的插件只是几个业务模块、不频繁更新独立 AppDomain 带来的复杂度可能大于收益但如果是 IDE 类工具、需要反复加载卸载插件那独立域就是必选项。4.2 独立 AppDomain从 MarshalByRefObject 到 Unload独立域加载的标准套路是先定义一个继承 MarshalByRefObject 的加载器由主域创建并进入新域加载器在新域内装载程序集、创建插件实例然后主域通过代理引用操作插件。卸载时调 AppDomain.Unload把整个域连同插件一起丢掉。// PluginLoader.cs — 继承 MarshalByRefObject才能跨 AppDomain 被主域引用 public class PluginLoader : MarshalByRefObject { public IPlugin CreatePlugin(string assemblyPath, string typeName) { Assembly asm Assembly.LoadFrom(assemblyPath); Type type asm.GetType(typeName, true); return (IPlugin)Activator.CreateInstance(type); } } // 主程序中创建独立域并加载 AppDomain pluginDomain AppDomain.CreateDomain(PluginDomain_ Guid.NewGuid()); PluginLoader loader (PluginLoader)pluginDomain.CreateInstanceAndUnwrap( typeof(PluginLoader).Assembly.FullName, typeof(PluginLoader).FullName); IPlugin plugin loader.CreatePlugin(dllFullPath, MyPlugin.PluginMain); // 需要卸载时 AppDomain.Unload(pluginDomain);代码里有个容易忽视的细节CreateInstanceAndUnwrap 的返回值。这里的 CreateInstanceAndUnwrap 作用是在新域中创建 PluginLoader 实例并把跨域引用以透明代理形式返回给主域。新域内要能加载 PluginLoader 的类型定义它的程序集必须能同时被主域和新域解析因此实践中通常把契约和加载器单独放一个程序集主程序和插件都引用。卸载时 AppDomain.Unload 会触发该域内的所有托管资源的清理但插件内非托管资源比如串口句柄、数据库连接不会自动释放所以插件最好实现 IDisposable由加载器在卸任前主动调用。这个细节不处理插件卸载后设备端口可能还被占着下一次加载同一插件就会初始化失败。4.3 跨域调用的边界可序列化参数与 UI 元素限制跨 AppDomain 调用不是零成本的。每次方法调用都要经透明代理封送参数如果是一个普通 List 且未标记可序列化跨域时就会翻车。实际项目里我倾向于让插件接口只传基础类型和简单 DTO把复杂对象留在插件内部消化。另一条硬约束是UI 元素不能跨域返回。WPF 控件依赖主线程与桌面句柄跨域拿回来只会得到代理对象界面根本渲染不出来大概率还会抛 InvalidOperationException。// 跨域安全的 DTO 示例 [Serializable] public class MeterReadingResult { public string MeterId { get; set; } public double Voltage { get; set; } public double Current { get; set; } }[Serializable] 标记是跨域封送的前置条件不是可有可无的装饰。缺了它数据从新域回到主域时.NET 会拒绝封送并抛 SerializationException。这个坑在本地调试时完全不出现因为单域内引用传递不需要序列化等到你兴致勃勃把插件挪进独立域第一轮调用就翻车。因此在架构设计上要区分「业务插件」和「界面插件」业务插件适合放进独立域界面插件必须留在主域并由主程序统一创建 UI。很多插件系统把业务层和界面层拆成两个 DLL就是为了解决这个矛盾——业务 DLL 走独立域界面 DLL 由主程序直接加载。5. 插件加载避坑记录五个高频踩坑与排查思路插件系统做得越多越觉得反射 API 本身只是门槛真正的复杂度全在边界条件里。下面这五条都是我在实际项目里踩过或者见到别人踩过的坑每一条都按「现象 → 原因 → 解决」整理方便排查时对照。5.1 插件 DLL 文件被锁死开发期反复改代码很痛苦现象改完插件代码重新编译主程序提示「DLL 文件被另一进程使用」编译失败只能重启主程序才能继续。原因插件被 Assembly.LoadFrom 装进了默认 AppDomain进程不退出、AppDomain 不卸载文件句柄就不会释放。这是 .NET 程序集加载的正常行为但它确实让插件开发体验极其糟糕。解决开发期给主程序加一个「重新加载插件」按钮点击后清空插件字典再对插件目录里的 DLL 全部重新 LoadFrom 一遍。注意操作前要把主界面 ContentArea 里的插件界面也清掉否则旧的插件实例还挂在控件树上重新加载时可能出现同一个插件两个实例。如果插件较多可以在重新加载前调用 GC.Collect 再尝试删除 DLL效果在某些机器上有用但不稳定最终我放弃了依赖 GC 的方案直接使用重加载机制。5.2 插件类型没被识别反射循环匹配不到类型现象插件 DLL 明明在目录里foreach 循环一个类型都没匹配上插件管理器里一个条目都没有。原因最常见的是插件类没写成 public。C# 里类默认是 internal跨程序集的反射能拿到类型但 IsAssignableFrom 判断时内部类型和公开接口的契约不匹配或者 Activator.CreateInstance 因类型不可见而失败。另一种概率不低的情况是插件工程根本没引用契约程序集而是复制了一份同名的 IPlugin 接口代码过去类型完全不一样自然匹配不上。解决先检查插件类的访问修饰符确保是 public class再看插件工程是否直接引用了契约工程而不是复制代码。排查时可以在 IsAssignableFrom 这行做断点逐个看 asm.GetTypes() 返回了什么。我自己的习惯是第一次加载时把 GetTypes 的结果全部打到日志里先把候选类型看全再判断是哪一步过滤掉了目标类型效率比盲猜高得多。5.3 插件界面加载后一片空白ContentArea 不渲染现象插件实例创建成功菜单也能点但界面区域空白什么控件都不显示。原因插件里返回的 UIElement 是在后台线程创建的或者插件构造函数里访问了尚未初始化的 UI 资源。WPF 的 Dispatcher 机制要求 UI 元素必须创建在 UI 线程上跨线程创建的控件即使不抛异常也可能不会被正常渲染。还有一种情况是插件返回的 UIElement 为 null但主程序没有做空值判断直接 Add 进 Children 集合界面也会空白。解决在主程序的菜单点击事件里通过 Dispatcher 把调用切到 UI 线程再调用 GetControl。如果插件确实返回 null主程序要主动跳过并记日志。排查时在 ContentArea.Children.Add 前后各打一条日志确认是否真的执行到了这里再在插件内部 GetControl 入口断点确认返回对象是否非空。这两个位置一查问题基本就定位了。5.4 插件名字冲突后加载的插件静默覆盖先加载的现象两个插件都叫 MeterReader加载完成后只显示一个另一个被「吞掉」了。原因Dictionary 的索引器赋值会静默覆盖已有键不会抛异常。对于第三方插件作者来说起名字的时候大概率没考虑和别人重名的问题覆盖行为让他们很难察觉自己的插件没有生效。解决在登记处把索引器赋值改成 TryAdd如果键已存在则记日志并拒绝该插件if (!_pluginDict.TryAdd(plugin.Name, plugin)) { Debug.WriteLine($[PluginManager] 插件名称重复已跳过: {plugin.Name}); continue; }这里的关键不只是 TryAdd 这个 API而是让重复名称的插件进不了系统。两个插件抢同一个菜单入口最后用户点的时候到底调谁是个隐患越早暴露名称冲突插件作者就能越快改名。如果产品上确实存在两个插件同名不同功能的场景就得把 Key 从单纯的名字升级为「名称 版本」否则后续菜单绑定和功能查找都会乱套。5.5 插件引用的第三方 DLL 版本和主程序冲突现象主程序本来运行正常某个插件加载后主程序某些功能开始报 TypeLoadException 或 FileNotFoundException而且异常堆栈指向第三方库。原因插件引用的第三库版本和主程序引用的版本不一致。CLR 在程序集绑定的时候每个程序集只认一个版本主程序加载了 A 版本插件又尝试加载 B 版本绑定失败就抛异常。这不是插件系统特有的问题但在插件场景下频率会被放大因为插件作者常常「锁定依赖版本」来保证自己代码的行为稳定但这和主程序的统一管理天然冲突。解决低成本方案是给主程序挂 AppDomain.AssemblyResolve 事件统一把常见第三方库重定向到主程序使用的版本正规做法是在 app.config 里写 bindingRedirect把程序集版本范围映射到公共版本。还有一个前置的约定值得写进插件开发文档插件不得单独携带主程序已经引用的第三方库所有共享依赖以主程序提供的版本为准。文档写清楚的不一定被遵守但至少出了问题时有章可循不会翻来覆去地排查。6. 把模板变成产品插件版本校验、依赖收敛与日志埋点模板工程的意义在于跑通链路但真实桌面应用不会只加载两三个插件就完事产品化阶段我一般再加三个增强都属于低成本高回报的改进。第一个是版本校验。插件接口里已经有 Version 字段但没人保证第三方插件作者会老实填。更可靠的做法是加载前读取程序集元数据里的版本号和主程序内置的兼容版本区间比对不在区间内的直接跳过连 LoadFrom 都不执行。这比「先加载再判断」干净得多也避免了文件锁和程序集冲突。string fileName Path.GetFileNameWithoutExtension(dllPath); AssemblyName info AssemblyName.GetAssemblyName(dllPath); Version pluginVer info.Version; if (pluginVer expectedMinVersion) { WriteLog($插件 {fileName} 版本过低: {pluginVer}已跳过); continue; }AssemblyName.GetAssemblyName 有个好处它只读取程序集的元数据不会把程序集加载进内存所以检查版本不会引发文件锁也不会触发后续的程序集冲突。这个 API 在插件加载场景里非常实用值得记进自己的工具箱。加了版本校验之后遇到老插件用户不会再收到一堆摸不着头脑的运行时错误而是清晰的「版本过低已跳过」提示。第二个是依赖收敛。主程序把插件允许引用的第三方库整理成一张白名单插件加载后通过反射检查程序集引用列表凡是引用了白名单之外的库就警告并记日志。这一步的目的不是阻止插件用第三方库而是防止多个插件各自携带同一库的不同版本最后在运行时产生绑定冲突。这里有段真实经历某个插件偷偷打包了一个旧版日志库结果主程序里所有日志写入都走了旧版 API线上排查了半天才定位到是插件覆盖了共享依赖。从那以后我每次加载插件都强制走一遍引用检查把第三方依赖锁在契约文档里。第三个是加载日志埋点。插件系统的故障往往发生在开发机之外用户机器上没有调试器日志就是唯一的现场。我习惯在 PluginManager 里放一个日志委托加载成功、跳过、异常三个节点各打一条日志日志带上插件路径和加载耗时。平时看着是噪音真出问题时这些日志能直接画出「哪些插件进来了、哪些被挡了、卡在哪个阶段」的完整链路比在用户机器上反复试错高效得多。插件式 DLL 动态加载这套东西技术难点从来不是反射 API 不会用而是边界条件太多文件锁、版本冲突、跨域封送、线程亲和任何一个都能让系统在用户机器上静默失效。模板工程帮你把主链路跑通剩下的就是把边界一个个补齐。把上述三个增强加进去之后这套模板就能从「能跑」变成「敢上生产」。在这之后我每次搭插件系统都会把版本校验、依赖检查和日志埋点这三样放进一开始的骨架里而不是等出了问题再补——希望帮到你。本文还有配套的精品资源点击获取