WPF 和 MVVM
WPF 和 MVVM
[TOC]
基本构成与启动流程
1. 整体印象:WPF项目的文件结构
Windows Presentation Foundation (WPF) 是一个用于创建 Windows 桌面应用程序的 UI 框架,它巧妙地将应用程序的界面(UI)与业务逻辑分离。当我们创建一个最基础的 WPF 项目时,通常会看到以下几个核心文件:
1 | MyWpfApp/ |
我们可以这样理解它们之间的关系:
- XAML 文件 (
.xaml
): 类似于 Web 开发中的 HTML,它是一种基于 XML 的声明式语言,用于定义 UI 的结构、布局和外观。例如,你可以在这里放置按钮、文本框等控件。 - 后置代码文件 (
.xaml.cs
): 它是与 XAML 文件配对的 C# 代码文件。主要负责处理该 UI 界面中的交互逻辑,例如响应按钮点击、处理用户输入等。它就像是为 HTML 界面编写的 JavaScript 脚本,但功能更为强大。(使用 MVVM 架构的话,它就只是用于辅助 UI 的)
2. 编译的幕后:XAML 如何变成 C# 代码
一个有趣的问题是,XAML 仅仅是一个标记文件,计算机并不能直接执行它。在编译 WPF 项目时,构建工具(如 MSBuild)会对 .xaml
文件进行处理,生成两个重要的中间文件:
.g.i.cs
(generated internal C#) 文件: 这是一个自动生成的 C# 代码文件。它会将 XAML 中定义的 UI 元素转换成 C# 对象,并将 XAML 中设置的属性(如Title
、Height
)转化为对这些对象属性的赋值操作。.baml
(Binary Application Markup Language) 文件: 这是 XAML 的二进制、标记化表示形式,经过了优化,加载速度比解析原始的 XML 文本更快。它作为资源嵌入到最终的程序集中。
关键在于,.g.i.cs
文件和我们手动编写的 .xaml.cs
文件都使用了 partial
关键字来定义同一个类。这意味着在编译时,这两个部分会合并成一个完整的类。
让我们以 MainWindow.xaml
为例:
1 | <Window x:Class="MyWpfApp.MainWindow" |
x:Class="MyWpfApp.MainWindow"
指示此 XAML 文件定义的是 MyWpfApp.MainWindow
这个类的一部分。其另一部分则在 MainWindow.xaml.cs
中:
1 | using System.Windows; |
当我们调用 InitializeComponent()
方法时,实质上是执行了由 XAML 编译而来的 C# 代码。这个方法的核心作用是加载对应的 BAML 资源,通过反序列化过程,在内存中创建出我们在 XAML 中定义的 Window
、Grid
、TextBlock
等对象的实例树,并设置好它们的各种属性。
3. 程序的起点:Main
函数在哪里?
细心的你可能会发现,在我们的项目中并没有找到熟悉的 static void Main()
入口函数。那么,程序究竟是如何启动的呢?
答案隐藏在 App.xaml
和 App.xaml.cs
中。与 MainWindow
类似,App.xaml
在编译时会生成一个包含 Main
函数的 App.g.i.cs
文件。这个自动生成的 Main
函数就是整个 WPF 应用程序的真正入口点。
默认的 App.xaml
文件内容如下:
1 | <Application x:Class="MyWpfApp.App" |
当程序启动时,流程大致如下: 1. 自动生成的 Main
函数被执行。 2. Main
函数创建 App
类的一个实例 (new App()
)。 3. 接着调用 App
实例的 InitializeComponent()
方法,加载 App.xaml
中定义的资源。 4. 最后,它会检查 App.xaml
中定义的 StartupUri
属性。在这里,StartupUri="MainWindow.xaml"
指示应用程序应该自动创建 MainWindow
类的一个实例,并将其显示出来作为主窗口。 5. Application
对象随后启动一个名为 Dispatcher 的消息循环,负责处理用户输入(键盘、鼠标)、更新数据绑定、执行UI重绘等任务,保证界面的响应和持续运行。
sequenceDiagram autonumber participant CLR as "CLR (.NET Runtime)" participant Main as "自动生成的 Main() in App.g.i.cs" participant App as "App 实例" participant MainWindow as "MainWindow 实例" participant Dispatcher as "Dispatcher (UI线程消息循环)" CLR->>Main: 执行程序入口 Main() Main->>App: new App() App->>App: InitializeComponent()
(加载 App.xaml 中的资源) Main->>App: 调用 App.Run() App->>MainWindow: 根据 StartupUri="MainWindow.xaml"
创建 MainWindow 实例 %% 用 rect 块标注“MainWindow 初始化过程”(替代 subgraph) rect rgba(200, 220, 255, 0.25) Note over MainWindow: MainWindow 初始化过程 MainWindow->>MainWindow: InitializeComponent() Note right of MainWindow: 1. 加载 BAML
2. 反序列化,构建控件树 (Window, Grid...)
3. 设置控件属性 end MainWindow->>MainWindow: Show()(将窗口显示在屏幕上) App->>Dispatcher: 启动消息循环 %% 用 loop 表达消息循环,更语义化 loop 消息循环 Dispatcher-->>Dispatcher: 处理:
- 用户输入(鼠标/键盘)
- 渲染更新
- 数据绑定等 end
XAML 布局系统
在了解了 WPF 应用程序的基本结构和启动流程后,我们现在深入探讨其最直观的部分:UI 布局。如果你有 Web 开发经验,你会发现 WPF 的布局理念非常亲切。
1. 核心理念:万物皆“嵌套”与“容器”
WPF 的界面布局与 HTML 非常相似,都是通过一层层嵌套的标签来构建可视化树。
- 最外层的
Window
标签代表一个独立的窗口,好比是网页的<html>
标签。 - 要在
Window
中安排内容,我们不能像在画板上随意拖拽(虽然技术上可以,但这不是推荐的最佳实践),而是需要使用布局容器(Layout Panels)。这些容器就如同 HTML 中的div
加上了flexbox
或grid
样式,它们负责定义其内部子元素的排列、定位和尺寸规则。
在 WPF 中,最常用也最基础的布局容器有两个:Grid
和 StackPanel
。
2. Grid
:强大的网格布局
Grid
是 WPF 中功能最强大、最灵活的布局容器。顾名思义,它将界面区域划分为一个由行和列组成的不可见网格,然后你可以将任何控件精确地放置在某个或某几个单元格中。
定义行与列
要定义网格的结构,我们需要在 <Grid>
标签内部使用 Grid.RowDefinitions
和 Grid.ColumnDefinitions
集合:
1 | <Grid> |
Height
和 Width
的值有三种主要类型:
- 固定值 (Fixed): 如
Width="200"
,表示一个精确的、设备无关的像素单位。 - 自动 (Auto): 如
Height="Auto"
,表示该行或列的尺寸将根据其内部最大子元素的大小来自动调整,实现“自适应内容”。 - 比例 (Star *): 如
Height="*"
,这是最有用的一个。星号(*)代表按比例分配剩余空间。*
相当于1*
。如果你有两个分别为*
和2*
的列,那么在分配完固定和自动尺寸后,后者获得的剩余空间将是前者的两倍。
放置控件与跨行跨列
定义好网格后,我们使用附加属性(Attached Properties)Grid.Row
和 Grid.Column
来告诉父级 Grid
容器应该把子控件放在哪个单元格。
- 行和列的索引都是从 0 开始的。
- 如果不显式指定,控件默认被放置在
Grid.Row="0"
和Grid.Column="0"
的位置。
1 | <Grid> |
这里还引入了 Grid.ColumnSpan="2"
,它能让一个控件横跨多个列(同理,Grid.RowSpan
可以跨越多行)。
嵌套 Grid
实现复杂布局
Grid
的强大之处在于它可以嵌套。你可以在一个大的 Grid
单元格里再放入一个新的 Grid
来进行更精细的局部布局,这使得构建复杂的界面结构成为可能。
1 | <Grid> |
3. StackPanel
:简洁的堆叠布局
如果你的布局需求很简单,只是想让一系列控件水平或垂直地排列,那么 Grid
就有点“杀鸡用牛刀”了。这时,StackPanel
是更好的选择。
StackPanel
会将其子元素按照添加的顺序,一个接一个地堆叠起来。
- 默认方向是垂直(
Orientation="Vertical"
)。 - 可以轻松改为水平方向(
Orientation="Horizontal"
)。
1 | <!-- 垂直堆叠 (默认) --> |
4. 填充内容:常用控件
搭建好布局框架后,就该往里面填充实际的“血肉”——控件了。最基础的两个文本相关控件是:
<TextBlock>
: 用于显示只读文本。它轻量、高效,是界面上各种标签、标题、描述文字的首选。1
2
3
4
5
6<TextBlock Text="欢迎使用 WPF"
FontSize="16"
FontWeight="Bold"
Foreground="DarkBlue"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"/><TextBox>
: 用于输入和编辑文本。它提供了用户与程序交互的窗口。1
2
3
4
5
6
7<TextBox Width="300" Height="100"
TextWrapping="Wrap"
AcceptsReturn="True"
VerticalScrollBarVisibility="Auto"
FontSize="14"
Foreground="Black"
Background="LightYellow"/>
到目前为止,我们已经能够用 XAML 构建出一个结构清晰、外观固定的静态界面了。
UI交互的两种思想
我们已经学会了如何用 XAML 搭建静态的用户界面。现在,是时候让这个界面“活”起来了。当用户点击按钮或输入文本时,程序应该如何响应?在桌面应用开发领域,主要有两种主流的交互思想:传统的事件驱动(Event-Driven)模型和现代的MVVM(Model-View-ViewModel)模式。
1. 传统方式:事件驱动 (Event-Driven)
事件驱动是最符合人类直觉的编程方式。它的核心思想非常简单:“当某个事件发生时,执行对应的处理代码。”
- 事件 (Event):由用户或系统产生的动作,例如鼠标点击、键盘输入、窗口尺寸改变等。
- 事件处理器 (Event Handler):一段专门用于响应特定事件的 C# 代码(一个方法)。
- 事件驱动编程:程序不再是自上而下顺序执行,而是进入一个等待状态,直到某个事件发生,才触发相应的处理器去执行。
在 WPF 中,我们通过在 XAML 中为控件指定事件处理器方法名,然后在后置代码(.xaml.cs
)中实现这个方法,来完成连接。
XAML (The "View"):
1 | <!-- "Click" 是事件, "SubmitButton_Click" 是事件处理器的名字 --> |
C# Code-behind (The "Handler"):
1 | private void SubmitButton_Click(object sender, RoutedEventArgs e) |
优点:
- 直观易懂:逻辑清晰,“点击按钮 -> 执行代码”,非常容易上手。
- 快速实现:对于简单的界面和逻辑,这种方式开发速度极快。
缺点 (随着项目变大,问题会愈发严重):
- 高度耦合 (The "Code-Behind Jungle"):UI (View) 和逻辑代码 (Code-behind) 像藤蔓一样紧紧缠绕在一起。如果你想更换
TextBlock
为另一个控件,或者重构 XAML 布局,就必须去修改.xaml.cs
中的代码。 - 难以测试:业务逻辑(如
SaveDataToDatabase()
)和 UI 更新逻辑(StatusText.Text = ...
)混杂在一起。你无法在不创建和运行整个UI窗口的情况下,单独测试你的业务逻辑。 - 难以维护:随着功能增多,
.xaml.cs
文件会变得异常臃肿,包含大量的事件处理器和临时状态变量,成为“意大利面条式代码”的重灾区。
旁注:Qt的信号与槽 (Signal & Slot)
Qt 框架中的信号与槽机制本质上是一种更优雅、更解耦的事件驱动模型。一个对象发出信号(Signal),另一个对象的槽(Slot)可以连接并响应它。它通过一个中介(
connect
函数)建立了连接,使得信号发送方和接收方可以互不知道对方的存在,实现了更好的解耦,甚至支持跨线程通信。这可以说是对传统事件模型的一次重大升级,其解耦思想也预示了 MVVM 的发展方向。
2. 现代思想:MVVM (Model-View-ViewModel)
当事件驱动的缺点在大型项目中变得无法忍受时,社区寻求一种能将 UI (View) 和 业务逻辑 (Model) 彻底分离的架构模式,MVVM 应运而生。
graph TD subgraph "传统事件驱动 (紧密耦合)" direction TB View["View (XAML + Code-Behind)"] Model["Model (业务逻辑)"] View <-->|直接操作/事件回调| Model end subgraph "MVVM (解耦)" direction TB M3[Model
业务数据/逻辑] VM[ViewModel
UI 状态 + 命令
INotifyPropertyChanged] V3[View
XAML + 数据绑定] M3 <-->|数据交互| VM VM <-->|数据绑定 - Data Binding
命令 - Commands| V3 end style View fill:#f9f,stroke:#333,stroke-width:2px style V3 fill:#f9f,stroke:#333,stroke-width:2px
在事件驱动模型中,我们只有两层:View (XAML) 和 Code-behind (混合了UI逻辑和业务逻辑)。MVVM 引入了一个关键的中间层——ViewModel,形成了三层结构。
- Model (模型层): 代表你的应用程序的核心业务数据和逻辑。它不关心任何关于界面的事情。例如,
User
类、数据库连接、文件读写操作等。它就是最纯粹的数据和操作。 - View (视图层): 就是我们的
.xaml
文件。它的职责只有一个:以美观的方式展示数据,并把用户的操作行为通知给 ViewModel。View 应该是“哑”的,它不包含任何业务逻辑。 - ViewModel (视图模型层): MVVM 的核心。它扮演着 View 和 Model 之间的桥梁或翻译官。
- 它从 Model 获取原始数据,并将其转换成 View 需要的格式进行暴露(例如,将
DateTime
对象转换成"2025-09-08"
字符串)。 - 它暴露命令 (Commands) 给 View,用于响应用户的操作(例如,一个
SaveCommand
)。 - 它持有 View 所需的状态(例如,
IsBusy
属性来控制一个加载动画的显示和隐藏)。
- 它从 Model 获取原始数据,并将其转换成 View 需要的格式进行暴露(例如,将
MVVM 解决了什么问题?
- 彻底解耦: View 只认识 ViewModel,Model 也只跟 ViewModel 打交道。View 和 Model 之间没有任何直接联系。你可以轻易地替换整个 View (比如从WPF桌面版换成移动版UI),只要 ViewModel 不变,底层的 Model 和逻辑完全不需要改动。
- 可测试性: ViewModel 是一个纯粹的 C# 类,不依赖任何具体的 UI 控件。这意味着你可以非常轻松地对它进行单元测试。你可以模拟用户行为(调用一个命令),然后断言 ViewModel 中的状态是否正确改变,整个过程完全不需要启动任何UI界面。
- 可维护性: 职责清晰。XAML 负责“看”,ViewModel 负责“想”,Model 负责“做”。代码不再是混乱的一团,而是分门别类,易于理解和修改。
命令 (Command) vs. 事件 (Event)
看到这里,你可能会有一个疑问:ViewModel 暴露“命令”给 View 去执行,这听起来不还是和“事件”差不多吗?都是“用户点击 -> 执行代码”。这是一个非常好的问题,也是理解 MVVM 精髓的关键。它们之间存在本质区别:
- 事件 是以 View 为中心的。它在说:“嘿,我在这个按钮上被点击了!” 它的处理逻辑通常写在 View 的后置代码里,与UI 控件紧密相关。
- 命令 是以 ViewModel 为中心的。它在说:“用户想要执行‘保存’这个意图。” View 只负责将用户的点击行为传递给这个“保存”意图,而完全不关心“保存”具体是怎么实现的。这个实现过程被封装在 ViewModel 中,与任何特定的UI按钮都无关。
简单来说,事件描述了“发生了什么”,而命令描述了“想要做什么”。
这种从“关心UI交互”到“关心用户意图”的转变,是 MVVM 模式解耦能力的核心。
上手第一个MVVM框架——MVVM Light
要真正实现 ViewModel 和 View 之间的数据同步与通信,我们需要解决两个关键的技术问题:
- 当 ViewModel 中的数据变化时,如何通知 View 更新?(这需要实现
INotifyPropertyChanged
接口) - 如何将 View 上的用户操作(如点击按钮)优雅地传递给 ViewModel 中的方法?(这需要实现
ICommand
接口)
虽然我们可以手动编写所有这些“胶水代码”,但这既繁琐又容易出错。这正是 MVVM 框架的价值所在:它们将这些底层、重复的基础设施封装起来,让我们能更专注于业务逻辑本身。
1. 认识 MVVM Light 框架
在 WPF 的世界里,MVVM Light 是一个非常经典且具有里程碑意义的框架。它轻量、易懂,帮助无数开发者走上了 MVVM 的道路。
不过需要明确的是,MVVM Light 项目目前已经停止更新和维护。其原作者 Laurent Bugnion 已加入微软,并将其精神和经验融入了新的官方推荐工具包中。当前,微软官方更推荐开发者使用 CommunityToolkit.Mvvm
(也称为 MVVM Toolkit)。它在 API 设计上与 MVVM Light 非常相似,并利用了最新的 C# 语言特性(如源代码生成器)来提供更强的性能和更简洁的语法。
然而,由于历史原因,我们仍然会遇到大量使用 MVVM Light 构建的存量项目。此外,MVVM Light 的设计思想是后续所有现代 MVVM 框架的基石。学会它,可以几乎无缝切换到 CommunityToolkit.Mvvm
或其他框架。因此,我们这里还是以 MVVM Light 为例,说明如何在 WPF 中使用 MVVM。
在 .NET 生态中,我们使用 NuGet 作为包管理器来添加第三方库,这和 Java 中的 Maven/Gradle、JavaScript 中的 npm/yarn 是一个道理。
方式一:使用 Visual Studio 的 NuGet 包管理器 (GUI)
如果在使用 Visual Studio,这是最直观的方式:
- 在解决方案资源管理器中,右键项目名称。
- 在弹出的菜单中,选择 “管理 NuGet 程序包...”。
- 在打开的窗口中,切换到 “浏览” 选项卡。
- 在搜索框中输入
MvvmLightLibs
。 - 在搜索结果中找到
MvvmLightLibs
包,点击它,然后在右侧面板点击 “安装”。 - Visual Studio 会处理好所有的下载和配置工作。
方式二:使用 .NET CLI (命令行)
如果更喜欢使用命令行,或者在使用 Visual Studio Code 这样的编辑器,可以使用 .NET Core 命令行接口(CLI)。
打开一个终端或命令行工具 (如 PowerShell, cmd, or Terminal)。
使用
cd
命令导航到项目文件夹(包含.csproj
文件的那个目录)。运行以下命令:
1
dotnet add package MvvmLightLibs
这个命令会自动寻找最新稳定版的 MvvmLightLibs
包,下载并配置到项目中。
无论使用哪种方式安装,NuGet 都会做两件核心的事情:
第一,将包下载到本地缓存中。
这个全局缓存的位置通常在:
- Windows:
%UserProfile%\.nuget\packages
- Linux / macOS:
~/.nuget/packages
这样做可以避免多个项目重复下载同一个包。
第二,修改项目配置文件 (.csproj
)。
打开 .csproj
文件,会发现多了一行类似这样的内容:
1 | <ItemGroup> |
这个 .csproj
文件是 C# 项目的核心配置文件,它定义了项目依赖、编译选项等所有信息。这与 Java 世界里 Maven 的 pom.xml
或 Gradle 的 build.gradle
文件扮演着完全相同的角色。
有了这条 <PackageReference>
记录,构建工具(MSBuild)在编译项目时,就会知道需要去 NuGet 缓存中找到对应版本的 MvvmLightLibs.dll
文件,并将其引用到项目中。这样,就可以在 C# 代码里通过 using GalaSoft.MvvmLight;
来使用框架提供的功能了。
2. 连接的桥梁——DataContext
我们已经准备好了 View (XAML) 和 ViewModel (一个继承自 ViewModelBase
的 C# 类),但它们目前是两个互不相干的东西。现在,我们需要搭建一座桥梁,让 View 知道应该从哪个 ViewModel 实例中去获取数据和命令。在 WPF 中,这座桥梁就是 DataContext
属性。
每个 UI 元素(从顶层的 Window
到内部的 Button
、TextBlock
)都有一个 DataContext
属性。当在一个控件上使用数据绑定(例如 {Binding UserName}
)时,WPF 会沿着 UI 树向上查找,直到找到一个不为空的 DataContext
,然后在这个 DataContext
对象中寻找名为 UserName
的公共属性。
因此,我们的核心任务就是:为我们的主窗口 (MainWindow
) 设置正确的 DataContext
,让它指向我们 MainViewModel
的一个实例。
首先,让我们准备好一个基础的 ViewModel。按照惯例,我们会在项目中创建一个 ViewModels
文件夹,并在其中定义 MainViewModel.cs
:
1 | // /ViewModels/MainViewModel.cs |
接下来,我们有两种主流的方式来完成 View 和 ViewModel 的“联姻”。
方式一:在 Code-Behind (.xaml.cs) 中设置
这是最直接、最容易理解的方式。我们直接在窗口的构造函数中,手动创建 ViewModel 的实例,并将其赋值给窗口的 DataContext
属性。
文件: MainWindow.xaml.cs
1 | using System.Windows; |
工作流程: 1. 程序运行时,MainWindow
的构造函数被调用。 2. InitializeComponent()
方法首先执行,加载并构建 MainWindow.xaml
中定义的控件树。 3. 紧接着,this.DataContext = new MainViewModel();
这行代码执行,将整个 MainWindow
的数据源指向了我们刚刚创建的 MainViewModel
实例。 4. 从此,XAML 中任何 {Binding ...}
语法都会自动到这个 MainViewModel
实例中去寻找对应的属性。
方式二:在 XAML 中声明 (纯粹的MVVM)
这种方式更加“纯粹”,它允许你的 .xaml.cs
文件保持完全干净,不写一行代码。
文件: MainWindow.xaml
1 | <Window x:Class="MyWpfApp.MainWindow" |
语法解析:
xmlns:vm="clr-namespace:MyWpfApp.ViewModels"
:xmlns
是 XML namespace 的缩写,意为“XML 命名空间”。:vm
是我们给这个命名空间起的别名,你可以用任何你喜欢的名字(vm
,viewmodel
,local
都可以)。clr-namespace:
是一个固定的关键字,告诉 XAML 解析器,我们要映射的是一个 .NET 的命名空间。MyWpfApp.ViewModels
是 C# 中MainViewModel
类所在的完整命名空间。- 整句话的意思是:“在当前 XAML 文件中,我用别名
vm
来代表 C# 中的MyWpfApp.ViewModels
这个命名空间。”
<Window.DataContext>
: 这是 WPF 的“属性元素语法”,允许我们为一个复杂的属性(如此处的DataContext
对象)赋值。<vm:MainViewModel/>
: 当 XAML 解析器读到这一行时,它会利用之前定义的vm
别名找到MyWpfApp.ViewModels.MainViewModel
这个类型,并调用其无参数的构造函数来创建一个实例,然后将这个实例赋值给Window.DataContext
。
混合使用?执行顺序决定一切
一个常见的困惑是:如果我在 .xaml.cs
和 .xaml
中都设置了 DataContext
,哪个会生效?
答案很简单:最后执行的那个会覆盖前面的。
让我们看看这个例子: 1
2
3
4
5
6
7
8
9
10
11
12
13
14// MainWindow.xaml.cs
public partial class MainWindow : Window
{
public MainWindow()
{
// [第1步] 在代码中,DataContext 被设置为 ViewModel 实例 A
this.DataContext = new MainViewModel();
// [第2步] InitializeComponent() 执行,它会解析 MainWindow.xaml
// 如果 XAML 中也定义了 <Window.DataContext>,
// 那么在这里,DataContext 会被一个新的 ViewModel 实例 B 覆盖。
InitializeComponent();
}
}
由于 InitializeComponent()
在手动赋值之后执行,因此 XAML 中设置的 DataContext
将会覆盖代码中设置的。
最佳实践:
为了避免混淆,对于同一个视图,请只选择一种方式来设置 DataContext
。通常来说:
- 对于初学者和需要向 ViewModel 传递参数的场景,使用 Code-Behind 的方式更佳。
- 对于简单的、无依赖的 ViewModel,或者追求极致解耦的场景,可以使用 XAML 的方式。
3. MVVM的“心脏”——属性绑定与变更通知
我们已经将 View 的 DataContext
指向了 ViewModel 实例。现在,我们需要深入探讨数据是如何真正在这两者之间流动的。这个过程的核心,就是 C# 的属性 (Property) 和 WPF 强大的数据绑定 (Data Binding) 系统。
绑定的基石:C# 属性 (Property) vs. 字段 (Field)
在开始绑定之前,必须先理解一个 C# 的核心概念:字段和属性是两码事。
字段 (Field):是类内部用来存储数据的私有变量。它就像一个仓库里的储物箱,直接存放数据。按照良好的编程实践,字段通常是
private
的。1
2
3
4public class Person
{
private string _name; // 这是私有字段,通常以下划线开头
}属性 (Property):是外部访问内部字段的公共“门卫”。它看起来像一个公共变量,但本质上是
get
和set
两个方法的语法糖。可以在这些方法里添加逻辑,比如验证数据、记录日志,或者——这在 MVVM 中至关重要——通知 UI 进行更新。1
2
3
4
5
6
7
8
9
10public class Person
{
private string _name; // 私有字段 (The Data)
public string Name // 公共属性 (The Gatekeeper)
{
get { return _name; }
set { _name = value; } // "value" 是一个上下文关键字,代表传入的值
}
}
一个黄金法则:WPF 的数据绑定系统只能绑定到 public
的属性上,不能绑定到 public
的字段上。
为了简化代码,如果 get
/set
中没有额外逻辑,C# 允许我们使用自动属性 (Auto-Implemented Property),编译器会自动为我们生成一个隐藏的私有字段。
1 | // 这段代码和上面那个长版本的效果完全一样 |
MVVM 的魔法:会“说话”的属性
如果只是一个普通的属性,当我们在代码中改变了它的值,UI 是不会知道的,也就不会自动更新。为了让属性拥有“通知”的能力,MVVM Light 框架的 ViewModelBase
类提供了一个至关重要的方法:Set()
。
让我们来看一下 MainViewModel
中属性的正确写法:
文件: /ViewModels/MainViewModel.cs
1 | using GalaSoft.MvvmLight; |
这里的 Set(ref _group, value)
就是魔法的核心。当给 Group
属性赋一个新值时,Set()
方法内部会做三件事: 1. 检查值是否有变化:它会比较新传入的 value
和旧的 _group
值是否相同。如果相同,则不做任何事,避免不必要的刷新。 2. 更新字段:如果值有变化,它会更新私有字段 _group
的值。 3. 发出通知:这是最关键的一步!它会触发一个名为 PropertyChanged
的事件,并广播一条消息:“嘿!所有绑定到 Group
属性的 UI 元素,我的值已经变了,请立即更新你们的显示!”
ViewModelBase
已经为我们处理了所有实现 INotifyPropertyChanged
接口的复杂细节,我们只需要调用 Set()
方法即可。
在 XAML 中建立连接
现在 ViewModel 的属性已经具备了通知能力,我们可以在 XAML 中使用 {Binding}
标记扩展将 UI 控件的属性与 ViewModel 的属性连接起来。
文件: MainWindow.xaml
1 | <Window ... (省略 xmlns 定义) ...> |
注意,绑定不是单一的,它有方向:
TwoWay
(双向绑定):数据可以在 ViewModel 和 View 之间双向流动。这是TextBox.Text
、CheckBox.IsChecked
等用户输入控件的默认模式。OneWay
(单向绑定):数据只能从 ViewModel (源) 流向 View (目标)。这是TextBlock.Text
等只读控件的默认模式。
在上面的 TextBox
示例中,我们还加了一个 UpdateSourceTrigger=PropertyChanged
。这意味着用户在文本框中每输入一个字符,ViewModel 的属性就会立即更新。默认情况下,TextBox
的更新触发器是 LostFocus
,即只有当焦点离开文本框时才会更新。PropertyChanged
提供了更实时的交互体验。
现在,让我们把所有部分串联起来,看看当用户在界面上输入时,到底发生了什么:
sequenceDiagram %% 可选:自动编号 autonumber participant User as 用户 participant TextBox as TextBox (View) participant Binding as WPF Binding Engine participant ViewModel as MainViewModel participant TextBlock as TextBlock (View) User->>TextBox: 输入字符 '1' TextBox->>Binding: Text 属性变为 "239.0.0.2221" Note over Binding: TwoWay 绑定生效 Binding->>ViewModel: 调用 Group 属性的 set 访问器,传入新值 ViewModel->>ViewModel: Set(ref _group, "...") %% 用 rect 高亮 ViewModel 内部逻辑,而不是 subgraph rect rgba(230, 240, 255, 0.5) Note over ViewModel: 1. 检查值是否变化(是)
2. 更新 _group 字段
3. 触发 PropertyChanged("Group") end ViewModel-->>Binding: 发出 "Group" 属性已变更的通知 Binding->>TextBlock: 收到 "Group" 变更通知 Note over Binding: OneWay 绑定生效 Binding->>ViewModel: 调用 Group 属性的 get 访问器 ViewModel-->>Binding: 返回新值 "239.0.0.2221" Binding->>TextBlock: 更新 TextBlock.Text 属性 TextBlock-->>User: 界面显示更新
通过这套精巧的机制,我们成功地将 View 和 ViewModel 解耦。ViewModel 只负责管理数据和状态,完全不知道界面长什么样;View 则通过数据绑定忠实地反映 ViewModel 的状态,实现了UI的自动化更新。
4. MVVM的“手臂”——命令绑定 (Command Binding)
我们已经成功地将 ViewModel 的数据绑定到了 View 上,实现了界面的自动更新。但应用程序不只有静态的展示,更需要响应用户的操作,比如点击按钮。
为什么不能用“Click”事件?
我们最开始使用 WPF 时,响应按钮点击的方式是这样的:
XAML: 1
<Button Content="开始监听" Click="StartButton_Click"/>
Code-Behind (.xaml.cs
):
1 | private void StartButton_Click(object sender, RoutedEventArgs e) |
这种方式在 MVVM 模式中是绝对要避免的。为什么?
- 破坏了关注点分离:它在 View 的后置代码中编写了逻辑,打破了 View 只负责“展示”的原则。View 开始“知道”了太多它不该知道的东西。
- 绕过了 ViewModel:用户的操作直接被 View 的代码捕获,ViewModel 被架空了,失去了对用户意图的控制。
- 难以测试:无法在不实例化整个 UI 窗口的情况下,去测试按钮点击后的业务逻辑。
我们需要一种方式,能让 View 将用户的“点击”这个动作,直接“翻译”成 ViewModel 中的一个“方法调用”,同时 View 本身不参与任何逻辑判断。这个翻译官,就是 ICommand
。
ICommand
:可绑定的“方法”
1 | public interface ICommand |
ICommand
是 .NET 提供的一个标准接口,它将一个方法封装成了一个对象,这个对象有三个关键成员:
void Execute(object parameter)
:- 这是什么?:命令的核心,它是一个方法,定义了该命令具体要执行什么操作。当用户点击按钮时,这个方法会被调用。
parameter
:可以从 View 传递一个参数给 ViewModel,非常有用。
bool CanExecute(object parameter)
:- 这是什么?:一个返回布尔值的方法,用于判断命令当前是否可以被执行。
- WPF 的魔法:如果
CanExecute
返回false
,那么绑定到此命令的按钮等控件会自动变为禁用状态 (IsEnabled = false)!反之则启用。这使得我们可以非常轻松地根据程序状态来控制界面的可交互性(例如,在未输入用户名时禁用“登录”按钮)。
event EventHandler CanExecuteChanged
:- 这是什么?:一个事件。当
CanExecute
的条件可能发生变化时,ViewModel 需要手动触发此事件,来通知 UI:“嘿,快来重新调用我的CanExecute
方法,检查一下我绑定的按钮是不是该启用/禁用了!”
- 这是什么?:一个事件。当
MVVM Light 的实现:RelayCommand
手动去完整实现 ICommand
接口会很繁琐。幸运的是,MVVM Light 框架为我们提供了一个开箱即用的实现:RelayCommand
。
RelayCommand
的作用就像它的名字一样——“中继”。它将 Execute
和 CanExecute
的逻辑,“中继”到我们在 ViewModel 中定义的普通方法上。
让我们来为 MainViewModel
添加一个最简单的命令,点击按钮时更新界面上的文本。
文件: /ViewModels/MainViewModel.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.Command;
namespace SimpleCommandDemo
{
public class MainViewModel : ViewModelBase
{
private string _greeting = "Ready";
public string Greeting
{
get => _greeting;
set => Set(ref _greeting, value); // 自动触发 UI 刷新
}
public RelayCommand SayHelloCommand { get; }
public MainViewModel()
{
// 命令绑定的逻辑
SayHelloCommand = new RelayCommand(() =>
{
Greeting = "Hello, World!";
});
}
}
}
代码解析:
- 我们定义了一个
RelayCommand
类型的属性SayHelloCommand
。 - 在构造函数中,实例化了一个
RelayCommand
,并传入一个 Lambda 表达式,它就是命令真正要执行的逻辑。 - 当用户点击按钮时,命令被触发,执行这段逻辑,把
Greeting
属性更新为"Hello, World!"
。 - 因为使用了
ViewModelBase.Set(...)
,更新属性会自动触发PropertyChanged
事件,WPF 的数据绑定机制会让界面自动刷新。
相比传统的事件写法(在 .xaml.cs
里写 Click="Button_Click"
),这里没有任何后置代码,UI 完全通过数据绑定与命令解耦。
对照 ICommand
Execute(object parameter)
在
RelayCommand
的构造函数里,传了一个 Lambda 表达式:1
() => { Greeting = "Hello, World!"; }
这就是 命令要执行的内容,相当于
ICommand.Execute()
。当你点击按钮时,WPF 内部会调用:
1
SayHelloCommand.Execute(null);
我们的 Hello 例子里就是实现了 Execute。
CanExecute(object parameter)
RelayCommand
构造函数有第二个可选参数,可以写一个方法决定命令是否能执行:1
new RelayCommand(ExecuteMethod, CanExecuteMethod);
在 Hello 例子里,没有传第二个参数 → 默认
CanExecute
永远返回true
。所以按钮一直可用。
我们的 Hello 例子里 没有自定义 CanExecute,等于用了默认实现。
event CanExecuteChanged
- 如果我们用了
CanExecute
,当状态发生变化时,我们需要触发这个事件,让 WPF 重新检查按钮是否可点。 - 在
RelayCommand
里,这就是RaiseCanExecuteChanged()
。 - 在 Hello 例子里,因为按钮永远可点,就用不到这个事件。
我们的 Hello 例子里 没有触发 CanExecuteChanged。
ICommand 成员 |
Hello 例子里的对应情况 |
---|---|
Execute(object parameter) |
就是传给 RelayCommand 的 Lambda:Greeting = "Hello, World!" |
CanExecute(object parameter) |
没写 → 默认始终返回 true |
CanExecuteChanged |
没用,因为没有写条件判断,按钮始终可点 |
在 XAML 中绑定命令
最后,也是最简单的一步,我们在 XAML 中将按钮的 Command
属性绑定到 ViewModel 中的 ICommand
属性。
文件: MainWindow.xaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15<Window ...>
<Window.DataContext>
<vm:MainViewModel/>
</Window.DataContext>
<StackPanel Margin="20" VerticalAlignment="Center">
<!-- 绑定 Greeting 属性 -->
<TextBlock Text="{Binding Greeting}" FontSize="20" Margin="0,0,0,12"/>
<!-- 绑定 SayHelloCommand 命令 -->
<Button Content="Say Hello"
Command="{Binding SayHelloCommand}"
Width="120" Height="32"/>
</StackPanel>
</Window>
现在运行程序,会看到:
- 初始时,TextBlock 显示
"Ready"
。 - 点击按钮后,触发
SayHelloCommand
,ViewModel 中的Greeting
变为"Hello, World!"
。 - 因为属性变更通知,界面自动刷新,不需要写一行后置事件代码。
小结
从最初 WPF 项目的文件结构,到最终实现一个功能完整的 MVVM 交互模型,我们一起走过了一段充满挑战但收获颇丰的旅程。现在,是时候停下来,回顾我们所学到的核心思想,并将这些零散的知识点串联成一张完整的蓝图。
如果需要从这个系列中带走最重要的几件事,那一定是以下四点:
- 关注点分离是灵魂 (Separation of Concerns):学习 MVVM 的根本目的,就是告别将 UI 逻辑、业务逻辑和数据处理混杂在后置代码(.xaml.cs)中的“意大利面条式”代码。通过将程序清晰地划分为 Model(数据模型)、View(用户界面)和 ViewModel(视图模型),我们让每一部分都只专注于自己的职责,代码因此变得清晰、可维护。
- DataContext是桥梁 (The Bridge):我们理解了 View 和 ViewModel 并不是天生就能互相通信的。DataContext 属性扮演了至关重要的桥梁角色,它告诉 View:“你的数据和行为都应该去这个 ViewModel 实例里寻找!”。无论是通过后置代码还是在 XAML 中设置,搭建这座桥梁是我们实践MVVM的第一步。
- 属性绑定是“心跳” (The Heartbeat):为了让数据能在 ViewModel 和 View 之间自由流动,我们掌握了属性绑定。借助ViewModelBase提供的 Set() 方法和其背后的 INotifyPropertyChanged 接口,我们的 ViewModel 拥有了“心跳”——每当数据发生变化,它都能主动通知 View 进行刷新,让界面永远忠实地反映程序的状态。
- 命令绑定是“意图” (The Intent):我们摒弃了传统的 UI 事件,转而拥抱 ICommand 和 RelayCommand。这不仅仅是换了一种写法,更是一种思维方式的转变。我们不再关心“哪个按钮被点击了”,而是关心“用户想要做什么(意图)”。通过命令,我们将用户的操作意图直接传递给 ViewModel,并能根据程序状态(CanExecute)轻松地控制UI的可用性,实现了彻底的解耦。
走完这一遭,可能会觉得 MVVM 比简单的事件驱动要复杂。但这份“复杂”换来的是无与伦比的长期收益:
- 可测试性 (Testability):ViewModel是一个纯粹的 C# 类,不依赖任何 UI 元素。可以为它编写单元测试,在不启动界面的情况下,验证所有业务逻辑的正确性。
- 可维护性 (Maintainability):当需求变更或出现 Bug 时,清晰的职责划分能快速定位问题所在。修改 ViewModel 不会影响View,反之亦然。
- 团队协作 (Teamwork):UI/UX 设计师可以专注于打磨 XAML(View),而软件工程师可以专注于实现 C# 代码(ViewModel和Model),两者可以并行工作,互不干扰。