Day 3 - 文件与对话框
建议用时:180-210 分钟
你将学会什么
- 文件对话框到底负责什么
- 为什么“选择文件”和“读取文件”是两件事
- 用户取消选择时应该怎么处理
- 为什么读写文件要用
try/catch - 怎么在 Avalonia 里打开文本文件
- 怎么在 Avalonia 里保存文本文件
本页会直接写一个能打开和保存文本文件的窗口。重点不是背 API,而是把流程想清楚:弹出对话框、处理取消、拿到文件、用流读写、显示结果。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“大列表和虚拟化”,今天继续学习“文件与对话框”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 3 项,就可以继续:
- 能用自己的话说明“文件与对话框”解决什么问题。
- 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
- 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。
第一部分:先学原理和最小知识
这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。文件功能最容易乱,是因为很多人把“路径、文件、内容、对话框”混成一件事。
先把流程拆开
打开文件不是一步完成的,它至少有 5 步:
- 弹出打开文件对话框。
- 用户选择文件,或者取消。
- 程序拿到文件对象。
- 程序打开读取流。
- 程序把流里的文本读出来。
保存文件也不是一步完成的,它至少有 5 步:
- 弹出保存文件对话框。
- 用户选择保存位置,或者取消。
- 程序拿到文件对象。
- 程序打开写入流。
- 程序把文本写进去。
对话框只负责让用户选择文件。真正读写内容,要靠后面的文件 API。
名词先讲清楚
| 名词 | 直接解释 | 本页对应代码 |
|---|---|---|
| 打开文件对话框 | 让用户选择一个已有文件 | OpenFilePickerAsync |
| 保存文件对话框 | 让用户选择保存位置 | SaveFilePickerAsync |
| 取消 | 用户关闭窗口,没有选文件 | files.Count == 0 或 file 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.csusing 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 步:运行后检查
按这个顺序检查:
- 点击“打开文件”,选择一个
.txt文件。 - 文件内容显示到文本框。
- 修改文本框内容。
- 点击“保存文件”,另存为新文件。
- 再用“打开文件”打开刚保存的文件,确认内容是修改后的内容。
- 打开对话框后点取消,状态文字应该显示“已取消选择”。
- 保存对话框后点取消,状态文字应该显示“已取消保存”。
常见错误和修法
| 错误 | 为什么错 | 修法 |
|---|---|---|
| 文件路径写死 | 换电脑或目录就失效 | 通过对话框或配置获取路径 |
| 没处理用户取消选择 | 对话框可能返回空结果 | 先判断是否选中文件 |
| 文件读写不捕获异常 | 权限、占用、缺失都会失败 | 用 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}";
}
}
}验收结果
运行后必须完成这些检查:
- 能打开
.txt文件并显示内容。 - 能打开
.csv文件并显示内容。 - 点击打开后取消,不会崩溃。
- 点击保存后取消,不会崩溃。
- 能把文本框内容保存到新文件。
- 读写失败时,状态文字显示失败原因。
为什么这个答案是对的
这个答案把文件功能分成了清楚的四层:
| 层级 | 代码 | 作用 |
|---|---|---|
| 用户选择 | OpenFilePickerAsync / SaveFilePickerAsync | 弹出系统对话框 |
| 取消处理 | files.Count == 0 / file is null | 用户取消时直接结束 |
| 文件读写 | OpenReadAsync / OpenWriteAsync | 打开文件通道 |
| 结果反馈 | StatusText.Text = ... | 告诉用户成功或失败 |
对话框不读写内容,文件流才读写内容。把这点分清,后面做导入、导出、保存配置文件都会更稳。