Skip to Content
Week 09Day 3 - 文件与对话框

Day 3 - 文件与对话框

建议用时:180-210 分钟

你将学会什么

  • 文件对话框到底负责什么
  • 为什么“选择文件”和“读取文件”是两件事
  • 用户取消选择时应该怎么处理
  • 为什么读写文件要用 try/catch
  • 怎么在 Avalonia 里打开文本文件
  • 怎么在 Avalonia 里保存文本文件

本页会直接写一个能打开和保存文本文件的窗口。重点不是背 API,而是把流程想清楚:弹出对话框、处理取消、拿到文件、用流读写、显示结果。

本页固定顺序

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

学习衔接

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

今天的最低通过线

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

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

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

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。文件功能最容易乱,是因为很多人把“路径、文件、内容、对话框”混成一件事。

先把流程拆开

打开文件不是一步完成的,它至少有 5 步:

  1. 弹出打开文件对话框。
  2. 用户选择文件,或者取消。
  3. 程序拿到文件对象。
  4. 程序打开读取流。
  5. 程序把流里的文本读出来。

保存文件也不是一步完成的,它至少有 5 步:

  1. 弹出保存文件对话框。
  2. 用户选择保存位置,或者取消。
  3. 程序拿到文件对象。
  4. 程序打开写入流。
  5. 程序把文本写进去。

对话框只负责让用户选择文件。真正读写内容,要靠后面的文件 API。

名词先讲清楚

名词直接解释本页对应代码
打开文件对话框让用户选择一个已有文件OpenFilePickerAsync
保存文件对话框让用户选择保存位置SaveFilePickerAsync
取消用户关闭窗口,没有选文件files.Count == 0file is null
文件类型过滤只显示某些后缀的文件Patterns = new[] { "*.txt" }
读取流从文件里读数据的通道OpenReadAsync()
写入流往文件里写数据的通道OpenWriteAsync()
异常读写过程中发生失败catch (Exception ex)

为什么 Avalonia 用 StorageProvider

Avalonia 是跨平台 UI 框架,同一套代码可能运行在 Windows、macOS、Linux。

不同系统的文件选择方式不完全一样,所以 Avalonia 把它包装成统一入口:

StorageProvider.OpenFilePickerAsync(...) StorageProvider.SaveFilePickerAsync(...)

在窗口里可以直接通过 StorageProvider 调用这些能力。

为什么不能假设一定有文件路径

有些平台会给你传统路径,有些平台会给你文件对象或权限句柄。

所以本页不用“拿到路径再 File.ReadAllTextAsync(path)”作为主流程,而是用:

await using Stream stream = await file.OpenReadAsync(); using var reader = new StreamReader(stream); FileContentBox.Text = await reader.ReadToEndAsync();

这样更符合 Avalonia 的跨平台文件访问方式。

为什么要处理取消

用户点“打开文件”,然后直接关闭对话框,这是正常操作。

正常操作不应该显示“错误”。所以打开文件时要写:

if (files.Count == 0) { StatusText.Text = "已取消选择。"; return; }

保存文件时要写:

if (file is null) { StatusText.Text = "已取消保存。"; return; }

这两段代码的作用是:没有选择就直接结束,不继续读写。

为什么要用 try/catch

用户选择了文件,不代表一定能读写成功。

可能失败的原因有很多:

  • 文件被别的程序占用。
  • 文件权限不足。
  • 文件太大,读取过程中失败。
  • 保存位置不允许写入。
  • 磁盘空间不足。

所以读写文件一定要把失败情况包住:

try { // 读写文件 } catch (Exception ex) { StatusText.Text = $"失败:{ex.Message}"; }

这样程序不会直接崩掉,用户也能知道失败原因。

为什么本页用 code-behind

文件对话框属于窗口能力,需要依赖当前窗口的 StorageProvider

为了把重点放在“文件选择和读写流程”上,本页先把按钮点击事件写在 MainWindow.axaml.cs 里。

后面做正式项目时,可以再把文件对话框包装成服务,然后给 ViewModel 调用。现在先把流程跑通。

文件对话框常用 API 速查

需求Avalonia 写法说明
打开文件StorageProvider.OpenFilePickerAsync(...)选择一个或多个文件
保存文件StorageProvider.SaveFilePickerAsync(...)选择保存路径
判断取消file is null 或结果为空用户可能取消
读文件流OpenReadAsync()读取选择文件
写文件流OpenWriteAsync()保存内容
文件类型过滤FilePickerFileType限制扩展名
异步等待await对话框都是异步

对话框流程:

调用 StorageProvider -> 判断用户是否取消 -> 打开流 -> 读写内容 -> 给 Message 提示结果

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

今天做一个“文本文件查看器”。

窗口上有两个按钮:

  • 点击“打开文件”,选择 .txt.csv 文件,把内容显示到文本框里。
  • 点击“保存文件”,选择保存位置,把文本框里的内容保存出去。

完整窗口

文件位置:

Views/MainWindow.axaml
<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="ProductApp.Views.MainWindow" Width="760" Height="560" Title="File Dialog Demo"> <Grid RowDefinitions="Auto,Auto,Auto,*" Margin="20" RowSpacing="12"> <TextBlock Text="文本文件查看器" FontSize="24" FontWeight="Bold" /> <TextBlock Grid.Row="1" Text="先选择文本文件,读取内容;也可以修改内容后另存为新文件。" Foreground="#555555" /> <StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"> <Button Content="打开文件" Click="OpenFileButton_Click" /> <Button Content="保存文件" Click="SaveFileButton_Click" /> <TextBlock x:Name="StatusText" VerticalAlignment="Center" Text="还没有选择文件。" /> </StackPanel> <TextBox x:Name="FileContentBox" Grid.Row="3" AcceptsReturn="True" TextWrapping="Wrap" PlaceholderText="文件内容会显示在这里" /> </Grid> </Window>

完整代码

文件位置:

Views/MainWindow.axaml.cs
using System; using System.IO; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Platform.Storage; namespace ProductApp.Views; public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); } private async void OpenFileButton_Click(object? sender, RoutedEventArgs e) { var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "选择文本文件", AllowMultiple = false, FileTypeFilter = new[] { new FilePickerFileType("Text files") { Patterns = new[] { "*.txt", "*.csv" } }, FilePickerFileTypes.All } }); if (files.Count == 0) { StatusText.Text = "已取消选择。"; return; } IStorageFile file = files[0]; try { await using Stream stream = await file.OpenReadAsync(); using var reader = new StreamReader(stream); FileContentBox.Text = await reader.ReadToEndAsync(); StatusText.Text = $"已读取:{file.Name}"; } catch (Exception ex) { StatusText.Text = $"读取失败:{ex.Message}"; } } private async void SaveFileButton_Click(object? sender, RoutedEventArgs e) { var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = "保存文本文件", SuggestedFileName = "products.txt", FileTypeChoices = new[] { new FilePickerFileType("Text files") { Patterns = new[] { "*.txt" } }, FilePickerFileTypes.All } }); if (file is null) { StatusText.Text = "已取消保存。"; return; } try { await using Stream stream = await file.OpenWriteAsync(); using var writer = new StreamWriter(stream); await writer.WriteAsync(FileContentBox.Text ?? ""); StatusText.Text = $"已保存:{file.Name}"; } catch (Exception ex) { StatusText.Text = $"保存失败:{ex.Message}"; } } }

第三部分:跟着敲代码

从这里开始动手。不要跳着写,按顺序完成。

第 1 步:写窗口结构

打开 Views/MainWindow.axaml,先写一个四行布局。

<Grid RowDefinitions="Auto,Auto,Auto,*" Margin="20" RowSpacing="12"> </Grid>

解释:

  • 第一行放标题。
  • 第二行放说明。
  • 第三行放按钮和状态文字。
  • 第四行放文本框。

第 2 步:加入标题和说明

<TextBlock Text="文本文件查看器" FontSize="24" FontWeight="Bold" /> <TextBlock Grid.Row="1" Text="先选择文本文件,读取内容;也可以修改内容后另存为新文件。" Foreground="#555555" />

解释:

  • Grid.Row="1" 表示放到第二行。
  • 不写 Grid.Row 的元素默认在第 0 行。

第 3 步:加入按钮和状态文字

<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"> <Button Content="打开文件" Click="OpenFileButton_Click" /> <Button Content="保存文件" Click="SaveFileButton_Click" /> <TextBlock x:Name="StatusText" VerticalAlignment="Center" Text="还没有选择文件。" /> </StackPanel>

解释:

  • Click="OpenFileButton_Click" 表示按钮点击后调用同名方法。
  • x:Name="StatusText" 表示 C# 代码里可以用 StatusText.Text 修改文字。
  • 两个按钮先只负责触发事件,真正逻辑在 .axaml.cs 文件里。

第 4 步:加入内容文本框

<TextBox x:Name="FileContentBox" Grid.Row="3" AcceptsReturn="True" TextWrapping="Wrap" PlaceholderText="文件内容会显示在这里" />

解释:

  • x:Name="FileContentBox" 表示 C# 代码里可以读写这个文本框。
  • AcceptsReturn="True" 表示可以输入多行。
  • TextWrapping="Wrap" 表示长文字自动换行。

第 5 步:写打开文件事件

打开 Views/MainWindow.axaml.cs,先写打开文件方法。

private async void OpenFileButton_Click(object? sender, RoutedEventArgs e) { var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "选择文本文件", AllowMultiple = false, FileTypeFilter = new[] { new FilePickerFileType("Text files") { Patterns = new[] { "*.txt", "*.csv" } }, FilePickerFileTypes.All } }); if (files.Count == 0) { StatusText.Text = "已取消选择。"; return; } IStorageFile file = files[0]; }

解释:

  • async void 常用于按钮点击事件。
  • AllowMultiple = false 表示只能选一个文件。
  • FileTypeFilter 用来过滤显示的文件类型。
  • files.Count == 0 表示没有选择文件。

第 6 步:读取文件内容

把读取逻辑接到第 5 步下面。

try { await using Stream stream = await file.OpenReadAsync(); using var reader = new StreamReader(stream); FileContentBox.Text = await reader.ReadToEndAsync(); StatusText.Text = $"已读取:{file.Name}"; } catch (Exception ex) { StatusText.Text = $"读取失败:{ex.Message}"; }

解释:

  • OpenReadAsync() 打开读取通道。
  • StreamReader 把字节流当成文本读取。
  • ReadToEndAsync() 读取全部文本。
  • catch 负责显示失败原因。

第 7 步:写保存文件事件

private async void SaveFileButton_Click(object? sender, RoutedEventArgs e) { var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = "保存文本文件", SuggestedFileName = "products.txt", FileTypeChoices = new[] { new FilePickerFileType("Text files") { Patterns = new[] { "*.txt" } }, FilePickerFileTypes.All } }); if (file is null) { StatusText.Text = "已取消保存。"; return; } }

解释:

  • SaveFilePickerAsync 会打开保存对话框。
  • SuggestedFileName 是默认文件名。
  • file is null 表示用户取消保存。

第 8 步:写入文本内容

把写入逻辑接到第 7 步下面。

try { await using Stream stream = await file.OpenWriteAsync(); using var writer = new StreamWriter(stream); await writer.WriteAsync(FileContentBox.Text ?? ""); StatusText.Text = $"已保存:{file.Name}"; } catch (Exception ex) { StatusText.Text = $"保存失败:{ex.Message}"; }

解释:

  • OpenWriteAsync() 打开写入通道。
  • StreamWriter 把文本写进文件。
  • FileContentBox.Text ?? "" 表示文本框是空值时,就保存空字符串。

第 9 步:运行后检查

按这个顺序检查:

  1. 点击“打开文件”,选择一个 .txt 文件。
  2. 文件内容显示到文本框。
  3. 修改文本框内容。
  4. 点击“保存文件”,另存为新文件。
  5. 再用“打开文件”打开刚保存的文件,确认内容是修改后的内容。
  6. 打开对话框后点取消,状态文字应该显示“已取消选择”。
  7. 保存对话框后点取消,状态文字应该显示“已取消保存”。

常见错误和修法

错误为什么错修法
文件路径写死换电脑或目录就失效通过对话框或配置获取路径
没处理用户取消选择对话框可能返回空结果先判断是否选中文件
文件读写不捕获异常权限、占用、缺失都会失败try/catch 给出提示
UI 线程做大文件读取界面可能卡住使用异步文件 API
不校验文件扩展名用户可能选错文件检查 .csv.json 等扩展名

小白重复敲写训练

文件对话框先练选择路径,再练读取和保存。

训练 1:选择一个文本文件

var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "选择文本文件", AllowMultiple = false }); if (files.Count > 0) { StatusMessage = files[0].Name; }

训练 2:读取选中文件

await using Stream stream = await files[0].OpenReadAsync(); using var reader = new StreamReader(stream); string content = await reader.ReadToEndAsync(); Console.WriteLine(content);

改动任务:显示内容长度。

训练 3:保存文本

var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = "保存报告", SuggestedFileName = "report.txt" }); if (file is not null) { await using Stream stream = await file.OpenWriteAsync(); await using var writer = new StreamWriter(stream); await writer.WriteAsync("Hello file"); }

第三遍增加取消选择时的状态提示。

每日小测

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

1. 判断题

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

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

2. 填空题

本页主题是:文件与对话框。今天至少要掌握的 3 个点是:

1. 文件对话框到底负责什么 2. 为什么“选择文件”和“读取文件”是两件事 3. 用户取消选择时应该怎么处理

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

3. 流程题

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

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

4. 找错误题

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

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

5. 改需求题

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

可选改法:

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

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:文件对话框到底负责什么。

上位机专项练习

文件对话框让操作者选择设备配置文件、报警导出位置和日志目录。

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

专项例子 1:选择配置文件

IReadOnlyList<IStorageFile> files = await StorageProvider.OpenFilePickerAsync( new FilePickerOpenOptions { Title = "选择设备配置", AllowMultiple = false }); if (files.Count > 0) FilePath = files[0].Path.LocalPath;

运行结果或界面效果:

选择后得到本地文件路径

改动任务: 限制只选择 json 文件。

专项例子 2:选择报警导出位置

IStorageFile? file = await StorageProvider.SaveFilePickerAsync( new FilePickerSaveOptions { SuggestedFileName = "alarms.csv" }); if (file is not null) await File.WriteAllTextAsync(file.Path.LocalPath, csvText);

运行结果或界面效果:

用户选择位置后保存 alarms.csv

改动任务: 默认文件名增加当天日期。

专项例子 3:界面显示已选路径

<Grid ColumnDefinitions="*,Auto"> <TextBox Text="{Binding ConfigPath}" IsReadOnly="True" /> <Button Grid.Column="1" Content="浏览..." Command="{Binding BrowseCommand}" /> </Grid>

运行结果或界面效果:

路径只读,点击浏览选择文件

改动任务: 增加“打开所在文件夹”按钮。

第四部分:作业完整答案

作业要求:做一个窗口,能打开文本文件、显示内容、修改后保存为新文件,并正确处理取消和失败。

答案文件 1:Views/MainWindow.axaml

<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="ProductApp.Views.MainWindow" Width="760" Height="560" Title="File Dialog Demo"> <Grid RowDefinitions="Auto,Auto,Auto,*" Margin="20" RowSpacing="12"> <TextBlock Text="文本文件查看器" FontSize="24" FontWeight="Bold" /> <TextBlock Grid.Row="1" Text="先选择文本文件,读取内容;也可以修改内容后另存为新文件。" Foreground="#555555" /> <StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"> <Button Content="打开文件" Click="OpenFileButton_Click" /> <Button Content="保存文件" Click="SaveFileButton_Click" /> <TextBlock x:Name="StatusText" VerticalAlignment="Center" Text="还没有选择文件。" /> </StackPanel> <TextBox x:Name="FileContentBox" Grid.Row="3" AcceptsReturn="True" TextWrapping="Wrap" PlaceholderText="文件内容会显示在这里" /> </Grid> </Window>

答案文件 2:Views/MainWindow.axaml.cs

using System; using System.IO; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Platform.Storage; namespace ProductApp.Views; public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); } private async void OpenFileButton_Click(object? sender, RoutedEventArgs e) { var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "选择文本文件", AllowMultiple = false, FileTypeFilter = new[] { new FilePickerFileType("Text files") { Patterns = new[] { "*.txt", "*.csv" } }, FilePickerFileTypes.All } }); if (files.Count == 0) { StatusText.Text = "已取消选择。"; return; } IStorageFile file = files[0]; try { await using Stream stream = await file.OpenReadAsync(); using var reader = new StreamReader(stream); FileContentBox.Text = await reader.ReadToEndAsync(); StatusText.Text = $"已读取:{file.Name}"; } catch (Exception ex) { StatusText.Text = $"读取失败:{ex.Message}"; } } private async void SaveFileButton_Click(object? sender, RoutedEventArgs e) { var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = "保存文本文件", SuggestedFileName = "products.txt", FileTypeChoices = new[] { new FilePickerFileType("Text files") { Patterns = new[] { "*.txt" } }, FilePickerFileTypes.All } }); if (file is null) { StatusText.Text = "已取消保存。"; return; } try { await using Stream stream = await file.OpenWriteAsync(); using var writer = new StreamWriter(stream); await writer.WriteAsync(FileContentBox.Text ?? ""); StatusText.Text = $"已保存:{file.Name}"; } catch (Exception ex) { StatusText.Text = $"保存失败:{ex.Message}"; } } }

验收结果

运行后必须完成这些检查:

  1. 能打开 .txt 文件并显示内容。
  2. 能打开 .csv 文件并显示内容。
  3. 点击打开后取消,不会崩溃。
  4. 点击保存后取消,不会崩溃。
  5. 能把文本框内容保存到新文件。
  6. 读写失败时,状态文字显示失败原因。

为什么这个答案是对的

这个答案把文件功能分成了清楚的四层:

层级代码作用
用户选择OpenFilePickerAsync / SaveFilePickerAsync弹出系统对话框
取消处理files.Count == 0 / file is null用户取消时直接结束
文件读写OpenReadAsync / OpenWriteAsync打开文件通道
结果反馈StatusText.Text = ...告诉用户成功或失败

对话框不读写内容,文件流才读写内容。把这点分清,后面做导入、导出、保存配置文件都会更稳。