C#スクリプトの小ネタです。7割くらいネタなのでそういう認識でお読みください。
目的
本記事は現在作ってるデスクトップマスコット(Harriet)の実装に関連した話題です。Harrietではマスコットキャラの会話や移動処理をユーザが編集しやすいよう、キャラの動作をRoslyn for ScriptingつまりC#スクリプトで書けるようにしています。(※1)
それでですが、C#スクリプトでキャラの会話等を作るに際して対話の途中でダイアログ入力を受け付ける(例えば「あなたの名前を教えて!」とか「最近ハマっていることは?」と質問する)方法はどう実装できるのかな、と試してみた結果が本記事になります。
Roslyn for Scriptingを使う方法自体については以前にいくつか記事を書いてるので、導入等についてはコチラをご覧下さい。
- 【C#】Roslyn for Scripting試してみた【スクリプト】
- 【C#】Roslyn for Scripting C#小ネタ集【Script】
- 【C#】C#スクリプトが外部リソース参照するときのパス設定【スクリプト】
ここでは4種類の方針を紹介します。
- 標準ダイアログで頑張る
- C#スクリプト上のC#コードで完全にGUIを記述する
- ユーザーコントロールを実装したDLLをC#スクリプトからロード
- ウィンドウを定義したxamlファイルを実行時にロードして使う
本記事のサンプルコードはGithubに置いてます。再利用制限は特に設けていませんがコードの利用は自己責任でお願いします。
1. 標準ダイアログで頑張る
正直コレはC#スクリプトでも通常のC#でも変わらない手です。たとえば「はい」「いいえ」が取得したいだけならMessageBox.Showで済むため、次のように書きます。
#r "PresentationFramework" using System; using System.Windows; var result = MessageBox.Show( "プログラミングは好きですか?", "test", MessageBoxButton.YesNo, MessageBoxImage.Question ); if (result == MessageBoxResult.Yes) { Console.WriteLine("私もです!"); } else { Console.WriteLine("それは残念です。"); }
文字列を拾いたいならMicrosoft.VisualBasicのInputBoxを使ってこんな感じ。
#r "Microsoft.VisualBasic" using System; using Microsoft.VisualBasic; string input = Interaction.InputBox("貴方の名前を教えて下さい"); Console.WriteLine($"こんにちは、{input}さん");
こうした方法の利点はとにかくすぐ用意できること、欠点はGUIのカスタム性がほぼゼロなことです。「UIの整備は後だ後!」というときにプレースホルダー的に使うのはアリだと思います。
2. C#スクリプト上のC#コードで完全にGUIを記述する
これはWindows Formでダイアログを作る場合には検討に値しそうな方針です(WPFでも出来ますが後述するXAMLを使った方法の方がオススメです)。手打ちでFormのレイアウトを書くのは面倒なので、サンプルコードでは次のような手順でスクリプトを作りました。
- VSで普通のWinFormsを作る(例えばhogeというFormを作成)
- デザイナコード(hoge.Designer.cs)とユーザコード(hoge.cs)をくっつけて一つのモジュールスクリプトにする
- ↑で作ったスクリプトをロードして使う
例はGithubのソースにフォーム定義、呼び出し側のスクリプトがあるので見ればなんとなく分かると思います。
この方法では最初の例よりカスタマイズ性が向上しますが、デザイナコードをコピペして使うのは何だか後味が悪いです。あとコレは個人的な意見ですが「そもそもWindows Formは使いたくない」的な意見も多いのではないかなあと思います…。
3. ユーザーコントロールを実装したDLLをC#スクリプトからロード
C#スクリプトではアセンブリがロード出来るのでGUIをあらかじめdllとして実装しといて使おう、という方針です。Githubのサンプルではexe本体にWPFのウィンドウクラスを定義してユーザーコントロールライブラリの役を兼任させ、それをC#スクリプトで拾ってきて使います。
#r "PresentationFramework" //あまりやらないパターン: 実行可能ファイルをアセンブリとして参照 #r "ShowDialogByRoslynScript.exe" using System; Console.WriteLine("Sample using dll reference..."); //UIElementは原則UIスレッド上で作る方がいい var window = UIDispatcher.Invoke(() => new DialogSample.MyWindow()); //この辺の処理も一般に非UIスレッド上で走ることに注意 if (window.ShowDialog() == true) { Console.WriteLine(window.PersonName); Console.WriteLine(window.IsMale); }
サンプルではexeファイルにスクリプト実行とコントロールライブラリの一人二役をさせていますが、普通は#r "ShowDialogByRoslynScript.exe"の部分が実行ファイルと無関係なdllの参照つまり #r "Hoge.dll"みたいな形になります。またウィンドウ生成はUIスレッドから行った方がいいので、スクリプトのホスト側からUIスレッドのDispatcherのホルダーを渡すようにしています。
この方法の利点としては、dllの作成時にスクリプトを意識し過ぎずに済むので「普段通りに作れる」という事が挙げられます。また本記事の本題と少しズレますが、凝ったGUIをわざわざスクリプトだけで用意してもようとするとコードが膨れて信頼性が落ちるだけなので、コードの分量が増えてきた場合もdll化を検討すべきです。
欠点を挙げるとすると、本来スクリプトを置く想定の場所にコンパイル済みのdllを配置する事になるので柔軟性が低下する事くらいでしょうか。
ウィンドウを定義したxamlファイルを実行時にロードして使う
これは「C#スクリプト上のC#コードでGUIを完全に記述する」のWPF版っぽい手順であり、個人的にはオススメな方針です。具体的にはXamlReader.LoadAsyncを使って実行時にXAMLからWindowへの変換を行います。
下の例では"xamls\TestWindow.xaml"にウィンドウ定義を配置し、C#スクリプトでロードします。
#r "PresentationFramework" using System; using System.Windows; using System.Windows.Input; using System.Windows.Markup; Console.WriteLine("Sample loading XAML file at runtime..."); var uri = new Uri("/xamls/TestWindow.xaml", UriKind.Relative); var info = Application.GetRemoteStream(uri); var window = UIDispatcher.Invoke(() => new XamlReader().LoadAsync(info.Stream) as Window); //適当にVMを組んで渡す //ウィンドウ渡すのは「OK」ボタン押したときに閉じる挙動をラクして定義するため var vm = new MyWindowViewModel(window); window.DataContext = vm; if (window.ShowDialog() == true) { Console.WriteLine(vm.PersonName); Console.WriteLine(vm.IsMale); } //ウィンドウのVM public class MyWindowViewModel { public MyWindowViewModel(Window w) { OKCommand = new ActionCommand(() => { w.DialogResult = true; w.Close(); }); } public string PersonName { get; set; } public bool IsMale { get; set; } public ICommand OKCommand { get; } } //テキトーなICommandの実装 public class ActionCommand : ICommand { public ActionCommand(Action action) { _action = action; } private readonly Action _action; public event EventHandler CanExecuteChanged; public bool CanExecute(object parameter) => true; public void Execute(object parameter) => _action(); }
XAMLはこんな感じで、データのやり取りはBinding頼みになります。Windows Formの例でもそうでしたが、コチラのxamlも編集はVisual Studio上で行っています。
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Height="300" Width="300"> <StackPanel Margin="50"> <TextBox Text="{Binding PersonName}"/> <CheckBox Content="男性" IsChecked="{Binding IsMale}"/> <Button Content="OK" Command="{Binding OKCommand}"/> </StackPanel> </Window>
この方法では普通WPFでやるようなWindowの継承クラスの定義と生成ではなく、まさにWindowクラスそのもののインスタンスを生成します。と言ってもバインディングで入力値やコマンドは普通に捌けるため、本記事で想定している「少しカスタマイズしたダイアログ」にはコレくらいがちょうど良いんじゃないかなあと思います。
欠点はXAMLに慣れてない人にとって(Windows Formのデザイナコードほどではないものの)GUIのメンテナンス性が下がる事でしょうか。個人的にはXAMLはそんなに読みづらくないと思っているのでコレはあまりデメリットにならないと主張したい所です。
まとめ
Roslyn for ScriptingでダイアログGUIを呼び出す方法をいくつか試してみました。主観に基づいて4つの方法を比較した表を載せておきます。
手軽さ | 維持性 | カスタム | 弄りやすさ | |
標準ダイアログ | ◎ | ○ | × | ◎ |
C#でGUI定義 | ○ | △ | △ | △ |
DLLの利用 | △ | ◎ | ◎ | △ |
XAMLのロード | ○ | ○ | ○ | ○ |
実用的にはコードの規模に応じて下の二つから選ぶ、あるいは本記事では紹介していませんがサードパーティで使いやすいダイアログがあるなら(DLL使う方法の延長として)そういうのに頼るのが良いのではと思います。参考になれば幸いです。
※1: あくまで開発段階の話です。1/24時点で公開してるベータ版はC#スクリプトではなくIronPythonがベースになってますが、正式版ではC#スクリプトがデフォルトになります。