WPF episode: 4 ~ DI だけど Unity さえあれば関係ないよね~

← 前回記事【episode: 3 ~ Re: ゼロから始める Prism 生活 ~】
前回は、Prism Shell に Prism Module 内の View を動的に読み込むまでを作成したので、今回はサンプルアプリで使用するデータと、Prism Shell – Module 間のデータ連携に使う DI コンテナ【Unity】の使い方を紹介します。(「次回は TreeViewItem を TreeView へ表示する」と予告していましたが、ソースコードが予想以上に長くなってしまったため、「TreeViewItem の表示」は次回とします。)
尚、この記事は Visual Studio 2017 Community Edition で .NET Framework 4.7.2 以上 と C# + Prism 7.1 を使用して 、WPF アプリケーションを MVVM パターンで作成するのが目的で、C# での基本的なコーディング知識を持っている人が対象です。
このリリースは、影響が大きい修正が加えられたため、Ver.6.3 から変わった部分も併記しています。
注意 Prism for Xamarin.Form の情報はありません。
TreeView を取り扱ったブログの記事等では、Windows のエクスプローラーを模した例をよく見かけますが、ここではもう少し実際的な例として、XML ファイルに保存したデータを TreeView へ表示します。
まず、データファイル周りの概要は以下とします。
- データの保存先は単一の XML ファイル
- データはオブジェクトをシリアライズして保存する
- データファイルの拡張子を『.abc』等に変更して、作成するアプリに関連付けている
(データファイルをダブルクリックするとアプリが起動するイメージ) - アプリ単体で起動した場合は、新規データ作成モードとし、デフォルト値をセットした空のデータを表示する
サンプルアプリで使用するデータ
サンプルアプリで使用するデータとして以下のクラスがあり、このクラスを XML ファイルからデシリアライズして、NavigationTree Module 内の TreeView へ表示します。
info『DataContract 属性』を付加する場合は、【System.Runtime.Serialization】への参照をプロジェクトへ追加する必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | using System.Collections.ObjectModel namespace WpfTestApp { [System.Runtime.Serialization.DataContract] public class WpfTestAppData { /// <summary>生徒の情報を取得・設定します。</summary> [System.Runtime.Serialization.DataMember] public PersonalInformation Student { get; set; } = new PersonalInformation(); /// <summary>身体測定データを取得します。</summary> [System.Runtime.Serialization.DataMember] public ObservableCollection<PhysicalInformation> Physicals { get; private set; } = new ObservableCollection<PhysicalInformation>(); /// <summary>試験結果データを取得します。</summary> [System.Runtime.Serialization.DataMember] public ObservableCollection<TestPointInformation> TestPoints { get; private set; } = new ObservableCollection<TestPointInformation>(); } } |
WpfTestAppData クラスは『個人識別情報』と『測定日別身体検査データリスト』、『試験日別得点データリスト』を含んでいます。現実でこのようなデータを 1 つのファイルで管理することは一般的ではないでしょうが、そのような要求があったと仮定してください。
又、2 つのリストで使用している【System.Collections.ObjectModel.ObservableCollection<T>】はメンバを追加・削除した時に通知してくれるコレクションです。
以下が『測定日別身体検査データリスト』のメンバクラスです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | namespace WpfTestApp { [System.Runtime.Serialization.DataContract] public class PhysicalInformation : Prism.Mvvm.BindableBase { /// <summary>身体測定データのIDを取得・設定します。</summary> public int Id { get; set; } = 0; private DateTime? measureDate = null; /// <summary>測定日を取得・設定します。</summary> [System.Runtime.Serialization.DataMember] public DateTime? MeasurementDate { get { return measureDate; } set { SetProperty(ref measureDate, value); } } private double bodyHeight = 0; /// <summary>身長を取得・設定します。</summary> [System.Runtime.Serialization.DataMember] public double Height { get { return bodyHeight; } set { SetProperty(ref bodyHeight, value); this.calcBmi(); } } private double bodyWeight = 0; /// <summary>体重を取得・設定します。</summary> [System.Runtime.Serialization.DataMember] public double Weight { get { return bodyWeight; } set { SetProperty(ref bodyWeight, value); this.calcBmi(); } } /// <summary>BMI を計算します。</summary> private void calcBmi() { if (this.Height == 0) { return; } this.Bmi = Math.Round(this.bodyWeight / Math.Pow((this.bodyHeight / 100), 2), 1, MidpointRounding.AwayFromZero); } private double _bmi = 0; /// <summary>BMI値を取得します。</summary> [System.Runtime.Serialization.DataMember] public double Bmi { get { return _bmi; } private set { SetProperty(ref _bmi, value); } } } } |
本来、【Bmi プロパティ】は読み取り専用プロパティとして定義するべきでしょうが、読み取り専用プロパティを View からバインドしても変更通知が来ないため、Height、Weight プロパティを更新しても View 側の値が更新されません。
Model が ViewModel の都合で構造を変えるのはおかしい気もしますが、『読取専用プロパティだが、他値の変更結果を反映して通知すること』的な仕様が指示された場合、この程度は許容範囲かと思っています。
実はもっと別の書き方もあるのかもしれませんが、管理人はこの書き方しか思い付きませんでした。
『試験日別得点データリスト』のメンバクラスです。
TestPointInformation.Average プロパティも上記、PhysicalInformation.Bmi プロパティと同様、読み取り専用ですが、変更が通知されるようにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 | namespace WpfTestApp { [System.Runtime.Serialization.DataContract] public class TestPointInformation : Prism.Mvvm.BindableBase { /// <summary>試験結果のIDを取得・設定します。</summary> public int Id { get; set; } = 0; private string testDay = string.Empty; /// <summary>試験日を取得・設定します。</summary> [System.Runtime.Serialization.DataMember] public string TestDate { get { return testDay; } set { SetProperty(ref testDay, value); } } private int japanScore = 0; /// <summary>国語の得点を取得・設定します。</summary> [System.Runtime.Serialization.DataMember] public int JapaneseScore { get { return japanScore; } set { SetProperty(ref japanScore, value); this.calcAverage(); } } private int mathScore = 0; /// <summary>数学の得点を取得・設定します。</summary> [System.Runtime.Serialization.DataMember] public int MathematicsScore { get { return mathScore; } set { SetProperty(ref mathScore, value); this.calcAverage(); } } private int engScore = 0; /// <summary>英語の得点を取得・設定します。</summary> [System.Runtime.Serialization.DataMember] public int EnglishScore { get { return engScore; } set { SetProperty(ref engScore, value); this.calcAverage(); } } /// <summary>平均点を計算します。</summary> private void calcAverage() { this.Average = (this.japanScore + this.mathScore + this.engScore) / 3; } private double ave = 0; /// <summary>平均点を取得します。</summary> /// [System.Runtime.Serialization.DataMember] public double Average { get { return ave; } private set { SetProperty(ref ave, value); } } } } |
個人識別情報クラスです。(Sex プロパティを文字列型で定義しているのは手抜きです)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | namespace WpfTestApp { [System.Runtime.Serialization.DataContract] public class PersonalInformation : Prism.Mvvm.BindableBase { private string personName = string.Empty; /// <summary>個人名を取得・設定します。</summary> [System.Runtime.Serialization.DataMember] public string Name { get { return personName; } set { SetProperty(ref personName, value); } } private string classNum = string.Empty; /// <summary>所属クラスを取得・設定します。</summary> [System.Runtime.Serialization.DataMember] public string ClassNumber { get { return classNum; } set { SetProperty(ref classNum, value); } } private string sex = string.Empty; /// <summary>性別を取得・設定します。</summary> [System.Runtime.Serialization.DataMember] public string Sex { get { return sex; } set { SetProperty(ref sex, value); } } } } |
MVVM のデータバインディングと言えば一般的に、View – ViewModel 間を指しますが、このサンプルアプリでは Model – ViewModel 間もデータバインディングで接続するので、前述した『読取専用プロパティだけど変更を通知する』ようにしています。
又、Model と ViewModel をバインドするために、Model 側にも INotifyPropertyChanged インタフェースを実装する必要がありますが、WPF 標準だとプロパティの記述が冗長になる(面倒な)ため【Prism.Mvvm.BindableBase】クラス(INotifyPropertyChanged インタフェース実装済み)を継承することで代用しています。(それでも標準のプロパティと比べると長いですが…)
モデルを別プロジェクトに分割していて、モデルのプロジェクトで BindableBase を使用したい場合、Prism.Core のインストールが別途必要なため、NuGet で『prism.core』を検索してモデルのプロジェクトへインストールしてください。
注意 他のプロジェクトにインストールした Prism と同じバージョンをインストールしてください。
又、Prism Template Pack がインストール済みであれば Prism のコードスニペットも同時にインストールされているので、プロパティを記述する時に『propp』スニペットを利用すれば、更に楽ができます。
『propp』以外のコードスニペットは【Prism Template Packについてくるコードスニペット】等で紹介されています。
起動時パラメータ
データファイルをダブルクリックすると、起動時パラメータに対象ファイルのフルパスが設定される Windows Form でも一般的な動作を前提に作成します。
そのために、データファイルからモデルを読み込むための DataLoader クラスをサービス層へ static なクラスとして作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | namespace WpfTestApp { public static class DataLoader { /// <summary>新規テストデータを作成します。</summary> /// <returns>新規テストデータを表すWpfTestAppData。</returns> private static WpfTestAppData createNewTestData() { var appData = new WpfTestAppData(); appData.Student.Name = "新しい生徒"; appData.Student.ClassNumber = "所属クラス"; appData.Student.Sex = "男"; appData.Physicals.Add(new PhysicalInformation() { Id = 1 }); appData.TestPoints.Add(new TestPointInformation() { Id = 1, TestDate = "新しい試験日" }); return appData; } /// <summary>データファイルからロードします。</summary> /// <param name="dataFilePath">データファイルのフルパスを表す文字列。</param> /// <returns>データファイルからロードしたWpfTestAppData。</returns> private static WpfTestAppData loadFromFile(string dataFilePath) { return new WpfTestAppData(); } /// <summary>データをロードします。</summary> /// <param name="dataFilePath">データファイルのフルパスを表す文字列。</param> /// <returns>ロードしたデータを表すWpfTestAppData。</returns> public static WpfTestAppData Load(string dataFilePath) { if (dataFilePath == string.Empty) { return DataLoader.createNewTestData(); } else { return DataLoader.loadFromFile(dataFilePath); } } } } |
現時点では、新規データを作成すると『個人識別情報クラス』にデフォルト値を設定して、『測定日別身体検査データリスト』、『試験日別得点データリスト』に新規データ用のメンバーを 1 つずつ追加したオブジェクトを返すだけのクラスです。
この DataLoader をアプリ起動時に呼び出す場合、Windows Form では Main メソッドから呼び出すのがよくあるパターンだと思いますが、ここでは単に起動時パラメータを取得したいだけなので、App.OnStartup メソッドをオーバーライドして取得します。
参考本来、WPF の Main メソッドは自動生成されるため隠蔽されていますが、自動生成をやめて Main メソッドを自作する方法もあるようです。(例:C#のWPFでMainメソッドを編集する [管理人は未確認])
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | namespace WpfTestApp { /// <summary> /// Interaction logic for App.xaml /// </summary> public partial class App : Application { protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); var filePath = string.Empty; if (e.Args.Length == 1) { filePath = e.Args[0]; } var appData = DataLoader.Load(filePath); var bootstrapper = new Bootstrapper(); bootstrapper.Run(); } } } |
info ソリューションエクスプローラーの『App.xaml』のコンテキストメニューから [コードの表示] を選択すると App のソースが表示できます
Windows Form アプリの場合ではスタートアップフォームに追加したプロパティ等を経由してデータを受け渡すようなパターンも多いでしょうが、このサンプルアプリの場合、例え MainWindow にデータを渡したとしても、実際に使用したいのは TreeView を含む Prism Module 側です。
今回は Shell から NavigationTree Module を直接参照設定しているので、Bootstrapper 経由で渡す等の方法(Prism 7.1 以降では Bootstrapper ではなく App 経由)が無いわけではありませんが、あまりスマートなやり方とは思えません。
それに Prism では Module を参照しなくても使用できる方法が用意されているため、Module を参照設定しない場合ではこの方法も採れません。
他にもよくあるパターンとしては、DataHolder のような名前の Singleton クラスを作成して Prism Shell、Moduleの両方から参照してデータを受け渡すと言う方法もあると思いますが、NavigationTree Module が DataHolder に依存する作りになってしまいそうです。
DI コンテナ Unity
ここで出てくるのが DI コンテナの【Unity】です。
UnityContainer はインスタンスを自由に出し入れできるグローバルなオブジェクト保管庫のように使うことができると考えてください。
つまり、UnityContainer にインスタンスを登録しておけばソリューション内のプロジェクト間で同一のインスタンスを引き回して使うことが出来ます。
そのため、まずは Prism の入り口である、Bootstrapper クラスへデータ受け渡し用のプロパティを追加して読み込んだデータを App クラスからセットします。
以下が、データ受け渡し用のプロパティを追加して、UnityContainer へデータを登録する処理を追加した Bootstrapper です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | namespace WpfTestApp { class Bootstrapper : UnityBootstrapper { /// <summary> /// アプリのデータを取得・設定します。 /// </summary> public WpfTestAppData TargetData { get; set; } = null; protected override DependencyObject CreateShell() { return Container.Resolve<MainWindow>(); } protected override void InitializeShell() { Application.Current.MainWindow.Show(); } protected override void ConfigureModuleCatalog() { var moduleCatalog = (ModuleCatalog)ModuleCatalog; moduleCatalog.AddModule(typeof(NavigationTreeModule)); } protected override void ConfigureContainer() { base.ConfigureContainer(); this.Container.RegisterInstance<WpfTestAppData>(this.TargetData, new ContainerControlledLifetimeManager()); } } } |
26 ~ 31 行目のオーバーライドした ConfigureContainer メソッドでデータオブジェクトを UnityContainer へ登録しています。これだけで登録したオブジェクトはいつでもどこでも取り出すことができるようになります。
そのため Prism 7.1 以降では App クラスへ上記 Bootstrapper と App クラスを結合したようなコードを記述することになります。以下が、Prism 7.1 以降の App クラスです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | public partial class App { private string dataFilePath = string.Empty; protected override void OnStartup(StartupEventArgs e) { if (e.Args.Length == 1) { this.dataFilePath = e.Args[0]; } base.OnStartup(e); } protected override Window CreateShell() { //if (this.dataFilePath == string.Empty) { this.Shutdown(); } return Container.Resolve<MainWindow>(); } protected override void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.RegisterInstance<WpfTestAppData>(DataLoader.Load(this.dataFilePath)); } protected override void ConfigureModuleCatalog(IModuleCatalog moduleCatalog) { moduleCatalog.AddModule<NavigationTreeModule>(InitializationMode.WhenAvailable); } } |
そのため、アプリ起動時に DI コンテナへオブジェクトを登録したい場合は、App クラスのオーバーライドした【RegisterTypesメソッド】のパラメータに渡される IContainerRegistry へデータを登録する形に変更されています。
上記コードの 20 行目が Prism 6 Bootstrapper 26 ~ 31 行目と同様の処理になります。
又、Prism 6系・7.1 以降に関わらず、App クラスでの起動を中断してアプリを終了したい場合、途中で Return するだけでは終了しないので、CreateShell メソッドで App.Shutdown メソッドを呼び出す必要があります。
14 行目のコメントアウトしている位置にアプリ終了用コードを追加します。
注意 CreateShell メソッドではなく OnStartup メソッド内等で App.Shutdown を呼び出した場合、MainWindow が一瞬表示されてからアプリが終了するため、CreateShell メソッドで呼び出す方が良いと思います。
Prism Shell でオブジェクトを登録した UnityContainer を Prism Module の ViewModel 等で使用したい場合、対象クラス(ここでは NavigationTreeViewModel)のコンストラクタに IUnityContainer 型のパラメータをを追加するだけです。
ブレークポイントを張って実行すれば一目瞭然ですが、追加したコンストラクタのパラメータへ UnityContainer のインスタンスが自動的にインジェクションされているのが確認できます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | using Prism.Mvvm; using Microsoft.Practices.Unity; namespace WpfTestApp.ViewModels { public class NavigationTreeViewModel : BindableBase { private IUnityContainer container = null; private WpfTestAppData appData = null; public NavigationTreeViewModel(IUnityContainer unityContainer) { this.container = unityContainer; this.appData = this.container.Resolve<WpfTestAppData>(); } } } |
IUnityContainer 型のオブジェクトは Prism が内部で自動的に登録してくれているため、プログラマ側で直接インスタンスを登録する必要はなく、コンストラクタにパラメータを追加するだけで注入されます。
当然、IRegionManager や IEventAggregator 等も同様に Prism が内部で自動的に登録してくれているため、コンストラクタへのパラメータ追加やプロパティに指定するだけで、自動的に注入してくれます。
ex. 上記のコンストラクタを public Constructor(IRegionManager rm,IUnityContainer uc, IEventAggregator ea) のように変更するだけで、3 つのパラメータ全てにインスタンスが注入されます。
DI コンテナをちゃんと理解している人たちからすれば、「それが何か?」かもしれませんが、管理人のように使い方を知らなかった人間からすれば、『そんな書き方が出来ること自体が驚き』でしたw
DI コンテナってすごいですねw どんな原理で動作しているか未だによく分かっていませんw
どんな原理で動いているか分からなくても使い方がわかれば先に進めます。
つまり、上記のようにコンストラクタで UnityContainer が取得できるので、後は Container に登録されているオブジェクトを引っ張り出せば OK と言うことになります。
DI コンテナ を使って、Prism Shell と Module 間でデータを共有するには上記のように書くことで実現できますが、UnityContainer にはどんなオブジェクトでも登録できるため、UnityContainer 自体をインジェクションしてしまうと、ユニットテスト的には問題があると言えます。
(Prism 6系 を使用していて Module 内で型やオブジェクトを登録したい場合は DI コンテナ自体をインジェクションする必要はあります)
DI コンテナは Prism 内部で自動的に登録されたオブジェクトのみインジェクションされるのではなく、コンテナへ登録したオブジェクトは何でもインジェクションしてくれるので、上記のコードを以下のように変更するだけで…
1 2 3 4 5 6 7 8 9 10 11 12 | namespace WpfTestApp.ViewModels { public class NavigationTreeViewModel : BindableBase { private WpfTestAppData appData = null; public NavigationTreeViewModel(WpfTestAppData testAppData) { this.appData = testAppData; } } } |
データオブジェクト自体もインジェクションしてくれます。
ユニットテスト等を考えると、WpfTestAppData 用に IWpfTestAppData インタフェースのようなものを定義して、インジェクションする型に指定する方が良いのかもしれませんとが、サンプルなので実際のオブジェクトを指定しています。
このように、関係が疎に保たれた Prism 内部をシームレスに繋ぐ仕組みとして DI コンテナの Unity が利用できます。
【UnityContainer】等そのものズバリの名称が見えなくなっただけで、プロジェクト作成時に Unity を選択すれば、Prism 6 系で使用していた時と同じ(上記で紹介したコンストラクタインジェクションも同様)ようにオブジェクトがインジェクションされます。
つまり Prism 7.1 からは、Prism Shell 起動時の Bootstrapper、Module 初期化時の IRegionManager の取得方法等が変わっただけと言うことになります。
このように DI コンテナ【Unity】を使用すると Prism Shell – Module 間でデータを簡単に受け渡すことができるようになります。
実際に Prism で DI コンテナの Unity を使うにはこの記事で紹介した通り簡単なので、大人数のプロジェクト等に導入を検討する場合は、検討前に『Dependency Injection』デザインパターンを理解してから使う方がトラブルは少ないかと思います。
大人数のプロジェクトで使用したい場合は、DI コンテナの派生クラス等を作って、決まったオブジェクトや型しか登録できないよう制限をかけた方が良い場合もあるでしょう。
アプリ起動時に Prism Shell で読み込んだデータを Module に渡すことができたので、後は受け取ったデータを元に TreeViewItem へ加工するだけですが、キリが良いので、今回はここまでとします。
次回こそは、TreeView へ TreeViewItem を表示する方法を紹介します!
又、ここまで作成したソリューションをリポジトリに上げておきます。
Livet v2.0.0 をリリースしました – かずきのBlog@hatena
しかも ReactiveProperty のメンテナーであるかずきさんが、Livet のメンテナーも引き受けられたようなので、かなり心動かされて Livet への鞍替えを真剣に考えてしまいました。
やはり国産で MVVM への対応がきめ細かいと言う評判をよく見かけるフレームワークなので(現時点で詳細は未調査)悩みましたが、ここまで Prism を調べてきたのに今更 Livet へ鞍替えするのはさすがに躊躇しました。
Livet の対応について、あまり詳しい内容は調べていませんが、Xamarin へは未対応(? 未調査です)のような気がするので、やはり当面は Prism 推しで行こうかとは考えています。
(Livet が Xamarin に対応!とか Livet と ReactiveProperty が連携して… 的な発表があった場合には乗り換えを再検討してしまうかもw かずきさんのブログには新機能への対応はしないような記載があるので、そんな状況は起こらなさそうですが…)
次回記事【WPF episode: 5 ~ご注文は TreeView ですか?~】→