Skip to Content
Week 11Day 4 - 后台任务

Day 4 - 后台任务

建议用时:180-210 分钟

你将学会什么

  • 为什么耗时任务不能阻塞界面
  • 什么是忙碌状态
  • 怎么显示进度
  • 怎么防止重复启动
  • 怎么取消任务
  • 怎么在结束后恢复界面状态

后台任务不是“另写一个方法”这么简单。页面要能知道任务是否运行中、进度是多少、能不能取消、完成后显示什么、失败后显示什么。

本页固定顺序

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

学习衔接

上一页学习的是“导入导出”,今天继续学习“后台任务”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

第一次学习不要求背完整页。完成下面 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

如果是文件、网络、数据库这类异步操作,优先使用对应的 ReadAsyncWriteAsyncExecuteAsync

后台任务常用 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.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(); } }

窗口

文件位置:

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 步:运行后检查

运行后按顺序检查:

  1. 初始状态显示“等待开始”。
  2. 点击开始后进度从 0 增加到 100。
  3. 任务运行时开始按钮不可点。
  4. 任务运行时取消按钮可点。
  5. 点击取消后提示“任务已取消”。
  6. 完成或取消后开始按钮恢复。

小白重复敲写训练

后台任务要练状态更新、取消和防止重复启动。

训练 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>

验收结果

运行后必须看到:

  1. 点击开始后进度条变化。
  2. 任务运行时开始按钮禁用。
  3. 任务运行时取消按钮启用。
  4. 点击取消后任务停止。
  5. 取消后界面状态恢复。
  6. 完成后进度到 100。
  7. 完成后界面状态恢复。

为什么这个答案是对的

这个答案覆盖后台任务的完整状态:

状态代码作用
是否忙碌IsBusy防止重复启动
进度Progress告诉用户处理到哪里
消息Message显示当前阶段
取消CancellationTokenSource允许用户停止任务
恢复finally保证按钮状态恢复

后台任务的关键不是只会 await,而是让界面在运行中、取消时、完成后都处于可理解的状态。