1

Простая задача: Обновить возможность выполнения кнопки по событию.

Пишу такой элементарный код при помощи MVVM Toolkit:

public partial class MainViewModel : ObservableObject
{
    public MainViewModel()
    {
        _ = Task.Run(async () =>
        {
            try
            {
                while (true)
                {
                    await Task.Delay(1000);
                    CanTest = !CanTest;
                }
            }
            catch (Exception e)
            {
                Debug.WriteLine(e.Message);
            }
        });
    }
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(TestCommand))]
private bool _canTest;

[RelayCommand(CanExecute = nameof(CanTest))]
public void Test()
{

}

}

public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); DataContext = new MainViewModel(); } }

Как видно, тут создается простой класс, который фоном крутит некую задачу, и раз в секунду обновляет состояние bool свойства, по которому идет обновление CanExecute команды.

Для наглядности, что этот код сгенерирует:

public bool CanTest
{
    get => _canTest;
    set
    {
        if (!EqualityComparer<bool>.Default.Equals(_canTest, value))
        {
            OnCanTestChanging(value);
            OnCanTestChanging(default, value);
            OnPropertyChanging(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangingArgs.CanTest);
            _canTest = value;
            OnCanTestChanged(value);
            OnCanTestChanged(default, value);
            OnPropertyChanged(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangedArgs.CanTest);
            TestCommand.NotifyCanExecuteChanged();
        }
    }
}

public IRelayCommand TestCommand => testCommand ??= new RelayCommand(new Action(Test), () => CanTest);

То есть, самая элементарная реализация INotifyPropertyChanged и ICommand, которая знакома многим.

Теперь суть вопроса:
Этот код приводит к ошибке

Вызывающий поток не может получить доступ к данному объекту, так как владельцем этого объекта является другой поток.

Оно и понятно, мы таском выходим из UI потока, из-за чего мы не можем так просто работать с UI, но как вернуться обратно? У нас есть типичные решения данной проблемы, в которых используется Dispatcher. Ок, допустим, только это ViewModel, отдельный класс, который ничего не знает про UI, мы можем написать костыльно такое

public MainViewModel(Dispatcher dispatcher)
{
    _ = Task.Run(async () =>
    {
        try
        {
            while (true)
            {
                await Task.Delay(1000);
                dispatcher.BeginInvoke(new Action(() => { CanTest = !CanTest; }));
            }
        }
        catch (Exception e)
        {
            Debug.WriteLine(e.Message);
        }
    });
}

и это решит проблему, но только это нарушение MVVM и прочих правил. Используя Dispatcher.CurrentDispatcher мы также избавимся от ошибки, но у нас перестанут обновляться данные, что тоже вроде логично. Собственно, как решить проблему?

А да, само изменение свойства к ошибки не приводит, приводит обновления CanExecute (вызов: TestCommand.NotifyCanExecuteChanged(); ), который генерирует [NotifyCanExecuteChangedFor(nameof(TestCommand))]. Если убрать, обновления в реальном времени у кнопки не будет, но bool свойство значение менять начнет.


P.S. Бесконечный await цикл это для примера, в реальном проекте у меня есть сервис, который отслеживает изменения процесса (запущен или нет), и отсылает событие наружу, это все зарегистрировано в контейнере, из которого и запрашивает ViewModel, где она подписывается и обновляет свойство, получая ошибку.

public class ProcessWatcherService : IProcessWatcher, IDisposable
{
    private readonly HashSet<string> _monitoredProcesses = new();
    public event EventHandler<ProcessChangedEventArgs>? ProcessChanged;
private readonly ManagementEventWatcher _startWatch;
private readonly ManagementEventWatcher _stopWatch;

private readonly ILogger&lt;ProcessWatcherService&gt; _logger;

public ProcessWatcherService(ILogger&lt;ProcessWatcherService&gt; logger)
{
    _logger = logger;

    _startWatch = new ManagementEventWatcher(new WqlEventQuery(&quot;SELECT * FROM Win32_ProcessStartTrace&quot;));
    _startWatch.EventArrived += (_, e) =&gt; ProcessChangedEvent(e.NewEvent.Properties[&quot;ProcessName&quot;].Value, ProcessStatus.Started);
    _startWatch.Start();

    _stopWatch = new ManagementEventWatcher(new WqlEventQuery(&quot;SELECT * FROM Win32_ProcessStopTrace&quot;));
    _stopWatch.EventArrived += (_, e) =&gt; ProcessChangedEvent(e.NewEvent.Properties[&quot;ProcessName&quot;].Value, ProcessStatus.Stopped);
    _stopWatch.Start();
}

public bool Verify(string process)
{
    var result = Process.GetProcesses()
        ?.Any(x =&gt; x.ProcessName.ToLower() == Path.GetFileNameWithoutExtension(process).ToLower()) == true;
    _logger.LogTrace(&quot;Verify process status {process} = {result}&quot;, process, result);
    return result;
}

public void StartWatch(string process)
{
    _logger.LogDebug(&quot;Process {process} added to watch list&quot;, process);
    _monitoredProcesses.Add(process.ToLower());
}

public bool AddAndWatch(string process)
{
    StartWatch(process);
    return Verify(process);
}

void ProcessChangedEvent(object name, ProcessStatus status)
{
    if (name is string processName &amp;&amp; _monitoredProcesses.Contains(processName.ToLower()))
    {
        _logger.LogInformation(&quot;Process {process} change status: {status}&quot;, processName, status);
        ProcessChanged?.Invoke(this, new(processName, status));
    }
}

public void Dispose()
{
    Dispose(true);
    GC.SuppressFinalize(this);
}

protected virtual void Dispose(bool disposing)
{
    _startWatch.Stop();
    _stopWatch.Stop();
}

}

aepot
  • 49,560
EvgeniyZ
  • 15,694

1 Answers1

1

Можно пойти тем же путём, которым реализован например Progress<T>, то есть использовать захват SynchronizationContext.

При этом логически про UI поток знать необязательно. Нужно просто придумать контракт типа "я буду дёргать свойства вьюмодели в том же контексте синхронизации, в котором был создан этот экземпляр вьюмодели". Кстати, само по себе использование контекста синхронизации - кроссплатформенное решение, что является неоспоримым плюсом на фоне использования диспетчера.

public class MainViewModel
{
    private readonly SynchronizationContext _context;
public MainViewModel()
{
    // захват контекста
    _context = SynchronizationContext.Current;
}

}

Здесь рождается ограничение: вьюмодель должна быть создана в UI потоке. Иначе ничего не получится. Ну а дальше просто:

_context.Send(_ => CanTest = !CanTest, null); // ну или _context.Post, если ждать не хочется

(проверить на null контекст только не забыть перед использованием)

И это всё счастье можно завернуть в красивую упаковку с кодогенераторами и добиться чего-то вроде:

[ObservableSynchronizedProperty]
private bool _canTest;

То есть лепить синхронизированные свойства точно так же красиво, как обычные. Здесь нет взаимодействия с View даже под капотом, а следовательно и MVVM в безопасности (хотя в том же WPF UI контекст всё прекрасно знает про диспетчера и нагло на его основе работает).

aepot
  • 49,560
  • 1
    Да, отличное решение, спасибо! Один вопрос, который покоя не дает... Почему я выхожу из UI потока, при ProcessChanged?.Invoke(this, new(processName, status));? Вроде вызываю не асинхронные методы, а из UI вышел... – EvgeniyZ Jun 01 '23 at 22:20
  • @EvgeniyZ это синхронный вызов делегата, никакого выхода здесь быть не может. – aepot Jun 01 '23 at 22:22
  • Ну, я не совсем про это, я про мой основной код, который после P.S.. Где там может быть выход? Простые синхронные вызовы, а ошибку получил, как, почему, не пойму... – EvgeniyZ Jun 01 '23 at 22:23
  • @EvgeniyZ быть может ManagementEventWatcher вызывает свой обработчик EventArrived в каком-то своём потоке? – aepot Jun 01 '23 at 22:25
  • 1
    Тоже на него грешу, ибо других мест с моей стороны нет, разве что IoC контейнер, но вряд ли он из UI выходить будет. Только вот информации не нашел про то, что он в другой поток уходит. Ладно, пойду генерацию кода освою, сделаю атрибут попробую) – EvgeniyZ Jun 01 '23 at 22:49
  • @EvgeniyZ выяснить просто Debug.WriteLine(Thread.CurrentThread.ManagedThreadId) - посмотреть это для UI потока, а затем для обработчика. Контейнер выходить не будет, он синхронно работает. – aepot Jun 01 '23 at 22:51
  • Ну да, Id потока в событии ManagementEventWatcher другой, когда сам сервис в UI (1). А безопасно кстати использовать SynchronizationContext внутри сервиса, чтоб все подписчики его события сразу были в UI, или чревато проблемами? – EvgeniyZ Jun 01 '23 at 22:56
  • 1
    @EvgeniyZ контекст потокобезопасный, ему фиолетово, из какого потока ему Send или Post вызвали. А дальше уже детали реализации. Для оптимизации можете проверять if (_context == SynchronizationContext.Current) { я в контексте } else { я в рабочем потоке }. Тогда валидные вызовы из UI потока к свойству пройдут короткой дорогой, например если сам View к свойству обратится. Ну и осторожней с безусловным вызовом Send, если его дёрнуть из UI, поймаете дедлок. – aepot Jun 01 '23 at 23:00
  • 1
    @EvgeniyZ в тему https://ru.stackoverflow.com/a/1158131/373567 ObservableCollection на потокобезопасных стеройдах. – aepot Jun 01 '23 at 23:06