WPF MVVM:如何使用子视图模型从复杂的视图模型异步加载数据



我一直在努力寻找一种在复杂视图模型中加载数据的良好模式。我在网上做了很多阅读,也做了很多实验,但我对异步操作仍然很陌生。

我读过斯蒂芬·克利里(Stephen Cleary)写的文章,我看到了它是如何工作的。我喜欢设计视图以处理不同状态(繁忙、出错、加载)的模式。像NotifyTask这样的类看起来非常适合用于绑定到异步加载的属性。

我在概念化如何扩展到更复杂的用例时遇到了一些麻烦。让我们假设一个具有多个属性的视图模型。某些属性很简单,可以同步加载。某些属性需要访问数据存储才能检索其值。某些属性是子 ViewModel 的集合,这些子视图模型本身具有需要同步或异步加载的各种属性。

起初,我觉得最干净的方法是将整个 ViewModel 图的所有数据加载包装到一个异步加载操作中。这将允许视图中的所有控件绑定到此操作的结果,并共享结果状态。

下面的示例有点长,但它是我能想到的最小的示例,用于演示第一种方法的优缺点。

internal class ParentViewModel : ViewModelBase
{
private readonly IUnitOfWorkFactory _unitOfWorkFactory;
private readonly int _personID;
public ParentViewModel(IUnitOfWorkFactory unitOfWorkFactory, int personID)
{  
this._unitOfWorkFactory = unitOfWorkFactory;
this._personID = personID;

_data = new NotifyTask<ParentViewModel_Context?>(LoadDataAsync(),null);
}
private NotifyTask<ParentViewModel_Context?> _data;
public NotifyTask<ParentViewModel_Context?> Data
{
get => _data;
set => SetField(ref _data, value); //SetField is responsible for raising INotifyPropertyChanged
}
private async Task<ParentViewModel_Context?> LoadDataAsync()
{
using var unitOfWork = await _unitOfWorkFactory.CreateAsync();
var person = await unitOfWork.People.GetByIdAsync(_personID);
List<Person> children = await unitOfWork.People.GetChildrenOfParentByParentIDAsync(_personID);
var data = new ParentViewModel_Context()
{
InChargeOfDepartment = person.Department,
PersonName = person.Name
};
var childrenVMs = new ObservableCollection<ChildViewModel>(
children.Select(c => new ChildViewModel()
{
PersonName = c.Name,
HostVM = data
}));
data.Children = childrenVMs;
return data;
}
}
internal class ParentViewModel_Context : ViewModelBase
{
private string? _inChargeOfDepartment;
public string? InChargeOfDepartment
{
get => _inChargeOfDepartment;
set => SetField(ref _inChargeOfDepartment, value);
}
private string? _personName;
public string? PersonName
{
get => _personName;
set => SetField(ref _personName, value);
}
public ObservableCollection<ChildViewModel>? _children;
public ObservableCollection<ChildViewModel>? Children
{
get => _children;
set => SetField(ref _children, value);
}
}
internal class ChildViewModel : ViewModelBase
{
private IViewModel? _hostVM;
public IViewModel? HostVM
{
get => _hostVM;
set => SetField(ref _hostVM, value);
}
private string? _personName;
public string? PersonName
{
get => _personName;
set => SetField(ref _personName, value);
}
}

我可以看到的缺点是:

  1. 正如您在示例中所看到的,我认为这将需要第二个"ViewModel"ParentViewModel_Context来包含所有可绑定属性,以确保所有值都通过单个 Task 结果返回到 UI 线程。通常,我会让ParentViewModel_Context的所有属性直接驻留在ParentViewModel上。
  2. 所有子视图模型都是LoadDataAsync中的新模型,并且它们的所有数据都被推送到其中。这是一个相对较小的示例,但具有深度嵌套 ViewModel 的实际示例将具有大量LoadDataAsync方法。我也觉得这是 DI 或ViewModelFactory可能是更好的方法。
  3. 线程安全对我来说仍然感觉像是晦涩难懂的知识,我不知道这种方法是否引入了可能的线程问题。

这种方法有一些可以调整的地方。我可以ParentViewModel_Context视为简单的 DTO,直接在ParentViewModel上具有可绑定属性,并在任务完成后从 DTO 加载可绑定属性。

这感觉好一点,但这看起来需要一种方法在LoadDataAsync()返回后触发一个延续任务,以便将数据从 DTO 传输到属性。我能看到的唯一方法是.ContinueWith(),但我的阅读提醒我不要使用.ContinueWith()(尽管我会说原因确实逃脱了我的理解)。我还认为.ContinueWith()在另一个线程上运行,所以我认为我必须使用该Dispatcher来确保我从 UI 线程进行设置。

我不知道这是否真的比通过单个Data.Result属性绑定更好。它还会改变绑定数据的方式,并且可能需要对如何表示状态进行更多思考。

我可以看到的第二种方法是让涉及异步加载操作的每个属性都有自己的NotifyTask

internal class ParentViewModel : ViewModelBase
{
private readonly IUnitOfWorkFactory _unitOfWorkFactory;
private readonly int _personID;
public ParentViewModel(IUnitOfWorkFactory unitOfWorkFactory, int personID)
{
this._unitOfWorkFactory = unitOfWorkFactory;
this._personID = personID;
_inChargeOfDepartment = new NotifyTask<string?>(LoadDepartmentAsync(), null);
_personName = new NotifyTask<string?>(LoadNameAsync(), null);
_children = new NotifyTask<ObservableCollection<ChildViewModel>?>(LoadChildrenAsync(), null);

}
private NotifyTask<string?> _inChargeOfDepartment;
public NotifyTask<string?> InChargeOfDepartment
{
get => _inChargeOfDepartment;
set => SetField(ref _inChargeOfDepartment, value);
}
private NotifyTask<string?> _personName;
public NotifyTask<string?> PersonName
{
get => _personName;
set => SetField(ref _personName, value);
}
public NotifyTask<ObservableCollection<ChildViewModel>?> _children;
public NotifyTask<ObservableCollection<ChildViewModel>?> Children
{
get => _children;
set => SetField(ref _children, value);
}
private async Task<string?> LoadNameAsync()
{
using var unitOfWork = await _unitOfWorkFactory.CreateAsync();
var person = await unitOfWork.People.GetByIdAsync(_personID);
return person.Name;
}
private async Task<string?> LoadDepartmentAsync()
{
using var unitOfWork = await _unitOfWorkFactory.CreateAsync();
var person = await unitOfWork.People.GetByIdAsync(_personID);
return person.Department;
}
private async Task<ObservableCollection<ChildViewModel>> LoadChildrenAsync()
{
using var unitOfWork = await _unitOfWorkFactory.CreateAsync();
List<Person> children = await unitOfWork.People.GetChildrenOfParentByParentID(_personID);        
var childrenVMs = new ObservableCollection<ChildViewModel>(
children.Select(c => new ChildViewModel()
{
PersonName = c.Name,
HostVM = this
}));
return childrenVMs;
}
}

这已经感觉好多了。不再有一个巨大的 Load 方法,每个属性都通过自己的专用NotifyTask.Result直接接收自己的值。

我能看到的两个最大的缺点是:

  1. 没有报告单一状态。每个NotifyTask都可以失败,也可以独立成功。我看不到一种简单的方法可以将所有这些单独任务的结果合并为一个结果,供 UI 用于选择其状态。
  2. 此方法可能会导致跨多个Load...()方法的重复工作。这可以通过LoadDepartmentAsync()LoadNameAsync()查询数据库以查找同一个人来访问单个属性来证明。

另一个悬而未决的问题是,该解决方案仍然是ChildViewModel的新实例并将数据推送到其中。我觉得使用深度嵌套的 ViewModel 层次结构,这将很快变得难以管理。

若要解决此问题,可以将每个嵌套的 ViewModel 视为负责加载自己的数据,使用与上面第二个示例中相同的模式。您甚至可以创建一个工厂模式来抽象出这些子视图模型的构造。然后,ChildViewModel类将如下所示:

internal class ChildViewModel : ViewModelBase
{
private readonly IUnitOfWorkFactory _unitOfWorkFactory;
private readonly int _personID;
public ChildViewModel(IUnitOfWorkFactory unitOfWorkFactory, int personID, IViewModel host)
{        
this._unitOfWorkFactory = unitOfWorkFactory;
this._personID = personID;
this._hostVM = host;
_personName = new NotifyTask<string?>(LoadNameAsync(), null);
}
private IViewModel? _hostVM;
public IViewModel? HostVM
{
get => _hostVM;
set => SetField(ref _hostVM, value);
}
private NotifyTask<string?> _personName;
public NotifyTask<string?> PersonName
{
get => _personName;
set => SetField(ref _personName, value);
}
private async Task<string?> LoadNameAsync()
{
using var unitOfWork = await _unitOfWorkFactory.CreateAsync();
var person = await unitOfWork.People.GetByIdAsync(_personID);
return person.Name;
}
}

LoadChildrenAsync()方法可能如下所示:

private async Task<ObservableCollection<ChildViewModel>> LoadChildrenAsync()
{
using var unitOfWork = await _unitOfWorkFactory.CreateAsync();
List<Person> children = await unitOfWork.People.GetChildrenOfParentByParentID(_personID);
var childrenVMs = new ObservableCollection<ChildViewModel>(
children.Select(c => _childViewModelFactory.Create(c.ID, this)));
return childrenVMs;
}

这应该可以解决必须加载整个 ViewModel 树并将数据推送到每个后代的问题。但是,这也加剧了视图中没有单个状态的问题,因为现在该状态分布在许多 ViewModel 上的许多属性中。

它还可能使重复工作问题更加明显。想象一下,如果ChildViewModel有一个List<City> Cities属性可以用作组合框的DataSource。如果每个ChildViewModel都负责获取自己的数据,则每个孩子都将自行访问数据库以检索同一城市列表。

如果您创建了用于缓存不可变下拉数据源的代理,则可以最大程度地减少这种情况,但这并不能解决根本问题。

是否有我没有看到的金发姑娘方法可以解决我遇到的所有问题:

  1. 异步加载视图模型深度嵌套层次结构中的属性
  2. 具有同步状态,可报告整个加载功能的聚合结果
  3. 避免大量LoadDataAsync()方法
  4. 避免多次访问数据库的原子和潜在的重复访问
  5. 与 IoC 模式配合良好
  6. 尊重线程安全

最后(尽管这更像是题外话),在任务中初始化和填充ObservableCollection感觉是错误的。我是否应该返回一个基本的项列表,以便它可以传递给 UI 线程上的ObservableCollection构造函数?如果是这样,我该怎么做?.ContinueWith()+Dispatcher

其中一些是反对力量:

避免使用大量的 LoadDataAsync() 方法

避免多次访问数据库的原子和潜在的重复访问

如果可以在单个操作中构建数据检索(例如,一个复杂的数据库查询,或对前端后端的单个调用),那么这通常更有效。不过,听起来您的大多数查询都是独立的,除了一些与其他查询完全相同的查询。

因此,我想您最终可能会得到一种延迟加载解决方案,其中每个查询如果已经运行,将返回其数据。请注意,这实际上与MVVM或ViewModels甚至async没有任何关系:问题实际上是您有一大堆工作要做(LoadDataAsync),但您不能完全分解它,因为您不想重复重叠的工作。惰性求值是这个问题的一个解决方案:分解它,但记住结果。即您建议的"缓存代理"。我的 AsyncEx 库中有一个AsyncLazy<T>类型,可能对此很有用。

现在,进入 VM/异步问题。

我更喜欢(从您的问题听起来您也这样做)让每个 VM 都有自己的异步数据加载 - 在这种情况下,点击代理,以便它只做必要的额外工作。每个属性是否应具有自己的加载 UI 状态是一个首选项问题;如果你有一个不错的悬念/微调器组件,有些人可以接受一堆在不同时间完成的微调器。

不过,听起来你只需要一个整体的"我下面的一切都已经加载了"的微调器。这也完全是一个不错的选择。

这种方法的关键是NotifyTask<T>.Task属性。它提供了一种使用实际Task而不是可观察属性来检测完成(和错误)的方法。

因此,可以使用Task.WhenAll生成标量属性完成加载时完成的Task;首先考虑子 VM:

var initializationTask = await Task.WhenAll(
_personName.Task,
... /* any other properties */ );

然后,可以将其包装成表示整个子 VM 初始化的单个NotifyTask

NotifyTask Initialization { get; }
...
Initialization = NotifyTask.Create(() => Task.WhenAll(
_personName.Task,
... /* any other properties */ ));

接下来,将同样的想法扩展到父 VM;标量属性很简单:

NotifyTask Initialization { get; }
...
Initialization = NotifyTask.Create(() => Task.WhenAll(
_inChargeOfDepartment.Task,
_personName.Task));

子 VM 有点奇怪;您需要首先await它们存在于集合中,然后await每个子 VM 的初始化:

NotifyTask Initialization { get; }
...
Initialization = NotifyTask.Create(async () =>
{
await Task.WhenAll(
_inChargeOfDepartment.Task,
_personName.Task,
_children.Task);
var children = await _children.Task;
await Task.WhenAll(children.Select(x => x.Initialization.Task));
});

然后,这会在父 VM 上提供一个Initialization属性,该属性表示其自己的所有属性及其所有子属性(表示其所有属性的初始化)的初始化。异常会像往常一样传播并在ParentViewModel.Initialization通知任务之外可用。同时,异步惰性代理可确保没有重复工作。

旁注:

  • 虽然可以设置NotifyTask<T>(有时您需要设置),但很多时候它们只能是仅get属性。
  • 您对使用ContinueWith犹豫是正确的.ContinueWith的现代替代品是await.
  • async方法中创建/填充ObservableCollection并没有错,只要该代码在 UI 线程上运行即可。如果从 UI 线程调用async方法,并且它不在任何地方使用ConfigureAwait(false),则代码将在 UI 线程上运行,并且没有问题。
  • 避免直接使用Dispatcher;我之所以提到这一点,是因为您在问题中的几点提到了它。几乎总是有比使用Dispatcher更好的解决方案。

最新更新