Day 4 - 后台任务
建议用时:180-210 分钟
你将学会什么
- 为什么耗时任务不能阻塞界面
- 什么是忙碌状态
- 怎么显示进度
- 怎么防止重复启动
- 怎么取消任务
- 怎么在结束后恢复界面状态
后台任务不是“另写一个方法”这么简单。页面要能知道任务是否运行中、进度是多少、能不能取消、完成后显示什么、失败后显示什么。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“导入导出”,今天继续学习“后台任务”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 3 项,就可以继续:
- 能用自己的话说明“后台任务”解决什么问题。
- 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
- 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。
第一部分:先学原理和最小知识
这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。后台任务的核心是:耗时工作不能让界面失去响应。
什么是阻塞界面
如果按钮点击后直接执行很长时间的同步代码,窗口可能出现:
- 按钮按不动。
- 窗口拖不动。
- 进度不刷新。
- 用户以为程序卡死。
后台任务的目标是:工作继续做,界面还能响应。
什么是忙碌状态
忙碌状态表示任务正在运行。
本页用:
[ObservableProperty]
private bool isBusy;然后提供两个只读属性:
public bool CanStart => !IsBusy;
public bool CanCancel => IsBusy;运行中:
- 开始按钮禁用。
- 取消按钮启用。
任务结束后:
- 开始按钮恢复。
- 取消按钮禁用。
为什么要防止重复启动
如果用户连续点 5 次开始,可能同时启动 5 个导入任务。
这样会导致:
- 进度互相覆盖。
- 文件重复写入。
- 数据重复导入。
- 错误很难排查。
所以开始前先判断:
if (IsBusy)
{
return;
}什么是进度
进度是当前完成比例。
本页处理 20 条数据,每条占 5%:
Progress = index * 5;如果处理 100 条数据,可以写:
Progress = index * 100 / total;什么是取消
取消不是强行杀掉程序。
取消是通知任务:“请尽快停止”。
本页使用:
CancellationTokenSource开始任务时创建:
cancellationTokenSource = new CancellationTokenSource();取消时调用:
cancellationTokenSource?.Cancel();循环里检查:
cancellationTokenSource.Token.ThrowIfCancellationRequested();为什么 finally 很重要
无论任务成功、失败、取消,都要恢复界面状态。
所以写:
finally
{
cancellationTokenSource.Dispose();
cancellationTokenSource = null;
IsBusy = false;
}如果没有 finally,任务失败后 IsBusy 可能一直是 true,开始按钮就永远不能点。
什么时候用 Task.Run
本页用 Task.Delay 模拟导入,重点是状态、进度和取消。
如果以后遇到真正 CPU 密集任务,例如大量计算,可以考虑 Task.Run。
如果是文件、网络、数据库这类异步操作,优先使用对应的 ReadAsync、WriteAsync、ExecuteAsync。
后台任务常用 API 速查
| 需求 | 写法 | 说明 |
|---|---|---|
| 异步方法 | async Task | 耗时操作 |
| 等待 | await | 不阻塞当前流程 |
| 模拟耗时 | Task.Delay(...) | 练习进度 |
| 取消源 | CancellationTokenSource | 发出取消信号 |
| 取消令牌 | CancellationToken | 传给任务 |
| 检查取消 | token.ThrowIfCancellationRequested() | 中断任务 |
| 进度属性 | Progress | 绑定进度 |
| 忙碌状态 | IsBusy | 禁用按钮 |
| 开始命令 | StartCommand | 启动任务 |
| 取消命令 | CancelCommand | 请求取消 |
后台任务流程:
创建 CancellationTokenSource
-> IsBusy = true
-> 循环处理并更新 Progress
-> 每轮检查取消
-> finally 里恢复 IsBusy第二部分:把知识组合成完整例子
今天做一个 Avalonia 后台任务页面。
页面上有:
- “开始导入”按钮。
- “取消”按钮。
- 进度条。
- 状态文字。
点击开始后,程序模拟处理 20 条数据,每处理一条更新一次进度。运行中不能重复开始,可以点击取消。
ViewModel
文件位置:
ViewModels/MainWindowViewModel.csusing System;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ProductApp.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
private CancellationTokenSource? cancellationTokenSource;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanStart))]
[NotifyPropertyChangedFor(nameof(CanCancel))]
private bool isBusy;
[ObservableProperty]
private int progress;
[ObservableProperty]
private string message = "等待开始。";
public bool CanStart => !IsBusy;
public bool CanCancel => IsBusy;
[RelayCommand]
private async Task StartImportAsync()
{
if (IsBusy)
{
return;
}
IsBusy = true;
Progress = 0;
Message = "开始导入。";
cancellationTokenSource = new CancellationTokenSource();
try
{
for (int index = 1; index <= 20; index++)
{
cancellationTokenSource.Token.ThrowIfCancellationRequested();
await Task.Delay(150, cancellationTokenSource.Token);
Progress = index * 5;
Message = $"正在处理 {index} / 20,进度 {Progress}%";
}
Message = "导入完成。";
}
catch (OperationCanceledException)
{
Message = "任务已取消。";
}
catch (Exception ex)
{
Message = $"任务失败:{ex.Message}";
}
finally
{
cancellationTokenSource.Dispose();
cancellationTokenSource = null;
IsBusy = false;
}
}
[RelayCommand]
private void CancelImport()
{
if (!IsBusy)
{
return;
}
Message = "正在取消任务。";
cancellationTokenSource?.Cancel();
}
}窗口
文件位置:
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="620"
Height="320"
Title="Background Task">
<StackPanel Margin="20" Spacing="12">
<TextBlock Text="后台导入任务"
FontSize="24"
FontWeight="Bold" />
<TextBlock Text="{Binding Message}" />
<ProgressBar Minimum="0"
Maximum="100"
Value="{Binding Progress}" />
<TextBlock Text="{Binding Progress}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="开始导入"
Command="{Binding StartImportCommand}"
IsEnabled="{Binding CanStart}" />
<Button Content="取消"
Command="{Binding CancelImportCommand}"
IsEnabled="{Binding CanCancel}" />
</StackPanel>
</StackPanel>
</Window>第三部分:跟着敲代码
从这里开始动手。打开 Avalonia 项目。
第 1 步:准备状态字段
private CancellationTokenSource? cancellationTokenSource;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanStart))]
[NotifyPropertyChangedFor(nameof(CanCancel))]
private bool isBusy;
[ObservableProperty]
private int progress;
[ObservableProperty]
private string message = "等待开始。";解释:
cancellationTokenSource用来取消任务。IsBusy表示任务是否运行中。Progress绑定进度条。Message显示状态文字。
第 2 步:写按钮启用条件
public bool CanStart => !IsBusy;
public bool CanCancel => IsBusy;解释:
- 不忙时可以开始。
- 忙碌时可以取消。
第 3 步:开始任务前设置状态
if (IsBusy)
{
return;
}
IsBusy = true;
Progress = 0;
Message = "开始导入。";
cancellationTokenSource = new CancellationTokenSource();解释:
- 防止重复启动。
- 重置进度。
- 创建取消控制器。
第 4 步:写模拟导入循环
for (int index = 1; index <= 20; index++)
{
cancellationTokenSource.Token.ThrowIfCancellationRequested();
await Task.Delay(150, cancellationTokenSource.Token);
Progress = index * 5;
Message = $"正在处理 {index} / 20,进度 {Progress}%";
}解释:
- 每次循环模拟处理一条数据。
- 每条处理后更新进度。
- 每次循环都检查是否取消。
第 5 步:处理取消、失败和恢复
catch (OperationCanceledException)
{
Message = "任务已取消。";
}
catch (Exception ex)
{
Message = $"任务失败:{ex.Message}";
}
finally
{
cancellationTokenSource.Dispose();
cancellationTokenSource = null;
IsBusy = false;
}解释:
- 取消不是错误,用单独分支处理。
- 其他异常显示失败原因。
finally保证界面状态恢复。
第 6 步:写取消命令
[RelayCommand]
private void CancelImport()
{
if (!IsBusy)
{
return;
}
Message = "正在取消任务。";
cancellationTokenSource?.Cancel();
}解释:
- 只有忙碌时取消才有意义。
Cancel()发出取消信号。
第 7 步:写进度条
<ProgressBar Minimum="0"
Maximum="100"
Value="{Binding Progress}" />解释:
Progress是 0 到 100。- 进度条会跟着属性变化。
第 8 步:写按钮
<Button Content="开始导入"
Command="{Binding StartImportCommand}"
IsEnabled="{Binding CanStart}" />
<Button Content="取消"
Command="{Binding CancelImportCommand}"
IsEnabled="{Binding CanCancel}" />解释:
CanStart控制开始按钮。CanCancel控制取消按钮。
第 9 步:运行后检查
运行后按顺序检查:
- 初始状态显示“等待开始”。
- 点击开始后进度从 0 增加到 100。
- 任务运行时开始按钮不可点。
- 任务运行时取消按钮可点。
- 点击取消后提示“任务已取消”。
- 完成或取消后开始按钮恢复。
小白重复敲写训练
后台任务要练状态更新、取消和防止重复启动。
训练 1:后台执行并更新状态
[RelayCommand]
private async Task RunTaskAsync()
{
StatusMessage = "运行中";
await Task.Delay(1000);
StatusMessage = "已完成";
}训练 2:防止重复点击
[ObservableProperty]
private bool _isRunning;
private bool CanRunTask() => !IsRunning;运行开始设为 true,finally 中恢复 false。
训练 3:增加取消
private CancellationTokenSource? _cts;
[RelayCommand]
private void CancelTask()
{
_cts?.Cancel();
}第三遍把 _cts.Token 传进真正的异步任务。
每日小测
做完本页后,用这 5 题检查是否真的掌握。
1. 判断题
本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。
答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。
2. 填空题
本页主题是:后台任务。今天至少要掌握的 3 个点是:
1. 为什么耗时任务不能阻塞界面
2. 什么是忙碌状态
3. 怎么显示进度答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。
3. 流程题
遇到本页相关功能时,先按什么顺序处理?
答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。
4. 找错误题
如果本页代码运行失败,第一步应该做什么?
答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。
5. 改需求题
在本页完整例子跑通后,至少改一个小需求。
可选改法:
- 改一个字段名称。
- 多加一个校验条件。
- 多输出一行结果。
- 把固定数据改成用户输入。
- 把一次处理改成多条数据处理。
答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:为什么耗时任务不能阻塞界面。
上位机专项练习
后台轮询要支持状态、重连、取消和错误隔离,单台设备失败不能拖垮全部设备。
下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。
专项例子 1:单设备失败隔离
foreach (Device device in devices)
{
try { await PollAsync(device, token); }
catch (Exception ex) { logger.LogWarning(ex, "{Device} 轮询失败", device.Name); }
}运行结果或界面效果:
一台失败后继续读取下一台改动任务: 记录连续失败次数。
专项例子 2:指数重连等待
int delaySeconds = Math.Min(30, (int)Math.Pow(2, failureCount));
StatusText = $"{delaySeconds} 秒后重连";
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), token);运行结果或界面效果:
失败越多等待越久,最大 30 秒改动任务: 成功后把 failureCount 清零。
专项例子 3:把结果切回界面线程
Dispatcher.UIThread.Post(() =>
{
device.Value = reading.Value;
device.LastSeen = reading.Time;
});运行结果或界面效果:
后台读取完成后安全更新界面属性改动任务: 一次性更新状态和质量。
第四部分:作业完整答案
作业要求:做一个后台任务页面,支持开始、进度、取消、忙碌状态和结束恢复。
答案文件 1:ViewModels/MainWindowViewModel.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ProductApp.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
private CancellationTokenSource? cancellationTokenSource;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanStart))]
[NotifyPropertyChangedFor(nameof(CanCancel))]
private bool isBusy;
[ObservableProperty]
private int progress;
[ObservableProperty]
private string message = "等待开始。";
public bool CanStart => !IsBusy;
public bool CanCancel => IsBusy;
[RelayCommand]
private async Task StartImportAsync()
{
if (IsBusy)
{
return;
}
IsBusy = true;
Progress = 0;
Message = "开始导入。";
cancellationTokenSource = new CancellationTokenSource();
try
{
for (int index = 1; index <= 20; index++)
{
cancellationTokenSource.Token.ThrowIfCancellationRequested();
await Task.Delay(150, cancellationTokenSource.Token);
Progress = index * 5;
Message = $"正在处理 {index} / 20,进度 {Progress}%";
}
Message = "导入完成。";
}
catch (OperationCanceledException)
{
Message = "任务已取消。";
}
catch (Exception ex)
{
Message = $"任务失败:{ex.Message}";
}
finally
{
cancellationTokenSource.Dispose();
cancellationTokenSource = null;
IsBusy = false;
}
}
[RelayCommand]
private void CancelImport()
{
if (!IsBusy)
{
return;
}
Message = "正在取消任务。";
cancellationTokenSource?.Cancel();
}
}答案文件 2: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="620"
Height="320"
Title="Background Task">
<StackPanel Margin="20" Spacing="12">
<TextBlock Text="后台导入任务"
FontSize="24"
FontWeight="Bold" />
<TextBlock Text="{Binding Message}" />
<ProgressBar Minimum="0"
Maximum="100"
Value="{Binding Progress}" />
<TextBlock Text="{Binding Progress}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="开始导入"
Command="{Binding StartImportCommand}"
IsEnabled="{Binding CanStart}" />
<Button Content="取消"
Command="{Binding CancelImportCommand}"
IsEnabled="{Binding CanCancel}" />
</StackPanel>
</StackPanel>
</Window>验收结果
运行后必须看到:
- 点击开始后进度条变化。
- 任务运行时开始按钮禁用。
- 任务运行时取消按钮启用。
- 点击取消后任务停止。
- 取消后界面状态恢复。
- 完成后进度到 100。
- 完成后界面状态恢复。
为什么这个答案是对的
这个答案覆盖后台任务的完整状态:
| 状态 | 代码 | 作用 |
|---|---|---|
| 是否忙碌 | IsBusy | 防止重复启动 |
| 进度 | Progress | 告诉用户处理到哪里 |
| 消息 | Message | 显示当前阶段 |
| 取消 | CancellationTokenSource | 允许用户停止任务 |
| 恢复 | finally | 保证按钮状态恢复 |
后台任务的关键不是只会 await,而是让界面在运行中、取消时、完成后都处于可理解的状态。