Avoid calling BeginInvoke() from ViewModel objects

2020-02-26 02:09发布

问题:

My C# application has a data provider component that updates asynchronously in its own thread. The ViewModel classes all inherit from a base class that implements INotifyPropertyChanged. In order for the asynchronous data provider to update properties in the View using the PropertyChanged event, I found my ViewModel became very closely coupled with the view due to the need to only raise the event from within the GUI thread!

#region INotifyPropertyChanged

/// <summary>
/// Raised when a property on this object has a new value.
/// </summary>
public event PropertyChangedEventHandler PropertyChanged;

/// <summary>
/// Raises this object's PropertyChanged event.
/// </summary>
/// <param name="propertyName">The property that has a new value.</param>
protected void OnPropertyChanged(String propertyName)
{
    PropertyChangedEventHandler RaisePropertyChangedEvent = PropertyChanged;
    if (RaisePropertyChangedEvent!= null)
    {
        var propertyChangedEventArgs = new PropertyChangedEventArgs(propertyName);

        // This event has to be raised on the GUI thread!
        // How should I avoid the unpleasantly tight coupling with the View???
        Application.Current.Dispatcher.BeginInvoke(
            (Action)(() => RaisePropertyChangedEvent(this, propertyChangedEventArgs)));
    }
}

#endregion

Are there any strategies for eliminating this coupling between the ViewModel and the View implementation?

EDIT 1

This answer is related and highlights the issue of updating collections. However, the proposed solution also uses the current dispatcher, which I do not want to be a concern for my ViewModel.

EDIT 2 Digging a bit deeper into the question above and I've found a link answer that does answer my question: create an Action<> DependencyProperty in the View that the View model can use to get the View (whatever that may be) to handle the dispatching where necessary.

EDIT 3 It appears the the question as asked "is moot". However, when my ViewModel exposes an Observable Collection as a property for the view to bind to (see EDIT 1), it still requires access to teh dispatcher to Add() to the collection. For example:

App.xaml.cs

using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;

namespace MultiThreadingGUI
{
    /// <summary>
    /// Interaction logic for App.xaml
    /// </summary>
    public partial class App : Application
    {
        public App()
        {
            Startup += new StartupEventHandler(App_Startup);
        }

        void App_Startup(object sender, StartupEventArgs e)
        {
            TestViewModel vm = new TestViewModel();
            MainWindow window = new MainWindow();
            window.DataContext = vm;
            vm.Start();

            window.Show();
        }
    }

    public class TestViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        public ObservableCollection<String> ListFromElsewhere { get; private set; }
        public String TextFromElsewhere { get; private set; }

        private Task _testTask;

        internal void Start()
        {
            ListFromElsewhere = new ObservableCollection<string>();
            _testTask = new Task(new Action(()=>
            {
                int count = 0;
                while (true)
                {
                    TextFromElsewhere = Convert.ToString(count++);
                    PropertyChangedEventHandler RaisePropertyChanged = PropertyChanged;
                    if (null != RaisePropertyChanged)
                    {
                        RaisePropertyChanged(this, new PropertyChangedEventArgs("TextFromElsewhere"));
                    }

                    // This throws
                    //ListFromElsewhere.Add(TextFromElsewhere);

                    // This is needed
                    Application.Current.Dispatcher.BeginInvoke(
                        (Action)(() => ListFromElsewhere.Add(TextFromElsewhere)));

                    Thread.Sleep(1000);
                }
            }));
            _testTask.Start();
        }
    }
}

MainWindow.xaml

<Window x:Class="MultiThreadingGUI.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow"
        SizeToContent="WidthAndHeight">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>
        <Label Grid.Row="0" Grid.Column="0" Content="TextFromElsewhere:" />
        <Label Grid.Row="0" Grid.Column="1" Content="{Binding Path=TextFromElsewhere}" />
        <Label Grid.Row="1" Grid.Column="0" Content="ListFromElsewhere:" />
        <ListView x:Name="itemListView" Grid.Row="1" Grid.Column="1"
            ItemsSource="{Binding Path=ListFromElsewhere}">
            <ListView.ItemTemplate>
                <DataTemplate>
                    <Label Content="{Binding}" />
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
    </Grid>
</Window>

So, how do I avoid that little call to BeginInvoke? Do I have to re-invent the wheel and create a ViewModel container for the list? Or can I delegate the Add() to the View somehow?

回答1:

  1. (from your edit) Sending updates to the UI for displatching via Actions is not only hacky it's completely unnecessary. You get absolutely no benefit from this over using a Dispatcher or a SynchronizationContext within the VM. Don't do that. Please. It's worthless.

  2. Bindings will automatically handle invoking updates on the UI thread when they are bound to objects that implement INotifyPropertyChanged*. Bullshit, you say? Take a minute and create a small prototype to test it out. Go ahead. I'll wait. ... Told ya.

So your question is actually moot--you don't need to worry about this at all.

* This change to the framework was introduced in 3.5, iirc, and so doesn't apply if you're building against 3.



回答2:

You can implement a general PropertyChanged Behavior in your Base(ViewModel) Class:

private void RaisePropertyChanged(string propertyName)
        {
            if (Application.Current == null || Application.Current.Dispatcher.CheckAccess())
            {
                RaisePropertyChangedUnsafe(propertyName);
            }
            else
            {
                Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.DataBind,
                    new ThreadStart(() => RaisePropertyChangedUnsafe(propertyName)));
            }
        }

And

 private void RaisePropertyChangingUnsafe(string propertyName)
        {
            PropertyChangingEventHandler handler = PropertyChanging;
            if (handler != null)
            {
                handler(this, new PropertyChangingEventArgs(propertyName));
            }
        }

This code will check the Access to your Main GUI Dispatcher and will raise the Property Changed event on the current or on the GUI Thread.

I hope this general approach will help you.



回答3:

This answer is based on Will's answer and the comment from Marcel B, and is marked as a community wiki answer.

In the simple application in the question, a public SynchronizationContext property is added to the ViewModel class. This is set by the View, where necessary, and used by the ViewModel to perform protected operations. In a unit test context that has no GUI thread, the GUI thread can be mocked and a SynchronizationContext for that used in place of the real one. For my actual application, where one of the Views does not have any special SynchronizationContext, it simply does not change the ViewModel's default ViewContext.

App.xaml.cs

using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;

namespace MultiThreadingGUI
{
    /// <summary>
    /// Interaction logic for App.xaml
    /// </summary>
    public partial class App : Application
    {
        public App()
        {
            Startup += new StartupEventHandler(App_Startup);
        }

        void App_Startup(object sender, StartupEventArgs e)
        {
            TestViewModel vm = new TestViewModel();
            MainWindow window = new MainWindow();
            window.DataContext = vm;
            vm.Start();

            window.Show();
        }
    }

    public class TestViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        public ObservableCollection<String> ListFromElsewhere { get; private set; }
        public String TextFromElsewhere { get; private set; }

        // Provides a mechanism for the ViewModel to marshal operations from
        // worker threads on the View's thread.  The GUI context will be set
        // during the MainWindow's Loaded event handler, when both the GUI
        // thread context and an instance of this class are both available.
        public SynchronizationContext ViewContext { get; set; }

        public TestViewModel()
        {
            // Provide a default context based on the current thread that
            // can be changed by the View, should it required a different one.
            // It just happens that in this simple example the Current context
            // is the GUI context, but in a complete application that may
            // not necessarily be the case.
            ViewContext = SynchronizationContext.Current;
        }

        internal void Start()
        {
            ListFromElsewhere = new ObservableCollection<string>();
            Task testTask = new Task(new Action(()=>
            {
                int count = 0;
                while (true)
                {
                    TextFromElsewhere = Convert.ToString(count++);

                    // This is Marshalled on the correct thread by the framework.
                    PropertyChangedEventHandler RaisePropertyChanged = PropertyChanged;
                    if (null != RaisePropertyChanged)
                    {
                        RaisePropertyChanged(this, 
                            new PropertyChangedEventArgs("TextFromElsewhere"));
                    }

                    // ObservableCollections (amongst other things) are thread-centric,
                    // so use the SynchronizationContext supplied by the View to
                    // perform the Add operation.
                    ViewContext.Post(
                        (param) => ListFromElsewhere.Add((String)param), TextFromElsewhere);

                    Thread.Sleep(1000);
                }
            }));
            _testTask.Start();
        }
    }
}

In this example, the Window's Loaded event is handled in code-behind to supply the GUI SynchronizationContext to the ViewModel object. (In my application I have no code-behand and have used a bound dependency property.)

MainWindow.xaml.cs

using System;
using System.Threading;
using System.Windows;

namespace MultiThreadingGUI
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        private void Window_Loaded(object sender, RoutedEventArgs e)
        {
            // The ViewModel object that needs to marshal some actions is
            // attached as the DataContext by the time of the loaded event.
            TestViewModel vmTest = (this.DataContext as TestViewModel);
            if (null != vmTest)
            {
                // Set the ViewModel's reference SynchronizationContext to
                // the View's current context.
                vmTest.ViewContext = (SynchronizationContext)Dispatcher.Invoke
                    (new Func<SynchronizationContext>(() => SynchronizationContext.Current));
            }
        }
    }
}

Finally, the Loaded event handler is bound in the XAML.

MainWindow.xaml

<Window x:Class="MultiThreadingGUI.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow"
        SizeToContent="WidthAndHeight"
        Loaded="Window_Loaded"
        >
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>
        <Label Grid.Row="0" Grid.Column="0" Content="TextFromElsewhere:" />
        <Label Grid.Row="0" Grid.Column="1" Content="{Binding Path=TextFromElsewhere}" />
        <Label Grid.Row="1" Grid.Column="0" Content="ListFromElsewhere:" />
        <ListView x:Name="itemListView" Grid.Row="1" Grid.Column="1"
            ItemsSource="{Binding Path=ListFromElsewhere}">
            <ListView.ItemTemplate>
                <DataTemplate>
                    <Label Content="{Binding}" />
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
    </Grid>
</Window>


回答4:

If an interface is used then MainWindow.xaml.cs loses TestViewModel dependency.

interface ISynchronizationContext
{
    System.Threading.SynchronizationContext ViewContext { get; set; }
} 
(this.DataContext as ISynchronizationContext).ViewContext  = 
(SynchronizationContext)Dispatcher.Invoke
(new Func<SynchronizationContext>(() => SynchronizationContext.Current));