
简介面向C#/WPF开发者的插件式DLL动态加载完整源码示例采用反射机制实现核心逻辑清晰可直接作为项目模板使用。资源共135个文件压缩包大小477KB主要由C#源文件、工程文件、配置文件、DLL及XAML界面文件等构成便于直接编译与对比学习。已有227人学习下载。代码覆盖插件接口定义、DLL查找与加载、通过反射获取类型并动态创建实例等关键环节同时包含插件注册与实际调用示例并兼顾异常处理和卸载机制有助于开发者理解如何让WPF应用在运行时灵活扩展功能。工程结构简洁PluginsTest等模块区分明确适合希望提升桌面应用可扩展性的初中级C#开发者研究参考。1. 插件式DLL动态加载为什么值得自己写一套反射解耦不只是省几个类拿到这套基于WPF开发的插件式DLL动态加载源码时我第一反应是这玩意儿看着玄学跑通之后发现核心就三件事定义插件接口、扫描目录里的DLL、用反射把类型找出来实例化。把这三件事理清楚一套插件框架的骨头就搭完了剩下的界面、配置、异常处理都是往骨头上贴肉。这篇文章就按这个顺序把模板里的关键代码、参数设置和最容易翻车的地方一层层拆开讲。适合两类人一是PC客户端项目里模块越堆越多、想按业务做拆分的WPF开发者二是想搞明白反射加载在项目里到底怎么落地的C#从业者。文末我会给出一套可以直接抄回去改的模板骨架。2. 反射加载的三个层次先把Assembly、Type和实例生命周期想清楚很多人把DLL动态加载想成「用一行代码把程序集塞进进程」实际拆开看是三层程序集层负责把DLL文件读进来类型层负责从程序集里筛出你要的class实例层负责把筛出来的type变成能用的对象。三层对应三个API顺序错了或者少了判断插件就起不来。2.1 从Assembly到Type按接口契约而不是按类名找插件加载入口常用两个APIAssembly.LoadFrom和Assembly.LoadFile。模板里的常规做法是用LoadFrom因为它在加载目标程序集的同时会去解析依赖插件DLL引用的公共库能被自动找到省去手工折腾依赖路径的时间。LoadFile则不做依赖解析适合插件完全自包含、不依赖任何共享程序集的场景但一旦插件引了宿主里的类运行时就会报找不到程序集。所以我一般只在排除依赖问题时才临时换成LoadFile正常业务一律走LoadFrom。拿到Assembly实例后下一步是扫类型。这里最忌讳用类名硬匹配比如type.Name OrderPlugin因为插件换了个类名宿主就废了。模板里用的是契约判断检查每个类型是否实现了统一的插件接口用IsAssignableFrom判断继承关系。var asm Assembly.LoadFrom(dllPath); foreach (var type in asm.GetTypes()) { if (typeof(IPlugin).IsAssignableFrom(type) !type.IsAbstract type.IsClass) { var plugin (IPlugin)Activator.CreateInstance(type); } }这段代码的逻辑分三步先拿类型集合再判断是不是IPlugin的实现最后实例化。IsAssignableFrom检查的是继承链插件类直接实现接口也好、继承某个基类再实现接口也好都能被识别它跟类型名没有任何关系。type.IsAbstract必须判断否则接口本身和抽象基类会被CreateInstance直接扔出异常。type.IsClass是保险排除掉值类型和枚举。Activator.CreateInstance内部走的是无参构造器这意味着插件类必须有一个公开的无参构造函数否则这里会在运行时抛MissingMethodException。2.2 实例化后的生命周期谁创建、谁释放、谁负责兜底反射实例化的对象生命周期默认是「没人管」。垃圾回收只盯着托管内存但插件里往往握着非托管资源——文件句柄、串口、Socket、第三方SDK的指针这些东西GC管不了。模板里常见的做法是让插件接口继承IDisposable宿主在卸载插件时统一调用Dispose。public interface IPlugin : IDisposable { string Name { get; } void Initialize(); }Initialize和Dispose的分工很明确Initialize在对象创建后立刻执行传入参数、连数据库、订阅事件都放这里Dispose负责把拿到的资源还回去。我把这个接口设计成不含任何业务方法业务能力由插件内部实现宿主只管「启动」和「清理」两个动作。这里有个容易忽略的边界反射创建对象不走构造函数注入所以你没法指望new一个插件时自动拿到宿主提供的日志、配置、数据库连接。模板的做法是让Initialize接收一个上下文对象把宿主的能力灌进去我见过不少新人在构造函数里直接new一个Logger结果每个插件一套日志路径排查问题的时候得翻好几个目录。把资源传递收敛到Initialize的入参上是这套模板最值得保留的习惯。2.3 为什么直接扫DLL目录是最简单可靠的做法插件发现机制有三种常见路线配置文件逐条列出DLL路径、按目录扫描、以及两者结合。模板里给的最简方案是扫目录把插件统一放进一个Plugins文件夹启动时用Directory.GetFiles找所有.dll后缀的程序集逐个加载。这个做法最大的优势是「新增插件不需要改宿主」——拷一个DLL进目录重启应用插件就出现了。var pluginFiles Directory.GetFiles(pluginRoot, *.dll, SearchOption.TopDirectoryOnly); foreach (var file in pluginFiles) { // TODO: 单独try/catch避免一个坏插件中断整个加载循环 LoadPluginFromFile(file); }SearchOption.TopDirectoryOnly只扫当前目录不递归子目录。这样做的考虑是避免把插件自己的依赖程序集也当插件加载一遍——如果某个插件把Common.dll复制到了自己的子目录递归扫描会把这个纯依赖库误判成插件轻则白加载一次重则类型冲突。等以后目录结构复杂了再改成递归扫描也不迟起步阶段保持最简。3. 把模板跑通WPF插件宿主的最小骨架与核心加载代码原理讲完就该动手了。模板直接打开就能跑但你要知道它为什么长这样才能改得动。一个最小的WPF插件宿主由三块组成接口定义、加载器、宿主界面。下面我从工程结构开始把每块的职责和代码讲清楚。3.1 宿主工程怎么建接口项目、加载器和插件目录三件事常见做法是建三个项目一个类库叫PluginContract放插件接口和公共上下文一个WPF应用程序叫Host放主窗口和加载器插件本体是一个或多个类库项目引用PluginContract。为什么要单独拆接口项目因为插件和宿主都要引用它但两边都不该互相引用对方的程序集——宿主不知道插件的具体类型插件也不知道宿主长什么样它们通过接口达成共识。PluginContract里除了接口再放一个PluginContext类用来传递宿主能力配置文件路径、主窗口句柄、日志委托都可以塞进去。这个类必须是公共的且不能被插件项目修改行为所以属性用init或者只读字段。public sealed class PluginContext { public string ConfigDirectory { get; init; } public Actionstring Log { get; init; } public IServiceProvider Services { get; init; } }sealed把类封死防止插件擅自继承改写。init是 C# 9 以后的写法属性只能在对象初始化时赋值初始化完就只能读。这样的上下文对象在Initialize时传给插件插件拿到的是宿主给的只读视图做不了越权的事。宿主工程的输出目录下建一个Plugins文件夹插件的构建事件把DLL复制进去。很多人图省事直接让插件项目把输出路径指向宿主目录结果宿主自己的bin里混进来一堆插件DLL哪天忘了清理加载列表宿主启动时把这些DLL也扫了就会出现加载了本该由插件自己管理的程序集的情况。3.2 反射加载核心代码扫描、匹配、实例化的完整方法把上一章的散装代码收拢成一个PluginManager这是模板里含金量最高的部分。它要做到一次扫描全部加载、单个插件失败不影响整体、支持重复调用幂等。public class PluginManager : IDisposable { private readonly ListIPlugin _plugins new(); private readonly Liststring _loadedAssemblies new(); public IReadOnlyListIPlugin Plugins _plugins; public void LoadPlugins(string pluginRoot) { foreach (var dll in Directory.GetFiles(pluginRoot, *.dll, SearchOption.TopDirectoryOnly)) { try { if (_loadedAssemblies.Contains(dll)) continue; var asm Assembly.LoadFrom(dll); _loadedAssemblies.Add(dll); foreach (var type in asm.GetTypes()) { if (!typeof(IPlugin).IsAssignableFrom(type)) continue; if (type.IsAbstract || !type.IsClass) continue; var plugin (IPlugin)Activator.CreateInstance(type); plugin.Initialize(new PluginContext { ConfigDirectory Path.Combine(pluginRoot, Configs), Log Console.WriteLine }); _plugins.Add(plugin); } } catch (Exception ex) { Debug.WriteLine($加载 {dll} 失败: {ex.Message}); } } } public void Dispose() { foreach (var plugin in _plugins) { try { plugin.Dispose(); } catch (Exception ex) { Debug.WriteLine(ex.Message); } } _plugins.Clear(); } }这个类做了四件关键事_loadedAssemblies记录已经加载过的DLL防止同一次启动里重复扫描导致插件被实例化两次每个DLL的加载和类型扫描被整体包进try/catch一个插件文件损坏不会让整个宿主崩溃Dispose遍历所有插件释放资源且每个插件的释放独立成块Plugins属性暴露只读列表外部拿不到内部可变集合的引用。Debug.WriteLine在开发期够用生产环境应该换成日志组件把失败信息写到文件里。3.3 插件UI怎么挂UserControl作为载体窗口里动态换ContentWPF做插件宿主插件界面最朴素的方案是让插件返回一个UserControl。宿主不需要知道插件内部画了什么按钮或图表它只关心一件事把这个UserControl塞进窗口的某个容器里。模板里IPlugin可以加一个UserControl CreateView()方法也可以用属性暴露。public interface IPluginView { UserControl CreateView(); }宿主主窗口里放一个ContentControl名字叫PluginHostArea选中插件后把插件创建的视图赋给它private void OnPluginSelected(IPlugin plugin) { if (plugin is IPluginView viewProvider) { PluginHostArea.Content viewProvider.CreateView(); } }这里能直接赋值Content是因为WPF的ContentControl支持任何UIElementUserControl自然没问题。切换插件时旧视图会被替换如果旧视图里有事件订阅或定时器需要在替换前主动清理——这又回到了Dispose的责任范围。配合WPF数据绑定可以把PluginHostArea.Content绑定到视图模型的CurrentPluginView属性上点击插件列表时更新CurrentPluginView界面就自动切换了。模板里没做太重的MVVM封装但这个绑定思路是我在实际项目里一定会加的一层。4. 参数怎么设插件目录、程序集版本与依赖解析的四个必调点跑通模板不难难的是换台机器、换个环境还能不能跑。这一章讲的四个参数点是实际交付时必调的模板里默认值能跑但到了真实项目里不调它们就会出幺蛾子。4.1 插件目录放哪输出目录还是独立Plugin文件夹模板默认在宿主运行目录下找Plugins这个路径在调试期没问题Visual Studio会把文件复制到bin\Debug。但我建议插件目录别直接用相对路径因为工作目录不等于程序集所在目录——你用计划任务启动、用其他进程Start启动时当前目录可能完全不对。稳妥的做法是取应用程序根目录再拼插件文件夹var pluginRoot Path.Combine(AppContext.BaseDirectory, Plugins);AppContext.BaseDirectory返回的是宿主程序集所在的目录不管从哪个工作目录启动这个值都恒定。然后把这个路径写进app.config或appsettings.json让目录可配。模板里既然有PluginContext.ConfigDirectory配这个的读取逻辑放进去也顺理成章。配置文件里设目录还有一个好处同一套宿主程序可以通过切换配置文件挂不同的插件组合。测试环境挂测试插件生产环境挂正式插件宿主代码一行不用改。把插件目录当成一个参数而不是一个写死的常量这是我一开始没注意、后来被运维同事提醒的教训。4.2 强命名与程序集版本为什么插件一更新宿主就崩插件项目一旦长期迭代最典型的现象是插件改了代码重新编译拷进Plugins目录宿主启动报错说「无法加载文件或程序集」或者加载出来的还是旧逻辑。原因通常是接口程序集的版本号变了。插件编译后引用的是PluginContract的某个版本而PluginContract.dll本身也躺在插件目录里宿主加载时发现程序集版本对不上解析就失败。模板解决这个问题有两条路可以走。第一条是把PluginContract的版本号固定住不改API就不改版本让所有插件引用同一个版本第二条是给宿主工程加程序集绑定重定向在app.config里加bindingRedirect把插件请求的旧版本统一重定向到当前版本runtime assemblyBinding xmlnsurn:schemas-microsoft-com:asm.v1 dependentAssembly assemblyIdentity namePluginContract publicKeyTokennull cultureneutral / bindingRedirect oldVersion1.0.0.0-9.9.9.9 newVersion1.0.0.0 / /dependentAssembly /assemblyBinding /runtime这个配置的意思是凡是请求PluginContract1.0到9.9之间任意版本的一律用1.0.0.0代替。它把版本差异兜住了前提是你的接口改动是向后兼容的。如果哪天接口删了一个方法那不管怎么重定向插件里调用的代码都会被MissingMethodException炸掉——所以接口项目要克制能不加方法就别加真加了就给默认实现。4.3 AssemblyResolve事件插件依赖缺失时的手动救场插件引用了某个公共库但那个库既不在插件目录、也不在宿主目录时运行时会抛FileNotFoundException。模板里第一版肯定没处理这个因为开发机上依赖齐全。到了现场部署少拷一个DLL就废。我一般会在宿主启动时挂AppDomain.CurrentDomain.AssemblyResolve事件手动补依赖AppDomain.CurrentDomain.AssemblyResolve (sender, args) { var assemblyName new AssemblyName(args.Name).Name .dll; var candidatePath Path.Combine(pluginRoot, assemblyName); return File.Exists(candidatePath) ? Assembly.LoadFrom(candidatePath) : null; };这里的关键是args.Name是完整程序集名包含版本、公钥标记等不能直接当文件名用得先取.Name属性拼DLL名。拼完在插件目录和宿主目录各找一遍找到了就LoadFrom找不到返回null让默认解析逻辑继续处理。注意AssemblyResolve是全局兜底事件千万别在里面写死推荐路径或无限递归——如果事件里调用Assembly.Load再次失败会触发事件本身形成死循环。模板里这个事件只处理文件名级别的查找不做复杂性兜底。4.4 配置文件驱动插件列表动态启停而不改代码扫目录有个小毛病目录里有但配置里不想启用的插件也会被加载。业务上常见「这个模块先装上但暂时不激活」的需求模板的解决办法是加个开关清单。用appsettings.json存一份启用的插件程序集名称{ Plugins: { Directory: Plugins, EnabledAssemblies: [ OrderPlugin.dll, ReportPlugin.dll ] } }加载器在扫目录时先过一遍清单清单里没有的跳过。这里有三点要提醒第一清单存的应该是DLL文件名不是类型名因为文件路径才是目录扫描的产物第二清单不推荐留空留空会被理解成「全部禁用」最好是「缺席即启用」这样只部署DLL不部署配置也能跑第三启停插件不用改代码、不用重编译宿主但需要重启程序让配置生效。想不重启就热启停得做到第六章的子进程方案模板本身管不了那么远。5. DLL加载避坑指南五个高频翻车现场与排查路径这一章是血泪经验汇总。模板能跑通只是入门下面五个现象是插件化落地时最常见的翻车点我在不同项目里都踩过。按「现象 → 原因 → 解决」写方便你遇到问题直接对照。5.1 DLL冲突同名不同版本的插件互相踩现象两个插件功能都正常单独加载谁都没事一起加载就报FileLoadException说是程序集版本或公钥标记不匹配。原因两个插件各自引用了不同版本的同一个依赖库比如一个用Newtonsoft.Json 12.0另一个用13.0它们被要求共存时CLR解析到同一个程序集名却拿到两个版本就冲突了。解决优先统一依赖版本所有插件都引同一个版本最干净统一不了就在app.config里给那个依赖库加绑定重定向把两个版本都指向一个兼容版本还不行就得拆分进程让冲突的插件住在不同进程里。解决顺序我建议从简单到复杂先查引用版本列表再改配置实在不行为冲突插件单开进程。5.2 文件占用插件更新时提示「文件正在使用」现象宿主运行中你想替换某个插件的DLLWindows提示文件被占用替换失败。原因CLR用LoadFrom加载程序集后会锁住DLL文件文件句柄不释放除非宿主进程退出。这是平台级行为不是代码bug。解决模板的做法是「先停再换」彻底停用插件对应的窗体、释放资源、记录待更新状态然后提示用户重启再拷文件想要在线替换就得让插件跑在独立进程中宿主和插件之间用管道或Socket通信把DLL文件放到独立进程能重新加载的位置。如果不做独立进程就老老实实把「替换插件需要重启」写进更新说明里。5.3 反射拿不到类型类的访问级别不对现象GetTypes()抛ReflectionTypeLoadException或者循环跑完一个插件都没加载成功。原因有两类插件类被写成了internal或者GetTypes()遇到类型加载不了的子依赖。Activator.CreateInstance只能创建公开类internal class在反射下拿得到类型但实例化会失败。解决先确认插件类声明为public class别省修饰符再在加载代码里捕获ReflectionTypeLoadException读它的LoaderExceptions属性看看具体哪个依赖没找到catch (ReflectionTypeLoadException ex) { foreach (var inner in ex.LoaderExceptions) { Debug.WriteLine(inner?.Message); } }LoaderExceptions会把所有失败的依赖异常列出来比只看主异常信息直接得多。我在项目里遇到过插件引用了System.Drawing.Common但目标机器没有对应运行时补丁的情况就是这个属性指出的方向。5.4 插件异常拖垮宿主一个插件炸了全家陪葬现象插件A在构造函数里连数据库超时抛异常加载循环中断插件B、C全都没加载宿主崩溃或半残。原因模板加载代码里try/catch位置不对把整个循环包在一个try里或者干脆没写try/catch。解决按第三章PluginManager的写法把try/catch放在每个DLL文件的循环内部一个文件失败只记录日志继续下一个。更进一步插件的Initialize也建议包一层因为构造成功不代表初始化不炸try { plugin.Initialize(context); } catch (Exception ex) { Debug.WriteLine($插件 {type.FullName} 初始化失败: {ex.Message}); plugin.Dispose(); continue; }这样做的好处是即使某个插件初始化失败它也能被Dispose清掉残存资源而不是挂在内存里变成一个半状态对象。记住插件加载不能有「全局事务」心态失败要局部化。5.5 卸载黑匣子为什么DLL一旦加载就卸不掉现象尝试写一个AppDomain.Unload或者把加载列表清空发现DLL文件还是锁着内存也没降。原因.NET Framework的AppDomain卸载已经是过去式.NET Core / .NET 5 里没有进程级卸载程序集的APIAssemblyLoadContext只对用它加载的程序集可见对LoadFrom加载的程序集无效。WPF项目如果跑在 .NET FrameworkAppDomain.Unload(pluginDomain)能做到隔离卸载但WPF控件、第三方SDK跨AppDomain边界会有一堆COM和窗口句柄问题。解决接受现实。模板在改不了进程的情况下干脆不做卸载用「加载后常驻」的方式规避需要真正热更的模块独立进程才是正路。有卸载诉求先问自己是不是真的需要不停机更新多数项目其实只需要「重启后加载新版本」那就别给自己挖坑。6. 从模板到产品热更新与插件加载性能验证的进阶做法模板能稳定工作之后再往前走一步就是两个方向让插件能独立热更新以及证明插件加载没拖慢宿主启动。这两个方向不是花架子它们在项目评审里能回答「你这套方案兜不兜得住生产环境」。6.1 热更新的现实做法子进程加载把插件关进独立进程不重启就能换插件唯一的正道是把每个需要热更新的插件放进独立进程。宿主进程只做调度和界面展示插件子进程通过命名管道或gRPC与宿主通信。方案的关键不是通信库选型而是职责边界宿主不再直接持有插件对象只持有连接句柄插件进程挂了宿主能感知并能弹提示插件需要UI时可以把窗口句柄传给宿主进程也有别的手段。这套成本不低通常只在插件本身不稳定或确实需要7x24更新的场景才上。对多数WPF桌面应用重启加载新版本已经够用——不用为了用热更新而用热更新。6.2 验证插件加载性能用Stopwatch测出反射真实的开销有人担心反射慢其实关键在于扫描和实例化是启动时做一次不是每次操作都做。为了打消顾虑模板里加个测量逻辑值得保留var sw Stopwatch.StartNew(); pluginManager.LoadPlugins(pluginRoot); sw.Stop(); Log($插件加载耗时: {sw.ElapsedMilliseconds} ms, 插件数量: {pluginManager.Plugins.Count});按经验几十个插件的场景反射扫描加实例化的耗时通常不到100毫秒真正慢的是插件构造函数里的初始化逻辑。所以性能优化应优先看Initialize里有没有做重活而不是急着改加载方式。我习惯每个插件在启动日志里上报自己的初始化耗时超过1秒的单独分析。这个模板我持续用了三年每次接手新项目都把PluginManager复制过去改改配置。最大的经验就是插件框架别追求一次到位接口越薄越稳。先把加载、实例化、释放捋顺版本管理靠约定热更新留给严肃场景。接口设计上每多加一个抽象都是在给未来的插件开发者加理解成本。希望帮到你把这套反射加载的骨架跑通后面的事都会顺很多。本文还有配套的精品资源点击获取