依赖注入与服务架构
这是「从零搭建灌装监控系统」系列第2篇。上一篇搭好了 MVVM 骨架,但 ViewModel 还是手动 new 的。这篇引入 DI 容器,把对象创建和依赖管理交给容器,为后续接入 PLC、数据库、报警服务打好架构底座。
当手动 new 变成噩梦
上篇结尾留了个坑:DataContext = new DashboardViewModel() 这种手动 new 的写法,项目小的时候没问题,一旦功能多了就遭殃。
我朋友后来真遭殃了。
他按上篇的骨架把项目搭起来,Dashboard 能跑了,开始往里加功能。先加日志查看页面,又加报警页面,再加数据查询页面。每个页面一个 ViewModel,每个 ViewModel 都在构造函数里 new 一堆依赖:
// 他写的 LogsViewModel
public LogsViewModel()
{
_logService = new LogService("logs\\\\log-.txt");
_dbProvider = new DbProvider("Data Source=FillTrack.db");
_alarmService = new AlarmService(_dbProvider);
}
// 他写的 AlarmsViewModel
public AlarmsViewModel()
{
_dbProvider = new DbProvider("Data Source=FillTrack.db"); // 又 new 一次
_alarmService = new AlarmService(_dbProvider); // 又 new 一次
}
问题来了——每个 ViewModel 都自己 new 依赖,DbProvider 被 new 了三回,三个不同的数据库连接实例。AlarmService 也被 new 了三回,报警事件三个地方各订阅一遍,一个报警弹三次窗。
他想"那把 DbProvider 改成单例吧",于是写了个 static Instance。改完发现 AlarmService 也得单例,LogService 也得单例……改到最后全是静态类,跟没封装一样。
更恶心的是测试。他想给 LogsViewModel 写单元测试,但构造函数里直接 new 了 LogService,测试的时候真去创建日志文件了。没法 mock。
手动 new 的本质问题是:对象自己负责创建依赖,导致对象之间强耦合,没法替换、没法测试、没法管理生命周期。
他需要的是——别人负责创建依赖,对象只管用。 这个"别人"就是 DI 容器。
DI 容器到底干了啥
DI 容器(Dependency Injection Container)干的事很简单:替你 new 对象,替你管理依赖关系,替你控制生命周期。
你告诉它"我需要这几个服务,它们分别是什么类型、什么生命周期",它就给你建个"对象工厂"。你需要哪个对象,问它要就行,它自动把依赖都注入好。

三步走:
.NET 生态里 DI 容器不少——Unity、Autofac、DryIoc、Ninject。但微软官方的 Microsoft.Extensions.DependencyInjection(简称 MEDI)够用了,而且 ASP.NET Core 同款,文档最全。这个系列就用它。
第一步:装包 + 建容器
1.1 安装 DI 包
程序包管理器控制台:
Install-Package Microsoft.Extensions.DependencyInjection
就这一个包,包含 ServiceCollection、IServiceProvider、IServiceCollection 这些核心接口。
1.2 改造 App.xaml.cs
上篇的 App.xaml.cs 里 OnStartup 直接 new MainWindow。现在改成用 DI 容器:
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using FillTrack.Models;
using FillTrack.Services;
using FillTrack.Services.Logs;
using FillTrack.ViewModels;
using FillTrack.Views;
namespace FillTrack
{
public partial class App : Application
{
// 保存构建好的 DI 容器,让其他类可以解析依赖
public IServiceProvider ServiceProvider { get; private set; }
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
SetExceptionHandling();
// 先初始化数据库(建表),再启动日志,避免写入冲突
await InitializeCoreService();
ConfigLogging();
try
{
// 1. 创建服务集合
var services = new ServiceCollection();
// 2. 注册所有服务和 ViewModel
ConfigureServices(services);
// 3. 构建容器
ServiceProvider = services.BuildServiceProvider();
// 4. 走登录流程,成功后显示主窗口
await InitialLoginFlowAsync();
LogService.Debug("Initializing PLC Service…");
// … 后续初始化 PLC
}
catch (Exception ex)
{
LogService.Fatal("应用程序启动失败:{0}", ex);
MessageBox.Show($"应用程序启动失败:{ex.Message}", "错误",
MessageBoxButton.OK, MessageBoxImage.Error);
Shutdown(–1);
}
}
private void ConfigureServices(IServiceCollection services)
{
// ViewModel 注册
services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<DashboardViewModel>();
services.AddSingleton<DataQueryViewModel>();
services.AddSingleton<LogsViewModel>();
services.AddSingleton<AlarmsViewModel>();
services.AddSingleton<SettingViewModel>();
services.AddTransient<LoginViewModel>(); // 登录窗口每次 new 一个
}
}
}
注意看 ConfigureServices 方法——所有 ViewModel 都在这里注册。AddSingleton<T>() 表示容器里只保留一个实例,谁要都给同一个;AddTransient<T>() 表示每次要都 new 一个新的。
登录窗口用 Transient 是有讲究的——每次登录都该是个全新的窗口,不该复用上次的(上次可能已经关闭了,复用会报异常)。
服务生命周期:Singleton vs Transient vs Scoped
MEDI 提供三种生命周期,搞不清楚会出大问题。
| 单例 | AddSingleton<T>() | 全局唯一实例,第一次请求时创建,之后都返回同一个 | 数据库连接、配置服务、全局状态服务 |
| 瞬态 | AddTransient<T>() | 每次请求都 new 一个新的 | 轻量无状态服务、ViewModel(如登录窗口) |
| 作用域 | AddScoped<T>() | 同一个作用域内单例,不同作用域不同实例 | ASP.NET Core 请求作用域;WPF 用得少 |

WPF 项目里基本只用前两种。Scoped 是给 Web 请求那种"一次请求一个作用域"的场景用的,桌面应用很少需要。
一个容易踩的坑:生命周期不一致
// ❌ 错误:单例依赖瞬态
services.AddSingleton<MainWindowViewModel>(); // 单例
services.AddTransient<ILogService, LogService>(); // 瞬态
public MainWindowViewModel(ILogService logService) { ... }
// MainWindowViewModel 是单例,构造时注入的 logService 会被一直持有
// 即使 ILogService 注册成 Transient,实际效果也是单例——因为容器只构造一次 MainWindowViewModel
这不是 bug,但会让人困惑。规则记一条:依赖的生命周期要 ≥ 使用者的生命周期。 单例只能依赖单例,瞬态可以依赖任何东西。
第二步:构造函数注入
容器注册了,怎么用?两种方式。
2.1 构造函数注入(推荐)
容器在创建对象时,会看它的构造函数需要哪些参数,自动从容器里解析对应类型注入进去。
public partial class MainWindowViewModel : ObservableObject
{
private readonly IServiceProvider _serviceProvider;
private readonly DispatcherTimer _timer;
// 构造函数声明需要 IServiceProvider,容器会自动注入
public MainWindowViewModel(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
// … 其他初始化
}
}
容器看到 MainWindowViewModel 的构造函数要 IServiceProvider,它自己就是 IServiceProvider,直接把自己传进去。你不用手动传。
如果构造函数要 DashboardViewModel,容器也会自动去注册表里找,找到了就 new 一个传进去。层层递归,自动解析整个依赖树。
2.2 服务定位器(Service Locator)
另一种方式是直接从容器里 GetRequiredService<T>():
public MainWindowViewModel(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
// 需要哪个 ViewModel,问容器要
var dashboard = _serviceProvider.GetRequiredService<DashboardViewModel>();
MainContent = dashboard;
}
这种方式叫服务定位器模式。它和构造函数注入的区别是:构造函数注入在创建时就明确依赖,服务定位器是运行时按需解析。
服务定位器被一些人骂是"反模式",因为依赖关系不明确——看构造函数不知道这个类用了哪些服务。但在导航场景下它有合理性:MainWindowViewModel 需要在用户点击不同菜单时动态切换 ViewModel,你不可能在构造函数里把所有 ViewModel 都注入进来(用户可能永远不点某个页面)。
这个项目里两种方式都用了:
- 构造函数注入用于明确、固定的依赖(如 IServiceProvider 本身)
- 服务定位器用于动态、按需的依赖(如导航时切换的 ViewModel)
务实一点,别教条。
第三步:用 DI 实现导航
现在把上篇的手动 new 改成 DI 容器解析。MainWindowViewModel 的导航逻辑:
public partial class MainWindowViewModel : ObservableObject
{
[ObservableProperty]
private object mainContent; // 当前显示的 ViewModel,绑定到 UI
private readonly IServiceProvider _serviceProvider;
public MainWindowViewModel(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
// 订阅 PLC 连接状态事件
PlcService.ConnectionChanged += (s, connected) => IsPlcConnected = connected;
// 订阅 PLC 数据事件
PlcService.DataReceived += PlcService_DataReceived;
// 启动时显示 Dashboard
MainContent = _serviceProvider.GetRequiredService<DashboardViewModel>();
// 时钟
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_timer.Tick += (s, e) => CurrentTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
_timer.Start();
}
[RelayCommand]
private void Navigate(string? destination)
{
if (destination == null) return;
switch (destination)
{
case "Dashboard":
MainContent = _serviceProvider.GetRequiredService<DashboardViewModel>();
break;
case "DataQuery":
MainContent = _serviceProvider.GetRequiredService<DataQueryViewModel>();
break;
case "Logs":
MainContent = _serviceProvider.GetRequiredService<LogsViewModel>();
break;
case "Alarms":
MainContent = _serviceProvider.GetRequiredService<AlarmsViewModel>();
break;
case "Settings":
MainContent = _serviceProvider.GetRequiredService<SettingViewModel>();
break;
}
}
}
Navigate 命令接收一个字符串参数(菜单项的 CommandParameter),根据参数从容器里解析对应的 ViewModel,赋值给 MainContent。界面上的 ContentControl 绑定 MainContent,自动切换显示。
注意——所有 ViewModel 都注册成 Singleton,所以每次导航返回的是同一个实例。Dashboard 切走再切回来,之前的状态还在(滚动位置、筛选条件都保留)。这是单例的好处。
如果某个页面需要每次进入都重置状态,就把它注册成 Transient,每次导航都 new 一个新的。
第四步:改造登录流程
登录窗口比较特殊——它在主窗口之前显示,登录成功后才创建主窗口。看 App.xaml.cs 怎么处理:
private async Task InitialLoginFlowAsync()
{
// 临时改成手动关闭模式,否则登录窗口关闭会直接退出程序
ShutdownMode = ShutdownMode.OnExplicitShutdown;
var loginWindow = new LoginWindow
{
WindowStartupLocation = WindowStartupLocation.CenterScreen
};
bool? result = loginWindow.ShowDialog();
if (result == true)
{
LogService.Info("登录成功,启动主窗口");
// 从容器解析 MainWindowViewModel(构造函数会自动注入 IServiceProvider)
var mainVM = ServiceProvider.GetRequiredService<MainWindowViewModel>();
var mainWindow = new MainWindow { DataContext = mainVM };
Current.MainWindow = mainWindow;
// 登录成功后改回主窗口关闭模式
ShutdownMode = ShutdownMode.OnMainWindowClose;
mainWindow.Show();
}
else
{
Shutdown(); // 登录取消,退出程序
}
}
关键点:
- ShutdownMode.OnExplicitShutdown — 登录窗口关闭时不要退出程序(默认是 OnLastWindowClose,登录窗口一关程序就没了)
- ServiceProvider.GetRequiredService<MainWindowViewModel>() — 从容器解析主 ViewModel,容器自动注入 IServiceProvider
- ShutdownMode.OnMainWindowClose — 登录成功后改回正常模式,主窗口关闭时退出
LoginViewModel 注册成 Transient,每次登录都是新实例。如果用户登录失败重试,不会残留上次输入的用户名密码。
这个项目的混合模式
到这里你可能发现一个问题——上面注册的全是 ViewModel,那些 Service(PlcService、DbProvider、UserService)怎么没注册?
因为这个项目用了混合 DI 模式:
#mermaid-svg-qsjl203BtrdoljVx{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-qsjl203BtrdoljVx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qsjl203BtrdoljVx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qsjl203BtrdoljVx .error-icon{fill:#552222;}#mermaid-svg-qsjl203BtrdoljVx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qsjl203BtrdoljVx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qsjl203BtrdoljVx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qsjl203BtrdoljVx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qsjl203BtrdoljVx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qsjl203BtrdoljVx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qsjl203BtrdoljVx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qsjl203BtrdoljVx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qsjl203BtrdoljVx .marker.cross{stroke:#333333;}#mermaid-svg-qsjl203BtrdoljVx svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qsjl203BtrdoljVx p{margin:0;}#mermaid-svg-qsjl203BtrdoljVx .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-qsjl203BtrdoljVx .cluster-label text{fill:#333;}#mermaid-svg-qsjl203BtrdoljVx .cluster-label span{color:#333;}#mermaid-svg-qsjl203BtrdoljVx .cluster-label span p{background-color:transparent;}#mermaid-svg-qsjl203BtrdoljVx .label text,#mermaid-svg-qsjl203BtrdoljVx span{fill:#333;color:#333;}#mermaid-svg-qsjl203BtrdoljVx .node rect,#mermaid-svg-qsjl203BtrdoljVx .node circle,#mermaid-svg-qsjl203BtrdoljVx .node ellipse,#mermaid-svg-qsjl203BtrdoljVx .node polygon,#mermaid-svg-qsjl203BtrdoljVx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qsjl203BtrdoljVx .rough-node .label text,#mermaid-svg-qsjl203BtrdoljVx .node .label text,#mermaid-svg-qsjl203BtrdoljVx .image-shape .label,#mermaid-svg-qsjl203BtrdoljVx .icon-shape .label{text-anchor:middle;}#mermaid-svg-qsjl203BtrdoljVx .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qsjl203BtrdoljVx .rough-node .label,#mermaid-svg-qsjl203BtrdoljVx .node .label,#mermaid-svg-qsjl203BtrdoljVx .image-shape .label,#mermaid-svg-qsjl203BtrdoljVx .icon-shape .label{text-align:center;}#mermaid-svg-qsjl203BtrdoljVx .node.clickable{cursor:pointer;}#mermaid-svg-qsjl203BtrdoljVx .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qsjl203BtrdoljVx .arrowheadPath{fill:#333333;}#mermaid-svg-qsjl203BtrdoljVx .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qsjl203BtrdoljVx .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qsjl203BtrdoljVx .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qsjl203BtrdoljVx .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qsjl203BtrdoljVx .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qsjl203BtrdoljVx .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qsjl203BtrdoljVx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qsjl203BtrdoljVx .cluster text{fill:#333;}#mermaid-svg-qsjl203BtrdoljVx .cluster span{color:#333;}#mermaid-svg-qsjl203BtrdoljVx 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-qsjl203BtrdoljVx .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qsjl203BtrdoljVx rect.text{fill:none;stroke-width:0;}#mermaid-svg-qsjl203BtrdoljVx .icon-shape,#mermaid-svg-qsjl203BtrdoljVx .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qsjl203BtrdoljVx .icon-shape p,#mermaid-svg-qsjl203BtrdoljVx .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qsjl203BtrdoljVx .icon-shape rect,#mermaid-svg-qsjl203BtrdoljVx .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qsjl203BtrdoljVx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qsjl203BtrdoljVx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qsjl203BtrdoljVx :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
静态服务(自行管理)
DI 容器管理
直接调用
直接调用
直接调用
直接调用
直接调用
MainWindowViewModel\\n单例
DashboardViewModel\\n单例
LogsViewModel\\n单例
其他 ViewModel\\n单例/瞬态
PlcService\\n静态类
DbProvider\\n静态类
UserService\\n静态类
ConfigServices\\n静态类
ViewModel 由 DI 容器管理生命周期,但 Service 是静态类,直接通过类名调用:
// DashboardViewModel 里直接用静态服务,不走构造函数注入
public DashBoardViewModel()
{
PlcService.DataReceived += PlcService_DataReceived;
PlcService.ConnectionChanged += (s, connected) => IsPlcConnected = connected;
}
为什么这么设计?
说实话,这是项目演进的结果,不是一开始就这么规划的。早期写 PlcService 的时候没想那么多,直接写成静态类方便全局调用。后来引入 DI 容器是为了管理 ViewModel 的生命周期(导航需要单例),Service 已经是静态类了,没必要再改。
这种混合模式有它的好处:
- Service 全局可用,任何 ViewModel 都能直接调,不用层层传参
- ViewModel 由容器管理,导航时单例复用,状态保留
- 改动最小,不用把所有静态类改成实例类再注册
但也有代价:
- Service 没法 mock,单元测试只能测 ViewModel,Service 是真跑
- Service 依赖关系不透明,看构造函数不知道用了哪些 Service
- Service 初始化顺序敏感,DbProvider.Initialize() 必须在 UserService.InitializeAsync() 之前
这个系列后面讲单元测试那篇会专门讲怎么处理这个问题——用反射测试私有方法,或者把关键 Service 抽接口。现在先跑起来,架构演进是渐进的,不是一步到位的。
异常处理:别让程序悄悄崩
App.xaml.cs 里有个 SetExceptionHandling 方法,跟 DI 没直接关系,但既然在改造启动流程,顺手讲一下。WPF 程序有三种异常需要兜住:
private void SetExceptionHandling()
{
// 1. UI 线程异常——界面上抛的未捕获异常
DispatcherUnhandledException += (s, e) =>
{
LogService.Error("UI线程未处理异常:{0}", e.Exception);
e.Handled = true; // 标记已处理,不退出程序
MessageBox.Show($"UI异常: {e.Exception.Message}", "错误",
MessageBoxButton.OK, MessageBoxImage.Error);
};
// 2. 非UI线程异常——后台线程抛的,UI 线程捕获不到
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
{
var ex = e.ExceptionObject as Exception;
LogService.Fatal("非UI线程未处理异常: {0}", ex?.Message);
};
// 3. Task 未观察异常——async Task 里抛了异常但没人 await
TaskScheduler.UnobservedTaskException += (s, e) =>
{
var ex = e.Exception?.InnerException ?? e.Exception;
LogService.Error($"Task.UnobservedTaskException: {ex?.Message}", ex);
e.SetObserved(); // 标记已观察,不触发崩溃
};
}
三种异常处理方式不一样:
- UI 线程:e.Handled = true 可以吞掉异常不退出
- 非UI 线程:没法阻止崩溃,只能记日志
- Task:e.SetObserved() 标记已观察,避免触发 UnobservedTaskException 事件导致崩溃
工业上位机 7×24 小时跑,一个未捕获异常把程序崩了,整条产线停工。这三个兜底必须有。
踩坑记录
| 忘了注册 ViewModel | GetRequiredService<T>() 抛 InvalidOperationException | 容器里没注册这个类型 | 在 ConfigureServices 里加上 services.AddSingleton<T>() |
| 单例依赖瞬态 | 瞬态服务实际变成单例 | 单例只构造一次,构造时注入的瞬态服务也被一直持有 | 依赖生命周期 ≥ 使用者生命周期,或把瞬态改成单例 |
| 循环依赖 | 启动时抛 InvalidOperationException: A circular dependency | A 构造需要 B,B 构造需要 A,容器不知道先 new 谁 | 重构,把循环依赖拆开(通常是把共享逻辑抽到第三个类) |
| 在构造函数里干重活 | 启动卡顿,界面半天不出来 | 容器在解析时调构造函数,构造函数里干 IO 操作会阻塞 | 构造函数只做赋值,重活放 InitializeAsync 方法,启动后异步调 |
第四个坑值得展开。我朋友在 DashboardViewModel 构造函数里去读数据库初始化数据,结果登录后主窗口卡了 2 秒才出来——因为 GetRequiredService<MainWindowViewModel>() 触发构造,构造里又同步读数据库。
正确做法是构造函数只做轻量初始化(赋值、订阅事件),耗时操作放异步方法,界面显示后再调:
public DashboardViewModel()
{
// 构造函数:只做轻量初始化
PlcService.DataReceived += PlcService_DataReceived;
}
// 异步初始化,界面显示后调用
public async Task InitializeAsync()
{
var records = await DbProvider.GetProductionRecordsAsync();
// … 填充数据
}
本篇小结
| 创建 DI 容器 | var services = new ServiceCollection(); |
| 注册服务 | services.AddSingleton<MainWindowViewModel>(); |
| 构建容器 | ServiceProvider = services.BuildServiceProvider(); |
| 构造函数注入 | public MainWindowViewModel(IServiceProvider sp) |
| 服务定位器 | _serviceProvider.GetRequiredService<DashboardViewModel>() |
| 单例 vs 瞬态 | AddSingleton<T>() / AddTransient<T>() |
| 异常兜底 | DispatcherUnhandledException + AppDomain.UnhandledException + TaskScheduler.UnobservedTaskException |
从手动 new 到 DI 容器,本质是把"对象创建"这件事从业务代码里剥离出来。你只管声明依赖,容器负责创建和注入。ViewModel 之间不再互相 new,Service 通过静态类全局可用,导航通过容器解析单例 ViewModel。
但有个问题还没解决——MainContent 是个 object 类型,界面上的 ContentControl 怎么知道该用哪个 View 来显示这个 ViewModel?下一篇讲主窗口与导航系统,用 ViewModel-first 导航 + DataTemplate 映射,让界面自动根据 ViewModel 类型选择对应的 View。
下期预告
第3篇:主窗口与导航系统
我们将设计主窗口的布局(侧边栏菜单 + 顶部状态栏 + 内容区),用 ViewModel-first 导航模式配合 DataTemplate 映射,让 ContentControl 根据绑定的 ViewModel 自动切换 View。告别手动写 View 切换逻辑,导航只管改 ViewModel,界面自己跟。
网硕互联帮助中心




评论前必须登录!
注册