0

Running an issue with multi-threading and WPF. I don't really know what I'm doing and the usual stackoverflow answers aren't working out.

First off, a bunch of WPF windows are created via:

var thread = new Thread(() =>
{
  var bar = new MainWindow(command.Monitor, _workspaceService, _bus);
  bar.Show();
  System.Windows.Threading.Dispatcher.Run();
});

thread.Name = "Bar";
thread.SetApartmentState(ApartmentState.STA);
thread.Start();

In the ctor of the spawned windows, a viewmodel is created and an event is listened to where the viewmodel should be changed.

this.DataContext = new BarViewModel();

// Listen to an event propagated on main thread.
_bus.Events.Where(@event => @event is WorkspaceAttachedEvent).Subscribe(observer => 
{
  // Refresh contents of viewmodel.
  (this.DataContext as BarViewModel).SetWorkspaces(monitor.Children);
});

The viewmodel state is modified like this:

public void SetWorkspaces(IEnumerable<Workspace> workspaces)
{
  Application.Current.Dispatcher.Invoke((Action)delegate
 {
   this.Workspaces.Clear(); // this.Workspaces is an `ObservableCollection<Workspace>`

   foreach (var workspace in workspaces)
     this.Workspaces.Add(workspace);

   this.OnPropertyChanged("Workspaces");
 });
}

Problem is accessing Application.Current.Dispatcher results in a NullReferenceException. Is there something wrong with the way the windows are spawned?

L. Berger
  • 266
  • 2
  • 15
  • 2
    *I don't really know what I'm doing* -> Why did you choose to construct the windows in a separate thread in the first place? All UI work should be done on the UI thread. – Peter Bons Aug 15 '21 at 11:23
  • The rest of the app is a standard .NET core console app and I needed a way to spawn WPF windows programmatically. I've got a main thread where my business logic lies, so I'm doing the above just because it seemed to get the job done to get the windows opened. Is there a better way to spawn the windows? – L. Berger Aug 15 '21 at 11:38

2 Answers2

2

The problem is, you wouldn't have an Application.Current instance. You are starting threads for your windows, every thread/window would have its own dispatcher. So the simplest solution is, to pass in the Dispatcher of your MainWindow to your ViewModel and use it instead of Application.Current.Dispatcher. A quick and very dirty solution:

static void Main(string[] args)
{
    do
    {
        switch (Console.ReadLine())
        {
            case "n":
                StartNewWindowOnOwnThread();
                break;
            default:
                return;
        }
    } while (true);
}

private static void StartNewWindowOnOwnThread()
{
    var t = new Thread(() =>
    {
        var w = new MainWindow();
        w.Show();
        System.Windows.Threading.Dispatcher.Run();
    });
    t.SetApartmentState(ApartmentState.STA);
    t.Start();
}

ViewModel of MainWindow:

class MainWindowModel : INotifyPropertyChanged
{
    private readonly Dispatcher dispatcher;
    private readonly Timer timer;
    private int count;

    public event PropertyChangedEventHandler PropertyChanged;

    internal MainWindowModel(Dispatcher dispatcher)
    {
        this.dispatcher = dispatcher;
        //Simulate background activity on another thread
        timer = new Timer(OnTimerTick, null, 1000, 1000);
    }

    public string ThreadId => $"Thread {dispatcher.Thread.ManagedThreadId}";

    public int Count
    {
        get { return count; }
        set
        {
            count = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count)));
        }
    }

    private void OnTimerTick(object state)
    {
        dispatcher.BeginInvoke(new Action(() => Count++));
    }
}

MainWindow:

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

    private void Button_Click(object sender, RoutedEventArgs e)
    {
        Dispatcher.InvokeShutdown();
    }
}

XAML - fragment:

<Grid>
    <StackPanel Orientation="Vertical">
        <Button Content="Close" Click="Button_Click"/>
        <TextBlock Text="{Binding ThreadId}"/>
        <TextBlock Text="{Binding Count}"/>
    </StackPanel>
</Grid>
Steeeve
  • 824
  • 1
  • 6
  • 14
  • Wow, I appreciate the response. I've tried exactly implementing your approach (although the underlying variables are different of course), and running into `'The calling thread cannot access this object because a different thread owns it.'` when updating the view model through `SetWorkspaces(...)`. Made the repo public if you decide to take a look. File in question is [this](https://github.com/glazerapp/glaze-wm/blob/8293dbf7922d0fd12e754d2c0b90b34233dc6024/LarsWM.Bar/MainWindow.xaml.cs) and can be replicated by pressing eg ALT+4 – L. Berger Aug 15 '21 at 14:21
  • Found a comment mentioning "You no longer have to worry about the correct dispatcher when updating UI-backing properties in your VM" in .NET core 4+. Sound like it could be my exact issue, so I'll give it a shot to bump the version and see if that does something. https://stackoverflow.com/questions/9732709/the-calling-thread-cannot-access-this-object-because-a-different-thread-owns-it – L. Berger Aug 15 '21 at 14:33
  • @L.Berger I assume, the event handler get's called from another thread. Invoke RefreshViewModel using the Dispatcher. Put `Dispatcher.BeginInvoke(new Action(() => RefreshViewModel(monitor));` in `Subscribe(observer => {...}`. – Steeeve Aug 15 '21 at 14:47
  • Yeah exactly, the `Subscribe` callback is called on another thread. The invoke via `Dispatcher` is in the viewmodel [here](https://github.com/glazerapp/glaze-wm/blob/feat/update-bar-on-workspace-events/LarsWM.Bar/BarViewModel.cs). Ended up upgrading to .NET 5, but this god damn error is still popping up – L. Berger Aug 15 '21 at 16:09
  • Finally seem to be getting somewhere, but still confused. If in the view model, I change the state from an `ObservableCollection` to a `string` (just for debugging) and mutate that in the `Subscribe` callback, it's updating successfully. For some reason, something specific to `ObservableCollection` seems to be causing the issue – L. Berger Aug 15 '21 at 16:22
1

The rest of the app is a standard .NET core console app and I needed a way to spawn WPF windows programmatically

Is there something wrong with the way the windows are spawned?

Yes.

In WPF applications you have one main thread (also called the UI thread) which handles all the UI work. Attempting to do UI work from other threads can lead to exceptions.

Here it sounds like your entry point is not a WPF application, but rather a console application and you're trying to spawn windows from that console application. This is not gonna work.

You need to create a WPF project like normal and have it be your main entry point.

Also, don't use Thread objects where you should be using Tasks.

asaf92
  • 1,557
  • 1
  • 19
  • 30
  • 1
    The problem is that showing the WPF windows is more of a side feature and there may be situations where no windows are shown. Using the above code, I can spawn the windows with an initial state, but updating that state from a different thread seems tricky. Also looked looked into using `SynchronizationContext`, but `SynchronizationContext.Current` is `null` when updating state.I appreciate the tip about `Task`s! – L. Berger Aug 15 '21 at 13:34
  • It's an interesting use-case. I believe you can do it without major refactoring by creating an `App` object and `Run`ning it. Try these solutions: https://stackoverflow.com/a/43388522/6104191 https://stackoverflow.com/a/23298284/6104191 – asaf92 Aug 15 '21 at 14:00