WPF 模块化最常见的失败形态不是分层没画好,而是模块之间直接 ProjectReference:A 模块要弹 B 模块的对话框,于是引用 B;B 又要调 A 的服务,反向引用回来。编译能过,但任何一个模块单独发布都不可能,单元测试必须拖起整个 UI。专业做法只有一条:模块之间零直接引用,全部依赖契约程序集,实现由组合根在启动时装配。本文按这条主线拆解,并给出可运行骨架。
模块化的物理边界就是程序集边界。核心约定:Contracts 只放接口、DTO、事件定义,不依赖 WPF;每个 Module 只引用 Contracts;Host 引用所有模块并负责装配。
// 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 进去。
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 在客户端场景有安全风险。
模块往宿主注册视图时,宿主不能知道具体类型。用 IWorkspace 契约交换,Region 管理器只持有接口。
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,发布订阅双方只依赖事件类型。
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 让订阅生命周期可控。
桌面应用常要求"插件可停用"。模块必须实现释放约定,否则关闭视图后定时器还在跑。
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 删除。