在 .NET 8 的 WPF 插件系统中,优先推荐使用 AssemblyLoadContext + AssemblyDependencyResolver 。Assembly.LoadFrom 适合简单、受控且不需要卸载的场景,但不适合作为长期插件架构的核心加载方案。
一、核心对比
| 对比项 | Assembly.LoadFrom |
AssemblyLoadContext |
|---|---|---|
| 使用难度 | 简单 | 较复杂 |
| 基本加载 | 支持 | 支持 |
| 插件依赖解析 | 受默认加载行为影响,控制较少 | 可自定义并精确控制 |
| 插件独立目录 | 支持有限 | 推荐支持 |
| 不同版本依赖共存 | 容易冲突 | 可通过独立加载上下文隔离 |
| 卸载插件 | 不支持 | isCollectible: true 时可支持 |
| 原生 DLL 加载 | 处理较困难 | 可通过 LoadUnmanagedDll 处理 |
| 类型冲突处理 | 容易出现 | 可控制共享程序集 |
| 适合 WPF 长期插件架构 | 不推荐 | 推荐 |
| 适合动态安装、更新、禁用插件 | 不推荐 | 推荐 |
| 安全隔离 | 不支持 | 不支持 |
AssemblyLoadContext是依赖隔离机制,不是安全沙箱。加载不可信 DLL 时,仍可能访问文件、网络、注册表或导致应用崩溃。
二、Assembly.LoadFrom 的使用方式
csharp
var assembly = Assembly.LoadFrom(pluginAssemblyPath);
var pluginTypes = assembly.GetTypes()
.Where(x => typeof(IPluginService).IsAssignableFrom(x))
.Where(x => !x.IsInterface && !x.IsAbstract);
var plugins = pluginTypes
.Select(x => (IPluginService)Activator.CreateInstance(x))
.ToList();
优点
- 写法简单;
- 适合内部测试插件;
- 适合依赖版本完全由主程序控制的场景;
- 启动时加载、运行期间不卸载的场景可以使用。
缺点
1. 无法卸载
程序集加载后通常会一直驻留到进程退出,无法实现:
- 禁用插件后释放内存;
- 运行时更新插件;
- 重新加载插件;
- 卸载插件的原生依赖。
2. 依赖版本容易冲突
例如:
plaintext
主程序:OpenCvSharp 4.13
插件 A:OpenCvSharp 4.13
插件 B:OpenCvSharp 4.11
使用 Assembly.LoadFrom 时,可能出现:
FileLoadException;FileNotFoundException;- 加载到非预期版本;
- 运行时缺少方法;
- 同名程序集导致类型转换失败。
3. 容易出现类型身份不一致
即使接口名称相同,只要来自不同加载上下文,也可能不是同一个类型:
csharp
plugin is IPluginService
可能返回 false。
因此,插件接口程序集必须由主程序统一加载,不能让每个插件复制一份 H.Modules.Plugin.dll、H.VisionMaster.Base.dll 或其他契约程序集。
三、AssemblyLoadContext 的基本使用
csharp
using System.Runtime.Loader;
var loadContext = new AssemblyLoadContext(
name: "H.VisionMaster.Plugins.Yolo",
isCollectible: true);
var assembly = loadContext.LoadFromAssemblyPath(pluginAssemblyPath);
这里的 isCollectible: true 表示该上下文理论上支持卸载。
但仅仅使用 LoadFromAssemblyPath 还不够。对于插件依赖,应配合 AssemblyDependencyResolver。
四、推荐方案:AssemblyLoadContext + AssemblyDependencyResolver
建议每个插件拥有独立目录,例如:
plaintext
Plugins/
├── H.VisionMaster.Plugins.Yolo/
│ ├── H.VisionMaster.Plugins.Yolo.dll
│ ├── H.VisionMaster.Plugins.Yolo.deps.json
│ ├── YoloSharp.dll
│ ├── Microsoft.ML.OnnxRuntime.dll
│ └── runtimes/
│
├── H.VisionMaster.Plugins.Calibration/
│ ├── H.VisionMaster.Plugins.Calibration.dll
│ └── ...
│
└── H.VisionMaster.Plugins.Communication/
├── H.VisionMaster.Plugins.Communication.dll
└── ...
自定义插件加载上下文
csharp
using System.Reflection;
using System.Runtime.Loader;
public sealed class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
public PluginLoadContext(string pluginAssemblyPath)
: base(Path.GetFileNameWithoutExtension(pluginAssemblyPath), isCollectible: true)
{
_resolver = new AssemblyDependencyResolver(pluginAssemblyPath);
}
protected override Assembly Load(AssemblyName assemblyName)
{
if (IsSharedAssembly(assemblyName))
{
return null;
}
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
if (assemblyPath is null)
{
return null;
}
return LoadFromAssemblyPath(assemblyPath);
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
var libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
if (libraryPath is null)
{
return IntPtr.Zero;
}
return LoadUnmanagedDllFromPath(libraryPath);
}
private static bool IsSharedAssembly(AssemblyName assemblyName)
{
return assemblyName.Name is
"H.Modules.Plugin" or
"H.VisionMaster.Base" or
"H.VisionDiagram.Vision";
}
}
加载插件入口类型
csharp
using System.Reflection;
public sealed class PluginLoader
{
public LoadedPlugin Load(string pluginAssemblyPath)
{
var loadContext = new PluginLoadContext(pluginAssemblyPath);
var assembly = loadContext.LoadFromAssemblyPath(pluginAssemblyPath);
var pluginType = assembly.GetTypes()
.FirstOrDefault(x =>
typeof(IPluginService).IsAssignableFrom(x) &&
!x.IsInterface &&
!x.IsAbstract);
if (pluginType is null)
{
loadContext.Unload();
throw new InvalidOperationException(
$"程序集未找到 {nameof(IPluginService)} 的实现:{pluginAssemblyPath}");
}
var plugin = (IPluginService)Activator.CreateInstance(pluginType);
return new LoadedPlugin(plugin, loadContext, assembly);
}
}
public sealed class LoadedPlugin
{
public LoadedPlugin(
IPluginService plugin,
PluginLoadContext loadContext,
Assembly assembly)
{
Plugin = plugin;
LoadContext = loadContext;
Assembly = assembly;
}
public IPluginService Plugin { get; }
public PluginLoadContext LoadContext { get; }
public Assembly Assembly { get; }
}
五、程序集共享规则
对于当前项目,建议将程序集分为两类。
1. 必须由主程序共享的程序集
这些程序集必须从默认加载上下文加载,插件不应复制:
plaintext
H.Modules.Plugin
H.VisionMaster.Base
H.VisionDiagram.Vision
H.Common
H.Extensions.*
Microsoft.Extensions.DependencyInjection.Abstractions
Microsoft.Extensions.Logging.Abstractions
原因是插件需要与主程序进行类型转换、服务注册和节点发现。
例如:
csharp
public class PluginService : IPluginService
{
}
这里的 IPluginService 必须和主程序使用的 IPluginService 是同一个程序集、同一个加载上下文中的类型。
2. 可以由插件独立加载的程序集
这些程序集可跟随插件发布:
plaintext
YoloSharp
Microsoft.ML.OnnxRuntime
PaddleOCRSDK
ZXing.Net
OpenCvSharp4
特定相机 SDK
插件私有算法 DLL
插件私有原生 DLL
但 OpenCV、Halcon、相机 SDK 等基础库是否独立加载,需要统一规划。若主程序和多个插件都需要共享同一套全局资源,建议由主程序统一管理版本;若插件确实需要不同版本,则必须充分验证类型互操作问题。
六、插件卸载的实际限制
理论上:
csharp
loadContext.Unload();
可以卸载程序集。
实际上,只有所有插件对象、程序集对象和类型对象都不再被引用时,才会真正卸载。
csharp
plugin.Dispose();
plugin = null;
loadContext.Unload();
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
常见无法卸载原因:
- 插件服务仍被
IServiceCollection或IServiceProvider引用; - 插件订阅了全局事件;
- 插件创建了未停止的线程、任务或定时器;
- WPF 控件、命令、绑定、资源字典仍引用插件类型;
- 插件类型被缓存;
- 插件程序集包含静态单例或静态事件;
- 原生 DLL 仍被使用。
因此,对当前 WPF 项目更合理的策略是:
插件在启动阶段加载,运行期间不支持卸载;安装、更新或移除插件后重启应用。
即使暂不支持卸载,仍建议使用 AssemblyLoadContext,因为它解决了插件目录、依赖解析和原生 DLL 解析的问题。
七、WPF 插件的特别注意事项
如果插件包含:
UserControl;Window;ResourceDictionary;DataTemplate;Style;Command;- ViewModel;
- 自定义控件;
则运行时卸载难度会明显增加。
加载插件资源示例:
csharp
var dictionary = new ResourceDictionary
{
Source = new Uri(
"/H.VisionMaster.Plugins.Yolo;component/Resources/PluginResources.xaml",
UriKind.Relative)
};
Application.Current.Resources.MergedDictionaries.Add(dictionary);
卸载前需要:
- 从页面移除所有插件控件;
- 移除插件资源字典;
- 停止后台任务和定时器;
- 注销事件;
- 释放插件服务;
- 清空宿主保存的插件引用;
- 调用
Unload。
对于视觉算法插件,尤其包含 OpenCV、Halcon、ONNX Runtime、相机 SDK 的模块,建议采用"重启应用完成更新"的方案,而不是追求热卸载。
八、还有更合适的插件方案吗?
方案 1:Assembly.LoadFrom
适合:
- 内部项目;
- 插件数量少;
- 所有插件依赖版本统一;
- 插件只在启动时加载;
- 不需要更新、禁用、卸载;
- 允许重启应用。
这是最简单的方案,但扩展性有限。
方案 2:AssemblyLoadContext + AssemblyDependencyResolver
适合:
- 插件独立目录;
- 插件依赖不同 NuGet 包;
- 包含原生 DLL;
- 需要稳定处理 YOLO、ONNX、OpenCV、相机 SDK;
- 未来可能支持插件更新、禁用或卸载;
- 需要长期维护插件生态。
这是当前项目最推荐的方案。
方案 3:MEF
MEF 用于插件发现和组合,例如通过特性导出插件:
csharp
[Export(typeof(IPluginService))]
public class PluginService : IPluginService
{
}
MEF 的优势:
- 自动发现插件;
- 支持元数据;
- 适合模块化组合。
但 MEF 不负责程序集隔离,也不能替代 AssemblyLoadContext。
适合的组合方式:
plaintext
AssemblyLoadContext
+ AssemblyDependencyResolver
+ MEF 或反射扫描
+ IPluginService
+ Microsoft.Extensions.DependencyInjection
当前已有 IPluginService 和 DI 注册机制,使用反射扫描插件入口类型即可,未必需要引入 MEF。
方案 4:NuGet 包形式分发插件
将每个插件打包为 NuGet:
plaintext
H.VisionMaster.Plugins.Yolo.nupkg
H.VisionMaster.Plugins.Calibration.nupkg
H.VisionMaster.Plugins.Communication.nupkg
安装时解压到:
plaintext
Plugins/{插件名}/{版本号}/
优点:
- 版本管理;
- 依赖清晰;
- 发布规范;
- 方便安装、升级、回滚。
但运行时加载仍建议使用 AssemblyLoadContext。
方案 5:独立进程插件
对于不可信、容易崩溃或强依赖原生 DLL 的插件,将插件运行在独立进程中,通过 IPC 通信:
- Named Pipe;
- gRPC;
- HTTP;
- ZeroMQ;
- MemoryMappedFile。
适合:
- 第三方不可控插件;
- 需要防止插件导致主程序崩溃;
- 高风险原生算法库;
- 独立升级和重启插件;
- 需要隔离 GPU、相机或其他硬件资源。
缺点:
- 复杂度高;
- 需要定义 IPC 协议;
- 图像数据传输成本较高;
- 调试和部署成本增加。
九、建议的项目方案
建议当前项目采用以下架构:
#mermaid-svg-7RwcxcI98ozg6BdS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7RwcxcI98ozg6BdS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7RwcxcI98ozg6BdS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7RwcxcI98ozg6BdS .error-icon{fill:#552222;}#mermaid-svg-7RwcxcI98ozg6BdS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7RwcxcI98ozg6BdS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7RwcxcI98ozg6BdS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7RwcxcI98ozg6BdS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7RwcxcI98ozg6BdS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7RwcxcI98ozg6BdS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7RwcxcI98ozg6BdS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7RwcxcI98ozg6BdS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7RwcxcI98ozg6BdS .marker.cross{stroke:#333333;}#mermaid-svg-7RwcxcI98ozg6BdS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7RwcxcI98ozg6BdS p{margin:0;}#mermaid-svg-7RwcxcI98ozg6BdS .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7RwcxcI98ozg6BdS .cluster-label text{fill:#333;}#mermaid-svg-7RwcxcI98ozg6BdS .cluster-label span{color:#333;}#mermaid-svg-7RwcxcI98ozg6BdS .cluster-label span p{background-color:transparent;}#mermaid-svg-7RwcxcI98ozg6BdS .label text,#mermaid-svg-7RwcxcI98ozg6BdS span{fill:#333;color:#333;}#mermaid-svg-7RwcxcI98ozg6BdS .node rect,#mermaid-svg-7RwcxcI98ozg6BdS .node circle,#mermaid-svg-7RwcxcI98ozg6BdS .node ellipse,#mermaid-svg-7RwcxcI98ozg6BdS .node polygon,#mermaid-svg-7RwcxcI98ozg6BdS .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7RwcxcI98ozg6BdS .rough-node .label text,#mermaid-svg-7RwcxcI98ozg6BdS .node .label text,#mermaid-svg-7RwcxcI98ozg6BdS .image-shape .label,#mermaid-svg-7RwcxcI98ozg6BdS .icon-shape .label{text-anchor:middle;}#mermaid-svg-7RwcxcI98ozg6BdS .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7RwcxcI98ozg6BdS .rough-node .label,#mermaid-svg-7RwcxcI98ozg6BdS .node .label,#mermaid-svg-7RwcxcI98ozg6BdS .image-shape .label,#mermaid-svg-7RwcxcI98ozg6BdS .icon-shape .label{text-align:center;}#mermaid-svg-7RwcxcI98ozg6BdS .node.clickable{cursor:pointer;}#mermaid-svg-7RwcxcI98ozg6BdS .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7RwcxcI98ozg6BdS .arrowheadPath{fill:#333333;}#mermaid-svg-7RwcxcI98ozg6BdS .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7RwcxcI98ozg6BdS .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7RwcxcI98ozg6BdS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7RwcxcI98ozg6BdS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7RwcxcI98ozg6BdS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7RwcxcI98ozg6BdS .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7RwcxcI98ozg6BdS .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7RwcxcI98ozg6BdS .cluster text{fill:#333;}#mermaid-svg-7RwcxcI98ozg6BdS .cluster span{color:#333;}#mermaid-svg-7RwcxcI98ozg6BdS div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7RwcxcI98ozg6BdS .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7RwcxcI98ozg6BdS rect.text{fill:none;stroke-width:0;}#mermaid-svg-7RwcxcI98ozg6BdS .icon-shape,#mermaid-svg-7RwcxcI98ozg6BdS .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7RwcxcI98ozg6BdS .icon-shape p,#mermaid-svg-7RwcxcI98ozg6BdS .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7RwcxcI98ozg6BdS .icon-shape .label rect,#mermaid-svg-7RwcxcI98ozg6BdS .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7RwcxcI98ozg6BdS .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7RwcxcI98ozg6BdS .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7RwcxcI98ozg6BdS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 应用启动
扫描 Plugins 目录
读取插件清单
验证主程序版本与插件依赖
创建 PluginLoadContext
加载插件程序集
发现 IPluginService
注册插件服务与节点
加载插件资源与功能分组
应用运行
退出时释放插件资源
分阶段实施建议
-
当前阶段
- 插件启动时加载;
- 使用
AssemblyLoadContext; - 每个插件独立目录;
- 使用
AssemblyDependencyResolver; - 插件更新后重启应用。
-
下一阶段
- 增加
plugin.json; - 声明插件名称、版本、入口程序集、依赖、兼容的主程序版本;
- 增加插件启用、禁用和加载失败提示。
- 增加
-
后续阶段
- 为非 UI、无原生资源的插件实现卸载;
- 增加插件版本回滚;
- 对不可信插件使用独立进程。
十、结论
- 简单受控场景:
Assembly.LoadFrom可以使用。 - 当前 .NET 8 WPF 视觉平台:推荐
AssemblyLoadContext。 - 最佳实践:
AssemblyLoadContext+AssemblyDependencyResolver+ 共享契约程序集 + 插件独立目录。 - 不可信或高风险插件:使用独立进程隔离。
- 对 OpenCV、Halcon、YOLO、ONNX、相机 SDK 等原生依赖插件,优先采用"加载后运行到应用退出,更新后重启"的策略。
了解更多
System.Windows.Controls 命名空间 | Microsoft Learn
控件库 - WPF .NET Framework | Microsoft Learn
使用 Visual Studio 创建新应用教程 - WPF .NET | Microsoft Learn
HeBianGu的个人空间-HeBianGu个人主页-哔哩哔哩视频