【笔记】使用Assembly.LoadFrom和AssemblyLoadContext加载插件对比,以及有没有更合适的加载插件方案

在 .NET 8 的 WPF 插件系统中,优先推荐使用 AssemblyLoadContext + AssemblyDependencyResolverAssembly.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.dllH.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();

常见无法卸载原因:

  • 插件服务仍被 IServiceCollectionIServiceProvider 引用;
  • 插件订阅了全局事件;
  • 插件创建了未停止的线程、任务或定时器;
  • 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);

卸载前需要:

  1. 从页面移除所有插件控件;
  2. 移除插件资源字典;
  3. 停止后台任务和定时器;
  4. 注销事件;
  5. 释放插件服务;
  6. 清空宿主保存的插件引用;
  7. 调用 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
注册插件服务与节点
加载插件资源与功能分组
应用运行
退出时释放插件资源

分阶段实施建议

  1. 当前阶段

    • 插件启动时加载;
    • 使用 AssemblyLoadContext
    • 每个插件独立目录;
    • 使用 AssemblyDependencyResolver
    • 插件更新后重启应用。
  2. 下一阶段

    • 增加 plugin.json
    • 声明插件名称、版本、入口程序集、依赖、兼容的主程序版本;
    • 增加插件启用、禁用和加载失败提示。
  3. 后续阶段

    • 为非 UI、无原生资源的插件实现卸载;
    • 增加插件版本回滚;
    • 对不可信插件使用独立进程。

十、结论

  • 简单受控场景:Assembly.LoadFrom 可以使用。
  • 当前 .NET 8 WPF 视觉平台:推荐 AssemblyLoadContext
  • 最佳实践:AssemblyLoadContext + AssemblyDependencyResolver + 共享契约程序集 + 插件独立目录。
  • 不可信或高风险插件:使用独立进程隔离。
  • 对 OpenCV、Halcon、YOLO、ONNX、相机 SDK 等原生依赖插件,优先采用"加载后运行到应用退出,更新后重启"的策略。

了解更多

Assembly 类

AssemblyLoadContext 类

AssemblyDependencyResolver 类

System.Windows.Controls 命名空间 | Microsoft Learn

控件库 - WPF .NET Framework | Microsoft Learn

WPF 介绍 | Microsoft Learn

使用 Visual Studio 创建新应用教程 - WPF .NET | Microsoft Learn

https://github.com/HeBianGu

HeBianGu的个人空间-HeBianGu个人主页-哔哩哔哩视频

GitHub - HeBianGu/WPF-Control: WPF轻量控件和皮肤库

GitHub - HeBianGu/WPF-ControlBase: Wpf封装的自定义控件资源库

相关推荐
XiaQ09191 小时前
Linux 常用命令学习笔记(用户管理篇):用户、组与权限的钥匙
linux·开发语言·笔记·学习
hhwyqwqhhwy1 小时前
正点原子-ZYNQ7020 开发笔记
笔记
LuminousCPP1 小时前
栈和队列专题(四):LeetCode 232. 用栈实现队列|双栈分工 + 按需迁移 + 摊还 O(1)
c语言·数据结构·笔记·算法·leetcode
GGMM7891 小时前
富士 EP‑3957E‑C4 电源驱动板维修实战笔记
笔记·变频器·变频器维修
Wang's Blog2 小时前
PostgreSQL笔记37:数据库性能瓶颈排查方法论与工具链
数据库·笔记·postgresql
Wang's Blog2 小时前
PostgreSQL笔记23:Checkpoint机制与脏页处理深度解析
笔记·postgresql
葡萄成熟时 !10 小时前
JAVA 常用API学习笔记
java·笔记·学习
DevOpenClub10 小时前
个人公开市场研究笔记功能需求文档
笔记
不会代码的小猴15 小时前
7. JSON
开发语言·c++·笔记·qt·算法·json