Day 6 - 小项目:MVVM CRUD
建议用时:270-300 分钟
你将学会什么
- 用 MVVM 写一个内存版商品 CRUD。
- 用
ObservableCollection保存列表数据。 - 用
SelectedProduct表示当前编辑项。 - 用右侧表单完成新增和编辑。
- 用命令完成新增模式、保存、删除。
- 保存前复用名称和价格验证。
本页不接数据库,不写文件保存。先做内存版 CRUD,把 MVVM 的列表、选中项、表单、命令、验证串成一条完整流程。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“集合绑定”,今天继续学习“小项目:MVVM CRUD”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 3 项,就可以继续:
- 能用自己的话说明“小项目:MVVM CRUD”解决什么问题。
- 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
- 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。
第一部分:先学原理和最小知识
这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。小项目最重要的是知道每个动作接在哪个状态上。
1. CRUD 是什么
CRUD 是四个动作:
| 动作 | 英文 | 本页怎么做 |
|---|---|---|
| 新增 | Create | NewProductCommand 进入新增模式,SaveCommand 添加到集合 |
| 读取 | Read | Products 绑定到 ListBox 显示 |
| 更新 | Update | 选中商品后修改表单,再保存回当前商品 |
| 删除 | Delete | DeleteSelectedCommand 删除当前选中商品 |
先做内存版 CRUD,是为了把页面流程练清楚。数据库只是后面的保存位置,不改变 CRUD 主流程。
2. 新增和编辑为什么共用一个表单
右侧表单只有一份:
- 没有选中商品时,表示新增模式。
- 有选中商品时,表示编辑模式。
判断方式:
public string ModeText => SelectedProduct is null ? "新增模式" : $"编辑模式:{SelectedProduct.Name}";这样不用做两个页面,也不用复制两套输入框。
3. SelectedProduct 变化时为什么要填表单
用户点击左侧商品后,右侧输入框要显示该商品的数据。
partial void OnSelectedProductChanged(ProductItem? value)
{
if (value is null)
{
EditName = "";
EditPriceText = "";
}
else
{
EditName = value.Name;
EditPriceText = value.Price.ToString();
}
}SelectedProduct 是列表选中项,EditName 和 EditPriceText 是右侧表单当前输入。
4. 保存时怎么判断新增还是更新
保存按钮只有一个,但要做两种事。
if (SelectedProduct is null)
{
// 新增
}
else
{
// 更新
}这就是“模式”的判断。
5. 为什么更新后要重插列表项
ProductItem 现在只是普通类,修改 Name 和 Price 后,列表项显示不一定自动刷新。
所以更新后做一次重插:
int index = Products.IndexOf(SelectedProduct);
Products.RemoveAt(index);
Products.Insert(index, SelectedProduct);
SelectedProduct = Products[index];这会触发集合变化通知,让列表重新显示这一项。
后面如果让 ProductItem 也继承 ObservableObject,就可以让单项属性变化也通知界面。
6. 删除为什么要处理空列表
删除当前商品后,列表可能还有商品,也可能已经空了。
SelectedProduct = Products.FirstOrDefault();如果还有商品,就选中第一个;如果没有商品,就变成 null,右侧进入新增模式。
7. 为什么保存前还要验证
按钮可执行状态不是业务安全的全部。保存方法里仍然要调用:
if (!ValidateForm())
{
Message = "请先修正表单错误。";
return;
}这样即使以后从别的入口调用保存,也不会绕过验证。
8. 小项目的稳定开发顺序
不要一上来就写完整 CRUD。按这个顺序做:
- 先显示列表。
- 再绑定选中项。
- 选中后填右侧表单。
- 加新增模式。
- 加保存命令。
- 加删除命令。
- 最后补验证和提示。
MVVM CRUD 常用写法速查
| 功能 | 常用写法 |
|---|---|
| 新增 | 读取输入 -> 校验 -> Products.Add(...) |
| 选中 | SelectedProduct |
| 编辑 | 把选中项复制到编辑字段 |
| 保存编辑 | 校验后修改选中项属性 |
| 删除 | Products.Remove(SelectedProduct) |
| 搜索 | Where 过滤原始列表 |
| 清空表单 | 把输入属性设为空 |
| 错误提示 | ErrorMessage |
CRUD 主线:
Create 新增
Read 列表显示和选中详情
Update 编辑后保存
Delete 删除选中项第二部分:把知识组合成完整例子
今天要做一个商品 CRUD 页面:
- 左侧显示商品列表。
- 右侧显示表单。
- 点击左侧商品,右侧进入编辑模式。
- 点击新增,右侧进入新增模式。
- 点击保存,如果没选中商品就是新增;如果选中了商品就是更新。
- 点击删除,删除当前选中商品。
ViewModels/MainWindowViewModel.cs
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ProductApp.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
private int nextId = 4;
public ObservableCollection<ProductItem> Products { get; } = new()
{
new ProductItem { Id = 1, Name = "Keyboard", Price = 199 },
new ProductItem { Id = 2, Name = "Mouse", Price = 99 },
new ProductItem { Id = 3, Name = "Monitor", Price = 899 }
};
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ModeText))]
[NotifyCanExecuteChangedFor(nameof(DeleteSelectedCommand))]
private ProductItem? selectedProduct;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private string editName = "";
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private string editPriceText = "";
[ObservableProperty]
private string nameError = "";
[ObservableProperty]
private string priceError = "";
[ObservableProperty]
private string message = "请选择商品,或点击新增商品。";
public string ModeText => SelectedProduct is null ? "新增模式" : $"编辑模式:{SelectedProduct.Name}";
public string ProductCountText => $"商品数量:{Products.Count}";
public MainWindowViewModel()
{
SelectedProduct = Products[0];
}
partial void OnSelectedProductChanged(ProductItem? value)
{
if (value is null)
{
EditName = "";
EditPriceText = "";
Message = "新增模式:请输入商品信息。";
}
else
{
EditName = value.Name;
EditPriceText = value.Price.ToString();
Message = $"正在编辑:{value.Name}";
}
NameError = "";
PriceError = "";
}
partial void OnEditNameChanged(string value)
{
NameError = GetNameError(value);
}
partial void OnEditPriceTextChanged(string value)
{
PriceError = GetPriceError(value);
}
[RelayCommand]
private void NewProduct()
{
SelectedProduct = null;
}
private bool CanSave()
{
return GetNameError(EditName).Length == 0 &&
GetPriceError(EditPriceText).Length == 0;
}
[RelayCommand(CanExecute = nameof(CanSave))]
private void Save()
{
if (!ValidateForm())
{
Message = "请先修正表单错误。";
return;
}
decimal price = decimal.Parse(EditPriceText.Trim());
if (SelectedProduct is null)
{
var product = new ProductItem
{
Id = nextId,
Name = EditName.Trim(),
Price = price
};
nextId++;
Products.Add(product);
SelectedProduct = product;
Message = $"已新增:{product.Name}";
OnPropertyChanged(nameof(ProductCountText));
return;
}
SelectedProduct.Name = EditName.Trim();
SelectedProduct.Price = price;
int index = Products.IndexOf(SelectedProduct);
Products.RemoveAt(index);
Products.Insert(index, SelectedProduct);
SelectedProduct = Products[index];
Message = $"已更新:{SelectedProduct.Name}";
}
private bool CanDeleteSelected()
{
return SelectedProduct is not null;
}
[RelayCommand(CanExecute = nameof(CanDeleteSelected))]
private void DeleteSelected()
{
if (SelectedProduct is null)
{
Message = "请先选择要删除的商品。";
return;
}
ProductItem removed = SelectedProduct;
Products.Remove(removed);
SelectedProduct = Products.FirstOrDefault();
Message = $"已删除:{removed.Name}";
OnPropertyChanged(nameof(ProductCountText));
}
private bool ValidateForm()
{
NameError = GetNameError(EditName);
PriceError = GetPriceError(EditPriceText);
return NameError.Length == 0 && PriceError.Length == 0;
}
private static string GetNameError(string value)
{
return string.IsNullOrWhiteSpace(value) ? "商品名称不能为空。" : "";
}
private static string GetPriceError(string value)
{
if (!decimal.TryParse(value?.Trim(), out decimal price))
{
return "价格必须是数字。";
}
return price <= 0 ? "价格必须大于 0。" : "";
}
}
public sealed class ProductItem
{
public int Id { get; set; }
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="860"
Height="520"
Title="MVVM CRUD">
<Grid RowDefinitions="Auto,*"
ColumnDefinitions="300,*"
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">
<StackPanel Spacing="8">
<TextBlock Text="商品列表"
FontWeight="Bold" />
<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>
</StackPanel>
</Border>
<Border Grid.Row="1"
Grid.Column="1"
BorderBrush="#DDDDDD"
BorderThickness="1"
Padding="16">
<StackPanel Spacing="10">
<TextBlock Text="{Binding ModeText}"
FontSize="20"
FontWeight="Bold" />
<TextBlock Text="商品名称" />
<TextBox Text="{Binding EditName, Mode=TwoWay}"
PlaceholderText="请输入商品名称" />
<TextBlock Text="{Binding NameError}"
Foreground="Red"
TextWrapping="Wrap" />
<TextBlock Text="商品价格" />
<TextBox Text="{Binding EditPriceText, Mode=TwoWay}"
PlaceholderText="请输入商品价格" />
<TextBlock Text="{Binding PriceError}"
Foreground="Red"
TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal"
Spacing="8">
<Button Content="新增商品"
Command="{Binding NewProductCommand}" />
<Button Content="保存"
Command="{Binding SaveCommand}" />
<Button Content="删除选中"
Command="{Binding DeleteSelectedCommand}" />
</StackPanel>
<TextBlock Text="{Binding Message}"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</Grid>
</Window>先用一句话理解
CRUD 在这个页面里就是:Products 负责列表,SelectedProduct 负责当前编辑项,右侧表单负责输入,命令负责新增、保存、删除。
第三部分:跟着敲代码
从这里开始动手。今天只做一个完整项目,不拆成多个不完整片段。按下面顺序敲:
步骤 1:先写数据和集合
先在 ViewModel 里准备:
public ObservableCollection<ProductItem> Products { get; } = new()
{
new ProductItem { Id = 1, Name = "Keyboard", Price = 199 },
new ProductItem { Id = 2, Name = "Mouse", Price = 99 },
new ProductItem { Id = 3, Name = "Monitor", Price = 899 }
};步骤 2:再写选中项和表单字段
[ObservableProperty]
private ProductItem? selectedProduct;
[ObservableProperty]
private string editName = "";
[ObservableProperty]
private string editPriceText = "";步骤 3:处理选中项变化
选中商品后,把商品数据放进右侧表单:
partial void OnSelectedProductChanged(ProductItem? value)
{
if (value is null)
{
EditName = "";
EditPriceText = "";
}
else
{
EditName = value.Name;
EditPriceText = value.Price.ToString();
}
}步骤 4:写新增、保存、删除命令
这一步直接使用第一部分的完整 ViewModel。不要只复制一个命令,否则页面流程会断。
步骤 5:写 XAML 页面
这一步直接使用第一部分的完整 Views/MainWindow.axaml。先完整跑通,再自己改字段名或按钮文字。
小白重复敲写训练
CRUD 项目分成新增、修改、删除三次单独训练。
训练 1:新增商品
[RelayCommand]
private void Add()
{
Products.Add(new Product
{
Name = $"Product {Products.Count + 1}",
Price = 10m
});
}先连续新增三次,只检查列表数量。
训练 2:修改选中商品
[RelayCommand]
private void RenameSelected()
{
if (SelectedProduct is null) return;
SelectedProduct.Name = "Updated";
}如果界面不更新,检查 Product 是否也实现了属性变化通知。
训练 3:删除并清空选中项
[RelayCommand]
private void DeleteSelected()
{
if (SelectedProduct is null) return;
Products.Remove(SelectedProduct);
SelectedProduct = null;
}第三遍增加“没有选中商品时不允许删除”的 CanExecute。
每日小测
做完本页后,用这 5 题检查是否真的掌握。
1. 判断题
本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。
答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。
2. 填空题
本页主题是:小项目:MVVM CRUD。今天至少要掌握的 3 个点是:
1. 用 MVVM 写一个内存版商品 CRUD。
2. 用 `ObservableCollection` 保存列表数据。
3. 用 `SelectedProduct` 表示当前编辑项。答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。
3. 流程题
遇到本页相关功能时,先按什么顺序处理?
答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。
4. 找错误题
如果本页代码运行失败,第一步应该做什么?
答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。
5. 改需求题
在本页完整例子跑通后,至少改一个小需求。
可选改法:
- 改一个字段名称。
- 多加一个校验条件。
- 多输出一行结果。
- 把固定数据改成用户输入。
- 把一次处理改成多条数据处理。
答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:用 MVVM 写一个内存版商品 CRUD。。
上位机专项练习
把属性绑定、命令、验证和集合组合成 MVVM 设备管理页。
下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。
专项例子 1:选中设备显示详情
[ObservableProperty] private DeviceItem? selectedDevice;
partial void OnSelectedDeviceChanged(DeviceItem? value)
{
DetailText = value is null ? "请选择设备" : $"{value.Name} / {value.Status}";
}运行结果或界面效果:
选择列表项后详情文字更新改动任务: 再显示 IP 地址。
专项例子 2:保存前检查错误
[RelayCommand(CanExecute = nameof(CanSave))]
private void Save() => Message = "设备配置已保存";
private bool CanSave() => !HasErrors;运行结果或界面效果:
有验证错误时不能保存改动任务: 保存后清空 Message,重新显示。
专项例子 3:设备管理页面布局
<Grid ColumnDefinitions="260,*" Margin="20">
<ListBox ItemsSource="{Binding Devices}" SelectedItem="{Binding SelectedDevice}" />
<StackPanel Grid.Column="1" Margin="24,0" Spacing="10">
<TextBox Text="{Binding SelectedDevice.Name}" />
<Button Content="保存" Command="{Binding SaveCommand}" />
</StackPanel>
</Grid>运行结果或界面效果:
左侧选择,右侧编辑并保存改动任务: 增加新增和删除按钮。
第四部分:作业完整答案
这一部分给出当天作业的完整答案。完整代码就是第一部分的两个文件。下面给出验收和排错答案。
作业要求
做一个 MVVM 内存版商品 CRUD:
- 左侧显示商品列表。
- 右侧显示当前商品表单。
- 点击新增进入新增模式。
- 新增模式下保存会添加商品。
- 编辑模式下保存会更新商品。
- 删除按钮删除当前商品。
- 名称不能为空。
- 价格必须是大于 0 的数字。
- 商品数量显示要跟着新增删除变化。
必须验证的操作
| 操作 | 应该看到 |
|---|---|
| 启动窗口 | 左侧 3 个商品,右侧编辑第一个商品 |
| 点击新增商品 | 右侧清空,标题变成新增模式 |
输入 Desk 和 500 后保存 | 左侧新增 Desk,数量加 1 |
选中 Mouse,价格改成 129 后保存 | 左侧 Mouse 的价格刷新 |
| 名称清空 | 名称下方显示错误,保存按钮不可点击 |
价格输入 abc | 价格下方显示错误,保存按钮不可点击 |
| 点击删除选中 | 当前商品从列表移除,数量减 1 |
常见错误完整修法
| 现象 | 原因 | 修法 |
|---|---|---|
| 列表不显示 | ItemsSource 没绑定 Products | 检查 ItemsSource="{Binding Products}" |
| 选中后右侧不变 | SelectedItem 没双向绑定 | 检查 SelectedItem="{Binding SelectedProduct, Mode=TwoWay}" |
| 保存按钮状态不变 | 字段没通知 SaveCommand | 给 editName、editPriceText 加 NotifyCanExecuteChangedFor |
| 更新后列表文字不变 | 单项属性变化没通知列表 | 更新后移除再插入当前项 |
| 删除按钮状态不变 | SelectedProduct 没通知删除命令 | 给 selectedProduct 加 NotifyCanExecuteChangedFor(nameof(DeleteSelectedCommand)) |
| 商品数量不刷新 | 没通知 ProductCountText | 新增和删除后调用 OnPropertyChanged(nameof(ProductCountText)) |
今天真正要掌握的闭环
Products 提供列表
-> ListBox 显示 Products
-> SelectedProduct 表示当前编辑项
-> 右侧表单显示 EditName / EditPriceText
-> NewProduct 进入新增模式
-> Save 判断新增还是更新
-> DeleteSelected 删除当前项
-> ObservableCollection 通知界面新增删除这就是 MVVM CRUD 的最小项目骨架。