如何构建一个现代的、同步的 ObservableCollection?

Posted

技术标签:

【中文标题】如何构建一个现代的、同步的 ObservableCollection?【英文标题】:How to build a modern, synchronized ObservableCollection? 【发布时间】:2021-07-10 14:46:11 【问题描述】:

阅读severaltutorials、snippets(似乎是@Xcalibur37 的blog post 的来源或几乎1:1 的副本)当然还有他们的“起源”questionsonSO,我'我不仅仍然对跨线程访问感到困惑,而且还在努力让我的 WPF 应用程序在 CollectionChanged 上正确执行绑定更新 - 这在启动和删除时有效,但不适用于插入副本。

SO 都是关于代码的,所以让我们直接开始吧 - 首先是两个集合,然后是 VM,“工作”和“失败”:

同步ObservableCollection<T>类:

public class SynchronizedCollection<T> : ObservableCollection<T> where T : class

  // AFAICT, event overriding is needed, yet my app behaves the same without it?!
  public override event NotifyCollectionChangedEventHandler CollectionChanged;

  public SynchronizedCollection()
  
    // Implemented this in my base-ViewModel's ctor first, but
    // a) read somewhere that it's supposed to be done here instead
    // b) call in base-VM resulted in 1 invocation per collection, though for _all_ VM at once!
    BindingOperations.CollectionRegistering += (sender, eventArgs) =>
    
      if (eventArgs.Collection.Equals(this)) // R# suggested, Equals() is wiser than == here.
      
        BindingOperations.EnableCollectionSynchronization(this, SynchronizationLock);
      
    ;
  

  // Can't be static due to class type parameter, but readonly should do.
  // Also, since EnableCollectionSynchronization() is called in ctor, 1 lock object per collection.
  private object SynchronizationLock  get;  = new object();

  protected override void InsertItem(int index, T item)
  
    lock (SynchronizationLock)
    
      base.InsertItem(index, item); 
    
  


  // Named InsertItems instead of AddRange for consistency.
  public void InsertItems(IEnumerable<T> items)
  
    var list = items as IList<T> ?? items.ToList();
    int start = Count;
    foreach (T item in list)
    
      lock (SynchronizationLock)
      
        Items.Add(item); 
      
    

    // Multi-insert, but notify only once after completion.
    OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
    OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
    OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, list, start));
  

  // Code left out for brevity...

  protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs eventArgs)
  
    lock (SynchronizationLock)
    
      if (!(CollectionChanged is NotifyCollectionChangedEventHandler eventHandler))
      
        return;
      

      foreach (Delegate @delegate in eventHandler.GetInvocationList())
      
        var handler = (NotifyCollectionChangedEventHandler)@delegate;
        if (handler.Target is DispatcherObject current && !current.CheckAccess())
        
          current.Dispatcher.Invoke(DispatcherPriority.DataBind, handler, this, eventArgs);
        
        else
        
          handler(this, eventArgs);
        
      
    
  

INotifyPropertyChanged 支持上面的SynchronizedCollection 项目:

public class NotifySynchronizedCollection<T> : SynchronizedCollection<T>, INotifySynchronizedCollection
  where T : class

  public event CollectionItemPropertyChangedEventHandler CollectionItemPropertyChanged;

  // Code left out for brevity...

  protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs eventArgs)
  
    // Seems to me like lock() isn't needed here...
    //lock (SynchronizationLock)
    //
      switch (eventArgs.Action)
      
        case NotifyCollectionChangedAction.Add:
          RegisterItemPropertyChanged(eventArgs.NewItems);
          break;

        case NotifyCollectionChangedAction.Remove:
        case NotifyCollectionChangedAction.Reset when !(eventArgs.OldItems is null):
          UnregisterItemPropertyChanged(eventArgs.OldItems);
          break;

        case NotifyCollectionChangedAction.Move:
        case NotifyCollectionChangedAction.Replace:
          UnregisterItemPropertyChanged(eventArgs.OldItems);
          RegisterItemPropertyChanged(eventArgs.NewItems);
          break;
      
    //
  

  private void OnItemPropertyChanged(object item, PropertyChangedEventArgs itemArgs) =>
    CollectionItemPropertyChanged?.Invoke(this, item, itemArgs);

  private void RegisterItemPropertyChanged(IEnumerable items)
  
    foreach (INotifyPropertyChanged item in items)
    
      if (item != null)
      
        item.PropertyChanged += OnItemPropertyChanged;
      
    
  

  private void UnregisterItemPropertyChanged(IEnumerable items)
  
    foreach (INotifyPropertyChanged item in items)
    
      if (item != null)
      
        item.PropertyChanged -= OnItemPropertyChanged;
      
    
  

众多 ViewModel 之一(使用 AsyncAwaitBestPractices.MVVM 的 IAsyncCommand):

public class OrdersViewModel : BaseViewModel

  // BindingOperations.EnableCollectionSynchronization was once in BaseViewModel's ctor (with
  // mentioned side-effects at this question's intro) & even right in this VM's ctor - none of
  // the tutorials I've found mentioned a solution for tedious EnableCollectionSynchronization
  // calls for each collection, in each VM, hence I tried CollectionRegistering in base-VM...

  // Code left out for brevity...

  public OrdersViewModel(INavigationService navService, IOrderDataService dataService)
    : base(navService)
  
    DataService = dataService;
    RegisterMessages();
  

  // Code left out for brevity...

  // Note: This works, except for the view which doesn't show the newly added item!
  //       However, another TextBlock-binding for Orders.Count _does_ update?!
  //       Using ConfigureAwait(true) inside instead didn't help either...
  public IAsyncCommand<OrderModel> CopyCommand =>
    _copy ?? (_copy = new AsyncRelayCommand<OrderModel>(
      async original =>
      
        if (!await ShowConfirmation("Copy this order?").ConfigureAwait(false))
        
          return;
        

        if (original.ProductId < 1)
        
          throw new ArgumentOutOfRangeException(
            nameof(original.ProductId),
            original.ProductId,
            @"Valid product missing.");
        

        await AddOrder(
          await DataService.CreateOrderCopy(original.Id).ConfigureAwait(false)
            ?? throw new ArgumentNullException(nameof(original.Id), $@"Copying failed."))
          .ConfigureAwait(false);
      ,
      original => original.Id > 0,
      async exception => await ShowError("Copying", exception).ConfigureAwait(false)));

  // Note: This works!
  public IAsyncCommand<OrderModel> Delete =>
    _delete ?? (_delete = new AsyncCommand<OrderModel>(
      async deletable =>
      
        bool isChild = deletable.ParentId > 0;
        if (!await ShowConfirmation($"Delete this order?").ConfigureAwait(false))
        
          return;
        

        await DataService.DeleteOrder(deletable.Id).ConfigureAwait(false);
        if (isChild)
        
          await RefreshParent(Orders.Single(order => order.Id == deletable.ParentId))
            .ConfigureAwait(false);
        

        Orders.Remove(deletable);
        await ShowInfo($"Order deleted.").ConfigureAwait(false);
      ,
      deletable => (deletable.ParentId > 0)
                   || (Orders.SingleOrDefault(order => order.Id == deletable.Id)
                      ?.ChildrenCount < 1),
      async exception => await ShowError("Deletion", exception).ConfigureAwait(false)));

  private async Task AddOrder(int orderId)
  
    // Note: Using ConfigureAwait(true) doesn't help either.
    //       But while 
    Orders.Add(await GetOrder(orderId, false).ConfigureAwait(false));
  

  // Code left out for brevity...

  private void RegisterMessages()
  
    Default.Register<OrdersInitializeMessage>(this, async message =>
    
      Orders.Clear();
      Task<CustomerModel> customerTask = DataService.GetCustomer(message.CustomerId);
      Task<List<OrderModel>> ordersTask = DataService.GetOrders(message.OrderId);
      await Task.WhenAll(customerTask, ordersTask).ConfigureAwait(false);

      Customer = await customerTask.ConfigureAwait(false) ?? Customer;
      (await ordersTask.ConfigureAwait(false)).ForEach(Orders.Add);  // NOTE: This works!
      SelectedOrder =
        Orders.Count == 1
          ? Orders[0]
          : Orders.SingleOrDefault(order => order.Id == message.OrderId);
    );

    // Code left out for brevity...
  

为什么Delete 命令和Orders.Add()(在RegisterMessages() 内部)都可以工作,而Copy 命令的Orders.Add() 调用却不行?

Delete 命令使用Orders.Remove(deletable);,它又在SynchronizedCollection&lt;T&gt; 中调用我覆盖的RemoveItem,其实现方式与上面的InsertItem 类似)

【问题讨论】:

为什么不使用带有或不带有BindingOperations.EnableCollectionSynchronization 的内置ObservableCollection&lt;T&gt;?你想解决什么问题? 为什么不在后台线程上做后台工作,awaiting 那个工作,然后在await 返回到 UI 线程后更新ObservableCollection&lt;T&gt; @Yoda:是的,只要你不需要上下文,就使用ConfigureAwait(false)。如果您需要在 await 之后更新 UI,那么这是您确实需要上下文的完美示例,因此您不会在那里使用 ConfigureAwait(false) @Yoda:如果您只从 UI 线程更新 ObservableCollection&lt;T&gt;(我总是这样做),那么不需要自定义集合或 locks。 ConfigureAwait(false) 应该只用于不需要上下文的方法;如果一个方法调用另一个需要上下文的方法,那么父方法也需要上下文;如果它只是awaits 任务,那么它不会。 @Yoda:如果您有一个检索要显示的数据的命令,那么这不是“一劳永逸”或IAsyncCommand。使用NotifyTask&lt;T&gt; or similar 可以更好地表示此类事情。此外,上下文需求从 child 流向 parent,而不是相反。 HttpClient 从不需要上下文,因为它的方法(和子)不会更新 UI。 【参考方案1】:

这是跨线程操作的正确和现代方法吗

必须在创建集合的线程上使用集合。当我有一个需要添加数据的线程操作时,我会调用 GUI 线程,例如

public static void SafeOperationToGuiThread(Action operation)
    => System.Windows.Application.Current?.Dispatcher?.Invoke(operation);

线程使用

SafeOperationToGuiThread(() =>  MyCollection.Add( itemfromthread);  );

【讨论】:

以上是关于如何构建一个现代的、同步的 ObservableCollection?的主要内容,如果未能解决你的问题,请参考以下文章

如何利用云原生技术构建现代化应用

如何利用云原生技术构建现代化应用

如何利用云原生技术构建现代化应用

Emacs 开发者讨论如何构建更“现代”的 Emacs

如何在现代环境中构建 android 内核

「新视野」如何利用云原生技术构建现代化应用