Skip to Content
Week 09Day 4 - 数据持久化

Day 4 - 数据持久化

建议用时:180-210 分钟

你将学会什么

  • 为什么程序关闭后内存数据会消失
  • 什么是持久化
  • 为什么 JSON 适合保存小型本地数据
  • 为什么保存路径不要随便写在当前目录
  • 怎么把保存和读取封装到 Repository
  • 怎么在 Avalonia 页面里保存和加载商品数据

本页目标不是只会写 JsonSerializer.Serialize。你要把完整流程连起来:界面输入商品、内存列表保存商品、Repository 写入 JSON、下次再从 JSON 读回来。

本页固定顺序

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

学习衔接

上一页学习的是“文件与对话框”,今天继续学习“数据持久化”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

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

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

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

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。持久化真正要理解的是:数据原来在哪里,保存后去了哪里,读取时又怎么回来。

为什么内存数据会丢

运行程序时,Products 这种集合存在内存里。

内存的特点是:程序开着时能用,程序关闭后就没了。

所以如果你只写:

Products.Add(new ProductItem { Name = "Keyboard", Price = 199m });

这个商品只是在这次运行中存在。关闭程序再打开,它不会自动回来。

持久化就是把这些数据保存到更稳定的位置,例如文件或数据库。

什么是 JSON

JSON 是一种文本格式,常用来保存对象和列表。

一个商品对象可以保存成这样:

{ "Name": "Keyboard", "Price": 199 }

一个商品列表可以保存成这样:

[ { "Name": "Keyboard", "Price": 199 }, { "Name": "Mouse", "Price": 59 } ]

JSON 的好处是:

  • 人能直接打开文件看懂。
  • C# 能方便地把对象转成 JSON。
  • C# 也能把 JSON 再转回对象。
  • 保存配置、小型列表、本地草稿都很合适。

序列化和反序列化

这两个词很重要。

名词含义本页代码
序列化C# 对象变成 JSON 文本JsonSerializer.Serialize(products)
反序列化JSON 文本变回 C# 对象JsonSerializer.Deserialize<List<ProductItem>>(json)

保存时走这个方向:

C# 商品列表 -> JSON 字符串 -> 写入文件

读取时走这个方向:

读取文件 -> JSON 字符串 -> C# 商品列表

为什么要封装 Repository

如果把保存和读取代码直接写在按钮里,代码很快会乱:

  • 按钮里有校验。
  • 按钮里有界面提示。
  • 按钮里又有 JSON。
  • 按钮里还要处理文件路径。

所以本页把文件读写放进 ProductRepository

Repository 可以理解成“数据仓库入口”。界面和 ViewModel 不需要关心 JSON 文件怎么写,只调用:

await repository.SaveAsync(Products); List<ProductItem> loadedProducts = await repository.LoadAsync();

这样代码职责更清楚。

为什么保存到 ApplicationData

很多代码会直接写:

await File.WriteAllTextAsync("products.json", json);

这在学习小例子里能跑,但做桌面程序时不稳定。

原因是:程序发布后,当前目录可能不是你以为的目录;有些目录也不允许写文件。

所以本页用:

Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)

它表示当前用户的应用数据目录。再拼上自己的应用文件夹:

Path.Combine(..., "ProductApp")

这样保存位置更明确,也更适合桌面程序。

为什么读取时要处理三种情况

读取 JSON 时,至少要考虑三种情况:

  1. 文件还不存在。
  2. 文件是空的。
  3. JSON 内容损坏,不能转成对象。

本页先处理前两种:

if (!File.Exists(filePath)) { return new List<ProductItem>(); } if (string.IsNullOrWhiteSpace(json)) { return new List<ProductItem>(); }

第三种用 ViewModel 里的 try/catch 捕获,显示“读取失败”。

为什么 Deserialize 结果可能是 null

这行代码:

JsonSerializer.Deserialize<List<ProductItem>>(json)

返回值类型是 List<ProductItem>?,意思是可能返回空值。

所以本页写成:

return JsonSerializer.Deserialize<List<ProductItem>>(json) ?? new List<ProductItem>();

如果反序列化结果是空值,就返回空列表,避免后面代码崩掉。

JSON 持久化常用 API 速查

需求写法说明
对象转 JSONJsonSerializer.Serialize(data, options)保存
JSON 转对象JsonSerializer.Deserialize<T>(json)加载
美化 JSONWriteIndented = true文件可读
读文件File.ReadAllText(path)加载文本
写文件File.WriteAllText(path, json)保存文本
判断文件存在File.Exists(path)第一次运行可能没有
创建目录Directory.CreateDirectory(path)保存前确保目录存在
拼路径Path.Combine(...)跨平台

持久化流程:

内存对象 -> Serialize 成 JSON -> 写入文件 -> 下次启动读文件 -> Deserialize 回对象

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

今天做一个“商品本地保存”页面。

页面上可以输入商品名和价格,点击“添加”放进列表。点击“保存 JSON”,把列表保存到本机文件。点击“读取 JSON”,再从文件恢复到列表。

文件结构

今天会用到 4 个文件:

Models/ProductItem.cs Services/ProductRepository.cs ViewModels/MainWindowViewModel.cs Views/MainWindow.axaml

数据模型

文件位置:

Models/ProductItem.cs
namespace ProductApp.Models; public sealed class ProductItem { public string Name { get; set; } = ""; public decimal Price { get; set; } }

JSON 保存和读取

文件位置:

Services/ProductRepository.cs
using System; using System.Collections.Generic; using System.IO; using System.Text.Json; using System.Threading.Tasks; using ProductApp.Models; namespace ProductApp.Services; public sealed class ProductRepository { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private readonly string filePath; public ProductRepository() { string folder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ProductApp"); Directory.CreateDirectory(folder); filePath = Path.Combine(folder, "products.json"); } public string FilePath => filePath; public async Task SaveAsync(IEnumerable<ProductItem> products) { string json = JsonSerializer.Serialize(products, JsonOptions); await File.WriteAllTextAsync(filePath, json); } public async Task<List<ProductItem>> LoadAsync() { if (!File.Exists(filePath)) { return new List<ProductItem>(); } string json = await File.ReadAllTextAsync(filePath); if (string.IsNullOrWhiteSpace(json)) { return new List<ProductItem>(); } return JsonSerializer.Deserialize<List<ProductItem>>(json) ?? new List<ProductItem>(); } }

ViewModel

文件位置:

ViewModels/MainWindowViewModel.cs
using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ProductApp.Models; using ProductApp.Services; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { private readonly ProductRepository repository = new(); public ObservableCollection<ProductItem> Products { get; } = new(); [ObservableProperty] private string name = ""; [ObservableProperty] private string priceText = ""; [ObservableProperty] private string message = "输入商品后点击添加。"; [RelayCommand] private void AddProduct() { if (string.IsNullOrWhiteSpace(Name)) { Message = "商品名不能为空。"; return; } if (!decimal.TryParse(PriceText, out decimal price) || price <= 0) { Message = "价格必须是大于 0 的数字。"; return; } Products.Add(new ProductItem { Name = Name.Trim(), Price = price }); Name = ""; PriceText = ""; Message = $"已添加商品,当前共 {Products.Count} 条。"; } [RelayCommand] private async Task SaveAsync() { try { await repository.SaveAsync(Products); Message = $"已保存 {Products.Count} 条到 {repository.FilePath}"; } catch (Exception ex) { Message = $"保存失败:{ex.Message}"; } } [RelayCommand] private async Task LoadAsync() { try { List<ProductItem> loadedProducts = await repository.LoadAsync(); Products.Clear(); foreach (ProductItem product in loadedProducts) { Products.Add(product); } Message = $"已读取 {Products.Count} 条。"; } catch (Exception ex) { Message = $"读取失败:{ex.Message}"; } } }

窗口

文件位置:

Views/MainWindow.axaml
<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" xmlns:models="using:ProductApp.Models" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="780" Height="560" Title="Json Persistence Demo"> <Grid RowDefinitions="Auto,Auto,Auto,*" Margin="20" RowSpacing="12"> <TextBlock Text="商品本地保存" FontSize="24" FontWeight="Bold" /> <TextBlock Grid.Row="1" Text="{Binding Message}" Foreground="#555555" /> <StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"> <TextBox Width="180" PlaceholderText="商品名" Text="{Binding Name, Mode=TwoWay}" /> <TextBox Width="120" PlaceholderText="价格" Text="{Binding PriceText, Mode=TwoWay}" /> <Button Content="添加" Command="{Binding AddProductCommand}" /> <Button Content="保存 JSON" Command="{Binding SaveCommand}" /> <Button Content="读取 JSON" Command="{Binding LoadCommand}" /> </StackPanel> <ListBox Grid.Row="3" ItemsSource="{Binding Products}"> <ListBox.ItemTemplate> <DataTemplate x:DataType="models:ProductItem"> <Grid ColumnDefinitions="200,120" Margin="4"> <TextBlock Text="{Binding Name}" /> <TextBlock Grid.Column="1" Text="{Binding Price}" /> </Grid> </DataTemplate> </ListBox.ItemTemplate> </ListBox> </Grid> </Window>

第三部分:跟着敲代码

从这里开始动手。按文件顺序写,不要先写界面。

第 1 步:创建 Models 文件夹和 ProductItem

新建文件:

Models/ProductItem.cs

写入:

namespace ProductApp.Models; public sealed class ProductItem { public string Name { get; set; } = ""; public decimal Price { get; set; } }

解释:

  • 这个类只表示要保存的数据。
  • NamePrice 要有 get; set;,JSON 才能正常写入和读取。

第 2 步:创建 Services 文件夹和 Repository

新建文件:

Services/ProductRepository.cs

先写类结构和保存路径。

public sealed class ProductRepository { private readonly string filePath; public ProductRepository() { string folder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ProductApp"); Directory.CreateDirectory(folder); filePath = Path.Combine(folder, "products.json"); } public string FilePath => filePath; }

解释:

  • filePath 保存 JSON 文件完整位置。
  • Directory.CreateDirectory(folder) 确保文件夹存在。
  • FilePath 暴露出来,是为了界面提示保存到了哪里。

第 3 步:写保存方法

继续在 ProductRepository 里写:

public async Task SaveAsync(IEnumerable<ProductItem> products) { string json = JsonSerializer.Serialize(products, JsonOptions); await File.WriteAllTextAsync(filePath, json); }

解释:

  • IEnumerable<ProductItem> 表示只要是商品集合就能保存。
  • Serialize 把商品集合转成 JSON。
  • WriteAllTextAsync 把 JSON 写入文件。

第 4 步:写读取方法

继续写:

public async Task<List<ProductItem>> LoadAsync() { if (!File.Exists(filePath)) { return new List<ProductItem>(); } string json = await File.ReadAllTextAsync(filePath); if (string.IsNullOrWhiteSpace(json)) { return new List<ProductItem>(); } return JsonSerializer.Deserialize<List<ProductItem>>(json) ?? new List<ProductItem>(); }

解释:

  • 没有文件时返回空列表。
  • 文件为空时返回空列表。
  • 有内容时,把 JSON 转回商品列表。

第 5 步:ViewModel 里准备输入和列表

打开:

ViewModels/MainWindowViewModel.cs

加入这些属性:

private readonly ProductRepository repository = new(); public ObservableCollection<ProductItem> Products { get; } = new(); [ObservableProperty] private string name = ""; [ObservableProperty] private string priceText = ""; [ObservableProperty] private string message = "输入商品后点击添加。";

解释:

  • Products 是界面正在显示的商品列表。
  • Name 绑定商品名输入框。
  • PriceText 绑定价格输入框。
  • Message 显示添加、保存、读取结果。

第 6 步:写添加商品命令

[RelayCommand] private void AddProduct() { if (string.IsNullOrWhiteSpace(Name)) { Message = "商品名不能为空。"; return; } if (!decimal.TryParse(PriceText, out decimal price) || price <= 0) { Message = "价格必须是大于 0 的数字。"; return; } Products.Add(new ProductItem { Name = Name.Trim(), Price = price }); Name = ""; PriceText = ""; Message = $"已添加商品,当前共 {Products.Count} 条。"; }

解释:

  • 商品名为空时不添加。
  • 价格不是数字时不添加。
  • 添加成功后清空输入框。
  • [RelayCommand] 会生成 AddProductCommand

第 7 步:写保存命令

[RelayCommand] private async Task SaveAsync() { try { await repository.SaveAsync(Products); Message = $"已保存 {Products.Count} 条到 {repository.FilePath}"; } catch (Exception ex) { Message = $"保存失败:{ex.Message}"; } }

解释:

  • SaveAsync 调用 Repository。
  • 成功后显示保存条数和文件路径。
  • 失败时显示失败原因。
  • [RelayCommand] 会生成 SaveCommand

第 8 步:写读取命令

[RelayCommand] private async Task LoadAsync() { try { List<ProductItem> loadedProducts = await repository.LoadAsync(); Products.Clear(); foreach (ProductItem product in loadedProducts) { Products.Add(product); } Message = $"已读取 {Products.Count} 条。"; } catch (Exception ex) { Message = $"读取失败:{ex.Message}"; } }

解释:

  • 先从 JSON 读取商品列表。
  • 再清空当前界面列表。
  • 最后把读取到的数据一条一条加入 Products
  • [RelayCommand] 会生成 LoadCommand

第 9 步:写窗口绑定

打开:

Views/MainWindow.axaml

先写输入区:

<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"> <TextBox Width="180" PlaceholderText="商品名" Text="{Binding Name, Mode=TwoWay}" /> <TextBox Width="120" PlaceholderText="价格" Text="{Binding PriceText, Mode=TwoWay}" /> <Button Content="添加" Command="{Binding AddProductCommand}" /> <Button Content="保存 JSON" Command="{Binding SaveCommand}" /> <Button Content="读取 JSON" Command="{Binding LoadCommand}" /> </StackPanel>

再写列表:

<ListBox Grid.Row="3" ItemsSource="{Binding Products}"> <ListBox.ItemTemplate> <DataTemplate x:DataType="models:ProductItem"> <Grid ColumnDefinitions="200,120" Margin="4"> <TextBlock Text="{Binding Name}" /> <TextBlock Grid.Column="1" Text="{Binding Price}" /> </Grid> </DataTemplate> </ListBox.ItemTemplate> </ListBox>

解释:

  • 输入框绑定 ViewModel 的属性。
  • 按钮绑定 ViewModel 的命令。
  • 列表绑定 Products
  • 每一行显示 NamePrice

第 10 步:运行后检查

按这个顺序检查:

  1. 输入 Keyboard199,点击添加。
  2. 输入 Mouse59,点击添加。
  3. 点击“保存 JSON”。
  4. 关闭程序。
  5. 重新启动程序。
  6. 点击“读取 JSON”。
  7. 列表恢复出 KeyboardMouse
  8. 输入空商品名,应该提示商品名不能为空。
  9. 输入价格 abc,应该提示价格必须是数字。

常见错误和修法

错误为什么错修法
程序启动不加载数据之前保存的数据看不到启动时调用加载方法
保存时覆盖错误内容数据可能丢失保存前确认序列化结果和路径
JSON 文件为空时报错空文件不能直接反序列化空白时返回默认列表
数据模型改了旧文件读不了版本变化导致字段不匹配给字段默认值,必要时写迁移逻辑
保存失败没有提示用户以为保存成功捕获异常并显示错误消息

小白重复敲写训练

持久化先保存一条对象,再保存集合。

训练 1:对象转 JSON

var product = new Product { Name = "Keyboard", Price = 199m }; string json = JsonSerializer.Serialize(product, new JsonSerializerOptions { WriteIndented = true }); Console.WriteLine(json);

训练 2:JSON 保存到文件

await File.WriteAllTextAsync("product.json", json); string saved = await File.ReadAllTextAsync("product.json"); Console.WriteLine(saved);

训练 3:从 JSON 恢复对象

Product? loaded = JsonSerializer.Deserialize<Product>(saved); if (loaded is not null) { Console.WriteLine($"{loaded.Name}: {loaded.Price:F2}"); }

第三遍改成保存 List<Product>,至少放两条数据。

每日小测

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

1. 判断题

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

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

2. 填空题

本页主题是:数据持久化。今天至少要掌握的 3 个点是:

1. 为什么程序关闭后内存数据会消失 2. 什么是持久化 3. 为什么 JSON 适合保存小型本地数据

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

3. 流程题

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

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

4. 找错误题

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

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

5. 改需求题

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

可选改法:

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

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:为什么程序关闭后内存数据会消失。

上位机专项练习

JSON 用来保存设备配置和界面设置,启动时读取,修改后写回。

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

专项例子 1:保存设备配置

var config = new DeviceConfig("PLC-01", "192.168.1.10", 502); string json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync("device.json", json);

运行结果或界面效果:

生成可读的 device.json

改动任务: 增加 PollInterval 属性。

专项例子 2:读取设备配置

string json = await File.ReadAllTextAsync("device.json"); DeviceConfig? config = JsonSerializer.Deserialize<DeviceConfig>(json); Console.WriteLine($"{config?.Name}: {config?.IpAddress}:{config?.Port}");

运行结果或界面效果:

PLC-01: 192.168.1.10:502

改动任务: 文件不存在时使用默认配置。

专项例子 3:保存最近使用设置

var settings = new AppSettings { LastDeviceId = "PLC-01", Theme = "Light", WindowWidth = 1200 };

运行结果或界面效果:

下次启动可恢复设备、主题和窗口宽度

改动任务: 增加 WindowHeight。

第四部分:作业完整答案

作业要求:做一个商品本地保存页面,能添加商品、保存 JSON、读取 JSON,并处理空名称、错误价格、文件不存在、读取失败。

答案文件 1:Models/ProductItem.cs

namespace ProductApp.Models; public sealed class ProductItem { public string Name { get; set; } = ""; public decimal Price { get; set; } }

答案文件 2:Services/ProductRepository.cs

using System; using System.Collections.Generic; using System.IO; using System.Text.Json; using System.Threading.Tasks; using ProductApp.Models; namespace ProductApp.Services; public sealed class ProductRepository { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private readonly string filePath; public ProductRepository() { string folder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ProductApp"); Directory.CreateDirectory(folder); filePath = Path.Combine(folder, "products.json"); } public string FilePath => filePath; public async Task SaveAsync(IEnumerable<ProductItem> products) { string json = JsonSerializer.Serialize(products, JsonOptions); await File.WriteAllTextAsync(filePath, json); } public async Task<List<ProductItem>> LoadAsync() { if (!File.Exists(filePath)) { return new List<ProductItem>(); } string json = await File.ReadAllTextAsync(filePath); if (string.IsNullOrWhiteSpace(json)) { return new List<ProductItem>(); } return JsonSerializer.Deserialize<List<ProductItem>>(json) ?? new List<ProductItem>(); } }

答案文件 3:ViewModels/MainWindowViewModel.cs

using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ProductApp.Models; using ProductApp.Services; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { private readonly ProductRepository repository = new(); public ObservableCollection<ProductItem> Products { get; } = new(); [ObservableProperty] private string name = ""; [ObservableProperty] private string priceText = ""; [ObservableProperty] private string message = "输入商品后点击添加。"; [RelayCommand] private void AddProduct() { if (string.IsNullOrWhiteSpace(Name)) { Message = "商品名不能为空。"; return; } if (!decimal.TryParse(PriceText, out decimal price) || price <= 0) { Message = "价格必须是大于 0 的数字。"; return; } Products.Add(new ProductItem { Name = Name.Trim(), Price = price }); Name = ""; PriceText = ""; Message = $"已添加商品,当前共 {Products.Count} 条。"; } [RelayCommand] private async Task SaveAsync() { try { await repository.SaveAsync(Products); Message = $"已保存 {Products.Count} 条到 {repository.FilePath}"; } catch (Exception ex) { Message = $"保存失败:{ex.Message}"; } } [RelayCommand] private async Task LoadAsync() { try { List<ProductItem> loadedProducts = await repository.LoadAsync(); Products.Clear(); foreach (ProductItem product in loadedProducts) { Products.Add(product); } Message = $"已读取 {Products.Count} 条。"; } catch (Exception ex) { Message = $"读取失败:{ex.Message}"; } } }

答案文件 4:Views/MainWindow.axaml

<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" xmlns:models="using:ProductApp.Models" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="780" Height="560" Title="Json Persistence Demo"> <Grid RowDefinitions="Auto,Auto,Auto,*" Margin="20" RowSpacing="12"> <TextBlock Text="商品本地保存" FontSize="24" FontWeight="Bold" /> <TextBlock Grid.Row="1" Text="{Binding Message}" Foreground="#555555" /> <StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"> <TextBox Width="180" PlaceholderText="商品名" Text="{Binding Name, Mode=TwoWay}" /> <TextBox Width="120" PlaceholderText="价格" Text="{Binding PriceText, Mode=TwoWay}" /> <Button Content="添加" Command="{Binding AddProductCommand}" /> <Button Content="保存 JSON" Command="{Binding SaveCommand}" /> <Button Content="读取 JSON" Command="{Binding LoadCommand}" /> </StackPanel> <ListBox Grid.Row="3" ItemsSource="{Binding Products}"> <ListBox.ItemTemplate> <DataTemplate x:DataType="models:ProductItem"> <Grid ColumnDefinitions="200,120" Margin="4"> <TextBlock Text="{Binding Name}" /> <TextBlock Grid.Column="1" Text="{Binding Price}" /> </Grid> </DataTemplate> </ListBox.ItemTemplate> </ListBox> </Grid> </Window>

验收结果

运行后必须看到这些结果:

  1. 商品名为空时不能添加。
  2. 价格不是数字时不能添加。
  3. 正常商品能显示在列表里。
  4. 点击“保存 JSON”后提示保存路径。
  5. 关闭程序再打开,点击“读取 JSON”能恢复列表。
  6. 第一次运行时,即使文件不存在,点击读取也不会崩溃。

为什么这个答案是对的

这个答案把持久化拆成了明确的四层:

层级代码作用
数据模型ProductItem决定 JSON 里有哪些字段
文件仓库ProductRepository负责 JSON 保存和读取
页面状态MainWindowViewModel负责输入、校验、命令、提示
界面显示MainWindow.axaml负责输入框、按钮、列表

关闭程序后,Products 里的内存数据会消失;保存到 JSON 后,数据写到了磁盘文件;下次读取 JSON,就能把磁盘文件恢复成内存里的商品列表。