在桌面端使用C#构建插件架构,核心目标是让主程序在不重新编译的前提下,动态装载功能模块。常见的做法是定义统一的接口契约,把业务功能拆成独立程序集,运行时发现并加载它们。这样既方便团队分工,也利于第三方扩展。

一、插件架构的基本组成
一个典型的C#桌面插件系统包含三个部分:主程序壳、插件接口契约、具体插件实现。主程序壳只提供窗口框架和基础服务,比如日志、配置;契约通常用单独的类库定义,例如IPlugin接口;插件则是实现了该接口的dll文件。
这种分层能避免主程序直接依赖插件细节。假如把接口写进主程序里,每次插件改方法签名都要重新编主程序,就失去了插件意义。因此契约库应保持稳定,插件和宿主都引用它,但互相不引用对方。
1.1 定义契约接口
下面是一个最基础的插件契约示例,放在名为PluginContract的类库中:
using System;
public interface IPlugin
{
string Name { get; }
void Initialize();
void Execute();
}
这个接口很简单,但实际项目里可以加入生命周期方法、获取UI控件的方法,或者接收宿主服务的参数。关键是让插件知道它能做什么、宿主知道怎么调用它。
为了保持兼容,契约接口一旦发布尽量不删方法,只用新增可选接口继承来扩展。否则旧插件在新宿主上会加载失败。
二、使用MEF实现插件发现
MEF(Managed Extensibility Framework)是.NET自带的合成容器,适合做插件架构。它通过Export和Import特性把部件连起来,不需要手写反射代码。
相比手动反射,MEF能自动处理依赖,而且支持延迟加载。桌面端如果插件很多,全部加载会拖慢启动,用Lazy<IPlugin>可以等用户真正用到再创建实例。
2.1 插件端导出
插件项目引用契约库后,用特性标记实现类:
using System.ComponentModel.Composition;
using PluginContract;
[Export(typeof(IPlugin))]
public class HelloPlugin : IPlugin
{
public string Name => "HelloPlugin";
public void Initialize()
{
// 初始化资源
}
public void Execute()
{
Console.WriteLine("插件被执行");
}
}
注意Export的特性参数是接口类型,这样宿主只认接口不认具体类。插件编译后把dll放到主程序指定文件夹即可。
如果插件还要依赖其它服务,可以在构造函数用ImportingConstructor注入,MEF会先满足依赖再创建对象,减少手动传参的麻烦。
2.2 宿主端导入
主程序使用DirectoryCatalog扫描插件目录:
using System.ComponentModel.Composition;
using System.ComponentModel.Composition.Hosting;
using System.IO;
using PluginContract;
public class PluginHost
{
[ImportMany(typeof(IPlugin))]
public IEnumerable<Lazy<IPlugin>> Plugins { get; set; }
public void Load(string pluginDir)
{
var catalog = new DirectoryCatalog(pluginDir);
var container = new CompositionContainer(catalog);
container.ComposeParts(this);
foreach (var p in Plugins)
{
p.Value.Initialize();
}
}
}
这段代码把指定目录里的dll全部扫进容器,并填充Plugins属性。桌面端通常在程序启动时调用一次Load,之后菜单或按钮绑定到对应插件。
若插件目录 dynamically 变化,可以监听文件系统事件,然后重建DirectoryCatalog来热加载,不过要注意线程安全和旧实例释放。
三、隔离与异常处理
桌面程序最怕一个插件抛异常让整个界面卡死。虽然MEF默认在同进程加载,但我们可以用AppDomain或单独进程做隔离。
简单方案是在主程序里用try-catch包住每个插件的Execute,记录日志但不中断其它插件。进阶方案是把危险插件丢到独立AppDomain,崩溃只影响那个域。
3.1 基础异常隔离
下面演示调用插件时做保护:
foreach (var p in Plugins)
{
try
{
p.Value.Execute();
}
catch (Exception ex)
{
LogError(p.Value.Name + " 执行失败: " + ex.Message);
}
}
这种写法成本最低,适合内部可信插件。但如果插件调用了原生库或死循环,光try-catch拦不住。
因此对外来插件,建议配合超时控制和单独AppDomain,甚至用进程间通信把插件变成子进程,主程序通过管道发命令。
四、界面与配置集成
桌面端插件往往要往主窗口加菜单或面板。可以在IPlugin里加GetMenu方法返回控件,宿主把它们插进工具栏。
另外用json或xml记录启用的插件列表,用户取消勾选就不加载对应dll。目录约定上,建议建一个Plugins子目录,放子文件夹区分版本,避免dll覆盖冲突。
4.1 简单菜单扩展
契约增加返回对象的方法:
public interface IPlugin
{
string Name { get; }
object GetMenuItem();
void Execute();
}
宿主拿到GetMenuItem返回的WPF或WinForms控件,加到界面即可。这样插件自己决定长什么样,主程序只管摆放位置。
配置方面,可以约定插件根目录放plugin.json写作者、版本、依赖,宿主启动先读配置再决定加载顺序,防止A插件依赖B插件却先跑导致空引用。
五、版本与兼容建议
插件架构跑久了必然遇到版本错位。最好给契约库定语义化版本,宿主检查插件依赖的契约版本号,低于要求就提示升级。
桌面端发布时把.NET运行时和契约库版本写清楚,第三方插件作者按文档编译。遇到破坏性更新,可以并行加载多版契约,用适配器把旧插件包装成新接口,延长寿命。
| 方案 | 优点 | 缺点 |
|---|---|---|
| MEF同进程 | 简单、性能好 | 插件崩影响主程序 |
| AppDomain隔离 | 异常可卸载 | 跨域调用写法繁琐 |
| 独立进程 | 最强隔离 | 通信复杂、占资源 |
综合来看,内部工具用MEF同进程足够;面向外部的桌面产品应至少做AppDomain隔离,保障主界面稳定。