跳到主要内容

3.2 属性通知与可观察集合

学习目标​

本节让普通 C# 对象在变化时通知界面。你会实现 INotifyPropertyChanged,使用 ObservableCollection 添加列表项,并区分“新增一个对象”和“修改已有对象”的通知来源。这是理解 MVVM 的基础,无需任何工具包。

绑定不会持续轮询属性​

窗口初始化时,绑定读取源属性。之后若业务代码修改字段,WPF 不会不断轮询所有对象寻找变化;源需要通过约定发出通知。INotifyPropertyChanged 只有一个事件,事件参数告诉订阅方哪个属性发生变化。接口不负责修改属性,也不会自动比较新旧值,这些工作仍由属性 setter 完成。

属性变化通知属于对象本身,和 HTTP DTO 的序列化职责不同。后端返回的 DTO 可以先保持简单,再映射为可通知的客户端状态对象。不要为了在列表上显示状态,就假定服务端每次更新都会直接修改当前窗口里的同一个对象。

先实现一个可通知的任务对象​

沿用 WpfBasics 项目。新增以下完整 TodoItem.cs 文件。属性先比较值,确实变化后再发事件;CallerMemberName 自动提供调用处的属性名,避免大量容易拼错的字符串。

TodoItem.cs(完整文件)
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 是只读属性;集合内部的新增操作由它自己的通知机制报告。

TaskListState.cs(完整文件)
using System.Collections.ObjectModel;

namespace WpfBasics;

public sealed class TaskListState
{
public ObservableCollection<TodoItem> Tasks { get; } =
[
new TodoItem { Title = "理解属性通知" },
new TodoItem { Title = "区分集合与元素变化" }
];
}

替换两个 MainWindow 完整文件。列表通过 ItemsSource 读取集合,每个数据模板的上下文则是当前 TodoItem。

MainWindow.xaml(完整文件)
<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>
MainWindow.xaml.cs(完整文件)
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 换成自动属性,观察“新增任务”和“重命名第一项”之间的差别,最后恢复通知实现。

验收标准:增加和删除立即改变行数;勾选完成立即更新状态文本;程序重命名立即刷新第一行;移除元素属性通知后,新增仍正常而程序重命名不再正常刷新。你应能判断每个现象由哪一种通知解释。

官方延伸资料​