WPF 学习笔记(五):数据绑定——WPF 的"响应式"基础

写在前面

版本说明:基于 .NET 8 LTS。

数据绑定是 WPF 的灵魂。前面几篇学的(DP、路由事件、布局)都是基础,本篇是把它们"串起来"的关键——声明式响应式 UI

理解了绑定,你就知道为什么 WPF 能让 UI 和业务逻辑完全解耦,为什么 MVVM 模式在 WPF 里行得通。本篇不引入 MVVM(留到第 6、7 篇),纯讲绑定机制本身。

本文要回答:

绑定怎么"自动同步"?为什么改 ViewModel 的属性,UI 立刻变?双向绑定怎么避免死循环?


一、绑定的本质

1.1 一个最简单的绑定

1
<TextBox Text="{Binding Name}"/>
1
2
3
4
5
6
表面:TextBox.Text 绑定到 Name 属性
含义:
  - 找 TextBox 的 DataContext
  - 取 DataContext.Name
  - 把值赋给 TextBox.Text
  - 同时监听 Name 变化,变了就更新 TextBox.Text

1.2 绑定 = 对象间声明式依赖

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
传统命令式 UI:
  1. 改 Name
  2. 手动找 TextBox
  3. 手动赋值 textBox.Text = Name

WPF 绑定:
  1. 改 Name
  2. TextBox.Text 自动变

  你只关心"数据",UI 自己跟着变

后端类比:
  绑定 ≈ 带生命周期的事件订阅
  Source.PropertyChanged += (s, e) => Target.Value = Source.Property
  但 WPF 帮你管理生命周期、转换、错误处理

1.3 绑定的两个端

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
绑定源(Source):
  数据来源(任何对象、任何属性)

绑定目标(Target):
  显示数据的 DP(必须是依赖属性)

绑定方向:
  OneWay:源 → 目标(默认,源变目标变)
  TwoWay:双向(任一变另一变)
  OneWayToSource:目标 → 源
  OneTime:源 → 目标,只一次
1
2
3
4
5
6
7
8
9
为什么目标必须是 DP?
  → 绑定系统要监听目标变化
  → DP 的 PropertyChanged 是天然的监听点
  → CLR 属性没有

为什么源不必须是 DP?
  → 源可以用 INotifyPropertyChanged 通知
  → 或 DependencyObject(也是 DP)
  → 或反射直接读属性

二、Binding 对象模型

{Binding Name} 在底层是 new Binding { Path = "Name" }

2.1 关键属性

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class Binding : MarkupExtension
{
    public object Source { get; set; }            // 显式指定源
    public RelativeSource RelativeSource { get; set; }  // 相对源
    public string ElementName { get; set; }       // 命名元素
    public PropertyPath Path { get; set; }        // 属性路径
    public BindingMode Mode { get; set; }         // 方向
    public UpdateSourceTrigger UpdateSourceTrigger { get; set; }
    public IValueConverter Converter { get; set; }
    public object ConverterParameter { get; set; }
    public string StringFormat { get; set; }
    public bool ValidatesOnDataErrors { get; set; }
    public object FallbackValue { get; set; }
    public object TargetNullValue { get; set; }
}

2.2 XAML 等价 C#

1
2
3
4
5
<!-- XAML -->
<TextBox Text="{Binding Name, Source={StaticResource vm},
                       Mode=TwoWay,
                       UpdateSourceTrigger=PropertyChanged,
                       Converter={StaticResource boolToVis}}"/>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// C# 等价
var binding = new Binding
{
    Source = Resources["vm"],
    Path = new PropertyPath("Name"),
    Mode = BindingMode.TwoWay,
    UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged,
    Converter = (IValueConverter)Resources["boolToVis"]
};
BindingOperations.SetBinding(textBox, TextBox.TextProperty, binding);

三、绑定源的三种指定方式

3.1 DataContext(隐式源,最常用)

1
2
3
4
5
6
7
<Window.DataContext>
    <local:MainViewModel/>
</Window.DataContext>

<!-- 子元素隐式继承 DataContext -->
<TextBox Text="{Binding Name}"/>     <!-- 绑定到 Window.DataContext.Name -->
<Button Content="{Binding Title}"/>
1
2
3
4
5
特点:
  - 不写 Source,默认用 DataContext
  - DataContext 是 DP 且可继承(详见第 2 篇)
  - 子元素没设置 → 用父级
  - 90% 的绑定都用这种

3.2 Source(显式指定)

1
2
3
4
5
<!-- 绑定到静态资源 -->
<TextBlock Text="{Binding Name, Source={StaticResource userVm}}"/>

<!-- 绑定到静态属性 -->
<TextBlock Text="{Binding MachineName, Source={x:Static sys:Environment.MachineName}}"/>

3.3 ElementName(命名元素)

1
2
<Slider x:Name="slider" Minimum="0" Maximum="100" Value="50"/>
<TextBlock Text="{Binding Value, ElementName=slider}"/>

3.4 RelativeSource(相对源)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!-- 找祖先 -->
<TextBlock Text="{Binding DataContext.Title,
                          RelativeSource={RelativeSource AncestorType=Window}}"/>

<!-- 找模板父 -->
<TextBlock Text="{Binding Background,
                          RelativeSource={RelativeSource TemplatedParent}}"/>

<!-- 自身 -->
<TextBlock Text="{Binding Width,
                          RelativeSource={RelativeSource Self}}"/>

四、Path:属性路径

4.1 简单路径

1
2
3
4
<TextBlock Text="{Binding Name}"/>           <!-- .Name -->
<TextBlock Text="{Binding User.Name}"/>      <!-- .User.Name(嵌套)-->
<TextBlock Text="{Binding Items[0]}"/>       <!-- 索引 -->
<TextBlock Text="{Binding Items.Count}"/>    <!-- 属性 -->

4.2 路径里的特殊语法

1
2
3
4
5
6
7
8
9
<!-- 当前对象 -->
<TextBlock Text="{Binding}"/>                <!-- DataContext 本身 -->

<!-- 路径中的索引 -->
<TextBlock Text="{Binding Items[0].Name}"/>

<!-- 附加属性 -->
<TextBlock Text="{Binding (Grid.Row),
                          RelativeSource={RelativeSource Self}}"/>

五、BindingMode 与 UpdateSourceTrigger

5.1 四种 Mode

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
OneWay(默认大部分):
  Source → Target
  Source 变 → Target 更新
  Target 不会改 Source

TwoWay:
  双向同步
  Target 改 → 推回 Source
  Source 变 → 更新 Target
  (TextBox.Text 默认 TwoWay)

OneWayToSource:
  Target → Source
  罕见(用于"反向"场景)

OneTime:
  只绑定一次(Source → Target 初始化)
  之后 Source 变 Target 不变
  适合"几乎不变"的数据(性能好)

5.2 各控件默认 Mode

1
2
3
4
5
6
7
8
控件                 默认 Mode
────────────────────────────────
TextBox.Text         TwoWay
CheckBox.IsChecked   TwoWay
Slider.Value         TwoWay
TextBlock.Text       OneWay
Border.Background    OneWay
ContentControl       OneWay

5.3 UpdateSourceTrigger

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
仅在 TwoWay/OneWayToSource 模式下生效
决定"Target 改了,什么时候推回 Source"

取值:
  Default:控件决定(TextBox=LostFocus,其他=PropertyChanged)
  PropertyChanged:Target 一变就推回
  LostFocus:Target 失焦才推回
  Explicit:必须手动 BindingExpression.UpdateSource()

实战:
  <!-- 实时验证:每输入一个字符就同步 -->
  <TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"/>
1
2
3
// 手动更新(Explicit 模式)
var expr = textBox.GetBindingExpression(TextBox.TextProperty);
expr?.UpdateSource();

六、INotifyPropertyChanged

绑定源通知"我变了"的标准方式。

6.1 接口实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class User : INotifyPropertyChanged
{
    private string _name;
    public string Name
    {
        get => _name;
        set
        {
            if (_name != value)
            {
                _name = value;
                OnPropertyChanged(nameof(Name));
            }
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void OnPropertyChanged(string propertyName)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

6.2 绑定如何监听

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
WPF 绑定系统监听源的变化:
  1. 源实现 INotifyPropertyChanged → 监听 PropertyChanged 事件
  2. 源是 DependencyObject → 监听 DP 的 PropertyChangedCallback
  3. 都不是 → 反射读一次(不监听变化)

PropertyChanged 事件触发:
  → 绑定系统收到
  → 检查 e.PropertyName 是否匹配 Path
  → 匹配 → 重新读取属性值
  → 写入 Target DP

6.3 INPC 的常见坑

坑 1:字符串硬编码

1
2
3
4
5
// ❌ 字符串写错,运行时不报错
OnPropertyChanged("Naem");   // 拼写错误

// ✅ nameof
OnPropertyChanged(nameof(Name));

坑 2:跨线程

1
2
3
4
5
// ❌ 后台线程改属性 → WPF 绑定跨线程异常
Task.Run(() => { user.Name = "New"; });   // 报错

// ✅ 切回 UI 程序(详见第 9 篇)
Application.Current.Dispatcher.Invoke(() => user.Name = "New");

坑 3:依赖属性

1
2
3
4
5
6
7
8
9
// FirstName/LastName 改了 FullName 也要变
public string FullName => $"{FirstName} {LastName}";

set
{
    _firstName = value;
    OnPropertyChanged(nameof(FirstName));
    OnPropertyChanged(nameof(FullName));   // ← 必须显式触发
}

6.4 现代写法(CommunityToolkit.Mvvm)

1
2
3
4
5
6
// 用源生成器,零样板
public partial class User : ObservableObject
{
    [ObservableProperty]
    private string _name;   // 自动生成 public Name + INPC
}

详细留到第 6 篇讲。


七、IValueConverter

源和目标类型不一致时,用 Converter 转换。

7.1 接口

1
2
3
4
5
public interface IValueConverter
{
    object Convert(object value, Type targetType, object parameter, CultureInfo culture);
    object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture);
}

7.2 经典 Converter:BoolToVisibility

1
2
3
4
5
6
7
8
public class BoolToVisibilityConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        => value is bool b && b ? Visibility.Visible : Visibility.Collapsed;

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        => value is Visibility v && v == Visibility.Visible;
}
1
2
3
4
5
6
7
<!-- 声明资源 -->
<Window.Resources>
    <local:BoolToVisibilityConverter x:Key="boolToVis"/>
</Window.Resources>

<!-- 使用 -->
<TextBlock Text="加载中..." Visibility="{Binding IsLoading, Converter={StaticResource boolToVis}}"/>

7.3 IMultiValueConverter

1
2
3
4
5
6
7
8
public class BooleanAndConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        => values.OfType<bool>().All(v => v);

    public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        => throw new NotSupportedException();
}
1
2
3
4
5
6
7
8
9
<Button Content="提交">
    <Button.IsEnabled>
        <MultiBinding Converter="{StaticResource andConv}">
            <Binding Path="IsNameValid"/>
            <Binding Path="IsEmailValid"/>
            <Binding Path="IsAgreed"/>
        </MultiBinding>
    </Button.IsEnabled>
</Button>

7.4 StringFormat

简单格式化不需要 Converter。

1
2
3
<TextBlock Text="{Binding Price, StringFormat=C}"/>
<TextBlock Text="{Binding Date, StringFormat=yyyy-MM-dd}"/>
<TextBlock Text="{Binding Name, StringFormat=用户:{0}}"/>

八、CollectionView:列表的"视图层"

绑定到集合时,WPF 自动包装成 CollectionView,提供过滤/排序/分组。

8.1 默认 CollectionView

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public ObservableCollection<User> Users { get; } = new();

// 绑定后,WPF 自动创建 CollectionView
// 获取默认视图
var view = CollectionViewSource.GetDefaultView(Users);

// 加过滤
view.Filter = item => item is User u && u.IsActive;

// 加排序
view.SortDescriptions.Add(new SortDescription(nameof(User.Name), ListSortDirection.Ascending));

// 加分组
view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(User.Department)));

8.2 视图的特点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
CollectionView 不复制数据:
  - 它是"原集合的视图层"
  - 过滤/排序/分组不修改原集合
  - 同一集合可以有多个视图

好处:
  - UI 端的"显示逻辑"和 ViewModel 的"数据"解耦
  - 多个 ListBox 同源不同视图(一个排序,一个过滤)

注意:
  - 视图的"当前项"(CurrentItem)跨视图共享
  - 选中项变化通常通过 CurrentItem 同步

8.3 分组的 UI

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<ListBox ItemsSource="{Binding Users}">
    <ListBox.GroupStyle>
        <GroupStyle>
            <GroupStyle.HeaderTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Name}" FontWeight="Bold"/>
                </DataTemplate>
            </GroupStyle.HeaderTemplate>
        </GroupStyle>
    </ListBox.GroupStyle>
</ListBox>

九、ObservableCollection

集合本身的"INPC"。

9.1 为什么需要

1
2
3
4
5
6
7
8
public ObservableCollection<User> Users { get; }

Users.Add(new User());  // UI 自动更新
Users.RemoveAt(0);      // UI 自动更新

// 普通 List<T> 即使绑定了,Add 也不会通知 UI
List<User> list = new();
list.Add(new User());   // UI 不知道

9.2 接口 INotifyCollectionChanged

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class ObservableCollection<T> : Collection<T>, INotifyCollectionChanged, INotifyPropertyChanged
{
    public event NotifyCollectionChangedEventHandler CollectionChanged;

    protected override void InsertItem(int index, T item)
    {
        base.InsertItem(index, item);
        CollectionChanged?.Invoke(this,
            new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, index));
    }
    // 类似重写其他方法
}
1
2
3
4
5
6
7
8
9
通知格式(NotifyCollectionChangedAction):
  Add:增加
  Remove:删除
  Replace:替换
  Move:移动
  Reset:整体刷新(最贵,UI 重新绑定)

ObservableCollection 默认 Notify 单项变化(性能好)
要触发 Reset:Users.Clear() + Users.Add(...)

9.3 跨线程集合

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ❌ 后台线程改 ObservableCollection → 报错
Task.Run(() => Users.Add(new User()));

// ✅ 启用跨线程同步
ObservableCollection<User> users = new();
BindingOperations.EnableCollectionSynchronization(users, _lock);
// 之后后台线程改 users 安全(用 _lock 同步)

// 或:切回 UI 线程
Application.Current.Dispatcher.Invoke(() => Users.Add(new User()));

十、绑定的进阶

10.1 FallbackValue / TargetNullValue

1
2
3
4
5
<!-- 源不存在时的回退值 -->
<TextBlock Text="{Binding Name, FallbackValue='未知'}"/>

<!-- 源是 null 时的显示值 -->
<TextBlock Text="{Binding Name, TargetNullValue='(空)'}"/>

10.2 绑定的优先级

1
2
3
4
5
6
7
8
DP 的优先级(详见第 2 篇)和绑定结合:
  Local Value > Style > ...

绑定本身是 Local Value 级别
  → 你 SetValue(dp, value) 会覆盖绑定!

恢复绑定:
  BindingOperations.ClearBinding(textBlock, TextBlock.TextProperty);

10.3 Validation

1
2
3
// ViewModel 实现 IDataErrorInfo 或 INotifyDataErrorInfo
public string this[string columnName]
    => columnName == nameof(Name) && string.IsNullOrEmpty(Name) ? "必填" : null;
1
2
<TextBox Text="{Binding Name, ValidatesOnDataErrors=true,
                        UpdateSourceTrigger=PropertyChanged}"/>

WPF 会自动给错误状态加红色边框(默认 ErrorTemplate)。


十一、实战:可过滤/排序的员工列表

不引入 MVVM,纯 code-behind 演示绑定机制。

11.1 Model

 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
27
28
29
30
31
32
33
public class Employee : INotifyPropertyChanged
{
    private string _name;
    public string Name
    {
        get => _name;
        set => SetField(ref _name, value);
    }

    private string _department;
    public string Department
    {
        get => _department;
        set => SetField(ref _department, value);
    }

    private decimal _salary;
    public decimal Salary
    {
        get => _salary;
        set => SetField(ref _salary, value);
    }

    public event PropertyChangedEventHandler PropertyChanged;
    protected void SetField<T>(ref T field, T value, [CallerMemberName] string name = null)
    {
        if (!EqualityComparer<T>.Default.Equals(field, value))
        {
            field = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
        }
    }
}

11.2 XAML

 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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<Window x:Class="BindingDemo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:scm="clr-namespace:System.ComponentModel;assembly=WindowsBase"
        Title="Employee Browser" Width="800" Height="500">
    <Window.Resources>
        <local:SalaryToStringConverter x:Key="salaryConv"/>
    </Window.Resources>

    <Grid Margin="12">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>

        <!-- 搜索栏 -->
        <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8">
            <TextBlock Text="搜索:" VerticalAlignment="Center"/>
            <TextBox x:Name="searchBox" Width="200" Margin="8,0,0,0"
                     TextChanged="SearchBox_TextChanged"/>
        </StackPanel>

        <!-- 排序控制 -->
        <StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,8">
            <Button Content="按姓名" Click="SortByName" Margin="0,0,8,0"/>
            <Button Content="按薪资" Click="SortBySalary" Margin="0,0,8,0"/>
            <CheckBox x:Name="groupCheck" Content="按部门分组" Margin="0,0,8,0"
                      Checked="GroupCheck_Changed" Unchecked="GroupCheck_Changed"/>
        </StackPanel>

        <!-- 列表 -->
        <ListBox Grid.Row="2" x:Name="employeeList" ItemsSource="{Binding}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <Grid Margin="4">
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="200"/>
                            <ColumnDefinition Width="150"/>
                            <ColumnDefinition Width="*"/>
                        </Grid.ColumnDefinitions>
                        <TextBlock Grid.Column="0" Text="{Binding Name}" FontWeight="Bold"/>
                        <TextBlock Grid.Column="1" Text="{Binding Department}"/>
                        <TextBlock Grid.Column="2"
                                   Text="{Binding Salary, Converter={StaticResource salaryConv},
                                                  StringFormat=C}"/>
                    </Grid>
                </DataTemplate>
            </ListBox.ItemTemplate>
            <ListBox.GroupStyle>
                <GroupStyle>
                    <GroupStyle.HeaderTemplate>
                        <DataTemplate>
                            <Border Background="LightBlue" Padding="4">
                                <TextBlock Text="{Binding Name}" FontWeight="Bold"/>
                            </Border>
                        </DataTemplate>
                    </GroupStyle.HeaderTemplate>
                </GroupStyle>
            </ListBox.GroupStyle>
        </ListBox>
    </Grid>
</Window>

11.3 Code-behind

 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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public partial class MainWindow : Window
{
    private ObservableCollection<Employee> _employees;
    private ListCollectionView _view;

    public MainWindow()
    {
        InitializeComponent();

        _employees = new ObservableCollection<Employee>
        {
            new() { Name = "Alice", Department = "Engineering", Salary = 25000 },
            new() { Name = "Bob", Department = "Sales", Salary = 18000 },
            new() { Name = "Carol", Department = "Engineering", Salary = 30000 },
            new() { Name = "Dave", Department = "HR", Salary = 15000 },
            new() { Name = "Eve", Department = "Sales", Salary = 22000 },
        };

        DataContext = _employees;
        _view = (ListCollectionView)CollectionViewSource.GetDefaultView(_employees);
    }

    private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
    {
        var keyword = searchBox.Text?.Trim() ?? "";
        _view.Filter = item => item is Employee emp
            && (string.IsNullOrEmpty(keyword)
                || emp.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase)
                || emp.Department.Contains(keyword, StringComparison.OrdinalIgnoreCase));
    }

    private void SortByName(object sender, RoutedEventArgs e)
    {
        _view.SortDescriptions.Clear();
        _view.SortDescriptions.Add(new SortDescription(nameof(Employee.Name), ListSortDirection.Ascending));
    }

    private void SortBySalary(object sender, RoutedEventArgs e)
    {
        _view.SortDescriptions.Clear();
        _view.SortDescriptions.Add(new SortDescription(nameof(Employee.Salary), ListSortDirection.Descending));
    }

    private void GroupCheck_Changed(object sender, RoutedEventArgs e)
    {
        if (groupCheck.IsChecked == true)
            _view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(Employee.Department)));
        else
            _view.GroupDescriptions.Clear();
    }
}

public class SalaryToStringConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        => value is decimal d ? $"{d:N0} 元/月" : value;

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        => throw new NotSupportedException();
}

11.4 知识点覆盖

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
✅ DataContext 绑定(Window.DataContext = 集合)
✅ ObservableCollection 集合通知
✅ INPC 属性通知
✅ CollectionView 过滤/排序/分组
✅ DataTemplate 显示
✅ IValueConverter 类型转换
✅ StringFormat 格式化
✅ GroupStyle 分组 UI

观察:
  - 没用 MVVM,但已经是响应式 UI
  - 搜索框输入立即过滤(不用手动刷新 ListBox)
  - 改 Employee.Name,UI 立刻变(INPC 触发)
  - 排序按钮只改 SortDescriptions,UI 自动重排

十二、常见陷阱

12.1 DataContext 没设

1
2
<!-- ❌ DataContext 是 null -->
<TextBox Text="{Binding Name}"/>   <!-- 绑定到 null.Name → 输出空 + 错误日志 -->
1
2
调试:检查 Output 窗口的 BindingExpression 错误
"BindingExpression path error: 'Name' property not found on 'object' ''Null'..."

12.2 属性没通知

1
2
3
4
// ❌ 改了字段但 UI 不更新
public string Name { get; set; }
vm.Name = "New";
// UI 还是旧的

12.3 绑定到值类型字段

1
2
3
4
// ❌ 字段不通知(即使 ObservableCollection 包装)
public struct Point { public int X; public int Y; }
ObservableCollection<Point> points;
// 改 points[0].X = 5 → UI 不知道
1
✅ Point 实现 INPC(或用 class 而不是 struct)

12.4 双向绑定死循环

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ❌ 自我反馈循环
public string Name
{
    get => _name;
    set
    {
        _name = value;
        OnPropertyChanged(nameof(Name));
        // ❌ 这里又触发别的逻辑改 Name
        Normalize();   // 内部又 set Name → 死循环
    }
}
1
2
3
4
5
6
WPF 有部分防护:
  - 绑定系统检测"值相同"会跳过通知
  - 但如果不相等就会死循环

✅ 加 if (_name != value) 检查
✅ 把副作用放后台或显式标记

12.5 内存泄漏

1
2
3
4
5
6
7
8
// ❌ 短生命周期对象订阅长生命周期对象的 PropertyChanged
window.PropertyChanged += shortLived.Handler;
// shortLived 被持有,泄漏

// ✅ 用弱事件 WeakEventManager
PropertyChangedEventManager.AddHandler(window, shortLived.Handler, nameof(Window.Title));

// 绑定系统内部用弱事件 → 绑定不会泄漏

十三、与 ASP.NET Core / 前端的对比

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
ASP.NET Core 后端          WPF 绑定
──────────────────────────────────────────
ViewModel (Razor)         DataContext
@Model.Property           {Binding Property}
Model 不变 UI 不更新      INPC → UI 自动更新
显式渲染                  隐式响应

Vue / React              WPF 绑定
──────────────────────────────────────────
data() { }                ObservableCollection + INPC
v-model                   Mode=TwoWay
computed                   IValueConverter + 依赖属性
watch                      PropertyChanged + 绑定

关键差异:
  - WPF 绑定是双向自动的(声明即响应)
  - Vue/React 也响应式但思路不同(虚拟 DOM diff vs 直接更新)
  - WPF 的"双向"是声明式,Vue 是 v-model 显式

十四、小结

本文深入 WPF 的数据绑定:

  • 绑定本质:对象间声明式依赖
  • Binding 对象模型(Source / Path / Mode / Converter)
  • DataContext 继承链(隐式源)
  • 三种源指定方式(DataContext / Source / ElementName / RelativeSource)
  • 四种 Mode + UpdateSourceTrigger
  • INotifyPropertyChanged 与陷阱(线程/字符串/依赖属性)
  • IValueConverter / IMultiValueConverter / StringFormat
  • CollectionView(过滤/排序/分组)
  • ObservableCollection 与跨线程
  • 实战:可过滤/排序/分组的员工列表
1
2
3
4
记住三句话:
  1. 绑定 = 声明式的事件订阅,源 INPC + 目标 DP
  2. DataContext 继承是绑定的根基——隐式源让 XAML 简洁
  3. CollectionView 是"列表的视图层"——过滤排序不污染原集合

下一篇将进入 WPF 的核心模式——命令与 MVVM。绑定解决了"显示",命令解决"操作",MVVM 把它们组合成完整架构。

Licensed under CC BY-NC-SA 4.0