3.2 属性通知与可观察集合
学习目标
本节让普通 C# 对象在变化时通知界面。你会实现 INotifyPropertyChanged,使用 ObservableCollection 添加列表项,并区分“新增一个对象”和“修改已有对象”的通知来源。这是理解 MVVM 的基础,无需任何工具包。
绑定不会持续轮询属性
窗口初始化时,绑定读取源属性。之后若业务代码修改字段,WPF 不会不断轮询所有对象寻找变化;源需要通过约定发出通知。INotifyPropertyChanged 只有一个事件,事件参数告诉订阅方哪个属性发生变化。接口不负责修改属性,也不会自动比较新旧值,这些工作仍由属性 setter 完成。
属性变化通知属于对象本身,和 HTTP DTO 的序列化职责不同。后端返回的 DTO 可以先保持简单,再映射为可通知的客户端状态对象。不要为了在列表上显示状态,就假定服务端每次更新都会直接修改当前窗口里的同一个对象。
先实现一个可通知的任务对象
沿用 WpfBasics 项目。新增以下完整 TodoItem.cs 文件。属性先比较值,确实变化后再发事件;CallerMemberName 自动提供调用处的属性名,避免大量容易拼错的字符串。
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace WpfBasics;
public sealed class TodoItem : INotifyPropertyChanged
{
private string _title = string.Empty;
private bool _isCompleted;
public string Title
{
get => _title;
set
{
if (_title == value) return;
_title = value;
OnPropertyChanged();
}
}
public bool IsCompleted
{
get => _isCompleted;
set
{
if (_isCompleted == value) return;
_isCompleted = value;
OnPropertyChanged();
OnPropertyChanged(nameof(StatusText));
}
}
public string StatusText => IsCompleted ? "已完成" : "待完成";
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
StatusText 是由 IsCompleted 推导出的只读属性。WPF 不会分析 C# getter 的依赖关系,因此完成状态改变时,要同时通知 IsCompleted 和 StatusText。这种派生属性是最容易漏掉的通知之一。
让集合与元素分别通知
新增完整的 TaskListState.cs,作为窗口上下文。这里集合实例保持稳定,所以 Tasks 是只读属性;集合内部的新增操作由它自己的通知机制报告。
using System.Collections.ObjectModel;
namespace WpfBasics;
public sealed class TaskListState
{
public ObservableCollection<TodoItem> Tasks { get; } =
[
new TodoItem { Title = "理解属性通知" },
new TodoItem { Title = "区分集合与元素变化" }
];
}
替换两个 MainWindow 完整文件。列表通过 ItemsSource 读取集合,每个数据模板的上下文则是当前 TodoItem。
<Window x:Class="WpfBasics.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="通知与集合" Width="640" Height="420">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ListBox ItemsSource="{Binding Tasks}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="4">
<CheckBox IsChecked="{Binding IsCompleted, Mode=TwoWay}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Title}" Margin="8,0" />
<TextBlock Text="{Binding StatusText}" Foreground="Gray" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,16,0,0">
<Button Content="新增任务" Click="OnAddClick" Padding="12,6" />
<Button Content="重命名第一项" Click="OnRenameClick"
Margin="12,0,0,0" Padding="12,6" />
</StackPanel>
</Grid>
</Window>
using System.Windows;
namespace WpfBasics;
public partial class MainWindow : Window
{
private readonly TaskListState _state = new();
private int _nextNumber = 1;
public MainWindow()
{
InitializeComponent();
DataContext = _state;
}
private void OnAddClick(object sender, RoutedEventArgs e)
{
_state.Tasks.Add(new TodoItem { Title = $"新任务 {_nextNumber++}" });
}
private void OnRenameClick(object sender, RoutedEventArgs e)
{
if (_state.Tasks.Count > 0)
_state.Tasks[0].Title = "第一项已重命名";
}
}
点击新增时,ObservableCollection 发出集合变化事件,列表增加一行。点击重命名时,集合结构没有变化,由 TodoItem.PropertyChanged 通知行内文本更新。勾选复选框后,双向绑定写回元素,再由元素通知状态文本刷新。
三种变化必须分清
| 变化 | 谁负责通知 | 需要什么 |
|---|---|---|
| 新增、删除、移动集合项 | 集合 | ObservableCollection 等集合通知机制 |
| 修改某个任务的标题或完成状态 | 元素对象 | 元素实现 INotifyPropertyChanged |
将 Tasks 换成另一个集合实例 | 持有 Tasks 属性的对象 | 对 Tasks 发属性通知 |
可观察集合不会自动给元素补上属性通知,也不会把所有元素变化重新广播成集合变化。List 配合可通知元素,能更新既有行的文字,但新增行通常不会自动出现;可观察集合配合无通知元素,能出现新增行,但程序修改既有元素后文字可能不刷新。
如果你增加 TotalCount => Tasks.Count 这样的派生属性,集合新增也不会自动发出 TotalCount 的属性通知。持有集合的状态对象需要订阅 CollectionChanged,再通知这个属性。更复杂的“已完成数量”还需要订阅元素的属性变化,并在移除时解除订阅,避免残留引用;先理解责任边界,再考虑工具库。
常见错误
- 实现接口却没有变化:setter 必须实际触发事件,且名称对应公开属性;只声明事件是不够的。
- 只通知字段名:绑定读取
Title,事件应通知Title,而不是_title。 - 每次刷新都创建新集合但不通知:界面仍订阅旧集合,需对集合属性通知,或保持集合实例并修改其内容。
- 后台线程直接修改 UI 绑定集合:默认场景下应在 UI 线程更新集合;不能把可观察理解为线程安全,详见异步与线程。
动手练习与验收
新增“删除最后一项”按钮;集合为空时安全返回。再临时把 TodoItem.Title 换成自动属性,观察“新增任务”和“重命名第一项”之间的差别,最后恢复通知实现。
验收标准:增加和删除立即改变行数;勾选完成立即更新状态文本;程序重命名立即刷新第一行;移除元素属性通知后,新增仍正常而程序重命名不再正常刷新。你应能判断每个现象由哪一种通知解释。