В моем приложении С# есть компонент поставщика данных, который обновляется асинхронно в своем потоке. Классы ViewModel наследуют от базового класса, который реализует INotifyPropertyChanged
. Чтобы поставщик асинхронных данных обновил свойства в представлении с помощью события PropertyChanged, я обнаружил, что мой ViewModel очень тесно связан с представлением из-за необходимости только поднимать событие из потока GUI!
#region INotifyPropertyChanged
/// <summary>
/// Raised when a property on this object has a new value.
/// </summary>
public event PropertyChangedEventHandler PropertyChanged;
/// <summary>
/// Raises this object 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
Существуют ли какие-либо стратегии устранения этой связи между реализацией ViewModel и View?
ИЗМЕНИТЬ 1
Этот ответ связан и подчеркивает проблему обновления коллекций. Однако в предлагаемом решении также используется текущий диспетчер, который я не хочу беспокоить для своей модели ViewModel.
EDIT 2 Копаем немного глубже в вопрос выше, и я нашел ответ на ссылку, который отвечает на мой вопрос: создайте Action <> DependencyProperty в представлении, которое модель View может использовать для получения представления (независимо от того, что может быть) при необходимости, обращаться с диспетчером.
РЕДАКТИРОВАТЬ 3 Кажется, вопрос, заданный "спорный". Однако, когда мой ViewModel предоставляет Observable Collection как свойство для привязки к представлению (см. РЕДАКТИРОВАНИЕ 1), он по-прежнему требует доступа к диспетчеру для Add()
в коллекцию. Например:
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>
Итак, как мне избежать этого небольшого звонка BeginInvoke? Должен ли я повторно изобретать колесо и создать контейнер ViewModel для списка? Или я могу каким-то образом делегировать Add()
в View?
Этот ответ основан на ответе Уилла и комментариях Марселя Б и отмечен как ответ сообщества wiki.
В простом приложении в вопросе к классу ViewModel добавляется общедоступное свойство SynchronizationContext
. Это задается View, где это необходимо, и используется ViewModel для выполнения защищенных операций. В единичном тестовом контексте, который не имеет ни одного потока GUI, поток графического интерфейса пользователя может быть изделен, а SynchronizationContext используется вместо реального. Для моего фактического приложения, когда в одном из представлений нет специального SynchronizationContext, он просто не меняет ViewContext по умолчанию ViewModel.
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 thread. The GUI context will be set
// during the MainWindow 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();
}
}
}
В этом примере событие Window Loaded обрабатывается в коде, чтобы предоставить GUI SynchronizationContext для объекта ViewModel. (В моем приложении у меня нет кода-behand и я использовал свойство зависимой зависимости).
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 reference SynchronizationContext to
// the View current context.
vmTest.ViewContext = (SynchronizationContext)Dispatcher.Invoke
(new Func<SynchronizationContext>(() => SynchronizationContext.Current));
}
}
}
}
Наконец, обработчик события Loaded привязан в 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>
(из вашего редактирования) Отправка обновлений в пользовательский интерфейс для смешивания с помощью действий не только хаки, но и совершенно ненужная. Вы не получаете от этого никакой пользы от использования диспетчера или синхронного контекста в виртуальной машине. Не делай этого. Пожалуйста. Это бесполезно.
Привязки будут автоматически обрабатывать вызовы обновлений в потоке пользовательского интерфейса, когда они привязаны к объектам, которые реализуют INotifyPropertyChanged *. Скупой, ты говоришь? Потратьте минутку и создайте небольшой прототип, чтобы проверить его. Преуспевать. Я буду ждать.... А я говорил тебе.
Поэтому ваш вопрос на самом деле спорный - вам не нужно об этом беспокоиться.
* Это изменение в структуре было введено в 3.5, iirc, и поэтому не применяется, если вы создаете против 3.
Вы можете реализовать общее свойство PropertyChanged в своем базовом классе (ViewModel):
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)));
}
}
А также
private void RaisePropertyChangingUnsafe(string propertyName)
{
PropertyChangingEventHandler handler = PropertyChanging;
if (handler != null)
{
handler(this, new PropertyChangingEventArgs(propertyName));
}
}
Этот код проверяет доступ к вашему главному диспетчеру графического интерфейса пользователя и поднимет событие Property Changed в текущем или в потоке графического интерфейса.
Надеюсь, этот общий подход поможет вам.
Если используется интерфейс, то MainWindow.xaml.cs теряет зависимость TestViewModel.
interface ISynchronizationContext
{
System.Threading.SynchronizationContext ViewContext { get; set; }
}
(this.DataContext as ISynchronizationContext).ViewContext =
(SynchronizationContext)Dispatcher.Invoke
(new Func<SynchronizationContext>(() => SynchronizationContext.Current));
PropertyChanged
в поток пользовательского интерфейса.SynchronizationContext
, но тогда оно должно быть установлено из представления (DispatcherSynchronizationContext
). И вы переключили одну зависимость на другую.