WPF Entity FrameworkチュートリアルのVとVMを分離したい
はじめに
Microsoft Docsにあるチュートリアル: WPF .NET Core のチュートリアル
View
の裏側、いわゆるコードビハインドにコードが書かれている。
コードビハインドに書かれているコードをViewModel
に移し替えたい。
簡単かと思ったら、「Grid
をビューモデル側から更新する」操作に手こずった・・・
【前段】まずは元記事の分析
XAML自体の知識が乏しいので、まずは元記事のコードを嚙み砕く
表に表示するリソースを定義する
画面(DataGrid
)に表示するリソースは、xaml
ファイルの冒頭で定義される。
XAMLファイル冒頭のリソース定義
Window
のリソースに2つのCollectionViewSource
が定義されている。
-
categoryViewSource
はコードビハインドで定義される。 -
categoryProductsViewSource
は、categoryViewSource
のProducts
コレクションをバインドする- こちらはソースを明示している。
<Window.Resources>
<CollectionViewSource x:Key="categoryViewSource"/>
<CollectionViewSource x:Key="categoryProductsViewSource"
Source="{Binding Products, Source={StaticResource categoryViewSource}}"/>
</Window.Resources>
Source
を明示するか否かにより、Binding
の参照先が変わるらしい
特に何も指定しない場合、バインディング・ソースは、UI要素のDataContextプロパティに与えられたオブジェクトになる。
一方で、Bindingマークアップ拡張のSourceプロパティを設定することで、バインディング・ソースを明示的に指定することもできる。
(引用元:第5回 WPFの「データ・バインディング」を理解する)
コードビハインド(MainWindow.xaml.cs)
xaml
ファイル冒頭でx:Key
に割り当てられるcategoryViewSource
がどのように定義されているかみてみる。
// インスタンス変数を宣言
private CollectionViewSource categoryViewSource;
// コンストラクター内
// 変数を初期化
categoryViewSource = (CollectionViewSource)FindResource(nameof(categoryViewSource));
// 画面ロード時のイベント
// ソースプロパティにメモリ内のデータを割り当てる
categoryViewSource.Source = context.Categories.Local.ToObservableCollection();
DbSet に対して LINQ クエリを直接実行すると、常にクエリがデータベースに送信されますが、現在、DbSet. Local プロパティを使用してメモリ内にあるデータにアクセスできます。
(引用元:Docs Entity Framework Entity Framework 6 クエリ データ ローカル データ)
コンストラクター内で変数を初期化するときに、Application.FindResource(Object) メソッドを使ってハンドラーを取得している。
このメソッドはobject
型を返すので、キャストが必要となる。
※ categoryViewSource = new CollectionViewSource();
で初期化するとうまくいかない。
データグリッド
データを表示する表のこと
表の内容は(許可されていれば)ユーザーが書き換え可能
<DataGrid
x:Name="productsDataGrid"
AutoGenerateColumns="False"
EnableRowVirtualization="True"
ItemsSource="{Binding Source={StaticResource categoryProductsViewSource}"
Margin="13,205,43,108"
RowDetailsVisibilityMode="VisibleWhenSelected"
RenderTransformOrigin="0.488,0.251">
プロパティ | 内容 |
---|---|
Name |
コントロールを識別するための名前。今回はなくてもいい |
AutoGenerateColumns |
列を手動で追加する場合はfalse にする。(既定値はtrue ) |
EnableRowVirtualization |
よくわからない、パフォーマンス向上のため? |
ItemsSource | 冒頭で定義したリソースをここで使う |
Margin | 見た目 |
RowDetailsVisibilityMode | 詳細表示。選択時に可視化されるプロパティとなっているけど、よくわからない |
RenderTransformOrigin | 描画原点をデフォルトの (0,0) から変更 |
AutoGenerateColumnsをTrueにしてみる
余計な列が出現する。
そのままさらに<DataGrid.Columns>
を使って手動で列を追加すると、同じ列名の重複した列が出現する。カオス。
AutoGenerateColumns
をTrue
にした状態でColumns
を手動で追加するケースは稀だと思う。
参考: Qiita - WPF DataGridを使ってみた
モデル
Product
クラスと Category
クラスにはvirtual
で定義されたナビゲーションプロパティがある。
virtual
修飾子を付けないで実行するとエラーになる
ナビゲーション プロパティ ゲッターを public および virtualとして宣言する必要があり、クラスを sealed (Visual Basic では NotOverridable) にしないようにする必要があります。
(引用元:Docs Entity Framework Entity Framework Core 作業の開始 WPF .NET Core のチュートリアル)
遅延読み込みとは、これらのプロパティにアクセスしようとしたときに、それらのプロパティの内容がデータベースから自動的に読み込まれることを意味します。
(引用元:Docs Entity Framework Entity Framework 6 モデルを作成する Code First を使用する ワークフロー 新しいデータベースの場合
)
1カテゴリーに対して、プロダクトは多の関係である。
カテゴリーのほうはProducts
コレクションをnew
している。
new
を無くすとうまくいかない。
public virtual Category Category { get; set; }
// 右辺に new がある
public virtual ICollection<Product> Products { get; private set; }
= new ObservableCollection<Product>();
//--------------------------------------------------------------------
// 個人的な好みで、宣言とインスタンス生成は分けたい
// 1. 宣言
public virtual ICollection<Product> Products { get; private set; }
public Category() // <---Categoryクラスのコンストラクター
{ // 2. コンストラクター内でインスタンス生成
this.Products = new ObservableCollection<Product>();
}
「クラス名 + Id」と命名すると、自動でキーとして認識してくれる。
Code First の規則の1つは、暗黙的なキープロパティです。Code First は、"Id" という名前のプロパティ、またはクラス名と "Id" の組み合わせ ("ブログ Id" など) を検索します。 このプロパティは、データベースの主キー列にマップされます。
(引用元:Docs Entity Framework Entity Framework 6 モデルを作成する Code First を使用する データの注釈)
WPF XAMLのバインディングを使う場合は、コレクションの型としてObservableCollection
を使う
WPF を使用してデータバインディングを実行する場合は、コレクションのプロパティに system.collections.objectmodel.observablecollection を使用することをお勧めします。これにより、wpf がコレクションに加えられた変更を追跡できるようになります。
(引用元:Docs Entity Framework Entity Framework 6 基礎 データ バインディング WPF)
Category
クラスに紐づくProduct
コレクションは人が手入力するものではないので、コンストラクター内でインスタンスを生成する
public class Category
{
public virtual ObservableCollection<Product> Products { get; private set; } // 外部からはsetされない
public Category() // コンストラクター
{
this.Products = new ObservableCollection<Product>();
}
}
【本題】ViewModelに移行する
元コードがだいたい分かったので、コードビハインドに書かれている内容をViewModel
に移し替える。
新たに2つのファイルをプロジェクトに追加する
フォルダー | ファイル |
---|---|
View |
TestWindow.xaml |
ViewModel |
TestWindowViewModel.cs |
コードビハインドは初期状態のままにする
この記事の目的に即して、コードビハインドには何も書かない
public partial class TestWindow : Window
{
// コンストラクターにメソッド1つだけ
public TestWindow()
{
InitializeComponent();
}
}
画面に表示するコレクションリソースをつくる
ViewModelの変更
CollectionViewSource
の代わりにObservableCollection
をつかう
// クラスのプライベート変数を宣言して
private CollectionViewSource categoryViewSource;
// コンストラクター内で初期化
categoryViewSource = (CollectionViewSource)FindResource(nameof(categoryViewSource));
↓ ↓ ↓
// クラスのプロパティを宣言して
public ObservableCollection<Category> CategoryViewSource { get; }
// コンストラクター内でデータベースの値をとってきて
_context.Database.EnsureCreated();
_context.Categories.Load();
// プロパティに代入
CategoryViewSource = new ();
CategoryViewSource = _context.Categories.Local.ToObservableCollection();
Viewの変更
<Window.Resources>
- <CollectionViewSource x:Key="categoryViewSource"/>
+ <CollectionViewSource x:Key="categoryViewSource" Source="{Binding CategoryViewSource}"/>
<CollectionViewSource x:Key="categoryProductsViewSource" Source="{Binding Products, Source={StaticResource categoryViewSource}}"/>
</Window.Resources>
ボタンクリックのコマンドをつくる
ここに一番時間がかかった
まず元コード
元コードは、いたってシンプル
ボタンが押されたら、コードビハインドにあるvoid Button_Click
メソッドが呼ばれる
<Button Content="Save" (中略) Click="Button_Click" />
ボタンが押されたら、データベースの内容を更新し、画面も更新する
private void Button_Click(object sender, RoutedEventArgs e)
{
// ユーザーが入力した表の変更をデータベースに保存
_context.SaveChanges();
// ビューの各DataGridを更新する
categoryDataGrid.Items.Refresh();
productsDataGrid.Items.Refresh();
}
MVVMに書き換える
新たに書き換えたコードは、ごちゃごちゃしている
2個あるGrid
コントロールを更新(Refresh
)しなくてはいけないので、ボタンコマンドに2個パラメーターを与えている
<Button Content="Save" (中略) Command="{Binding Button_Click}">
<Button.CommandParameter>
<MultiBinding Converter="{StaticResource Converter}">
<Binding ElementName="productsDataGrid"/>
<Binding ElementName="categoryDataGrid"/>
</MultiBinding>
</Button.CommandParameter>
</Button>
マルチバインディングとコンバーターの考え方は知らなかった
以下参考にしたサイト
ビューモデルでボタンの動作を定義する
// クラス内でプロパティ宣言
public ReactiveCommand Button_Click { get; }
// コンストラクター内
// インスタンス生成
Button_Click = new ReactiveCommand();
// 動作定義
Button_Click.Subscribe(x => {
_context.SaveChanges(); // ここは元コードと同じ
// 各DataGridを更新(Rifresh)する
foreach (var xx in (Array)x)
((System.Windows.Controls.DataGrid)xx).Items.Refresh();
});
x
はObject
型なので、Array
でキャストし、2つあるDataGrid
を漏れなく処理する
コンバーター
マルチバインディングしたコマンドパラメーターをどう扱うかは、Converter
メソッドによって決められる
<!-- ビューモデルを使うので、Windowで定義 -->
<Window (中略)
xmlns:viewmodels="clr-namespace:DatabeseViewerWPF.ViewModels"
>
<!-- Windowのリソースにビューモデルのクラスを登録 -->
<Window.Resources>
(中略)
<viewmodels:MyMultiValueConverter x:Key="Converter"/>
</Window.Resources>
Converter
の実装
マルチバインディングで流れてくる2つのGrid
をClone()
してそのまま返している
public class MyMultiValueConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
return values.Clone();
}
// こっちは何も変えていない
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
Windowが閉じられたときの動作
元コードでは、丁寧にデータベースコンテキストをDisppose
して
かつ親のメソッドを呼び出している
protected override void OnClosing(CancelEventArgs e)
{
_context.Dispose();
base.OnClosing(e);
}
MVVMに書き換える場合、Window
のClosing
イベントにコマンドを割り当てる。
古い記事だと、Window
のxmlns:i
の内容が違うので注意する。
詳しく説明されている記事:System.Windows.Interactivity.dll から Xaml.Behaviors.Wpf へ
<Window ...(略)
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
>
<i:Interaction.Triggers>
<i:EventTrigger EventName="Closing">
<i:InvokeCommandAction Command="{Binding Closing_Command}"/>
</i:EventTrigger>
</i:Interaction.Triggers>
...
</Window>
// クラスのプロパティ
public ReactiveCommand Closing_Command { get; }
// コンストラクター内でインスタンス生成
Closing_Command = new ReactiveCommand();
// さらに動作を割り当て
Closing_Command.Subscribe(() =>
{
_context.Dispose();
Debug.WriteLine("ウィンドウを閉じます");
});
ウィンドウを閉じたときに、ちゃんとコマンドの動作に入っていることがわかる
おわりに
ケースバイケースでコードビハインドも使うべし
Discussion