👻

WPF Entity FrameworkチュートリアルのVとVMを分離したい

2021/07/13に公開

はじめに

Microsoft Docsにあるチュートリアル: WPF .NET Core のチュートリアル

Viewの裏側、いわゆるコードビハインドにコードが書かれている。
コードビハインドに書かれているコードをViewModelに移し替えたい。

簡単かと思ったら、Gridをビューモデル側から更新する」操作に手こずった・・・

【前段】まずは元記事の分析

XAML自体の知識が乏しいので、まずは元記事のコードを嚙み砕く

表に表示するリソースを定義する

画面(DataGrid)に表示するリソースは、xamlファイルの冒頭で定義される。

XAMLファイル冒頭のリソース定義

Windowのリソースに2つのCollectionViewSourceが定義されている。

  • categoryViewSourceはコードビハインドで定義される。
  • categoryProductsViewSourceは、categoryViewSourceProductsコレクションをバインドする
    • こちらはソースを明示している。
MainWindow.xaml
<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がどのように定義されているかみてみる。

MainWindow.xaml.cs
// インスタンス変数を宣言
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();で初期化するとうまくいかない。

データグリッド

データを表示する表のこと
表の内容は(許可されていれば)ユーザーが書き換え可能

MainWindow.xaml
<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>を使って手動で列を追加すると、同じ列名の重複した列が出現する。カオス。

AutoGenerateColumnsTrueにした状態で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を無くすとうまくいかない。

Product.cs
public virtual Category Category { get; set; }
Category.cs
// 右辺に 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コレクションは人が手入力するものではないので、コンストラクター内でインスタンスを生成する

Category.cs
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

コードビハインドは初期状態のままにする

この記事の目的に即して、コードビハインドには何も書かない

TestWindow.xaml.cs
public partial class TestWindow : Window
{
    // コンストラクターにメソッド1つだけ
    public TestWindow()
    {
        InitializeComponent();
    }
}

画面に表示するコレクションリソースをつくる

ViewModelの変更

CollectionViewSourceの代わりにObservableCollectionをつかう

元コード
// クラスのプライベート変数を宣言して
private CollectionViewSource categoryViewSource;
// コンストラクター内で初期化
categoryViewSource = (CollectionViewSource)FindResource(nameof(categoryViewSource));

↓ ↓ ↓

新ViewModelのコード
// クラスのプロパティを宣言して
public ObservableCollection<Category> CategoryViewSource { get; }

// コンストラクター内でデータベースの値をとってきて
_context.Database.EnsureCreated();
_context.Categories.Load();
// プロパティに代入
CategoryViewSource = new ();
CategoryViewSource = _context.Categories.Local.ToObservableCollection();

Viewの変更

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メソッドが呼ばれる

MainWindow.xaml
<Button Content="Save" (中略) Click="Button_Click" />

ボタンが押されたら、データベースの内容を更新し、画面も更新する

MainWindow.xaml.cs
private void Button_Click(object sender, RoutedEventArgs e)
{
    // ユーザーが入力した表の変更をデータベースに保存
    _context.SaveChanges();

    // ビューの各DataGridを更新する
    categoryDataGrid.Items.Refresh();
    productsDataGrid.Items.Refresh();
}

MVVMに書き換える

新たに書き換えたコードは、ごちゃごちゃしている
2個あるGridコントロールを更新(Refresh)しなくてはいけないので、ボタンコマンドに2個パラメーターを与えている

TestWindow.xaml
<Button Content="Save" (中略) Command="{Binding Button_Click}">
    <Button.CommandParameter>
        <MultiBinding Converter="{StaticResource Converter}">
            <Binding ElementName="productsDataGrid"/>
            <Binding ElementName="categoryDataGrid"/>
        </MultiBinding>
    </Button.CommandParameter>
</Button>

マルチバインディングとコンバーターの考え方は知らなかった
以下参考にしたサイト

ビューモデルでボタンの動作を定義する

TestWindowViewModel.cs
// クラス内でプロパティ宣言
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();
});

xObject型なので、Arrayでキャストし、2つあるDataGridを漏れなく処理する

コンバーター

マルチバインディングしたコマンドパラメーターをどう扱うかは、Converterメソッドによって決められる

TestWindow.xaml
<!-- ビューモデルを使うので、Windowで定義 -->
<Window (中略)
     xmlns:viewmodels="clr-namespace:DatabeseViewerWPF.ViewModels"
>
<!-- Windowのリソースにビューモデルのクラスを登録 -->
<Window.Resources>
    (中略)
    <viewmodels:MyMultiValueConverter x:Key="Converter"/>
</Window.Resources>

Converterの実装
マルチバインディングで流れてくる2つのGridClone()してそのまま返している

TeswWindowViewModel
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して
かつ親のメソッドを呼び出している

MainWindow.xaml.cs
protected override void OnClosing(CancelEventArgs e)
{
    _context.Dispose();
    base.OnClosing(e);
}

MVVMに書き換える場合、WindowClosingイベントにコマンドを割り当てる。

古い記事だと、Windowxmlns:iの内容が違うので注意する。

詳しく説明されている記事:System.Windows.Interactivity.dll から Xaml.Behaviors.Wpf へ

TestWindow.xaml
<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>
TestWindowViewModel.cs
// クラスのプロパティ
public ReactiveCommand Closing_Command { get; }

// コンストラクター内でインスタンス生成
Closing_Command = new ReactiveCommand();
// さらに動作を割り当て
Closing_Command.Subscribe(() =>
{
    _context.Dispose();
    Debug.WriteLine("ウィンドウを閉じます");
});

ウィンドウを閉じたときに、ちゃんとコマンドの動作に入っていることがわかる

おわりに

ケースバイケースでコードビハインドも使うべし

Discussion