
简介一套基于 WPF 的插件式 DLL 动态加载源码面向需要搭建可扩展桌面应用的 C# 开发者通过反射机制实现插件目录扫描、程序集加载与类型实例化可直接作为开发模板。压缩包内含 135 个文件以 34 个 C# 源码和 6 个 csproj 工程文件为主配合 xaml 界面、config 配置以及 dll/exe 运行文件共 477KB便于直接打开工程对照学习。已有 227 人学习代码规模紧凑适合入门插件化架构。源码以 PluginsTest 为示例完整展示插件接口定义、利用 System.IO 定位并筛选插件 DLL、通过 Assembly.LoadFrom 加载程序集、反射获取类型后经 Activator 创建实例并注册到容器等关键环节。同时还涉及异常处理和独立 AppDomain 卸载策略可帮助开发者理解在不重新编译主程序的前提下动态扩展功能并快速迁移为自身项目的插件化模板。1. WPF插件式DLL加载为什么把反射当作模板核心做 WPF 桌面项目超过一年后几乎所有人都会撞上同一个需求主程序要按菜单按钮动态加载某个功能模块模块以独立 DLL 提供主程序在写代码时甚至不知道这个 DLL 里有哪些类、哪些窗体。这就是典型的 WPF 插件式架构而实现它最直接、依赖最少的方式就是反射。标题里这套基于 WPF 开发的插件式 DLL 动态加载源码核心是用Assembly.LoadFrom配合反射在运行时扫描类型、实例化并调用整套结构定位是“模板”意味着拿到手之后改接口名、改命名空间就能接进自己的业务。它适合两类人一类是 WPF 项目开始膨胀、想把功能拆成独立模块的中级开发者另一类是公司有多套产品线、想统一插件规范的架构初学者。反射加载方案不是性能最优的也不是最安全的但它是理解插件化原理成本最低的一条路。2. 反射加载前的架构约定宿主程序凭什么“看见”未知的DLL2.1 编译期不知道类型运行期却要强调用靠的是一份公共约定很多初学者第一次接触 WPF 插件化时会问一个很实在的问题主程序连插件 DLL 里的类名都不知道怎么写出调用代码答案是不直接写插件里的类而是写一个插件类都继承的接口这个接口放在一个独立的、主程序和插件共同引用的类库里。运行时主程序拿到的只是Assembly对象随后通过反射遍历 Type、筛选出实现该接口的类型、创建实例后转成接口引用。后续主程序只用接口成员完全不触碰具体类型。这套约定就是插件式架构的“接口层”。接口里通常放三类东西插件名字、插件启动入口Execute或者ShowView、以及插件自己用来向宿主回传消息的事件。名字和入口是必需的事件是为了让插件能主动通知宿主刷新菜单、写日志或者弹提示。public interface IHostPlugin { string PluginName { get; } string PluginVersion { get; } void Initialize(IPluginHost host); FrameworkElement CreateView(); }逻辑说明IPluginHost是宿主提供给插件的上下文接口插件需要写日志、访问主配置时通过它操作避免插件直接引用宿主程序集造成循环依赖。CreateView返回FrameworkElementWPF 里窗体、用户控件、Page 都继承自它插件可以自由返回自己内部实现好的UserControl。参数说明接口成员不要把宿主里的具体类写进去比如直接传主窗体的ContentControl写进去等于把插件和宿主绑死模板的意义就没了。2.2 反射到底反射了什么程序集、Type 和实例三层解析反射在动态加载场景里做的事可以拆成三层。第一层是Assembly.LoadFrom它把 DLL 文件读进来得到一个程序集对象这一步并没有执行插件代码只是把元数据读到了内存。第二层是assembly.GetTypes()这一步拿到该程序集里所有公开类型的数组WPF 插件里常用的是拿全部类型再逐个检查是否实现了目标接口。第三层是Activator.CreateInstance(type)走无参构造器创建对象实例。Assembly pluginAssembly Assembly.LoadFrom(pluginPath); Type[] types pluginAssembly.GetTypes(); foreach (Type type in types) { if (type.IsClass !type.IsAbstract typeof(IHostPlugin).IsAssignableFrom(type)) { IHostPlugin plugin Activator.CreateInstance(type) as IHostPlugin; } }逻辑说明过滤条件必须同时检查是否是普通类、是否抽象类、是否实现了IHostPlugin。IsAssignableFrom 可以理解为“这个 type 能不能安全地当成 IHostPlugin 用”凡是直接实现接口的类、继承接口类的类都会返回 true。参数说明pluginPath建议用完整路径用相对路径时注意当前工作目录和主程序目录可能不一致WPF 程序用AppDomain.CurrentDomain.BaseDirectory拼出 DLL 目录最稳。2.3 为什么不选 MEF 或 Prism而把反射当模板底座业界做 WPF 插件化绕不开 MEF 和 Prism但标题里的模板选择了纯反射实现这是有意为之。MEF 用[Import]特性做组合写起来少但排错时多了一层 CompositionContainer 的黑匣子Prism 的 ModuleCatalog 更精细但它直接绑定 MVVM 框架如果你的项目不服 Prism 那一套导航和 Region 机制引入它是给自己添约束。纯反射实现的加载器全程代码不超过两百行任何一步出问题都能断点跟进去这对拿来当模板恰恰是最重要的特性——先把骨架看懂再决定要不要升级成 MEF。我一般给团队的建议是如果你只有三五个内部功能模块反射模板直接上如果模块数量过十、要热插拔和依赖管理再把加载器替换成 MEF接口层完全不用动。这正好说明模板设计里的接口独立有多重要只要公共接口不引用加载器实现后期替换容器就是换一个类的事。3. 把模板搭起来插件接口、宿主调用框架与首个测试插件3.1 解决方案目录结构怎么摆三个工程各管什么拿这套模板建解决方案时我建议摆成三个工程的结构。第一个是公共接口类库名字叫Plugin.Contract里面只放接口定义第二个是宿主程序WPF 的App.xaml和主窗体放这里引用Plugin.Contract但不引用任何具体插件工程第三个是插件实现类库类库里放一个或 N 个实现了IHostPlugin的 UserControl。这三级结构是插拔的关键宿主不认识插件插件又本来就要引用Plugin.Contract依赖方向永远是单向的。目录结构上插件 DLL 不要直接编译进主输出目录。常见做法是给宿主加一个 Plugins 子目录在项目属性里把插件工程的输出路径改成..\Host\bin\Debug\Plugins\或者写个 Post-Build 事件拷贝 DLL。这样做的好处是主程序目录干净日后做更新时整个 Plugins 文件夹可以直接替换。模板里如果没配这个你自己建目录时也别嫌麻烦这个动作决定了后续更新流程顺不顺利。3.2 宿主侧的三板斧扫描目录、加载程序集、按菜单装配宿主启动时需要做三件事扫描插件目录里的 DLL 文件、逐个做版本号检查、把可用的插件注册到一个字典里。菜单装配有一个很实用的做法把插件名直接作为菜单项的 HeaderTag 绑插件类型名点击时从字典里取实例创建视图并塞进 ContentControl。public class PluginManager { private Dictionarystring, IHostPlugin _plugins new Dictionarystring, IHostPlugin(); private readonly IPluginHost _host; private readonly string _pluginDirectory; public PluginManager(IPluginHost host, string pluginDirectory) { _host host; _pluginDirectory pluginDirectory; } public void LoadPlugins() { if (!Directory.Exists(_pluginDirectory)) return; string[] dllFiles Directory.GetFiles(_pluginDirectory, *.dll); foreach (string dllPath in dllFiles) { try { Assembly asm Assembly.LoadFrom(dllPath); foreach (Type type in asm.GetTypes()) { if (type.IsClass !type.IsAbstract typeof(IHostPlugin).IsAssignableFrom(type)) { IHostPlugin plugin (IHostPlugin)Activator.CreateInstance(type); plugin.Initialize(_host); _plugins[plugin.PluginName] plugin; } } } catch (BadImageFormatException) { // 非.NET程序集的DLL文件先跳过不阻塞其它插件 } } } }逻辑说明LoadPlugins里只加载 Plugins 文件夹避免把主程序目录下的 WPF 框架程序集都扫进来导致内存浪费。BadImageFormatException单独捕获是模板粘到真实项目里必留的保护逻辑——插件目录里混入非托管 DLL 或第三方非 .NET 程序集时不至于让整个加载流程中断。参数说明_pluginDirectory直接传Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Plugins)别在工作目录上拼相对路径否则用调试器启动和双击 exe 运行会得到两个不同结果。3.3 写一个最小插件验证模板跑得通最小验证插件的做法是不放业务逻辑只放一个写死的 UserControl。界面上放一个 TextBlock 显示插件名构造函数里把标题栏赋值。这个插件存在的意义是验证整个反射链路是否通了插件写得太复杂加载失败时你分不清是反射问题还是插件自身问题。public partial class DemoPluginView : UserControl, IHostPlugin { public string PluginName 示例插件; public string PluginVersion 1.0.0; public DemoPluginView() { InitializeComponent(); } public void Initialize(IPluginHost host) { // 这里可以调用host.WriteLine等日志接口做实机验证 } public FrameworkElement CreateView() { return this; } }逻辑说明CreateView直接返回自身是 WPF 插件组件最常见的写法插件视图本身就是一个 UserControl这比返回窗体更容易嵌进 TabControl 或 ContentControl。一个边界要注意Initialize不是构造函数每次通过反射Activator.CreateInstance创建对象时构造函数先跑随后才调Initialize所以放配置读取和安全检查的代码放 Initialize别放构造函数里这样宿主可以做到创建时轻量、显式初始化。参数说明IPluginHost在示例里可以只声明一个void WriteLine(string message)方法够验证插件到宿主的回传链路即可。3.4 菜单点击与宿主加载的动态联动菜单点击事件里最常见的错误是每次都重建插件实例。反射创建实例成本虽低但对一个内部持有绑定和后台任务的 UserControl 来说重建意味着状态丢失。习惯做法是菜单点击先从_plugins字典拿现成实例的视图把它设为 ContentControl 的 Content如果新视图不要求每次重建就一直复用。private void MenuItem_Click(object sender, RoutedEventArgs e) { string pluginName (string)((MenuItem)sender).Tag; if (_manager.TryGetPlugin(pluginName, out IHostPlugin plugin)) { ContentArea.Content plugin.CreateView(); } }逻辑说明这个写法把菜单界面和插件管理逻辑解耦菜单 Helper 只负责把菜单项的 Tag 绑成插件名。TryGetPlugin必须做插件在扫描时可能初始化失败直接访问字典会抛KeyNotFoundException。参数说明ContentArea是主窗体的 ContentControl 实例如果你要用 TabControl 支持多插件切换就改成一个可追加 TabItem 的容器但依然要走上面的TryGetPlugin出口。4. 反射驱动的核心代码与参数从Assembly到真实业务实例4.1 Assembly.LoadFrom 与 LoadFile 的差别加载路径为何要用全路径反射加载这块最容易被忽略的是LoadFrom与LoadFile的差别。LoadFrom会用程序集标识名称、版本、公钥令牌做缓存比对同样一个 DLL 从同一路径加载第二次它直接返回缓存的程序集LoadFile则是按文件路径无条件再读一次即使你加载的是同一个逻辑程序集也会造出两个不同实例。插件场景里用LoadFrom是习惯因为它自动去重避免同一个插件出现两份副本导致类型比较和事件订阅全部错乱。但LoadFrom有个隐藏行为你要知道它会把 DLL 所在目录附加到探测路径中。如果你的宿主目录下有同名 DLL 和插件目录下有同名 DLL加载顺序可能造成一个保险插件意外加载到另一个业务模块的 DLL这就是很多人说“LoadFrom 有玄学问题”的来源。我一般建议所有插件 DLL 用独立的强名称签名并在加载前先校验文件名或程序集名再进LoadPlugins用强名称规避同名冲突。string pluginsRoot Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Plugins); string pluginPath Path.Combine(pluginsRoot, ReportPlugin.dll); if (!File.Exists(pluginPath)) { throw new FileNotFoundException(插件文件不存在, pluginPath); } Assembly asm Assembly.LoadFrom(pluginPath); Console.WriteLine($已加载程序集: {asm.FullName});逻辑说明这段重点不是File.Exists而是asm.FullName的输出。插件加载失败时的第一手素材就是程序集全名泄露出程序集加载 ID 或版本不一致问题时你能直接看出来是引用了错误版本的程序集还是从别的目录硬拖来的同名文件。参数说明Assembly.LoadFrom的assembly参数若传了不完全路径如只传Plugins/ReportPlugin.dll运行时当前目录一变化就可能找不到文件所以参数务必先Path.GetFullPath或Path.Combine展开。4.2 GetTypes 可能抛 ReflectionTypeLoadException必须在模板里兜住插件 DLL 不是孤立存在的。它依赖的第三方类库比如 Newtonsoft.Json如果没进插件目录运行时加载这个程序集本身不失败一访问GetTypes()就抛ReflectionTypeLoadException的LoaderExceptions属性里有一个个未能加载的依赖项。这是初学者最容易遇到的“现象很怪、报错信息却不指路”的场景。Type[] types; try { types asm.GetTypes(); } catch (ReflectionTypeLoadException ex) { types ex.Types?.Where(t t ! null).ToArray(); foreach (Exception loaderEx in ex.LoaderExceptions) { Debug.WriteLine($依赖加载失败: {loaderEx.Message}); } }逻辑说明这张ReflectionTypeLoadException的处理逻辑值得直接搬进模板ex.Types里能正常解析的类型会保留真正找不全的类型是null过滤掉后不妨碍其余插件功能继续使用。参数说明Debug.WriteLine在冲刺阶段会经常用到生产环境建议换成统一日志接口写入文件内容重点是每个 loaderEx 的Message里面会写明具体哪个依赖程序集没找到。4.3 通过接口筛选类型时的边界不写死类型名的好处上一篇代码里判断插件类型用的都是typeof(IHostPlugin).IsAssignableFrom(type)不是type.Name.Contains(Plugin)这类字符串匹配。字符串匹配的错误率太高一个ProductManagementPlugin的命名空间有Management字样就可能被误加载。接口判断是反射容器与插件约定的公理只要约定本身保持独立加载器就能一直正确识别这就是 WPF 插件式架构里“约定优于配置”的体现。当你的加载器支持多个插件接口时比如模块实现IHostPlugin数据源实现IDataSourcePlugin可以用GetInterfaces()从类型上反查它实现了哪些接口再决定放进哪个注册字典。public IEnumerableType FindPluginTypes(Assembly assembly, Type pluginInterface) { Type[] types SafeGetTypes(assembly); return types.Where(t t.IsClass !t.IsAbstract !t.IsGenericTypeDefinition pluginInterface.IsAssignableFrom(t)); }逻辑说明这个工具方法提炼出FindPluginTypes参数pluginInterface传typeof(IHostPlugin)后续要支持新接口类型直接复用不用改代码。参数说明!t.IsGenericTypeDefinition很重要泛型类型冷的“模板定义”不能被Activator.CreateInstance直接实例化加入这个过滤条件后不会因为插件里有一个泛型中间类就把整个扫描流程中断。参数 name 不要叫type它接收的是插件接口类IHostPlugin 这种 Type 对象叫pluginInterface让后续调用者一眼看清语义。4.4 插件释放的可选实现IDisposable 与事件退订纯反射加载的插件没有内置卸载能力这是模板必须承认的边界。真正能做的“释放”是在主程序退出时挨个调用IDisposable.Dispose()把插件内部注册的事件和资源清理掉要支持运行中卸载插件那就要 AppDomain 或者 AssemblyLoadContext 出场。对模板应用来说走 IDisposable 通道已经够日常绝大多数场景用。public void ShutdownPlugins() { foreach (IHostPlugin plugin in _plugins.Values) { if (plugin is IDisposable disposable) { disposable.Dispose(); } } _plugins.Clear(); }逻辑说明is IDisposable disposable这种模式匹配写法在 C# 7.0 以后都是常用姿势模板里直接用。_plugins.Clear()只是清掉了宿主侧的引用实际程序集在内存里依然占着但如果插件里没有监听静态事件、没有非托管句柄不卸载不会造成实际问题不用自己吓自己。参数说明要同时调用插件内部的资源释放就在IHostPlugin里再定义void Shutdown()宿主在Dispose之前统一调用这样避免了插件作者忘记手动订阅事件导致的内存泄漏。5. 插件的 5 个高频翻车点加载失败、文件占用与依赖丢失排查5.1 插件文件被占用重新编译提示“文件正在使用”现象宿主程序运行中Visual Studio 对插件工程点重新生成IDE 报“无法将文件复制到 xxx另一个进程正在使用”。原因宿主加载插件时Assembly.LoadFrom会锁定 DLL 文件只要进程还活着宿主就握着这个文件的读锁生成时无法覆盖替换。解决调试时尽量让宿主进程完整退出再编译插件模板里如果插件需要热更新就要用AssemblyLoadContext实现可卸载上下文LoadFrom 直载的方案不具备热更新能力这是死结不必硬扛。开发习惯上我会把宿主的调试启动目录单独建一个bin\Debug的软链插件输出到另一个目录有了隔离后宿主锁文件根本波及不到编译输出目录。5.2 插件路径是对的却报 FileNotFoundException现象Assembly.LoadFrom抛FileNotFoundException但断点检查_pluginDirectory下的 DLL 确实存在。原因最常见的是插件依赖的第三方 DLL 没随插件一起复制到插件目录宿主加载程序集本体成功访问类型时才抛出找不到依赖。另一个隐蔽原因是配置了.config文件里probing privatePathPlugins/却拼错了子目录名导致运行时找不到组装。解决在加载代码里捕获ReflectionTypeLoadException把LoaderExceptions全部打印出来检查是否指向某个具体依赖缺失。应对方案是统一包管理把插件的所有 NuGet 包都设为 Copy Local发布时确认插件 DLL 和它的依赖 DLL 同目录。5.3 插件加进去了菜单上就是不显示现象PluginManager 执行完LoadPlugins菜单项数量却一个没变“像被吞了一样”。原因插件类型筛选条件过严比如要求type.GetConstructor(Type.EmptyTypes) ! null插件作者给类写的构造函数带参数于是被当不可创建也可能是typeof(IHostPlugin).IsAssignableFrom(type)成立但程序集本身没被扫描到目标目录。排查方法在LoadPlugins的循环里逐步打点第一步数有多少个 DLL 文件第二步数每个程序集的类型数量发现类型数量为 0 时检查是不是过滤器在作祟。Debug.WriteLine($扫描到 {dllFiles.Length} 个文件); foreach (string dllPath in dllFiles) { Assembly asm Assembly.LoadFrom(dllPath); Debug.WriteLine(${asm.FullName} 类型数: {SafeGetTypes(asm).Length}); }5.4 安装包更新后插件 DLL 是旧的现象部署新版本后某些界面看不到新功能重新编译后却正常。原因脚本或安装程序把插件 DLL 复制到了宿主目录而宿主启动逻辑仍然在 Plugins 目录找 DLL两处版本不一致加载到了老文件。解决模板的PluginDirectory做成可配置项默认读AppSettings里的名值对更新包时明确告诉脚本执行rd /s /q Plugins再拷贝或者干脆统一成一个 Plugins 目录禁止把插件 DLL 散布到宿主输出根目录。我在踩过这个坑之后给团队立了个规矩所有插件统一输出到Plugins\子目录宿主的Post-Build做一次清理防止老的插件文件残留在输出目录被误加载。5.5 反射创建实例成功但视图显示空白现象菜单点击能触发ContentControl 却一片空白无任何报错。原因插件视图的构造函数里抛了异常宿主侧没有捕捉到或者 UserControl 的InitializeComponent没能加载到对应的 XAML 资源。后果是异常在构造阶段被吞掉宿主继续执行把 null 塞进 ContentControl。应对在Activator.CreateInstance外层加强捕获把异常信息和 InnerException 全部写到日志视图返回前做个 null 检查是 null 就提示“插件初始化失败”。提示排查带头这个坑最快的方法是临时在构造函数里写Debugger.Break()看到断点命中后立即继续 F11 跟进确认是 XAML 初始化问题还是业务代码问题。生产环境别留这个断点。6. 进阶调试与验证技巧用程序集信息日志让加载路径可追溯应用跑上生产环境后插件加载类问题最痛苦的时刻就是没有日志证据。下面这个模板级调试技巧几乎不用额外成本在加载前后输出上下文关键信息形成一条可以被 Easysearch 或日志平台检索的加载链路。具体做法是把插件扫描日志加上三个字段插件物理路径、加载后的程序集全名含版本、以及当前 AppDomain 的 BaseDirectory。这三者同时记录能够快速判断是路径拼接问题、版本冲突还是目录污染。public void LogLoadContext(string pluginPath, Assembly asm) { string contextInfo $PluginPath {path}, $AsmFullName {asm?.FullName ?? NULL}, $BaseDir {AppDomain.CurrentDomain.BaseDirectory}; logger.Info(contextInfo); }除了加载上下文日志给模板加一个“参数自检”启动参数是很实用的边界验证手段。程序启动时带上--check-plugins宿主只做扫描与加载、不弹主窗体把所有插件名和版本号打印后立即退出。把这个开关留给测试同学每次发布前跑一遍就知道哪些插件被漏加载、哪些类型被过滤掉了不用手工开界面点菜单。反射实现插件框架的收尾习惯是每次改完接口都跑一遍--check-plugins自检每次加新插件都确认它的 DLL 只出现在 Plugins 目录每半年检查一次插件接口是不是已经被业务“渗透”进了不该有的功能。接口一旦开始放业务字段这个插件模板就走到了该维护多插件版本的岔路口。我在好几个项目里吃过接口膨胀的亏一开始约定只有Initialize和CreateView后来有人往里加了IsSystemPlugin、RequirePermission改一次接口所有插件都要跟着编译发布成本直线上升。往后我给自己定的习惯是接口只放生命周期方法业务能力全部放插件自己的视图模型里宿主与插件之间永远保持最小约定。希望帮到你。本文还有配套的精品资源点击获取