0. はじめに
Freeradicalの中の人、yamarahです。
自前ダイアログで条件を設定し、Inventor自体に重い処理をさせる場合に、キャンセルボタンが欲しいですよね。
完全にシングルスレッドにしてしまうと、重い処理を実行中にUIが操作できなくなります。何らかの方法でマルチスレッドにするしかないのですが、どのようにすれば・・・というお話です。
1. 前置き
1.1. 正攻法
通常のWPFアプリケーションでは、メインスレッドたるUIスレッドは瞬時リターンが原則で、時間のかかる処理はasync
, await
を使って別スレッドに委託します。
今回の時間がかかる処理は、Inventorが処理します。すると、Inventorが考え込む = UIスレッドが止まる、ためにキャンセルボタンが押せなくなります。
1.2. 方針
Inventorは構造解析などの例外を除きシングルスレッドなので、Inventorに重い処理をさせている間はUIスレッドが止まります。これは逃げられないです。
ですので、await
→ Task.Run()
→ InventorのAPI呼び出し
と繋いでも無意味で、UIがロックします。
仕方が無いので正攻法とは逆の発想で、標準とは別のUIスレッドを作って、そこでダイアログを表示させます。
1.3. 本記事のテストコードについて
InventorのAddIn上で実行するのが本来の目的のものの、別スレッドでUIを動かくことが本記事の主題なので、以下の方針でテストコードを書きます。
- InventorのAddInにせず、単独アプリケーションとする
-
Button1
,Button2
の2つのButtonを持ったMainWindow
をxamlで用意しているものとする -
Button1
を押すと、新たなMainWindow
を別スレッドで開く -
Button2
を押すと、Button1
で開いたMainWindow
を閉じる -
lock
やSemaphoreSlim
を使いスレッド間で同期を取るべき場面も、今回は省略しています。ですので、このままのコードでは危険です。
2. 別スレッドでUI起動
この記事を参考にして、まずは起動側のコードを書きます。
(参考記事は「複数UIスレッドは止めとけ」って内容なのですけど)
using System.Reactive.Concurrency;
using System.Windows;
using System.Windows.Threading;
namespace WpfApp1;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private MainWindow? ChildWindow;
private void Button1_Click(object sender, RoutedEventArgs e)
{
if (ChildWindow is not null) return;
NewThreadScheduler scheduler = NewThreadScheduler.Default;
var context = SynchronizationContext.Current;
var AnotherUIThread = new Thread(_ =>
{
ChildWindow = new MainWindow();
ChildWindow.ShowDialog();
Dispatcher.CurrentDispatcher.BeginInvokeShutdown(DispatcherPriority.SystemIdle);
Dispatcher.Run();
ChildWindow = null;
});
AnotherUIThread.SetApartmentState(ApartmentState.STA);
AnotherUIThread.IsBackground = true;
AnotherUIThread.Start();
}
private void Button2_Click(object sender, RoutedEventArgs e)
{
if (ChildWindow is null) return;
// ChildWindowを閉じる処理
}
}
元記事ではWindow.Closed
でしていたDispatcher
のshutdownを、ShowDialog()
の後に変更しています。
また、IsBackground
を設定しました。
起動はこれで良しとします。
3. 別スレッドのWindowを閉じる
ここからが、いばらの道の始まりです。
3.1. 無策で挑む
まず、想像に難しくありませんが単純な以下のコードでは例外が発生します。
private void Button2_Click(object sender, RoutedEventArgs e)
{
if (ChildWindow is null) return;
ChildWindow.Close(); // 例外発生
}
ChildWindow
は別スレッドで走っているので、そのスレッドでClose()
しないといけません。
3.2. ObserveOnの登場
「はいはい、Observable
からのObserveOn
ね。」と思ったあなた、お目が高い。その方針で進めます。
ReactiveProperty
でコマンドを作り、
private ReactiveCommandSlim CloseCommand = new();
Button2_Click()
で実行します。
private void Button2_Click(object sender, RoutedEventArgs e)
{
if (ChildWindow is null) return;
CloseCommand.Execute();
}
3.3. DispatcherScheduler.Current
あとは、別スレッドでwindow.ShowDialog()
する前に、
CloseCommand.ObserveOn(DispatcherScheduler.Current).Subscribe(_ => window.Close());
とやるだけ・・・のはずが、NET8
にはDispatcherScheduler
が無いのです。NetFramework
時代はこの手法を使っていたのに、さて困りました。
3.4. Scheduler.CurrentThread
この辺りのAPIはNET
移行時に整理されたようです。似たような名前のScheduler.CurrentThread
を使ってみます。
CloseCommand.ObserveOn(Scheduler.CurrentThread).Subscribe(_ => window.Close());
はい、これも失敗。ドキュメントを読んでみます。
解説
CurrentThread スケジューラは、元の呼び出しを行うスレッドで実行されるアクションをスケジュールします。 アクションはすぐには実行されませんが、キューに配置され、現在のアクションが完了した後にのみ実行されます。
つまり、ここでいうCurrentThread
とは絶対的ではなく相対的であり、呼び出しスレッドで実行するという意味です。罠だよ、こんなの。
3.5. SynchronizationContext.Current
ObserveOn
はIScheduler
もしくはSynchronizationContext
を引数にします。ここまでは前者で検討しましたが、ここでは後者で攻めてみます。
これまでと同様に、ShowDialog()
の手前に1行加えます。
CloseCommand.ObserveOn(SynchronizationContext.Current).Subscribe(_ => window.Close());
ここでコンパイラから、「引数がnull
かもしれない」と警告が。念のため、
var context = SynchronizationContext.Current ?? throw new Exception();
CloseCommand.ObserveOn(context).Subscribe(_ => window.Close());
としたところ、なんとcontext
がnull
でした。万策尽きた!!
3.6. SynchronizationContext.Current 解決編
上記事をよると、UIスレッド以外ではnull
が返ってくるとのことです。
今回はUIスレッドで実行しているつもりですが、window.ShowDialog()
の手前なので、まだUIスレッドと認識されていないのではないか? と想定しました。
window.ShowDialog()
の後では、閉じた後でないと実行されないので無意味です。そこで、Loaded
に記述しました。
ChildWindow.Loaded += (_, _) =>
{
var context = SynchronizationContext.Current ?? throw new Exception();
CloseCommand.ObserveOn(context).Subscribe(_ => ChildWindow.Close());
};
これが当たりで、無事閉じられるようになりました。
4. 結論
using Reactive.Bindings;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Windows;
using System.Windows.Threading;
namespace WpfApp1;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private MainWindow? ChildWindow;
private ReactiveCommandSlim CloseCommand = new();
private void Button1_Click(object sender, RoutedEventArgs e)
{
if (ChildWindow is not null) return;
NewThreadScheduler scheduler = NewThreadScheduler.Default;
var context = SynchronizationContext.Current;
var AnotherUIThread = new Thread(_ =>
{
ChildWindow = new MainWindow();
ChildWindow.Loaded += (_, _) =>
{
var context = SynchronizationContext.Current ?? throw new Exception();
CloseCommand.ObserveOn(context).Subscribe(_ => ChildWindow.Close());
};
ChildWindow.ShowDialog();
Dispatcher.CurrentDispatcher.BeginInvokeShutdown(DispatcherPriority.SystemIdle);
Dispatcher.Run();
ChildWindow = null;
});
AnotherUIThread.SetApartmentState(ApartmentState.STA);
AnotherUIThread.IsBackground = true;
AnotherUIThread.Start();
}
private void Button2_Click(object sender, RoutedEventArgs e)
{
if (ChildWindow is null) return;
CloseCommand.Execute();
}
}