AsyncTaskExecutor

A reusable GUI component for Task execution

Posted by eagleboost on July 9, 2020

1. The Problem

During application development we often see some patterns can be reused. For example, in a WPF application we send some async calls to a background Task to execute without blocking the main GUI, when the Task is completed we then update the result on the main GUI thread. The process of starting a task and wait for its finish can be a good candidate of a pattern to encapsulate: Show name of the task and feedback during execution of the task etc.

Let’s take below case as an example: say we have a button, click it to start a task to retrieve data from some remote service. In the mean time GUI should also be updated, developers often set the button to disabled and enable it back after the task is completed. But a more optimized design can provide richer UI to show the task execution states in-place as well as interaction abilities:

  • Display the name of the task

  • Show busy status to indicate there’s something going on

  • Show progress if the task reports status like % of completion to manage user expectations

  • Allow user cancel the task

  • Optionally, allow user pause and resume the task based on the use case

  • Handle task exceptions gracefully

2. Analysis

The IAsyncTaskExecutor interface below is the abstraction of the async task executions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface IAsyncTaskExecutor : IBusyStatus
{  
  string TaskName { get; }
    
  AsyncTaskExecutionStatus Status { get; }

  Task ExecuteTask(object parameter);

  void CancelTask();

  void PauseTask();
    
  void ResumeTask();
  
  event EventHandler Started;
    
  event EventHandler<ExecutionErrorEventArgs> Faulted;
    
  event EventHandler Executed;
}

.net provides built-in support for task cancellation via CancellationTokenSource, but does not support pause/resume tasks, probably because pause/resume is not used that often.Stephen Toub demonstrated an elegant pause/resume implementation PauseTokenSource in his post Cooperatively pausing async methods, we’ll use it in this article to support pause/resume.

For the purpose of Binding we also abstract an IAsyncTaskComponent interface.

1
2
3
4
5
6
7
8
9
10
11
12
public interface IAsyncTaskComponent
{
  IAsyncTaskExecutor Executor { get; }

  IProgress Progress { get; }

  ICommand ExecuteCommand { get; }
  
  ICommand CancelCommand { get; }

  ICommand PauseResumeCommand { get; }
}

For the UI feedback of the task execution, it can be very different case by case, in this article we only focus on an in-place UI feedback, aka show an overlay on top of the button with the task execution status.

3. Implementations

AsyncTaskExecutor does all the jobs of start an task and cancel/pause/resume the task, some of the key parts are shown below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
public class AsyncTaskExecutor<T> : NotifyPropertyChangedBase, IAsyncTaskExecutor
{
  private CancellationTokenSource _cts;
  private readonly PauseTokenSource _pts = new PauseTokenSource();

  public Task ExecuteAsync(object parameter)
  {
    var ct = RefUtils.Reset(ref _cts);
    ct.Register(OnCanceled);
    
    SetStatus(AsyncTaskExecutionStatus.Started);
    var option = new TaskExecutionOption(TaskName, ct, _pts.Token);
    return ExecuteAsyncCore(option, CreateExecuteTask(parameter, option));
  }
  
  public void CancelTask()
  {
    RefUtils.Cancel(ref _cts);
  }
  
  public void PauseTask()
  {
    _pts.IsPaused = true;
    SetStatus(AsyncTaskExecutionStatus.Paused);
  }
  
  public void ResumeTask()
  {
    _pts.IsPaused = false;
    SetStatus(AsyncTaskExecutionStatus.Resumed);
  }
  
  private async Task ExecuteAsyncCore(TaskExecutionOption option, Task task)
  {
    try
    {
      RaiseStarted();
      SetBusy(option.TaskName + "...");

      await task.ConfigureAwait(false);

      SetBusy("Completed");

      await Task.Delay(800).ConfigureAwait(false);
      
      SetStatus(AsyncTaskExecutionStatus.Completed);
    }
    catch (Exception ex)
    {
      SetStatus(AsyncTaskExecutionStatus.Faulted);
      RaiseFaulted(ex);
    }
    finally
    {
      ClearBusy();
      RaiseExecuted();
    }
  }

  private Task CreateExecuteTask(object parameter, TaskExecutionOption option)
  {
    return _taskFunc((T) parameter, option);
  }
  
  private void SetStatus(AsyncTaskExecutionStatus status)
  {
    Status = status;
  }

  private void OnCanceled()
  {
    _pts.IsPaused = false;
    ClearBusy();
    SetStatus(AsyncTaskExecutionStatus.Canceled);
  }
  
  private void SetBusy(string status)
  {
    IsBusy = true;
    BusyStatus = status;
  }

  private void ClearBusy()
  {
    IsBusy = false;
    BusyStatus = null;
  }
}

AsyncTaskComponent<T> in turn creates an instance of AsyncTaskExecutor<T> or accept a custom implementation of IAsyncTaskExecutor in the constructor, then expose the API of IAsyncTaskExecutor as Commands. Please note that AsyncTaskComponent<T> also contains a reference to IProgress, it’s not necessary but only to make data binding easier in the DataTemplate.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class AsyncTaskComponent<T> : NotifyPropertyChangedBase, IAsyncTaskComponent
{
  public AsyncTaskComponent(IAsyncTaskExecutor executor, Func<T, bool> canExecute = null)
  {
    ...
  }
  
  public AsyncTaskComponent(string taskName, Func<T, TaskExecutionOption, Task> taskFunc, Func<T, bool> canExecute = null)
  {
    ...
  }
  
  public IAsyncTaskExecutor Executor { get; }

  public IProgress Progress { get; }

  public ICommand ExecuteCommand => _executeCommand ??= CreateExecuteCommand();

  public ICommand CancelCommand => _cancelCommand ??= CreateCancelCommand();

  public ICommand PauseResumeCommand => _pauseResumeCommand ??= CreatePauseResumeCommand();
}

For complete implementations, please visible github

4. Usages

Let’s revisit the example presented in the beginning of the article, the ViewModel below shows the usages of AsyncTaskComponent and AsyncTaskExecutor. The client codes only need to provider name of the task and an async methods that matches below signature:

1
Func<T, TaskExecutionOption, Task> taskFunc

AsyncTaskComponent and AsyncTaskExecutor would do the rest of the jobs. TaskExecutionOption contains context information including the task name, CancellationTokenSource and PauseTokenSource for the async method to use。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class ViewModel : NotifyPropertyChangedBase
{
  private readonly ProgressViewModel _loadProgress = new ProgressViewModel();

  public ViewModel()
  {
    AsyncLoad = new AsyncTaskComponent<string>("Loading items", ExecuteAsync) {Progress = _loadProgress};
  }
  
  public IAsyncTaskComponent AsyncLoad { get; }
  
  ////Query if the task is paused in each loop and report progress every one second
  private async Task ExecuteAsync(string parameter, TaskExecutionOption option)
  {
    var progress = _loadProgress;
    progress.Report(0);
    
    for (var i = 0; i < 100; i++)
    {
      var pt = option.PauseToken; 
      await pt.WaitWhilePausedAsync();
      if (!option.CancellationToken.IsCancellationRequested)
      {
        await Task.Delay(1000, option.CancellationToken);
        progress.Report(i * 1);
      }
    }
  }
}

In the XAML, we bind the button’s Command property to IAsyncTaskComponent.ExecuteCommand, and created an Attached Behavior called AsyncTaskUi to handle the overlay of the button, i.e. AsyncTaskUi listens to the events of IAsyncTaskExecutor, add overlay to the button’s AdornerLayer when Started is triggered and remove overlay from the AdornerLayer when Executed is triggered.

AsyncTaskUi also accepts a DataTemplate to allow customize template.

1
2
3
4
5
6
<Button Command="{Binding AsyncLoad.ExecuteCommand}"
        Width="150" Height="30" HorizontalAlignment="Center" Content="Load">
  <b:Interaction.Behaviors>
    <controls:AsyncTaskUi AsyncTaskComponent="{Binding AsyncLoad}" Template="{StaticResource AsyncTaskTemplate}"/>
  </b:Interaction.Behaviors>
</Button>

References

1. 问题

在应用程序开发的过程中注意观察的话总会发现一些通用的模式可以选择封装起来重用。比如在WPF应用程序中我们常常把一些需要异步调用的工作交给某个后台Task去执行以便不阻塞主线程,等到Task结束后再回到主线程来更新状态。这个从开始执行一个任务到等待其结束的过程就是一个很好的封装对象:执行的任务有一个名字,在执行的过程中显示某种反馈等等。

具体可以用下面的例子来说明:假设有一个按钮,点击按钮开始调用某个远程服务获取数据,同时界面发生相应变化,通常开发者会把按钮设置为失效状态,任务结束后再恢复。一个更优质的设计可以在按钮失效后就地显示任务执行状态并提供丰富的交互:

  • 显示当前执行的任务名

  • 显示繁忙动画告知用户有任务在执行

  • 如果该任务有反馈,比如完成的百分比进度,也应显示进度以便管理用户的心理预期

  • 允许用户取消任务

  • 根据实际情况,允许用户暂停任务

  • 处理异步任务中发生的异常

2. 分析

对异步任务执行的封装大致可以抽象出下面的IAsyncTaskExecutor接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface IAsyncTaskExecutor : IBusyStatus
{  
  string TaskName { get; } ////任务名称
    
  AsyncTaskExecutionStatus Status { get; } ////当前执行状态

  Task ExecuteTask(object parameter); ////执行任务

  void CancelTask(); ////取消任务

  void PauseTask(); ////暂停任务
    
  void ResumeTask(); ////恢复任务
  
  event EventHandler Started; ////任务开始后触发
    
  event EventHandler<ExecutionErrorEventArgs> Faulted; ////任务出错后触发
    
  event EventHandler Executed; ////任务结束后触发
}

.net对于取消任务通过CancellationTokenSource提供内建支持。对于暂停/恢复任务,由于使用场景不多并没有内建支持。Stephen Toub在这篇博客Cooperatively pausing async methods给出了一个优雅的实现PauseTokenSource,本文对于暂停/恢复任务的支持即来源于此。

为方便数据绑定再定义一个IAsyncTaskComponent接口,

1
2
3
4
5
6
7
8
9
10
11
12
public interface IAsyncTaskComponent
{
  IAsyncTaskExecutor Executor { get; }

  IProgress Progress { get; }

  ICommand ExecuteCommand { get; }
  
  ICommand CancelCommand { get; }

  ICommand PauseResumeCommand { get; }
}

至于任务执行的界面反馈,不同的场景有不同的方式,本文只给出就地显示状态执行反馈的实现。

3. 实现

AsyncTaskExecutor是执行任务的核心,其关键代码摘录如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
public class AsyncTaskExecutor<T> : NotifyPropertyChangedBase, IAsyncTaskExecutor
{
  private CancellationTokenSource _cts;
  private readonly PauseTokenSource _pts = new PauseTokenSource();

  public Task ExecuteAsync(object parameter)
  {
    var ct = RefUtils.Reset(ref _cts);
    ct.Register(OnCanceled);
    
    SetStatus(AsyncTaskExecutionStatus.Started);
    var option = new TaskExecutionOption(TaskName, ct, _pts.Token);
    return ExecuteAsyncCore(option, CreateExecuteTask(parameter, option));
  }
  
  public void CancelTask()
  {
    RefUtils.Cancel(ref _cts);
  }
  
  public void PauseTask()
  {
    _pts.IsPaused = true;
    SetStatus(AsyncTaskExecutionStatus.Paused);
  }
  
  public void ResumeTask()
  {
    _pts.IsPaused = false;
    SetStatus(AsyncTaskExecutionStatus.Resumed);
  }
  
  private async Task ExecuteAsyncCore(TaskExecutionOption option, Task task)
  {
    try
    {
      RaiseStarted();
      SetBusy(option.TaskName + "...");

      await task.ConfigureAwait(false);

      SetBusy("Completed");

      await Task.Delay(800).ConfigureAwait(false);
      
      SetStatus(AsyncTaskExecutionStatus.Completed);
    }
    catch (Exception ex)
    {
      SetStatus(AsyncTaskExecutionStatus.Faulted);
      RaiseFaulted(ex);
    }
    finally
    {
      ClearBusy();
      RaiseExecuted();
    }
  }

  private Task CreateExecuteTask(object parameter, TaskExecutionOption option)
  {
    return _taskFunc((T) parameter, option);
  }
  
  private void SetStatus(AsyncTaskExecutionStatus status)
  {
    Status = status;
  }

  private void OnCanceled()
  {
    _pts.IsPaused = false;
    ClearBusy();
    SetStatus(AsyncTaskExecutionStatus.Canceled);
  }
  
  private void SetBusy(string status)
  {
    IsBusy = true;
    BusyStatus = status;
  }

  private void ClearBusy()
  {
    IsBusy = false;
    BusyStatus = null;
  }
}

AsyncTaskComponent<T>创建一个AsyncTaskExecutor<T>的实例或接受一个自定义的IAsyncTaskExecutor实现,并把IAsyncTaskExecutorAPI暴露为Command供绑定使用,注意AsyncTaskComponent<T>也包含了对IProgress的引用,并非必须,只是方便DataTemplate中的绑定使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class AsyncTaskComponent<T> : NotifyPropertyChangedBase, IAsyncTaskComponent
{
  public AsyncTaskComponent(IAsyncTaskExecutor executor, Func<T, bool> canExecute = null)
  {
    ...
  }
  
  public AsyncTaskComponent(string taskName, Func<T, TaskExecutionOption, Task> taskFunc, Func<T, bool> canExecute = null)
  {
    ...
  }
  
  public IAsyncTaskExecutor Executor { get; }

  public IProgress Progress { get; }

  public ICommand ExecuteCommand => _executeCommand ??= CreateExecuteCommand();

  public ICommand CancelCommand => _cancelCommand ??= CreateCancelCommand();

  public ICommand PauseResumeCommand => _pauseResumeCommand ??= CreatePauseResumeCommand();
}

完成实现请移步github

4. 使用简介

就本文开篇给出的例子,假设有个ViewModel,如下实现演示了如何使用AsyncTaskComponentAsyncTaskExecutor。客户端代码所需提供的只是任务名称和一个签名如下的异步方法。

1
Func<T, TaskExecutionOption, Task> taskFunc

其余的任务则完全由AsyncTaskComponentAsyncTaskExecutor完成。TaskExecutionOption则包含了上下文信息,包括任务名称,CancellationTokenSourcePauseTokenSource以便异步方法使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class ViewModel : NotifyPropertyChangedBase
{
  private readonly ProgressViewModel _loadProgress = new ProgressViewModel();

  public ViewModel()
  {
    AsyncLoad = new AsyncTaskComponent<string>("Loading items", ExecuteAsync) {Progress = _loadProgress};
  }
  
  public IAsyncTaskComponent AsyncLoad { get; }
  
  ////循环100次,每次循环开始查询任务是否被暂停,每间隔一秒报告一次进度。
  private async Task ExecuteAsync(string parameter, TaskExecutionOption option)
  {
    var progress = _loadProgress;
    progress.Report(0);
    
    for (var i = 0; i < 100; i++)
    {
      var pt = option.PauseToken; 
      await pt.WaitWhilePausedAsync();
      if (!option.CancellationToken.IsCancellationRequested)
      {
        await Task.Delay(1000, option.CancellationToken);
        progress.Report(i * 1);
      }
    }
  }
}

XAML中一方面按钮的Command绑定到IAsyncTaskComponent.ExecuteCommand,另一方面通过一个名为AsyncTaskUiAttached Behavior为按钮添加和删除用于显示任务执行反馈的蒙版,也就是响应IAsyncTaskExecutor的事件,当Started触发时在按钮的AdornerLayer中显示蒙版,Executed触发时从按钮的AdornerLayer中删除蒙版。

从灵活性角度考虑AsyncTaskUi还接受一个DataTemplate用于生成任务执行反馈的界面。

1
2
3
4
5
6
<Button Command="{Binding AsyncLoad.ExecuteCommand}"
        Width="150" Height="30" HorizontalAlignment="Center" Content="Load">
  <b:Interaction.Behaviors>
    <controls:AsyncTaskUi AsyncTaskComponent="{Binding AsyncLoad}" Template="{StaticResource AsyncTaskTemplate}"/>
  </b:Interaction.Behaviors>
</Button>

参考资料