最初に結論
WPF で複数 UI スレッドやろうと思った人はやらないでください…。
何か複数 UI スレッドで回避しようとしている問題に、他の回避方法があるならそっちを検討してください。
「おっ?UI スレッドもう 1 つ作ったら解決じゃない?」くらいの軽い気持ちでやると、注意深くプログラムを組まないと長時間稼働してたら死んだり、何かの拍子に死ぬような厄介な問題が起きることが多い印象です。
本文
WPF というか、ほとんどの UI を持ってるプラットフォームは単一の UI スレッドがあって、そこでイベント(メッセージ)を処理していくループがあると思います。
私が知ってる中では UWP が Window ごとに独立した UI スレッドを持っています。
複数の UI スレッドがあると何がうれしいの?
重たい処理やモーダルなダイアログを出しても別の UI スレッドで動いてる人には影響を与えません。
おそらく UWP は Window ごとに UI スレッドがあるほうが UI のフリーズが起こりにくいのでこのようにしてるのかなぁと思います。
WPF だとどうなる?
単一の UI スレッドなので UI スレッドがブロックされるとアプリの全 Window が固まります。
試してみましょう。WPF アプリのプロジェクトを作って MainWindow に以下のようにボタンを置いてみます。
<Window
x:Class="WpfApp5.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="MainWindow"
Width="800"
Height="450"
mc:Ignorable="d">
<StackPanel>
<Button Click="ModalButton_Click" Content="Modal" />
<Button Click="NonModalButton_Click" Content="NonModal" />
<Button Click="FileDialogButton_Click" Content="FileDialog" />
</StackPanel>
</Window>
そして、コードビハインドに以下のようにクリックイベントを実装していきます。
using Microsoft.Win32;
using System.Windows;
namespace WpfApp5
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void ModalButton_Click(object sender, RoutedEventArgs e)
{
new MainWindow().ShowDialog();
}
private void NonModalButton_Click(object sender, RoutedEventArgs e)
{
new MainWindow().Show();
}
private void FileDialogButton_Click(object sender, RoutedEventArgs e)
{
new OpenFileDialog().ShowDialog();
}
}
}
ボタンを押すたびに Window が増えたりファイルを開くダイアログが出るのですが ModalButton_Click の処理が走ると、新しく表示された Window 以外操作できなくなります。これは UI スレッドがブロックされたというよりは ShowDialog で表示された Window 以外を触れなくするという動きですね。
沢山 Window を出しているようなアプリで、何処かで Window を ShowDialog しただけで他の Window が触れなくなると困るケースはあるかなと思います。
回避方法 (個人的にはお勧めしない)
WPF の Window は Single Thread Apartment だと別スレッドでも使えるので、こうすると ShowDialog しても大丈夫です。
private void ModalButton_Click(object sender, RoutedEventArgs e)
{
var t = new Thread(_ => new MainWindow().ShowDialog());
t.SetApartmentState(ApartmentState.STA); // 必須
t.Start();
}
これをすると、新しい Window は別スレッドで動いてくれるので、そこから先でどんな重い処理をしたりスレッドをブロックしても元の Window の動きが阻害されることはありません。
ただ、この方法だと Dispatcher を終了させないとメモリリークしてしまうという問題が起きたりします。
軽く見てみましょう。VS 2019 でデバッグ実行してメモリのスナップショットをとります。その後、普通に Window を 3 つ表示させて全部閉じた後 GC を走らせてメモリのスナップショットをとります。
MainWindow クラスのインスタンス数の Diff をとってみると 0 ですね。ちゃんと閉じた Window は回収されてそうです。
次に新しい UI スレッドで表示させるほうで同じ手順でやってみます。
増えてる…。
という感じになります。こちらの記事にも同じようなことが書いてあります。
では、対処法にならって対処してみましょう。
private void ModalButton_Click(object sender, RoutedEventArgs e)
{
var t = new Thread(_ =>
{
var w = new MainWindow();
w.Closed += (_, __) =>
{
// Window が閉じたら Dispatcher を終了
Dispatcher.CurrentDispatcher.BeginInvokeShutdown(DispatcherPriority.SystemIdle);
Dispatcher.Run();
};
w.ShowDialog();
});
t.SetApartmentState(ApartmentState.STA); // 必須
t.Start();
}
無事消えてくれました。
辛い点
UI スレッド以外からコレクションの変更イベントが飛んでくると WPF の DataGrid や ListBox なんかは簡単に死にます。なので、別スレッドに所属する Window が同一のコレクションを参照して画面に出してると厄介です。
そもそも、標準提供されている ObservableCollection<T>
自体がスレッドセーフじゃないので、何も考えずに複数スレッドから操作してると、たまにうまく動かないとかがあったりして死にます。
なのでコレクションを複製して、それぞれが良い感じに同期をとるとかそういうことをやってコレクションの変更イベントを自分が表示されてる Window の UI スレッドで発行するような仕組みを作らないと辛そうです。
自分は可能であれば複数スレッドが協調作業しないといけない状態を作りたくないので嫌です。
別の対処法
デフォルトの ShowDialog で思った以上に Window にロックがかかるのが嫌な場合は自分で動かさないようにする Window を制御してしまうとかっていう手がありますね。例えばこうすると子 Window が閉じるまで親 Window の UI は触れない。
private void ModalButton_Click(object sender, RoutedEventArgs e)
{
var originalWindowStyle = WindowStyle;
WindowStyle = WindowStyle.None;
IsEnabled = false;
var w = new MainWindow { Owner = this };
w.Closed += (_, __) =>
{
WindowStyle = originalWindowStyle;
IsEnabled = true;
};
w.Show();
}
気合入れて Windows API 叩けば WindowStyle を None に指定するよりも本物の動きに似せることはできます。例えば以下のようにすると子 Window が閉じるまでは移動も最大化も最小化も閉じることもできない感じになります。
using System;
using System.Windows;
using System.Windows.Interop;
namespace WpfApp1
{
public partial class MainWindow : Window
{
private bool _isLock;
public MainWindow()
{
InitializeComponent();
}
private void ShowButton_Click(object sender, RoutedEventArgs e)
{
_isLock = true;
IsEnabled = false;
var w = new MainWindow { Owner = this };
w.Closed += (_, __) =>
{
_isLock = false;
IsEnabled = true;
};
w.Show();
}
const int WM_SYSCOMMAND = 0x0112;
const int SC_MOVE = 0xF010;
const int SC_MASK = 0xFFF0;
const int SC_MAXIMIZE = 0xF030;
const int SC_MINIMIZE = 0xF020;
const int WM_CLOSE = 0x0010;
private void Window_Loaded(object sender, RoutedEventArgs e)
{
var source = HwndSource.FromHwnd(new WindowInteropHelper(this).Handle);
source.AddHook(WndProc);
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
// _isLock が true の間は閉じたり最大化したり最小化したり移動させない
if (msg == WM_CLOSE)
{
handled = _isLock;
}
if (msg == WM_SYSCOMMAND)
{
var sc = wParam.ToInt32() & SC_MASK;
if (sc is SC_MOVE or SC_MAXIMIZE or SC_MINIMIZE) // C# 9.0 の書き方なので、それより前の場合は == と || 使って書いて
{
handled = _isLock;
}
}
return IntPtr.Zero;
}
}
}
Windows のメッセージとか調べたりしないといけないけど、マルチスレッド プログラミングの苦しみに比べたら幸福度が高いです。
重たい処理
他に UI が固まる可能性のある処理ですが UI スレッドで重たい処理を行うケースがあります。
それについては可能な限り async/await を使って非同期でやるなり、どうしても重たい処理だけ別スレッドでやるという風にして UI スレッドを止めることが無いようにしてください。
こうすることで、多分かなりの部分で単一の UI スレッドで出来るようになると思います。
まとめ
WPF で複数の UI スレッドは本当に最後の手段にとっておいて、できる限りやらないですむ方法を探る方がトータルで幸せになると個人的には感じています。
特に普通のマルチスレッド プログラミングの難しさに加えて
- Dispatcher をシャットダウンしないとメモリリークする
- 別の UI スレッドでやったイベントが伝搬してきたら死ぬ
などのハマりどころがあるので、泣きたくなります。なんかメモリリークしてるから Window 開いたりスレッド作ってそうなところを全部チェックしてくれ。すべてのルートで Dispatcher がシャットダウンされてるか確認してくださいと言われたら個人的には泣きたくなります。
あと、ReactiveProperty は UI スレッドが単一であるという前提のもので作ってるので ReactiveProperty を使う場合は UI スレッドを複数作らないでください。多分つらくなります。
自動で UI スレッドにイベントをディスパッチしてるのですが、このディスパッチ先はアプリが最初からもってる UI スレッドになるので、他の UI スレッドのことなんか知らないってなります。