MVVM异步等待模式

9
我一直在尝试为WPF应用程序编写MVVM屏幕,使用async和await关键字编写异步方法来实现以下三个方面:1. 初始加载数据,2. 刷新数据,3. 保存更改并刷新。虽然我已经实现了这些,但是代码非常混乱,我认为肯定有更好的实现方式。有人能够提供简单的实现建议吗?
以下是我的ViewModel的缩减版本:
public class ScenariosViewModel : BindableBase
{
    public ScenariosViewModel()
    {
        SaveCommand = new DelegateCommand(async () => await SaveAsync());
        RefreshCommand = new DelegateCommand(async () => await LoadDataAsync());
    }

    public async Task LoadDataAsync()
    {
        IsLoading = true; //synchronously set the busy indicator flag
        await Task.Run(() => Scenarios = _service.AllScenarios())
            .ContinueWith(t =>
            {
                IsLoading = false;
                if (t.Exception != null)
                {
                    throw t.Exception; //Allow exception to be caught on Application_UnhandledException
                }
            });
    }

    public ICommand SaveCommand { get; set; }
    private async Task SaveAsync()
    {
        IsLoading = true; //synchronously set the busy indicator flag
        await Task.Run(() =>
        {
            _service.Save(_selectedScenario);
            LoadDataAsync(); // here we get compiler warnings because not called with await
        }).ContinueWith(t =>
        {
            if (t.Exception != null)
            {
                throw t.Exception;
            }
        });
    }
}

在视图中,IsLoading被公开绑定到了一个繁忙指示器。

当屏幕第一次被浏览时或者刷新按钮被按下时,导航框架会调用LoadDataAsync方法。这个方法应该同步设置IsLoading,然后将控制权返回给UI线程,直到服务返回数据。最后抛出任何异常,以便全局异常处理程序捕获(不接受讨论!)。

SaveAync由按钮调用,传递表单中的更新值给服务。它应该同步设置IsLoading,异步调用服务上的Save方法,然后触发刷新。


1
你有看过这个吗?https://msdn.microsoft.com/zh-cn/magazine/dn605875.aspx。 - Mauro Sampietro
是的,这是一篇很棒的文章。但我不确定我喜欢绑定到Something.Result,感觉ViewModel应该让它的状态更加明显。 - waxingsatirical
只是一个尝试的想法...创建一个标准的只读属性,在其get方法中使用await等待某些东西。通过设置IsAsync=true来进行绑定。 - Mauro Sampietro
1
如果您想获得有关改进工作代码的帮助,请访问[codereview.se]。 - user1228
1个回答

24

以下是我注意到的代码问题:

  • ContinueWith的使用。 ContinueWith是一个危险的API(它对于其TaskScheduler有一个出乎意料的默认值,因此只有在指定了TaskScheduler时才应该使用它)。与等同的await代码相比,它也更加笨拙。
  • 从线程池线程设置Scenarios。 我总是遵循我的代码指南,将数据绑定的VM属性视为UI的一部分,必须仅从UI线程访问它们。当然,这个规则有一些例外(尤其是在WPF上),但它们不适用于每个MVVM平台(并且在我看来是一个可疑的设计),所以我把VM视为UI层的一部分。
  • 异常抛出的位置。 根据注释,您希望异常引发到Application.UnhandledException,但我不认为这段代码会实现。假设在LoadDataAsync/SaveAsync的开始时TaskScheduler.Currentnull,那么重新引发异常代码实际上会在线程池线程上引发异常,而不是UI线程,从而将其发送到AppDomain.UnhandledException而不是Application.UnhandledException
  • 异常的重新抛出方式。您将失去堆栈跟踪。
  • 在没有await的情况下调用LoadDataAsync。使用这个简化的代码,它可能会正常工作,但它会导致忽略未处理的异常的可能性。特别是,如果LoadDataAsync的同步部分引发任何异常,则该异常将被静默忽略。

我建议不要瞎搞手动异常重新抛出,而是使用更自然的通过await传播异常的方法:

  • 如果异步操作失败,任务上会放置一个异常。
  • await会检查此异常,并以适当的方式重新引发它(保留原始堆栈跟踪)。
  • async void方法没有一个任务来放置异常,因此它们将直接在其SynchronizationContext上重新引发异常。在这种情况下,由于您的async void方法在UI线程上运行,因此异常将被发送到Application.UnhandledException

(我所指的async void方法是传递给DelegateCommandasync委托。)

代码现在变为:

public class ScenariosViewModel : BindableBase
{
  public ScenariosViewModel()
  {
    SaveCommand = new DelegateCommand(async () => await SaveAsync());
    RefreshCommand = new DelegateCommand(async () => await LoadDataAsync());
  }

  public async Task LoadDataAsync()
  {
    IsLoading = true;
    try
    {
      Scenarios = await Task.Run(() => _service.AllScenarios());
    }
    finally
    {
      IsLoading = false;
    }
  }

  private async Task SaveAsync()
  {
    IsLoading = true;
    await Task.Run(() => _service.Save(_selectedScenario));
    await LoadDataAsync();
  }
}
现在所有的问题都已经解决:
  • ContinueWith 已被更合适的 await 替换。
  • Scenarios 是从 UI 线程设置的。
  • 所有异常都传播到 Application.UnhandledException 而不是 AppDomain.UnhandledException
  • 异常保留其原始堆栈跟踪。
  • 没有未等待的任务,所以所有异常都会被某种方式观察到。
而且代码也更加清晰。在我看来。 :)

1
嗨Stephen,非常感谢你提供如此完整的答案。这对我的代码来说是一个很大的改进。 - waxingsatirical
LoadDataAsync方法实际上是我用于ViewModel的基类上的,它调用一个抽象方法loadData,该方法调用特定的服务并设置特定的属性。有没有办法保留这个功能并仍然在UI线程上设置属性?protected abstract void loadData(); protected virtual async Task loadDataAsync() { IsLoading = true; await Task.Run(() => { loadData(); IsLoading = false; }); } - waxingsatirical
@waxingsatirical:你应该将IsLoading = false放在Task.Run之外,其他方面都应该没问题。请注意,如果LoadDataasync void,那么这会引起问题 - 如果实现需要是async,那么抽象方法应该返回Task - Stephen Cleary

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接