首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >C# + WPF 模块化开发:把"引用关系"从代码里挪到组合根

C# + WPF 模块化开发:把"引用关系"从代码里挪到组合根

原创
作者头像
IT大佬 jzit-top
发布于 2026-10-24 18:30:35
发布于 2026-10-24 18:30:35
140
举报

WPF 模块化最常见的失败形态不是分层没画好,而是模块之间直接 ProjectReference:A 模块要弹 B 模块的对话框,于是引用 B;B 又要调 A 的服务,反向引用回来。编译能过,但任何一个模块单独发布都不可能,单元测试必须拖起整个 UI。专业做法只有一条:模块之间零直接引用,全部依赖契约程序集,实现由组合根在启动时装配。本文按这条主线拆解,并给出可运行骨架。

一、三程序集分治:Contracts / Modules / Host

模块化的物理边界就是程序集边界。核心约定:Contracts 只放接口、DTO、事件定义,不依赖 WPF;每个 Module 只引用 Contracts;Host 引用所有模块并负责装配。

代码语言:javascript
复制
// Contracts:无 WPF 依赖,可被任何项目引用
namespace App.Contracts;

public interface IWorkspace { string Title { get; } object View { get; } }

public interface IModule {
    string Name { get; }
    void Register(IServiceCollection services);   // 模块自注册服务
    void Initialize(IServiceProvider sp);         // 启动时装配 UI
}

要点:Register 与 Initialize 分离——前者在容器构建期执行,后者在容器构建完成后执行。混在一起会导致模块初始化时拿不到其他模块的服务。

二、组合根:反射发现模块,而非硬编码列表

Host 不应手写 new ModuleA()。用程序集扫描 + IModule 约定自动发现,新增模块只需丢一个 DLL 进去。

代码语言:javascript
复制
public static class ModuleLoader {
    public static IEnumerable<IModule> Discover(string dir) =>
        Directory.EnumerateFiles(dir, "App.Modules.*.dll")
            .Select(Assembly.LoadFrom)
            .SelectMany(a => a.GetTypes())
            .Where(t => typeof(IModule).IsAssignableFrom(t) && !t.IsAbstract)
            .Select(t => (IModule)Activator.CreateInstance(t)!)
            .OrderBy(m => m.Name);   // 顺序确定,避免启动竞态
}

// App.xaml.cs
var modules = ModuleLoader.Discover(AppContext.BaseDirectory).ToList();
var services = new ServiceCollection();
foreach (var m in modules) m.Register(services);
var provider = services.BuildServiceProvider();
foreach (var m in modules) m.Initialize(provider);

要点:OrderBy(m => m.Name) 让初始化顺序可预测;生产环境应改为读取模块清单文件并校验签名,反射加载任意 DLL 在客户端场景有安全风险。

三、导航:Region 只认契约,不认具体视图

模块往宿主注册视图时,宿主不能知道具体类型。用 IWorkspace 契约交换,Region 管理器只持有接口。

代码语言:javascript
复制
public sealed class ModuleA : IModule {
    public string Name => "ModuleA";

    public void Register(IServiceCollection s) {
        s.AddSingleton<IOrderService, OrderService>();
    }

    public void Initialize(IServiceProvider sp) {
        var shell = sp.GetRequiredService<IShell>();
        shell.AddWorkspace(new Workspace("订单", new OrderView {
            DataContext = sp.GetRequiredService<OrderViewModel>()
        }));
    }
}

要点:AddSingleton 而非 AddTransient——View 被 Region 长期持有,Transient 的 ViewModel 会随导航泄漏;IShell 由 Host 实现,模块只依赖接口。

四、模块间通信:弱引用事件总线

A 模块不能引用 B,但业务上常常需要联动。解法是事件总线:事件定义放在 Contracts,发布订阅双方只依赖事件类型。

代码语言:javascript
复制
public interface IEventBus {
    void Publish<T>(T e);
    IDisposable Subscribe<T>(Action<T> handler);
}

public sealed class EventBus : IEventBus {
    private readonly ConcurrentDictionary<Type, List<WeakReference<Delegate>>> _subs = new();

    public void Publish<T>(T e) {
        if (!_subs.TryGetValue(typeof(T), out var list)) return;
        foreach (var w in list.ToArray()) {
            if (w.TryGetTarget(out var d)) ((Action<T>)d)(e);
            else list.Remove(w);        // 顺手清理已回收的订阅者
        }
    }

    public IDisposable Subscribe<T>(Action<T> handler) {
        var list = _subs.GetOrAdd(typeof(T), _ => new());
        var wr = new WeakReference<Delegate>(handler);
        list.Add(wr);
        return new Subscription(() => list.Remove(wr));
    }
}

要点:用 WeakReference 存订阅者,防止 ViewModel 被事件总线钉住而无法释放——这是 WPF 内存泄漏的头号来源;返回 IDisposable 让订阅生命周期可控。

五、模块卸载与资源释放

桌面应用常要求"插件可停用"。模块必须实现释放约定,否则关闭视图后定时器还在跑。

代码语言:javascript
复制
public interface IModuleLifetime : IDisposable { bool IsRunning { get; } }

模块内部把 PeriodicTimer、CancellationTokenSource、事件订阅统一挂在 CompositeDisposable 上,Dispose 时一并释放。宿主侧只在确认 IsRunning == false 后才允许卸载程序集——.NET 默认不支持卸载单个 Assembly,需配合 AssemblyLoadContext 才真正可行。

六、模块化的验收标准

判断这套架构是否成立,看三个具体能力:能否只带 Contracts 编译一个模块并跑单测;新增模块是否只需改清单、不动 Host 代码;停用某模块后内存是否回落、定时器是否停止。三者都能通过,才算模块化;否则只是分了文件夹。

结语

WPF 模块化的本质,是把"谁依赖谁"从编译期的 ProjectReference 挪到运行期的组合根。契约程序集划死边界,反射发现解除硬编码,Region 交换接口屏蔽类型,事件总线用弱引用解除双向耦合。检验标准很朴素:拿掉任意一个模块的 DLL,宿主是否还能启动并给出明确提示。能,说明它真是插件;不能,那它只是被拆开写的单体应用。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、三程序集分治:Contracts / Modules / Host
  • 二、组合根:反射发现模块,而非硬编码列表
  • 三、导航:Region 只认契约,不认具体视图
  • 四、模块间通信:弱引用事件总线
  • 五、模块卸载与资源释放
  • 六、模块化的验收标准
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档