-1

After reading several tutorials, snippets (seems either source or almost 1:1 copy of @Xcalibur37's blog post) & of course their "originating" questions on SO, I'm not only still confused about cross-thread access, but also struggling to get my WPF application to correctly perform binding updates upon CollectionChanged - which works upon launch & delete, but not for inserting copies.

SO is all about code, so let's get straight to it - both collections first, then VM, "works" & "fails":

Synchronized ObservableCollection<T> class:

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 support for above's SynchronizedCollection items:

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;
      }
    }
  }
}

One of many ViewModels (uses AsyncAwaitBestPractices.MVVM's 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...
  }
}

Why do both the Delete command and Orders.Add() (inside RegisterMessages()) work, while the Copy command's Orders.Add() call doesn't?

The Delete command uses Orders.Remove(deletable); which in turn calls my overridden RemoveItem in SynchronizedCollection<T> which is implemented just like above's InsertItem)

TylerH
  • 20,799
  • 66
  • 75
  • 101
Yoda
  • 539
  • 1
  • 9
  • 18
  • Why don't you use the built-in `ObservableCollection` with or without `BindingOperations.EnableCollectionSynchronization`? What issue are you trying to solve? – mm8 Apr 15 '21 at 15:00
  • 2
    Why don't you do background work on background threads, `await`ing that work, and then update the `ObservableCollection` after the `await` has returned to the UI thread? – Stephen Cleary Apr 15 '21 at 15:18
  • @mm8: because it lacks `AddRange` (which I named `InsertItems` instead) &, most importantly, would require doing the `BindingOperations.EnableCollectionSynchronization` calls in each ViewModel & the `lock` block in each ViewModel's (command) operation instead of the collection doing all this by itself - which is what I was suggested to be the better approach. – Yoda Apr 15 '21 at 21:21
  • @StephenCleary: because after watching Brandon Minnick's https://youtu.be/J0mcYVxJEl0 about async/await best practices, I thought doing `await service.GetAsync().ConfigureAwait(false)` is the way to go wherever possible to exactly do that. If that's wrong, is there some "more complete" example/tutorial I couldn't find so far that you know of & can point me to? – Yoda Apr 15 '21 at 21:29
  • 1
    @Yoda: Yes, use `ConfigureAwait(false)` whenever you don't need the context. If you need to update the UI after an `await`, then that's a perfect example of when you *do* need the context, so you wouldn't use `ConfigureAwait(false)` there. – Stephen Cleary Apr 16 '21 at 00:16
  • @StephenCleary thanks for the clarification, very much appreciated! However 3 questions left for me: a) Is a custom `ObservableCollection` for doing `BindingOperations.EnableCollectionSynchronization` via `BindingOperations.CollectionRegistering` in its ctor still needed then? b) And `lock(MyLock)` (inside the collection or within the ViewModel?) around operations modifying that collection, too? c) What about `ConfigureAwait(false)` inside my command if it doesn't modify anything of the UI, but does `await` another Task that executes `MessageBox.Show()` - should it continue on the context? – Yoda Apr 16 '21 at 06:15
  • 1
    @Yoda: If you only update the `ObservableCollection` from the UI thread (as I always do), then no custom collections or `lock`s are necessary. `ConfigureAwait(false)` should be used only for methods that don't require context; if a method calls another method that does require context, then the parent method also requires the context; if it only `await`s the task, then it does not. – Stephen Cleary Apr 16 '21 at 11:26
  • @StephenCleary That leaves me with 1 final question, though: an `IAsyncCommand` doing "fire & forget" requires `ConfigureAwait(true)`, but the `await`ed data-service task also requires it, because after retrieval, items are added to `ObservableCollection`, right? If so, then the actual `HTTPClient`'s GET/POST requests require the context, too - ultimately resulting in a chain of tasks where _all_ require the context, i. e. no more performance gain due to always requiring to return to UI thread's context. :( – Yoda Apr 16 '21 at 12:09
  • 1
    @Yoda: If you have a command that retrieves data to be shown, then that's not "fire and forget" or an `IAsyncCommand`. Such things are better represented using [`NotifyTask` or similar](https://learn.microsoft.com/en-us/archive/msdn-magazine/2014/march/async-programming-patterns-for-asynchronous-mvvm-applications-data-binding). Also, context requirement flows from *child* to *parent*, not the other way. `HttpClient` never require context, because its methods (and children) do not update the UI. – Stephen Cleary Apr 16 '21 at 15:22

1 Answers1

0

Is this the correct & modern approach for cross-thread manipulation of

One must work with a collection on the thread the collection was created. When I have a threaded operation which needs to add data, I invoke to the GUI thread such as

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

usage in thread

SafeOperationToGuiThread(() => { MyCollection.Add( itemfromthread); } );
ΩmegaMan
  • 29,542
  • 12
  • 100
  • 122