UI 只是数据的外壳:依赖注入
UI 只是数据的外壳:依赖注入
[TOC]
引言
当我们着手开发一个拥有图形用户界面(UI)的系统时,无论是桌面应用还是 Web 应用,通常会面临两种截然不同的开发范式。
旧时光:一切从UI开始 (UI-based)
还记得早期开发未使用 MVVM 模式的 WPF 应用吗?那时的我们常常采用一种“所见即所得”的直接方式:
从UI设计开始:我们习惯于先在设计器上拖拽一个按钮和文本框,构建出应用的“骨架”;
在事件中编写逻辑:然后,双击按钮,IDE会自动生成一个
button_Click
事件处理函数,这里便成了我们安放代码的“大本营”;在代码中直接“遥控”UI:在
button_Click
里,我们会写下类似下面的代码:1
2
3
4string city = cityTextBox.Text;
var temperature = GetWeatherFromApi(city)
temperatureLabel.Text = temperature.ToString() + '℃';
this.Title = "天气已更新";
这种方式简单直观,上手快,但其弊端也如影随形:
- 高度耦合,代码混乱:业务逻辑(调用API)、数据处理(拼接字符串)和UI操作(修改Text属性)像一团乱麻般纠缠在UI事件处理函数中,难以拆分;
- 可测试性几乎为零:如何测试
button_Click
里的逻辑?唯一的办法似乎就是启动整个程序,像用户一样手动输入、点击,然后用肉眼来验证结果,费时费力且容易出错; - 维护困难,牵一发而动全身:如果想把简单的
Label
换成一个功能更丰富的第三方控件,可能需要重写大量的后台代码,因为它们与旧控件的实现细节绑定得太紧了。
新范式:数据是宇宙的中心 (Data-based)
为了解决上述问题,现代 UI 开发理念发生了根本性的转变:UI 只是数据的“可视化形态”。在这种“数据驱动”的模式下,我们的开发流程焕然一新:
- 数据是绝对的起点:我们首先思考应用需要什么样的数据(Model),定义好核心的数据结构。整个开发的重心从“按钮应该放在哪”转移到了“应用的核心是什么”;
- 逻辑为数据服务:ViewModel 作为连接 UI 和数据的桥梁,其核心职责就是管理和准备数据。它从服务(Service)获取原始数据,处理后暴露给界面;它响应用户的操作(Command),但最终目的仍然是改变数据;
- UI 是被动的观察者:视图(View)变得非常“纯粹”,它不包含任何主动的业务逻辑。其唯一的使命就是忠实、实时地反映ViewModel中数据的当前状态。
在数据驱动的流程里,ViewModel 是一个纯粹的 C# 类。这意味着我们可以轻松地为它编写单元测试,在不启动任何UI界面的情况下,验证在给定输入下,它的各个属性是否变成了我们期望的值。
理论与实践:工业场景的观察
当然,不同的领域有不同的侧重。在科学计算领域,流程往往是面向过程的:几何建模、网格生成、属性设置、求解…… 但在每一个具体的环节,依然会采用面向对象(OOP)的思想进行抽象和封装。
而在工业领域,数据驱动的思想则体现得淋漓尽致。典型的工业监控系统或上位机,其核心工作就是将采集卡的数据进行实时的处理、可视化及持久化。这正是数据驱动理念的最佳实践场景。
在这种场景下,我们构建应用的顺序自然变成了:
- 定义核心:首先关注应用的数据(Model)和获取这些数据的服务(Service);
- 构建桥梁:然后,构建 ViewModel 来作为数据和 UI 之间的桥梁,负责处理所有业务逻辑;
- 呈现视图:最后,才创建 View 来“消费” ViewModel 中的数据和命令。
这种分层、解耦的思想正是 MVVM(Model-View-ViewModel)模式的精髓。
接下来,我们将通过一个最简单的 C# MVVM WPF 桌面应用实例,一步步搭建起一个现代化的GUI系统。这个应用的功能极其简单:界面上实时显示一个从模拟 API 获取的温度值。
我们将从一个在 ViewModel 中手动创建服务实例的“原始”版本开始,分析其在测试和灵活性上的弊端,然后逐步引入依赖注入(DI)和控制反转(IoC)容器的概念,并最终介绍 ViewModelLocator 是如何进一步简化和自动化这一过程的。
实际上,这个看似简单的演进过程,正是许多复杂工业系统的核心架构。尽管真实场景下每个部分都更为复杂,但其背后的设计哲学与分层思想是完全一致的。
基础项目
代码实现
我们创建一个最基础的 WPF MVVM 实例,这个例子使用 CommunityToolkit.Mvvm 这个官方推荐的库,主要分为以下几个步骤:
- 创建模拟服务 (TemperatureService):它会模拟一个持续不断产生新数据的硬件或 API;
- 创建视图模型 (MainViewModel):它将直接创建并使用 TemperatureService,并将获取到的数据通过属性暴露给视图;
- 创建视图 (MainWindow.xaml):它将绑定到 MainViewModel 的属性以显示实时温度。
我们在这里直接给出创建的代码,并整理一下里面涉及到的一些 C# 语法和使用。
创建模拟数据服务 (TemperatureService.cs)
这个服务将模拟一个实时数据源。为了达到这个效果,我们将使用一个定时器 (System.Timers.Timer) 每秒生成一个随机的温度值,并通过一个事件将它广播出去。
创建一个新类 TemperatureService.cs:
1 | using System; |
创建视图模型 (MainViewModel.cs)
这是 MVVM 模式的核心。MainViewModel 将负责:
- 创建 TemperatureService 的实例 (这是我们后续要优化的点)。
- 订阅服务的 TemperatureUpdated 事件。
- 提供一个 Temperature 属性,当数据更新时,通过 [ObservableProperty] 特性自动通知UI。
创建一个新类 MainViewModel.cs:
1 | using CommunityToolkit.Mvvm.ComponentModel; |
创建并绑定视图 (MainWindow.xaml)
打开 MainWindow.xaml,修改代码如下。我们添加了 DataContext
的设置,并用 {Binding}
语法来绑定 MainViewModel
的 Temperature
属性。
1 | <Window x:Class="MvvmDemoApp.MainWindow" |
相关前置知识
成员变量与构造函数
在 Python 中,所有的实例变量都必须在 __init__()
构造函数中声明。而在 C# 中,成员变量(字段)可以直接在类体内声明并初始化,无需放到构造函数里。例如:
1 | public class TemperatureService |
在 C# 中:
- 实例字段:普通字段,每个对象都有自己的副本。
- 静态字段 (
static
):属于类本身,而非对象实例。整个程序只有一份。
命名约定(C# 命名规范)
C# 的命名风格非常统一,大体遵循微软官方的 C# 命名约定:
类型 | 说明 | 命名风格 |
---|---|---|
类名、方法名、属性名、事件名 | 公共可见成员 | PascalCase(帕斯卡命名) |
局部变量、方法参数 | 方法内部临时变量 | camelCase(驼峰命名) |
私有字段 | 一般加下划线前缀 _camelCase |
_temperature |
常量 | 全大写 + 下划线 | MAX_SPEED |
接口 | 通常以字母 I 开头 |
IService , IRepository |
1 | public class Car |
事件(Event)
事件是 C# 中一个非常有特色的机制,用于解耦“谁触发”和“谁响应”。简单理解:事件是一种特殊的对象,它管理着一组“订阅者函数”,并能在被触发时依次调用它们。
定义事件
声明一个事件需要指定它的委托类型(即事件触发时要调用的方法签名):
1 | public event Action<double>? TemperatureUpdated; |
这表示我们定义了一个事件 TemperatureUpdated
,它的订阅者必须是接收 double
参数的方法(比如温度值)。
其中:
event
:表明这个成员是事件;Action<double>
:事件委托类型(无返回值,带一个double
参数);?
:允许事件为空(无订阅者时不触发警告)。
订阅事件
通过 +=
将一个方法加入事件的订阅者列表:
1 | TemperatureUpdated += PrintTemperature; |
方法签名必须匹配事件的委托类型:
1 | public void PrintTemperature(double newTemperature) |
取消订阅用 -=
:
1 | TemperatureUpdated -= PrintTemperature; |
触发事件(Invoke)
发布者触发事件通常写成:
1 | TemperatureUpdated?.Invoke(newTemperature); |
这会自动调用所有订阅该事件的方法,依次执行。
?.Invoke
是 C# 6 引入的语法糖,表示如果事件不为空(即有订阅者),则调用它。
执行线程
事件在哪个线程被触发,就在哪个线程执行所有订阅的回调:
如果在主线程触发,回调在主线程执行;
如果在子线程(例如
System.Timers.Timer
的回调)触发,回调也在那个子线程执行。
因此在 WPF / WinForms 中,若事件触发于后台线程而回调中又操作了 UI,就需要通过 Dispatcher.Invoke()
或 Control.Invoke()
切回主线程。
计时器(Timer)
在很多场景中,我们需要周期性执行任务(例如每秒更新一次温度)。C# 提供了多种计时器类,其中最常用的就是 System.Timers.Timer
。
System.Timers.Timer
内部使用 线程池 来调度事件。当间隔时间到达时,系统会从线程池中取出一个线程执行它的 Elapsed
事件处理器。
1 | System.Timers.Timer _timer = new(1000); // 每1秒触发一次 |
对应的事件回调,也就是在相应线程池中的线程执行的:
1 | private void OnTimerElapsed(object? sender, ElapsedEventArgs e) |
注意,Elapsed
事件的触发是由系统自己执行的。
从这里也可以看到,System.Timers.Timer.Elapsed
事件的回调函数的形式和我们自己定义的事件有很大的差异。这是因为微软在设计 .NET 时,为了让所有事件都具有一致的结构,定义了一个标准事件签名模式:
1 | void EventHandler(object? sender, EventArgs e); |
标准事件在触发时,会像订阅函数传递两个参数:
sender
:指向事件的触发者对象(通常是this
),让订阅者知道是谁发出了这个事件;EventArgs
:封装事件附带的数据。
于是,整个框架的事件都遵循这个模式,例如:
Button.Click
→EventHandler
Timer.Elapsed
→ElapsedEventHandler
FileSystemWatcher.Changed
→FileSystemEventHandler
TextBox.TextChanged
→EventHandler
这样一来,所有的事件都能用统一的机制处理,比如使用统一的事件订阅语法、通用的反射调用、以及方便的可视化设计器支持(比如 WPF 设计器里自动生成事件绑定)。
如果事件需要携带额外信息(不止一个参数),就可以继承 EventArgs
:
1 | public class ElapsedEventArgs : EventArgs |
System.Timers.Timer
里就是这么做的:
1 | public delegate void ElapsedEventHandler(object? sender, ElapsedEventArgs e); |
于是订阅方可以写:
1 | _timer.Elapsed += (sender, e) => |
这样一来,不论事件来源是谁(sender
),订阅者都能获取统一的信息结构。这里的 (sender, e) => {};
和 Python 的 Lambda 表达式一样:lambda x, y: x + y
,都是用来定义匿名函数的。
MVVM 的事件机制
早期的 MVVM 框架 MvvmLight 已经停止维护,微软官方现在推荐使用 CommunityToolkit.Mvvm。 其实两者在理念上是一致的——ViewModel(VM)负责连接 View 与 Model。当 Model 的数据变化时,VM 需要通过某种机制通知 View 更新界面;这个机制的核心就是 事件。
正如我们在最开始说到的,在 data-based 的图景下,View 本身不保存业务状态,也不直接修改数据。 View 的职责只有一个:反映 ViewModel 中数据的当前状态。因此我们面临一个核心问题:
当 ViewModel 中的数据发生变化时,WPF 是如何“自动”让界面同步更新的?
答案依然是:事件机制。我们可以把 MVVM 的数据绑定理解为一场“广播—收听”通信:
- ViewModel(发布者)
- 持有数据(如用户名、温度、状态等)。
- 当属性的值发生变化时,主动“广播”通知。
- View(订阅者)
- 界面元素(如
TextBlock
、TextBox
等)。 - 它并不直接存储数据,而是“收听”来自 ViewModel 的广播。
- 界面元素(如
- WPF 数据绑定引擎(中间邮差)
- 框架层组件:
System.Windows.Data.Binding
。 - 它负责监听 ViewModel 的通知,一旦检测到变化,就自动取回最新值并更新到界面控件上。
- 框架层组件:
这种“广播”和“收听”的约定,就是通过 INotifyPropertyChanged
接口 实现的。在 C# 中,接口是一种“契约”:谁实现它,就必须履行相应的承诺。
INotifyPropertyChanged
的定义非常简洁:
1 | public interface INotifyPropertyChanged |
任何类只要实现了该接口,就向外界承诺:
“当我的某个属性变化时,我会触发一个
PropertyChanged
事件来通知你。”
我们可以实现一个最简单的 ViewModel:
1 | using System.ComponentModel; |
在这里,我们在 set
访问器中调用了 PropertyChanged?.Invoke(...)
。这一步就是整个“数据更新通知”的核心——ViewModel 对外广播变化。
在这里,我们并没有写出任何 viewModel.PropertyChanged += ...
的订阅代码。那绑定引擎是怎么知道要监听这个事件的呢?
其实,当我们在 XAML 中写下绑定语句时:
1 | <TextBlock Text="{Binding Title}" /> |
WPF 框架会在后台自动完成一整套订阅流程:
- 通过控件的
DataContext
找到当前绑定的 ViewModel 对象; - 检查该对象是否实现了
INotifyPropertyChanged
; - 如果是,框架就自动
+=
订阅它的PropertyChanged
事件; - 当事件触发时,绑定引擎立刻获取最新值并刷新 UI。
整个链路如下:
1 | ViewModel 属性变化 → 触发 PropertyChanged → WPF 绑定引擎收到事件 → 读取新值 → 更新 UI 控件显示 |
因此,View 本身是“被动”的,它只是 ViewModel 状态的镜像。真正驱动数据流动的,是底层的 事件机制 + 绑定引擎。
概念 | 角色 | 职责 |
---|---|---|
ViewModel |
发布者 | 属性变化时触发事件 |
View |
订阅者 | 显示 ViewModel 中的数据 |
Binding 引擎 |
中间人 | 监听事件并自动同步 UI |
在上面的例子里,我们手动实现了事件触发逻辑。但在实际项目中,这样的样板代码(if
检查 + PropertyChanged?.Invoke
)会重复出现在每个属性中,非常繁琐。MVVM 框架(如旧的 MvvmLight、新的 CommunityToolkit.Mvvm)就是帮我们简化这部分“通知样板”的工具:
- 自动实现
INotifyPropertyChanged
; - 自动生成属性变更通知;
- 提供命令绑定(
ICommand
); - 支持依赖注入、消息总线等高级功能。
下一节我们就会介绍现代的 CommunityToolkit.Mvvm 框架,看看它如何用最简洁的写法实现相同的功能。
CommunityToolkit.Mvvm
CommunityToolkit.Mvvm 框架,它是微软官方推荐的轻量 MVVM 工具包。使用 CommunityToolkit.Mvvm 后,我们的 ViewModel 类通常这样定义:
1 | using CommunityToolkit.Mvvm.ComponentModel; |
我们的 ViewModel 类继承自 ObservableObject
,ObservableObject
是 Toolkit 中的核心基类,它自动实现了 INotifyPropertyChanged
接口,并提供 OnPropertyChanged()
方法等基础逻辑。
同时,使用 partial
修饰类,表示这个类的定义可以被拆分到多个文件或由编译器扩展。Toolkit 就是通过 源代码生成器(Source Generator) 在编译阶段为我们自动“补上”另一半代码,例如属性的 get/set
和事件通知逻辑。也就是说,我们只需要写“声明”,不再需要手动实现样板代码。
如果我们希望某个字段能被 WPF 数据绑定引擎监听,只需要在它前面加上特性(attribute):
1 | [ ] |
编译后,生成器会自动生成一个完整的、带事件通知的公开属性:
1 | public double Temperature |
⚡ 也就是说,只写一行
[ObservableProperty]
,Toolkit 就自动帮我们生成PropertyChanged
通知逻辑。
CommunityToolkit 的属性生成逻辑遵循一套约定命名规则:
私有字段名 | 生成的公开属性名 |
---|---|
_temperature |
Temperature |
_userName |
UserName |
m_value |
Value |
temperature (无下划线) |
Temperature |
Temperature (首字母大写) |
Temperature1 (避免冲突) |
- 生成器会自动去掉前缀
_
或m_
并将首字母大写; - 如果字段本身是大写开头(例如
Temperature
),为避免命名冲突,会生成Temperature1
; - 因此推荐始终使用下划线命名私有字段:
_fieldName
。
使用 [ObservableProperty]
时,在未编译的状态下,IDE 可能会提示错误:
“
Temperature
在当前上下文中不存在。”
这并不是我们代码的问题。原因在于 Temperature
是 编译期生成的 属性,而不是我们手写的。源代码生成器只在编译阶段参与,所以编辑器的实时语法分析(IntelliSense)可能一时“看不到”它。
同样地,在某些情况下(尤其是新项目),XAML 编辑器 也可能提示:
“无法解析绑定的属性或 ViewModel 类。”
我们可以把 WPF 项目的生成过程想象成两步:
- 第一步:编译 C# 代码:编译器首先会处理我们所有的 .cs 文件(包括 MainWindow.xaml.cs、MainViewModel.cs、WeatherData.cs 等)。它会将这些 C# 代码编译成一个中间程序集(Assembly),通常是一个 .dll 或 .exe 文件。如果在这个阶段有任何 C# 语法错误,编译就会失败。 那么这个包含所有类定义的程序集就无法被成功创建出来。
- 第二步:编译 XAML 代码:在 C# 代码编译成功后,编译器才会开始处理 .xaml 文件。当它读到
<vm:MainViewModel/>
这句时,它会去第一步成功生成的那个程序集里寻找一个叫做 ViewModels.MainViewModel 的类。当它读到{Binding Temperature}
时,它会去检查这个绑定的数据上下文(DataContext)对应的类里,有没有一个叫做 Temperature 的公共属性 (public property)。
因此,这个错误提示也可能是因为 XAML 设计器在尝试解析尚未编译的中间文件,或者我们的编译有错误。只要能正常编译运行,绑定机制在运行时就会工作一切正常。
在实际项目中,属性的更新不一定总发生在主线程。例如,我们的 TemperatureService
使用计时器在后台线程触发事件。 而 WPF 的 UI 操作必须在主线程执行,因此我们需要一个线程切换:
1 | private void OnTemperatureUpdated(double newTemperature) |
Dispatcher 的作用相当于“把这段代码丢回 UI 线程执行”。
依赖反转与依赖注入
对代码进行测试
在 MVVM 模式 中,View 只是被动反映 ViewModel 中数据的当前状态,不承担任何主动逻辑。这意味着:即使没有 UI,我们依然可以独立测试 ViewModel 的行为。例如,我们可以直接验证:在给定输入下,ViewModel 的属性是否按预期变化。这是一种非常高效的开发方式——先写逻辑,再接界面。
在 .NET 中,测试通常通过独立的测试项目来完成,以保证主程序的纯净与可维护性。步骤如下:
添加测试项目
- 在 Visual Studio 的“解决方案资源管理器”中,右键点击解决方案(最顶层节点);
- 选择 “添加 (Add)” → “新建项目 (New Project...)”;
- 搜索 “MSTest”;
- 选择 “MSTest 测试项目 (C#)” → “下一步”;
- 命名为
WpfApp.Tests
→ 点击“创建”。
建立项目引用
在
WpfApp.Tests
上右键点击 “依赖项 (Dependencies)” → “添加项目引用 (Add Project Reference...)”;勾选主项目
WpfApp
→ 点击“确定”。现在就可以在测试代码中引用主项目的命名空间:
1
2using WpfApp.Models;
using WpfApp.Services;
当我们的主项目是 WPF 应用 时,测试项目默认会出现“目标平台不匹配”的错误。原因是 WPF 依赖于 Windows 特定的 UI 组件,而 MSTest 默认是跨平台。解决方法也很简单,打开测试项目属性 → 将 目标 OS 改为 Windows 即可。
下面是一个最简测试示例:
1 | namespace WpfApp.Tests |
元素 | 作用 |
---|---|
[TestClass] |
标记一个类是测试容器 |
[TestMethod] |
标记一个方法是测试用例 |
Assert |
提供各种断言方法(判断结果是否符合预期) |
Test Explorer |
在 Visual Studio 中运行与查看测试结果的面板 |
编写好了测试代码后,我们可以在 Visual Studio 的顶部菜单栏,选择 测试 -> 测试资源管理器。会弹出一个”测试资源管理器“窗口,我们的所有测试方法都会列在里面。点击左上角的”全部运行“按钮,如果一切顺利,我们会在每个测试方法旁边看到一个绿色的对勾。如果有问题,我们会看到一个红色的叉,可以点击查看详细的错误信息。
测试运行器会使用 反射 扫描 WpfApp.Tests.dll
,找到所有带 [TestMethod]
的方法,独立执行并报告结果(✅ 通过 / ❌ 失败)。测试项目没有 Main()
函数,它是一个类库,由 测试运行器 控制执行。
现在我们想测试 MainViewModel
的逻辑,但当我们打开当前实现时,问题来了:
1 | public MainViewModel() |
在进行单元测试的时候,我们不希望测试依赖于网络、API Key、或者任何外部因素。测试应该是快速、可靠、可重复的,因此我们需要用一个模拟的服务对象(Mock 对象)来替代真实的服务。然而在现在的代码中:
- ViewModel 直接依赖了具体实现类
TemperatureService
; - 在测试中无法替换为“假数据”或“模拟服务”(mock);
- 订阅事件中又依赖了 UI (
Application.Current.Dispatcher
),进一步增加耦合。
因此我们几乎无法在不运行 WPF 界面的情况下测试 ViewModel。
依赖反转与依赖注入
这就涉及到软件设计中的一个核心思想——依赖反转原则(Dependency Inversion Principle, DIP):
高层模块(如 ViewModel)不应该依赖于低层模块(如 Service 的具体实现),两者都应该依赖于抽象(接口)。
换句话说:
MainViewModel
不应该关心“温度是从哪里来的”;- 它只需要一个能“提供温度数据”的抽象接口;
- 具体实现(真实服务或测试服务)由外部传入。
为了让外部传入这个服务,我们不再在 ViewModel 里 new
一个对象,而是通过 构造函数 接收它 —— 这就叫 依赖注入(DI)。
我们新建一个 ITemperatureService.cs
文件,在里面定义一个温度服务的接口。这个服务应该有一个 TemperatureUpdated
的事件,以及一个 Start()
和 Stop()
方法。
1 | public interface ITemperatureService |
真实的服务实现:
1 | public class TemperatureService : ITemperatureService |
我们的 ViewModel 不再直接在构造函数内部创建一个具体的 TemperatureService
对象,而是接收一个 ITemperatureService
的接口:
1 | public partial class MainViewModel : ObservableObject |
OnTemperatureUpdated
正常也需要处理这个依赖,因为 ViewModel 不应该依赖于 UI 线程,这里我们用最小的改动来绕过这个问题。现在,MainViewModel
不依赖于具体的 TemperatureService
,而只依赖于一个抽象接口 ITemperatureService
。但是此时,我们就没办法在 XAML 里通过 DataContext="{...}"
的方式来自动创建 ViewModel 了,需要我们自己手动创建并传入依赖。我们删去 MainWindow.xaml
中的 DataContext
部分,在 MainWindow.xaml.cs
中手动传入依赖创建:
1 | using System; |
Moq 框架
测试项目中,我们可以定义一个“假服务”来代替真实服务:
1 | public class MockTemperatureService : ITemperatureService |
这样硬编码虽然简单,但是问题非常明显:
不灵活:这个 Mock 永远只会返回
25.0
。如果想测试当温度为-10.0
时,UI 是否会显示负号呢?或者当温度为999.0
时,UI 是否会正确布局?为了测试这些场景,必须:- 创建
MockNegativeTemperatureService
、MockHighTemperatureService
等更多的 Mock 类。 - 或者修改
MockTemperatureService
,给它增加复杂的逻辑来返回不同的值。 - 这两种方式都会导致测试代码迅速膨胀和混乱。
- 创建
代码量大:每有一个接口,就可能需要为它手写一个或多个 Mock 类。这会产生大量只用于测试的“胶水代码”,增加了项目的维护负担。
功能有限:如果我想验证
Stop()
方法是否被调用了怎么办?或者Start()
方法被调用了恰好一次?手动写的 Mock 很难优雅地实现这些“行为验证”。
核心问题是:测试的“准备工作”(设置假数据、定义假行为)与“实现”(手写一个类)耦合得太紧了。
Moq (发音类似 "Mock-you" 或者 "Mok") 是 .NET 平台下最受欢迎的“模拟框架” (Mocking Framework) 之一。
它的核心思想是:不再需要我们手动去写 MockSomethingService
这样的类。 相反,可以在单元测试方法中,用几行代码动态地、临时地创建一个“假”的对象。
这个假对象具备以下能力:
- 按需定制行为:可以告诉它:“当这个方法被调用时,请返回这个指定的值” 或者 “当这个事件发生时,请携带这个数据”。
- 行为验证:可以质问它:“
Start
方法有没有被调用过?调用过几次?” - 无需实体类:它在运行时动态生成一个实现了
ITemperatureService
接口的代理对象,完全不需要为测试而去创建新的.cs
文件。
现在,让我们来用 Moq 写一个测试,验证当服务传来温度 37.5
时,MainViewModel
的 Temperature
属性是否也变成了 37.5
。
1 | using Microsoft.VisualStudio.TestTools.UnitTesting; |
代码解释:
new Mock<ITemperatureService>()
:这是 Moq 的核心。它在内存中创建了一个实现了ITemperatureService
接口的虚拟对象。mockService.Object
:mockService
本身是一个“控制器”,它有很多配置方法(如Setup
,Raise
,Verify
)。而.Object
属性才是那个可以被注入到MainViewModel
的、真正的“假”服务实例。mockService.Raise(...)
:这就是 Moq 强大的地方。我们不再需要调用Start()
方法。我们可以直接命令 Mock 对象:“现在,立刻,触发TemperatureUpdated
事件,并带上37.5
这个值!” 这让我们的测试意图变得极其清晰和精确。
这里我们仔细理解一下这个 .Raise
里面的逻辑。在测试中,我们的目标是模拟 TemperatureUpdated
这个事件被触发。我们想对 MainViewModel
说:“嘿,你依赖的那个服务刚刚广播了一个新的温度,请你响它!”
然而,在 C# 中,事件有一个非常重要的封装规则:一个事件只能在声明它的那个类(或结构体)的内部被触发(Invoke)。
- 在
TemperatureService
内部,我们可以写TemperatureUpdated?.Invoke(25.0);
- 在我们的测试方法(即类的外部)中,我们不能写
mockService.Object.TemperatureUpdated(25.0);
这会导致编译错误。
从外部,我们只能对事件做两件事:订阅 (+=
) 和取消订阅 (-=
)。注意,我们甚至不能获取事件的引用以传递给其他方法,也就是说单独把事件作为其他方法的参数也是不合法的,只能够对事件进行 +=
或者 -=
。
这就给 Moq 带来了一个挑战:它作为一个外部工具,如何才能告诉那个模拟对象去触发它自己的内部事件呢?
Moq 的设计者想出了一个聪明的办法:“你(开发者)给我一个表达式,这个表达式只要能唯一地‘指到’你想触发的那个事件就行,剩下的交给我。”
这就是 mockService.Raise(s => s.TemperatureUpdated += null, expectedTemperature);
这行代码的全部意义。
让我们把它分解成三个部分:
Part 1: s => ...
(Lambda 表达式本身)
s
是什么?它只是一个参数名,代表着我们正在操作的那个模拟对象本身(也就是
Mock<ITemperatureService>
的实例)。可以把它换成任何我们喜欢的名字,比如service => ...
或者x => ...
。=>
是 Lambda 操作符,表示“goes to”(映射到)。
Part 2: s.TemperatureUpdated += null
(表达式的主体,也是最迷惑的部分)
这部分是 Moq 的“魔法”所在。
- 为什么是
+=
? 因为正如我们上面所说,+=
是从外部访问事件的合法操作之一。Moq 需要一个语法上合法的表达式。 - 为什么是
null
? 因为我们并不想真的订阅一个事件处理器。我们只是想利用这个语法来“指认”TemperatureUpdated
这个事件。+= null
是一个在语法上有效但实际上什么也不做的操作,完美地满足了 Moq 的需求。
关键点:Moq 并不会真的去执行 s.TemperatureUpdated += null
这段代码。相反,Moq 会分析这个表达式的结构。它会检查这个表达式,然后说:
“哦,我看到了!开发者正在访问
s
对象的TemperatureUpdated
事件,并且正在对其进行订阅操作。那么,他想让我触发的事件一定就是TemperatureUpdated
了!”
这就像你指着一本书对朋友说:“就是那本红色的书。” 你并不是在打开书,你只是在指认它。这个 Lambda 表达式就是那个“指认”的动作。
Part 3: expectedTemperature
(事件的参数)
在 Moq 确定了要触发哪个事件之后,它就需要知道:“触发这个事件时,应该传递什么数据?”
- 我们的
TemperatureUpdated
事件的定义是Action<double>
,这意味着它需要一个double
类型的参数; Raise
方法的第二个参数expectedTemperature
(值为37.5
) 就是用来提供这个数据的。
Moq 拿到这个值后,就会在内部安全地调用 TemperatureUpdated.Invoke(37.5);
。
IoC 容器
我们通过构造函数注入,成功地将 MainViewModel
与 TemperatureService
的具体实现解耦。这非常棒,我们的 ViewModel
现在变得高度可测试了。
我们在 MainWindow.xaml.cs
的后台代码中是这样做的:
1 | // 在 MainWindow.xaml.cs 中 |
这种手动“接线”的方式,我们称之为“组合根” (Composition Root)——它是应用程序中唯一一个知道所有具体实现并将它们组合在一起的地方。这种方式在简单应用中行之有效,但随着应用程序的复杂度增加,它会迅速变得难以管理:
依赖链地狱 (Dependency Chain Hell):想象一下,
TemperatureService
自身也需要一个依赖,比如ILogger
和INetworkClient
。那么我们的代码就会变成:1
2
3
4
5// 如果依赖增多...
ILogger logger = new ConsoleLogger();
INetworkClient networkClient = new UdpClient();
ITemperatureService temperatureService = new TemperatureService(logger, networkClient);
DataContext = new MainViewModel(temperatureService);如果依赖关系有三层、四层深,这里的创建逻辑会变得像一颗巨大的、盘根错节的树,极难维护。
生命周期管理:我们希望
TemperatureService
在整个应用中是唯一的实例(即单例,Singleton),这样它就不会被重复创建。在手动模式下,我们需要自己管理这个单例实例,并确保每个需要它的地方都得到的是同一个对象。如果应用中有几十个需要单例的服务,这将是一场噩梦。代码臃肿:启动窗口的后台代码(本应只关心UI逻辑)却充斥着大量关于对象创建和依赖关系的“内务”代码,这违反了单一职责原则。
我们需要一个“智能工厂”来自动处理这些繁琐的创建和注入工作。这个工厂,就是IoC(Inversion of Control, 控制反转)容器。
IoC 容器:依赖注入管家
IoC 容器是一个框架,它能自动完成依赖注入的过程。我们只需要做两件事:
- 注册 (Register):在程序启动时,告诉容器你的“服务蓝图”。比如,“当有代码需要
ITemperatureService
接口时,请给它一个TemperatureService
类的实例。” - 解析 (Resolve):当需要一个对象时(比如
MainViewModel
),直接向容器索取。容器会自动检查MainViewModel
的构造函数,发现它需要一个ITemperatureService
,然后根据注册的蓝图创建TemperatureService
实例并注入进去,最后将一个完全准备就绪的MainViewModel
对象交给你。
在 .NET 世界中,最常用、最标准的 IoC 容器实现是 Microsoft.Extensions.DependencyInjection
。我们来梳理一下这个框架使用的核心图像,大致可以被划分为三个核心阶段。
第一阶段:设计蓝图 (IServiceCollection
)
IServiceCollection
就是我们应用程序的依赖关系蓝图。
- 它是什么? 它是一个配置列表,一个“服务注册表”。它本身不做任何事情,只是用来记录规则。
- 它像什么?
- Dockerfile / docker-compose.yml:我们在这里声明式地定义了我们的应用环境。我们不会说“先 new A,再 new B,然后把 B 传给 A”,而是说“服务 A 依赖于接口 B 的实现”。在
docker-compose
中我们只定义depends_on
,而不关心容器启动顺序。 - 建筑蓝图:建筑师在图纸上画出承重墙、电路和水管的位置,但他并没有开始砌墙或接电线。他只是定义了所有组件之间的关系和规格。
- Dockerfile / docker-compose.yml:我们在这里声明式地定义了我们的应用环境。我们不会说“先 new A,再 new B,然后把 B 传给 A”,而是说“服务 A 依赖于接口 B 的实现”。在
- 核心操作:
AddSingleton
,AddTransient
,AddScoped
这些方法就是我们在蓝图上标注的指令,它们定义了组件的生命周期:AddSingleton
:在图纸上标注“公共设施”。比如整个大楼的中央空调主机,只需要一台,所有人共享。AddTransient
:在图紙上标注“一次性耗材”。比如每个办公室门口的访客登记表,每次有新访客来都用一张新的。
第二阶段:建造工厂 (BuildServiceProvider
)
一旦蓝图设计完成,我们就需要一个能根据这张图纸进行施工的团队。BuildServiceProvider()
就是这个“建造”动作。
- 它是什么? 它会读取
IServiceCollection
里的所有规则,进行验证(比如检查是否有循环依赖),然后创建一个高度优化的、只读的“服务提供者”——IServiceProvider
。 - 它像什么?
docker build
或docker-compose up -d --build
:这个命令会读取我们的Dockerfile/docker-compose.yml
,并实际地构建出镜像、创建网络、拉起容器,让我们的声明变成一个可运行的、真实的环境。- 将建筑蓝图交给施工队:施工队拿到图纸后,会制定施工计划,并准备好所有的工具和材料。这个准备就绪的施工队就是
IServiceProvider
。
这个被创建出来的 IServiceProvider
(IoC 容器) 是不可变的。一旦工厂建成,不能再给它添加新的生产线规则,这保证了应用程序在运行时的行为是稳定和可预测的。
第三阶段:启动装配线 (GetRequiredService<T>
)
现在工厂已经建好,随时可以生产产品了。GetRequiredService<T>()
就是我们下的第一张“生产订单”。
- 它是什么? 这是向 IoC 容器请求一个对象实例的入口点。
- 它像什么?
- 下单一辆汽车:向汽车工厂下订单要一辆顶配的汽车。工厂的全自动装配线就会启动:
- 订单:
GetRequiredService<MainViewModel>()
- 装配线分析:”好的,要生产
MainViewModel
。查阅蓝图,它的构造函数需要一个ITemperatureService
。” - 寻找零件:“
ITemperatureService
的规则是什么?哦,是TemperatureService
的一个单例。” - 获取/生产零件:“仓库里有
TemperatureService
的实例吗?没有。好吧,立刻生产一个。哦,TemperatureService
没有其他依赖,直接new
一个就行了。把它存到单例仓库里。” - 最终组装:“现在我手里有
TemperatureService
的实例了,可以把它传给MainViewModel
的构造函数,new
一个MainViewModel
出来。” - 交付产品:“
MainViewModel
生产完毕,这是你要的对象。”
- 订单:
- 下单一辆汽车:向汽车工厂下订单要一辆顶配的汽车。工厂的全自动装配线就会启动:
这个“链式的创建过程”,在专业术语里叫做依赖解析 (Dependency Resolution)。IoC 容器就是这个过程的自动化引擎,它会递归地分析整个依赖关系图 (Dependency Graph),并确保在创建任何对象之前,它的所有依赖项都已经被正确地创建和准备好。
实战:用 IoC 容器改造我们的应用
我们将把依赖注入的配置逻辑移到应用程序的真正入口——App.xaml.cs
。
第1步:安装 NuGet 包
在我们的 WPF 项目中,通过 NuGet 包管理器安装 Microsoft.Extensions.DependencyInjection
。
第2步:配置 App.xaml.cs
这是本次改造的核心。我们将在这里建立我们的“智能工厂”。
1 | using Microsoft.Extensions.DependencyInjection; |
结合这个例子,我们再整理一下控制反转在实际应用中的基本图像。在没有 IoC 容器的时候,我们是这样手动“接线”的:
1 | // 我们自己画接线图 |
我们作为“控制者”,精确地定义了 A 连接到 B。
而使用 IoC 容器的 IServiceCollection
进行配置时,我们完全改变了思路。我们不是在画一张“接线图”,而是在创建一本“零件目录”或“原料清单”。
我们向 IServiceCollection
中添加的每一行,都是在告诉它: * services.AddSingleton<ITemperatureService, TemperatureService>();
> “目录里记一下:如果将来有任何零件需要一个符合 ITemperatureService
规格的部件,就去生产线上造一个 TemperatureService
。哦对了,这个部件很特殊,是单例的,所以第一次造完后就把它放仓库里,以后谁要都给这个旧的。”
services.AddTransient<MainViewModel>();
> “目录里再记一下:我们也能生产MainViewModel
这种成品。这个东西是一次性的,每次有订单就造个全新的。”
关键点:在这个“配置原料”的阶段,MainViewModel
和 ITemperatureService
彼此之间毫不知情。容器也没有在这里建立任何它们之间的连接。
“接线”发生在什么时候?
发生在 serviceProvider.GetRequiredService<MainViewModel>()
被调用的那一刻。
当这个请求发出时,IoC 容器这个“总装配师”才会去翻阅它手中的“零件目录”,然后: 1. “哦,要一个 MainViewModel
。” 2. “让我看看 MainViewModel
的构造函数......啊,它需要一个 ITemperatureService
作为零件。” 3. “再查查目录,ITemperatureService
怎么造?......找到了,规则是提供一个 TemperatureService
的单例。” 4. “仓库里有 TemperatureService
吗?没有?那就造一个,放进去。” 5. “好了,现在我手里有 TemperatureService
了,可以把它传给 MainViewModel
的构造函数来完成最终组装了。”
配置阶段就是准备原料和生产说明。真正的组装(依赖注入)是按需、实时、自动发生的。我们把“如何组装”的控制权,从我们自己手里反转给了容器。
当然,目前我们的程序的接口只有一个具体的实现类:ITemperatureService
-> TemperatureService
,所以在 IServiceCollection
里我们直接把接口指定为这个类就可以了。但假如我们的接口有多个实现呢?为了解决这个问题,.NET 8+ 提供了叫作键控服务(Keyed Services)的方案。它的核心思想是,在注册服务时,给同一个接口的每个不同实现都附加一个唯一的键(可以实字符串或枚举)。在注入时,通过一个特性 [FromKeyServices]
来指定需要哪个键对应的服务。假如目前我们有两个 Window,对应两个 ViewModel,每个 ViewModel 依赖于一个不同的温度获取服务:
1 | private void ConfigureServices(IServiceCollection services) |
在 ViewModel 中,可以使用特性来注入:
1 | public partial class MainViewModel : ObservableObject |
第3步:清理 MainWindow.xaml.cs
现在,App.xaml.cs
承担了创建 ViewModel 的职责,我们的 MainWindow
后台代码可以恢复到最干净、最原始的状态!
1 | using System.Windows; |
第4步:修改 App.xaml
默认情况下,App.xaml
会有一个 StartupUri="MainWindow.xaml"
的属性,这会导致应用自动创建一个 MainWindow
实例,绕过我们在 OnStartup
里的逻辑。我们需要移除它。
打开 App.xaml
,删除 StartupUri
属性:
1 | <!-- 从 <Application ...> 标签中删除 StartupUri="MainWindow.xaml" --> |
现在运行我们的程序,它的行为和之前完全一样。但其内部架构已经发生了质的飞跃:我们不再需要手动管理对象的创建和依赖关系,一切都由 IoC 容器自动、可靠地完成了。
斩断最后一丝联系 —— ViewModelLocator
在前面的步骤中,我们通过 IoC 容器极大地简化了依赖管理。我们的 App.xaml.cs
现在是配置所有服务和 ViewModel 的中心,而 MainWindow.xaml.cs
已经变得非常干净。
但仔细观察 App.xaml.cs
,我们仍然能发现一个小小的“瑕疵”:
1 | // 在 App.xaml.cs 的 OnStartup 方法中... |
- 视图与逻辑的耦合:我们的应用启动逻辑 (
App.xaml.cs
) 仍然需要明确知道并创建MainWindow
这个具体的视图。如果想把启动窗口换成LoginWindow
,就必须修改这里的代码。 - XAML 设计器失效:这是一个 WPF 开发中的经典痛点。当我们f打开
MainWindow.xaml
的可视化设计器时,它只是简单地渲染 XAML,并不会执行App.xaml.cs
里的OnStartup
逻辑。因此,设计器里的DataContext
永远是null
,所有的绑定都会失效,无法预览界面效果。
为了解决这两个问题,我们引入 ViewModelLocator 模式。
ViewModelLocator 是什么?
ViewModelLocator 是一个专门用来连接视图(View)和视图模型(ViewModel)的“桥梁”。
它是一个全局可访问的类,被放置在 XAML 的资源字典中。它的唯一工作就是对外暴露一系列属性,每个属性都对应一个 ViewModel 实例。当视图在 XAML 中请求它的 DataContext
时,它会绑定到 ViewModelLocator 上的某个属性,这个属性的 get
访问器则会从我们的 IoC 容器中解析出对应的 ViewModel 实例。
这样,视图(XAML)和 IoC 容器(C#)这两个世界就被优雅地连接起来了,并且完全不需要任何后台代码(code-behind)的干预。
实战:用 ViewModelLocator 完成最终改造
第1步:创建 ViewModelLocator.cs
这个类非常简单,它就像一个 ViewModel 的目录。
1 | using Microsoft.Extensions.Dependency-injection; |
代码解释:
- 我们为每个需要被视图绑定的 ViewModel 都创建了一个只读属性(
MainViewModel
,HistoryViewModel
)。 - 每个属性的
get
访问器都会通过App.ServiceProvider
从 IoC 容器中请求一个 ViewModel 实例。
注意,这里我们又遇到了一个新的语法糖,叫作表达式主体定义(Expression-bodied definition),它是用于替代那些只包含一个 return
的语句的 get
访问器的。以下两段代码在功能上是完全等价的:
1 | // 经典写法 |
都是定义了一个类型是 MainViewModel
的属性,这个属性的名字也是 MainViewModel
。可以把 => 理解为:“当这个属性被访问时,它的值由箭头后面的这个表达式来定义。”
1 | public class Calculator |
它和 Lambda 表达式是不一样的,Lambda 表达式的使用场景是作为方法参数、赋值给委托变量;而表达式主体定义是在类/结构体内部,声明一个成员的实现:
特性 | Lambda 表达式 | 表达式主体定义 |
---|---|---|
本质 | 匿名函数 (一个可以传递的值) | 语法糖 (一种简化的写法) |
有名字吗? | 没有。它本身是匿名的。 | 有。它定义的是一个有名字的成员 (方法名、属性名等)。 |
使用场景 | 作为方法参数、赋值给委托变量 | 在类/结构体内部,声明一个成员的实现 |
替代了什么? | 替代了手写一个完整的命名方法再创建委托的过程 | 替代了方法体/属性访问器的大括号 { return ...; } |
=> 的含义 | “goes to” 或 “maps to” (输入映射到输出) | “is defined as” (这个成员的实现是...) |
第2步:清理启动逻辑
我们彻底移除 OnStartup
中所有与 MainWindow
相关的代码。
1 | using Microsoft.Extensions.DependencyInjection; |
OnStartup
现在只负责一件事:配置和构建 IoC 容器。它的职责变得非常单一和清晰。
第3步:在 App.xaml
中将 ViewModelLocator 声明为全局资源
现在,我们在应用的 XAML 资源字典里创建 ViewModelLocator
的一个实例。
1 | <Application x:Class="WpfApp.App" |
代码解释:
- 我们把
StartupUri="MainWindow.xaml"
重新加了回来。因为App.xaml.cs
不再负责创建窗口,我们需要告诉 WPF 应用应该首先启动哪个窗口。 <local:ViewModelLocator x:Key="Locator"/>
这行代码会在应用启动时,创建一个ViewModelLocator
的实例,并给它起一个名字叫 "Locator",这样我们就可以在其他任何地方通过这个名字来引用它。
第4步:在 MainWindow.xaml
中完成最终的绑定
打开 MainWindow.xaml
,我们用一种纯粹的 XAML 方式来设置 DataContext
。
1 | <Window x:Class="MvvmDemoApp.MainWindow" |
绑定代码剖析:
Source={StaticResource Locator}
:这告诉绑定系统,“数据源不是当前元素,请去资源字典里查找一个名叫Locator
的对象。”Binding MainViewModel
:这接着告诉它,“在那个Locator
对象上,找到一个名叫MainViewModel
的属性,并把它的值作为DataContext
。”
这一切是如何串联起来的?
- 应用启动,WPF 加载
App.xaml
,看到StartupUri="MainWindow.xaml"
; - 在创建
MainWindow
之前,WPF 初始化应用资源,于是<local:ViewModelLocator x:Key="Locator"/>
被执行,一个ViewModelLocator
实例被创建; - WPF 开始创建
MainWindow
实例; - 当渲染
MainWindow
时,它解析到DataContext="{Binding ...}"
; - 绑定系统触发,它找到了名为
Locator
的资源,然后访问了它的MainViewModel
属性; ViewModelLocator
的MainViewModel
属性的get
访问器被调用;- 访问器代码
App.ServiceProvider!.GetRequiredService<MainViewModel>()
执行,向我们早已准备好的 IoC 容器请求一个MainViewModel
; - IoC 容器自动解析
MainViewModel
的所有依赖(ITemperatureService
),创建并返回一个完整的实例; - 这个实例被设置为
MainWindow
的DataContext
; - 绑定成功,UI 正常显示数据。
最终我们得到了一个完美的架构:
- View (XAML):完全不知道 ViewModel 的具体类型,只通过一个全局的 Locator 来连接;
- Code-behind (
.xaml.cs
):完全是空的; - ViewModel:不知道任何关于 View 的信息,只专注于业务逻辑和数据;
- Service:实现了具体的业务,并被 Io-C 容器管理;
- IoC Container (
App.xaml.cs
):是唯一的配置中心,但它不与任何具体的 View 耦合。
我们已经走完了一条从混乱的 UI 驱动开发,到最终实现高度解耦、可测试、可维护的现代化 MVVM 架构的完整路径。
总结
我们回顾一下这篇博客:
- 告别混乱:我们首先摒弃了将逻辑、数据和UI操作纠缠在一起的“UI驱动”模式,转向了以数据为核心的现代开发思想;
- 拥抱MVVM:我们引入了 MVVM 模式,它如同一份清晰的蓝图,为我们划分了视图(View)、视图模型(ViewModel)和模型(Model)的职责边界,实现了最初的关注点分离;
- 依赖注入(DI):为了让 ViewModel 摆脱对具体服务的依赖,我们采用了依赖注入的原则,使得 ViewModel 变得高度可测试,也为服务的替换和升级打开了大门;
- IoC容器:面对手动注入日益增长的复杂性,我们请来了 IoC 容器这位“智能管家”。它自动化了整个对象的创建和依赖装配过程,将我们从繁琐的“接线”工作中解放出来,让我们只需关注“蓝图”的绘制;
- ViewModelLocator:最后,我们通过 ViewModelLocator 这座“优雅的桥梁”,彻底斩断了视图与后台逻辑的最后一点联系,实现了纯粹的XAML驱动开发,并解决了设计器预览的痛点。
这不仅仅是一系列技术的堆砌,更是一种开发思想的升华。我们所做的一切,都是为了追求软件工程的终极目标:高内聚、低耦合。我们构建的系统不再是脆弱的“纸牌屋”,每一个模块都变得独立、健壮、且易于测试和维护。我们文中的例子虽然简单——只是一个实时更新的温度计——但它如同一只“麻雀”,五脏俱全。无论是复杂的工业监控系统,还是功能丰富的商业桌面应用,其背后健壮的架构都离不开这些内容。