Skip to Content
Week 07Day 6 - 小项目:MVVM CRUD

Day 6 - 小项目:MVVM CRUD

建议用时:270-300 分钟

你将学会什么

  • 用 MVVM 写一个内存版商品 CRUD。
  • ObservableCollection 保存列表数据。
  • SelectedProduct 表示当前编辑项。
  • 用右侧表单完成新增和编辑。
  • 用命令完成新增模式、保存、删除。
  • 保存前复用名称和价格验证。

本页不接数据库,不写文件保存。先做内存版 CRUD,把 MVVM 的列表、选中项、表单、命令、验证串成一条完整流程。

本页固定顺序

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

学习衔接

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

今天的最低通过线

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

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

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

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。小项目最重要的是知道每个动作接在哪个状态上。

1. CRUD 是什么

CRUD 是四个动作:

动作英文本页怎么做
新增CreateNewProductCommand 进入新增模式,SaveCommand 添加到集合
读取ReadProducts 绑定到 ListBox 显示
更新Update选中商品后修改表单,再保存回当前商品
删除DeleteDeleteSelectedCommand 删除当前选中商品

先做内存版 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 是列表选中项,EditNameEditPriceText 是右侧表单当前输入。

4. 保存时怎么判断新增还是更新

保存按钮只有一个,但要做两种事。

if (SelectedProduct is null) { // 新增 } else { // 更新 }

这就是“模式”的判断。

5. 为什么更新后要重插列表项

ProductItem 现在只是普通类,修改 NamePrice 后,列表项显示不一定自动刷新。

所以更新后做一次重插:

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。按这个顺序做:

  1. 先显示列表。
  2. 再绑定选中项。
  3. 选中后填右侧表单。
  4. 加新增模式。
  5. 加保存命令。
  6. 加删除命令。
  7. 最后补验证和提示。

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:

  1. 左侧显示商品列表。
  2. 右侧显示当前商品表单。
  3. 点击新增进入新增模式。
  4. 新增模式下保存会添加商品。
  5. 编辑模式下保存会更新商品。
  6. 删除按钮删除当前商品。
  7. 名称不能为空。
  8. 价格必须是大于 0 的数字。
  9. 商品数量显示要跟着新增删除变化。

必须验证的操作

操作应该看到
启动窗口左侧 3 个商品,右侧编辑第一个商品
点击新增商品右侧清空,标题变成新增模式
输入 Desk500 后保存左侧新增 Desk,数量加 1
选中 Mouse,价格改成 129 后保存左侧 Mouse 的价格刷新
名称清空名称下方显示错误,保存按钮不可点击
价格输入 abc价格下方显示错误,保存按钮不可点击
点击删除选中当前商品从列表移除,数量减 1

常见错误完整修法

现象原因修法
列表不显示ItemsSource 没绑定 Products检查 ItemsSource="{Binding Products}"
选中后右侧不变SelectedItem 没双向绑定检查 SelectedItem="{Binding SelectedProduct, Mode=TwoWay}"
保存按钮状态不变字段没通知 SaveCommandeditNameeditPriceTextNotifyCanExecuteChangedFor
更新后列表文字不变单项属性变化没通知列表更新后移除再插入当前项
删除按钮状态不变SelectedProduct 没通知删除命令selectedProductNotifyCanExecuteChangedFor(nameof(DeleteSelectedCommand))
商品数量不刷新没通知 ProductCountText新增和删除后调用 OnPropertyChanged(nameof(ProductCountText))

今天真正要掌握的闭环

Products 提供列表 -> ListBox 显示 Products -> SelectedProduct 表示当前编辑项 -> 右侧表单显示 EditName / EditPriceText -> NewProduct 进入新增模式 -> Save 判断新增还是更新 -> DeleteSelected 删除当前项 -> ObservableCollection 通知界面新增删除

这就是 MVVM CRUD 的最小项目骨架。