Skip to Content
Week 07Day 5 - 集合绑定

Day 5 - 集合绑定

建议用时:240-270 分钟

你将学会什么

  • 知道 ObservableCollection<T> 为什么适合绑定列表。
  • 会把 ObservableCollection<ProductItem> 绑定到 ListBox.ItemsSource
  • 会用 DataTemplate 控制每一项怎么显示。
  • 会用 SelectedItem 绑定当前选中商品。
  • 会写新增命令和删除命令。
  • 会在选中项变化时刷新右侧详情。

本页重点是“集合变化通知界面”。新增和删除时不要重新创建整个窗口,也不要手动刷新控件;直接修改 ObservableCollection

本页固定顺序

  1. 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
  2. 再学第二部分:把刚学的知识组合成一个完整例子。
  3. 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
  4. 最后做第四部分:先独立完成作业,再用完整答案检查。

学习衔接

上一页学习的是“表单验证”,今天继续学习“集合绑定”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

第一次学习不要求背完整页。完成下面 3 项,就可以继续:

  • 能用自己的话说明“集合绑定”解决什么问题。
  • 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
  • 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。

第一部分:先学原理和最小知识

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。集合绑定的关键是“列表本身变化时,界面怎么知道”。

1. 为什么普通 List 不够

普通 List<T> 可以保存多条数据:

var products = new List<string>(); products.Add("Keyboard");

但它不会主动通知界面:

我新增了一项 我删除了一项 我清空了列表

如果界面绑定的是普通 List<T>,数据变了以后界面不一定自动刷新。

2. ObservableCollection 是什么

ObservableCollection<T> 是能通知界面集合变化的集合。

public ObservableCollection<ProductItem> Products { get; } = new();

当你调用:

Products.Add(product); Products.Remove(product);

绑定到它的 ListBox 会知道集合发生了变化。

3. ItemsSource 是什么

ItemsSource 是列表控件的数据来源。

<ListBox ItemsSource="{Binding Products}" />

意思是:ListBox 要显示 ViewModel 里的 Products 集合。

ListBox 不负责创建商品,它只负责显示 Products 里的商品。

4. DataTemplate 是什么

如果列表里是对象,界面要知道每个对象怎么显示。

本页的商品对象:

public sealed class ProductItem { public string Name { get; set; } = ""; public decimal Price { get; set; } }

DataTemplate 告诉 ListBox:每个 ProductItem 显示名称和价格。

<DataTemplate x:DataType="vm:ProductItem"> <StackPanel> <TextBlock Text="{Binding Name}" /> <TextBlock Text="{Binding Price}" /> </StackPanel> </DataTemplate>

5. SelectedItem 是什么

SelectedItem 是当前选中的那一项。

SelectedItem="{Binding SelectedProduct, Mode=TwoWay}"

意思是:

  • 用户在界面选中一项,ViewModel 的 SelectedProduct 会变化。
  • ViewModel 改 SelectedProduct,界面选中项也会变化。

列表详情页面基本都离不开 SelectedItem

6. 为什么 SelectedProduct 要可空

列表可能被删空。删空后没有当前商品。

所以写:

private ProductItem? selectedProduct;

右侧详情也要处理没有选中项的情况:

public string SelectedName => SelectedProduct?.Name ?? "未选择商品";

不要假设永远有选中项。

7. 删除命令为什么需要 CanExecute

没有选中商品时,删除按钮不应该能执行。

private bool CanRemoveSelected() { return SelectedProduct is not null; }

并把它接到命令:

[RelayCommand(CanExecute = nameof(CanRemoveSelected))] private void RemoveSelected()

SelectedProduct 改变时,要通知删除命令重新判断:

[NotifyCanExecuteChangedFor(nameof(RemoveSelectedCommand))]

8. ProductCountText 为什么要手动通知

ProductCountText 是根据 Products.Count 算出来的。

新增或删除后,Products.Count 变了,但 ViewModel 的属性通知不会自动知道 ProductCountText 变了。

所以新增和删除后写:

OnPropertyChanged(nameof(ProductCountText));

这句话告诉界面:ProductCountText 要重新读取。

9. 集合变化和对象属性变化不是一回事

这点很重要:

变化ObservableCollection 会通知吗
新增一项
删除一项
清空列表
修改某一项的 Name不一定

本页只做新增删除。后面如果要编辑列表项本身,需要让列表项也具备属性通知能力。

集合绑定常用写法速查

需求写法说明
可刷新列表ObservableCollection<T>增删时界面更新
列表绑定ItemsSource="{Binding Products}"显示数据源
选中项绑定SelectedItem="{Binding SelectedProduct}"当前选中对象
新增Products.Add(item)界面自动出现
删除Products.Remove(item)界面自动移除
清空Products.Clear()清空界面列表
空状态IsEmpty = Products.Count == 0控制提示显示

注意:

ObservableCollection 负责集合增删通知 集合里单个对象属性变化,还需要对象自己支持属性通知

第二部分:把知识组合成完整例子

今天要做一个 MVVM 列表页面:

  • 左侧 ListBox 显示商品集合。
  • 每个商品显示名称和价格。
  • 选中商品后,右侧显示详情。
  • 点击新增,列表自动增加一条。
  • 点击删除,删除当前选中商品。

ViewModels/MainWindowViewModel.cs

using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { private int nextProductNumber = 4; public ObservableCollection<ProductItem> Products { get; } = new() { new ProductItem { Name = "Keyboard", Price = 199 }, new ProductItem { Name = "Mouse", Price = 99 }, new ProductItem { Name = "Monitor", Price = 899 } }; [ObservableProperty] [NotifyPropertyChangedFor(nameof(SelectedName))] [NotifyPropertyChangedFor(nameof(SelectedPriceText))] [NotifyCanExecuteChangedFor(nameof(RemoveSelectedCommand))] private ProductItem? selectedProduct; [ObservableProperty] private string message = "请选择一个商品。"; public string SelectedName => SelectedProduct?.Name ?? "未选择商品"; public string SelectedPriceText => SelectedProduct is null ? "-" : SelectedProduct.Price.ToString(); public string ProductCountText => $"商品数量:{Products.Count}"; public MainWindowViewModel() { SelectedProduct = Products[0]; } [RelayCommand] private void AddProduct() { var product = new ProductItem { Name = $"New Product {nextProductNumber}", Price = nextProductNumber * 100 }; nextProductNumber++; Products.Add(product); SelectedProduct = product; Message = $"已新增:{product.Name}"; OnPropertyChanged(nameof(ProductCountText)); } private bool CanRemoveSelected() { return SelectedProduct is not null; } [RelayCommand(CanExecute = nameof(CanRemoveSelected))] private void RemoveSelected() { if (SelectedProduct is null) { Message = "请先选择要删除的商品。"; return; } ProductItem removed = SelectedProduct; Products.Remove(removed); SelectedProduct = Products.Count > 0 ? Products[0] : null; Message = $"已删除:{removed.Name}"; OnPropertyChanged(nameof(ProductCountText)); } } public sealed class ProductItem { public string Name { get; set; } = ""; public decimal Price { get; set; } }

Views/MainWindow.axaml

<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="760" Height="460" Title="集合绑定"> <Grid RowDefinitions="Auto,*" ColumnDefinitions="260,*" RowSpacing="12" ColumnSpacing="16" Margin="20"> <TextBlock Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Text="{Binding ProductCountText}" FontSize="24" FontWeight="Bold" /> <Border Grid.Row="1" Grid.Column="0" BorderBrush="#DDDDDD" BorderThickness="1" Padding="12"> <ListBox ItemsSource="{Binding Products}" SelectedItem="{Binding SelectedProduct, Mode=TwoWay}"> <ListBox.ItemTemplate> <DataTemplate x:DataType="vm:ProductItem"> <StackPanel Spacing="4"> <TextBlock Text="{Binding Name}" FontWeight="Bold" /> <TextBlock Text="{Binding Price}" /> </StackPanel> </DataTemplate> </ListBox.ItemTemplate> </ListBox> </Border> <StackPanel Grid.Row="1" Grid.Column="1" Spacing="10"> <TextBlock Text="当前商品" FontSize="20" FontWeight="Bold" /> <TextBlock Text="{Binding SelectedName}" /> <TextBlock Text="{Binding SelectedPriceText}" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button Content="新增商品" Command="{Binding AddProductCommand}" /> <Button Content="删除选中" Command="{Binding RemoveSelectedCommand}" /> </StackPanel> <TextBlock Text="{Binding Message}" TextWrapping="Wrap" /> </StackPanel> </Grid> </Window>

先用一句话理解

Products 是列表数据源,ListBox.ItemsSource 显示它;SelectedProduct 是当前选中项;新增和删除直接修改 ObservableCollection,界面会收到集合变化通知。

第三部分:跟着敲代码

从这里开始动手。每个例子都按真实文件来写。

例子 1:只显示字符串集合

ViewModels/MainWindowViewModel.cs

using System.Collections.ObjectModel; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { public ObservableCollection<string> Products { get; } = new() { "Keyboard", "Mouse", "Monitor" }; }

Views/MainWindow.axaml

<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="480" Height="300" Title="字符串集合"> <ListBox Margin="20" ItemsSource="{Binding Products}" /> </Window>

例子 2:新增字符串

ViewModels/MainWindowViewModel.cs

using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.Input; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { private int nextNumber = 4; public ObservableCollection<string> Products { get; } = new() { "Keyboard", "Mouse", "Monitor" }; [RelayCommand] private void AddProduct() { Products.Add($"Product {nextNumber}"); nextNumber++; } }

Views/MainWindow.axaml

<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="480" Height="340" Title="新增集合项"> <StackPanel Margin="20" Spacing="10"> <Button Content="新增商品" Command="{Binding AddProductCommand}" /> <ListBox ItemsSource="{Binding Products}" /> </StackPanel> </Window>

点击新增后,列表会自动多一项。

例子 3:对象集合和 DataTemplate

ViewModels/MainWindowViewModel.cs

using System.Collections.ObjectModel; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { public ObservableCollection<ProductItem> Products { get; } = new() { new ProductItem { Name = "Keyboard", Price = 199 }, new ProductItem { Name = "Mouse", Price = 99 }, new ProductItem { Name = "Monitor", Price = 899 } }; } public sealed class ProductItem { public string Name { get; set; } = ""; public decimal Price { get; set; } }

Views/MainWindow.axaml

<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="520" Height="360" Title="对象集合"> <ListBox Margin="20" ItemsSource="{Binding Products}"> <ListBox.ItemTemplate> <DataTemplate x:DataType="vm:ProductItem"> <StackPanel Spacing="4"> <TextBlock Text="{Binding Name}" FontWeight="Bold" /> <TextBlock Text="{Binding Price}" /> </StackPanel> </DataTemplate> </ListBox.ItemTemplate> </ListBox> </Window>

例子 4:选中项和右侧详情

ViewModels/MainWindowViewModel.cs

using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { public ObservableCollection<ProductItem> Products { get; } = new() { new ProductItem { Name = "Keyboard", Price = 199 }, new ProductItem { Name = "Mouse", Price = 99 }, new ProductItem { Name = "Monitor", Price = 899 } }; [ObservableProperty] [NotifyPropertyChangedFor(nameof(SelectedName))] [NotifyPropertyChangedFor(nameof(SelectedPriceText))] private ProductItem? selectedProduct; public string SelectedName => SelectedProduct?.Name ?? "未选择商品"; public string SelectedPriceText => SelectedProduct is null ? "-" : SelectedProduct.Price.ToString(); public MainWindowViewModel() { SelectedProduct = Products[0]; } } public sealed class ProductItem { public string Name { get; set; } = ""; public decimal Price { get; set; } }

Views/MainWindow.axaml

<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="700" Height="400" Title="选中项"> <Grid ColumnDefinitions="260,*" ColumnSpacing="16" Margin="20"> <ListBox Grid.Column="0" ItemsSource="{Binding Products}" SelectedItem="{Binding SelectedProduct, Mode=TwoWay}"> <ListBox.ItemTemplate> <DataTemplate x:DataType="vm:ProductItem"> <StackPanel Spacing="4"> <TextBlock Text="{Binding Name}" FontWeight="Bold" /> <TextBlock Text="{Binding Price}" /> </StackPanel> </DataTemplate> </ListBox.ItemTemplate> </ListBox> <StackPanel Grid.Column="1" Spacing="8"> <TextBlock Text="当前商品" FontSize="20" FontWeight="Bold" /> <TextBlock Text="{Binding SelectedName}" /> <TextBlock Text="{Binding SelectedPriceText}" /> </StackPanel> </Grid> </Window>

例子 5:新增和删除

这一段就是本页完整版本。

常见错误和修法

错误为什么错修法
集合用 List 绑定新增删除界面不自动刷新ObservableCollection<T>
修改集合后不通知空状态空提示不更新集合变化后通知 IsEmpty
选中项没判空删除或编辑会出错操作前检查 SelectedItem
列表项显示类型名没有 DataTemplate 或显示字段用模板显示名称、价格等字段
直接替换集合实例绑定可能断开或状态丢失优先清空后重新添加,或通知属性变化

小白重复敲写训练

集合绑定先练新增,再练删除和选中。

训练 1:绑定字符串集合

public ObservableCollection<string> Products { get; } = new() { "Keyboard", "Mouse" };
<ListBox ItemsSource="{Binding Products}" />

训练 2:命令新增一项

[RelayCommand] private void AddProduct() { Products.Add($"Product {Products.Count + 1}"); }
<Button Content="新增" Command="{Binding AddProductCommand}" />

连续点击三次,观察列表自动更新。

训练 3:删除选中项

[ObservableProperty] private string? _selectedProduct; [RelayCommand] private void DeleteSelected() { if (SelectedProduct is not null) Products.Remove(SelectedProduct); }

第三遍在 XAML 中补上 SelectedItem 绑定和删除按钮。

每日小测

做完本页后,用这 5 题检查是否真的掌握。

1. 判断题

本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。

答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。

2. 填空题

本页主题是:集合绑定。今天至少要掌握的 3 个点是:

1. 知道 `ObservableCollection<T>` 为什么适合绑定列表。 2. 会把 `ObservableCollection<ProductItem>` 绑定到 `ListBox.ItemsSource`。 3. 会用 `DataTemplate` 控制每一项怎么显示。

答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。

3. 流程题

遇到本页相关功能时,先按什么顺序处理?

答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。

4. 找错误题

如果本页代码运行失败,第一步应该做什么?

答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。

5. 改需求题

在本页完整例子跑通后,至少改一个小需求。

可选改法:

  • 改一个字段名称。
  • 多加一个校验条件。
  • 多输出一行结果。
  • 把固定数据改成用户输入。
  • 把一次处理改成多条数据处理。

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:知道 ObservableCollection<T> 为什么适合绑定列表。。

上位机专项练习

ObservableCollection 适合设备列表和报警列表,增删项目时界面会自动刷新。

下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。

专项例子 1:准备设备集合

public ObservableCollection<DeviceItem> Devices { get; } = [ new("PLC-01", "在线"), new("PLC-02", "离线") ];

运行结果或界面效果:

集合中有两台设备

改动任务: 增加温控器-01。

专项例子 2:列表绑定集合

<ListBox ItemsSource="{Binding Devices}" SelectedItem="{Binding SelectedDevice}"> <ListBox.ItemTemplate> <DataTemplate><TextBlock Text="{Binding Name}" /></DataTemplate> </ListBox.ItemTemplate> </ListBox>

运行结果或界面效果:

列表显示每台设备名称

改动任务: 同时显示 Status。

专项例子 3:命令添加设备

[RelayCommand] private void AddDevice() { int number = Devices.Count + 1; Devices.Add(new DeviceItem($"PLC-{number:00}", "离线")); }

运行结果或界面效果:

每点击一次就新增一台设备

改动任务: 增加删除 SelectedDevice 的命令。

第四部分:作业完整答案

这一部分给出当天作业的完整答案。先照第三部分敲一遍,再用这里检查结果。

作业要求

做一个集合绑定页面:

  1. 使用 ObservableCollection<ProductItem> 保存商品。
  2. 使用 ListBox.ItemsSource 绑定商品集合。
  3. 使用 DataTemplate 显示每个商品的名称和价格。
  4. 使用 SelectedItem 绑定当前选中商品。
  5. 点击新增后,集合增加一条,界面自动刷新。
  6. 点击删除后,删除当前选中商品。
  7. 删除按钮在没有选中项时不可执行。

ViewModels/MainWindowViewModel.cs 完整答案

using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { private int nextProductNumber = 4; public ObservableCollection<ProductItem> Products { get; } = new() { new ProductItem { Name = "Keyboard", Price = 199 }, new ProductItem { Name = "Mouse", Price = 99 }, new ProductItem { Name = "Monitor", Price = 899 } }; [ObservableProperty] [NotifyPropertyChangedFor(nameof(SelectedName))] [NotifyPropertyChangedFor(nameof(SelectedPriceText))] [NotifyCanExecuteChangedFor(nameof(RemoveSelectedCommand))] private ProductItem? selectedProduct; [ObservableProperty] private string message = "请选择一个商品。"; public string SelectedName => SelectedProduct?.Name ?? "未选择商品"; public string SelectedPriceText => SelectedProduct is null ? "-" : SelectedProduct.Price.ToString(); public string ProductCountText => $"商品数量:{Products.Count}"; public MainWindowViewModel() { SelectedProduct = Products[0]; } [RelayCommand] private void AddProduct() { var product = new ProductItem { Name = $"New Product {nextProductNumber}", Price = nextProductNumber * 100 }; nextProductNumber++; Products.Add(product); SelectedProduct = product; Message = $"已新增:{product.Name}"; OnPropertyChanged(nameof(ProductCountText)); } private bool CanRemoveSelected() { return SelectedProduct is not null; } [RelayCommand(CanExecute = nameof(CanRemoveSelected))] private void RemoveSelected() { if (SelectedProduct is null) { Message = "请先选择要删除的商品。"; return; } ProductItem removed = SelectedProduct; Products.Remove(removed); SelectedProduct = Products.Count > 0 ? Products[0] : null; Message = $"已删除:{removed.Name}"; OnPropertyChanged(nameof(ProductCountText)); } } public sealed class ProductItem { public string Name { get; set; } = ""; public decimal Price { get; set; } }

Views/MainWindow.axaml 完整答案

<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="760" Height="460" Title="集合绑定"> <Grid RowDefinitions="Auto,*" ColumnDefinitions="260,*" RowSpacing="12" ColumnSpacing="16" Margin="20"> <TextBlock Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Text="{Binding ProductCountText}" FontSize="24" FontWeight="Bold" /> <Border Grid.Row="1" Grid.Column="0" BorderBrush="#DDDDDD" BorderThickness="1" Padding="12"> <ListBox ItemsSource="{Binding Products}" SelectedItem="{Binding SelectedProduct, Mode=TwoWay}"> <ListBox.ItemTemplate> <DataTemplate x:DataType="vm:ProductItem"> <StackPanel Spacing="4"> <TextBlock Text="{Binding Name}" FontWeight="Bold" /> <TextBlock Text="{Binding Price}" /> </StackPanel> </DataTemplate> </ListBox.ItemTemplate> </ListBox> </Border> <StackPanel Grid.Row="1" Grid.Column="1" Spacing="10"> <TextBlock Text="当前商品" FontSize="20" FontWeight="Bold" /> <TextBlock Text="{Binding SelectedName}" /> <TextBlock Text="{Binding SelectedPriceText}" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button Content="新增商品" Command="{Binding AddProductCommand}" /> <Button Content="删除选中" Command="{Binding RemoveSelectedCommand}" /> </StackPanel> <TextBlock Text="{Binding Message}" TextWrapping="Wrap" /> </StackPanel> </Grid> </Window>

必须验证的操作

操作应该看到
启动窗口左侧有 3 个商品,右侧显示第一个商品
点击 Mouse右侧详情变成 Mouse
点击新增商品左侧新增一项,并自动选中新商品
点击删除选中当前商品从列表中移除
删到没有商品右侧显示未选择商品,删除按钮不可执行

如果报错,按这个顺序检查

  1. 如果 Products 绑定报错,检查 ViewModel 是否有 ObservableCollection<ProductItem> Products
  2. 如果列表项绑定报错,检查 DataTemplate x:DataType="vm:ProductItem"
  3. 如果右侧详情不更新,检查 SelectedProduct 是否有 NotifyPropertyChangedFor
  4. 如果删除按钮状态不更新,检查 SelectedProduct 是否有 NotifyCanExecuteChangedFor(nameof(RemoveSelectedCommand))
  5. 如果商品数量不刷新,检查新增和删除后是否调用了 OnPropertyChanged(nameof(ProductCountText))

今天真正要掌握的闭环

ObservableCollection 保存多条数据 -> ListBox.ItemsSource 绑定集合 -> DataTemplate 控制每一项显示 -> SelectedItem 绑定当前选中项 -> 新增命令 Products.Add -> 删除命令 Products.Remove -> 集合变化后界面自动更新

集合绑定不是手动刷新控件,而是让集合和界面之间建立通知关系。