なか日記

一度きりの人生、楽しく生きよう。

MVVMに触れてみた(3)

前回「MVVMに触れてみた(2) - タイトルは未定」の続きで、今回から少しずつMVVMのエッセンスに触れていきます。使用する仕組みやクラスの解説については他力本願ですが、他の方がまとめられている*1内容を引用させて頂きます。

データバインディング

WPFSilverlightにはデータバインディングという機能が備わっています。
詳細は@さんが下記にまとめられています。

データバインディングというのは、 例えば、あるコントロールのプロパティとプロパティを結びつけたり、 データベースなどに格納されたデータとリストコントロールを結びつけたりする機構です。

「結びつける」というのは、具体的に言うと、 あるコントロール、例えばテキストボックスの中身が変更されたときに、 その中身と同期して、他のコントロールの中身を変更したりということです。

http://ufcpp.net/study/dotnet/wpf_binding.html

このデータバインディングを使用して、コードビハインドの記述をなくしていきます。

INotifyPropertyChangedインターフェイスを実装

INotifyPropertyChangedの詳細はMSDNライブラリに載ってますのでそちらを見てください。

INotifyPropertyChanged インターフェイスは、プロパティ値が変更されたことをクライアント (通常はバインド元クライアント) に通知するために使用されます。

http://msdn.microsoft.com/ja-jp/library/system.componentmodel.inotifypropertychanged%28v=vs.80%29.aspx

プロパティの値が変更されたことを通知するためにBmiViewModelにINotifyPropertyChangedインターフェイスを実装します。

BmiViewModel.cs
using System;
using System.ComponentModel;

namespace MVVMSample
{
    public class BmiViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        private void RaisePropertyChanged(string propertyName)
        {
            if (PropertyChanged == null) return;
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }

        public double Height { get; set; }

        public double Weight { get; set; }

        private double _bmi;
        public double Bmi
        {
            get { return _bmi; }

            set
            {
                _bmi = value;
                RaisePropertyChanged("Bmi");
            }
        }

        public void Calc()
        {
            //小数第2位で切り捨て
            Bmi = Math.Truncate((Weight / Math.Pow((Height / 100), 2)) * 100) / 100;
        }
    }
}
ポイント1
        public event PropertyChangedEventHandler PropertyChanged;

        private void RaisePropertyChanged(string propertyName)
        {
            if (PropertyChanged == null) return;
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }

プロパティが変更されたことを通知するためのコアな部分です。
BmiViewModelのプロパティが変更された際にRaisePropertyChangedメソッドを呼び出すことで、View側*2にプロパティの変更を通知します。

ポイント2
        private double _bmi;
        public double Bmi
        {
            get { return _bmi; }

            set
            {
                _bmi = value;
                RaisePropertyChanged("Bmi");
            }
        }

BMI値はBmiViewModel側で計算するので、値が変更された場合にはそのことをViewへ通知する必要があります。
そのため、ちょっと面倒ですが明示的にプロパティのgetとsetを記述する必要があります。

ポイント3
        public void Calc()
        {
            //小数第2位で切り捨て
            Bmi = Math.Truncate((Weight / Math.Pow((Height / 100), 2)) * 100) / 100;
        }

BMI計算部分はメソッドに切り出しました。
Calcメソッドが呼ばれる→Bmiプロパティに値がセットされる→Bmiプロパティの変更がViewに通知される→Viewの表示が変わる
という連鎖になります。

テストケースの変更と追加

BmiViewModelに手が入ったので、それに合わせてテストケースを変更、追加します。

BmiViewModelTest.cs
using System;
using System.ComponentModel;
using MVVMSample;
using NUnit.Framework;

namespace MVVMSampleTest
{
    [TestFixture]
    public class BmiViewModelTest
    {
        [Test]
        public void 正常()
        {
            var viewModel = new BmiViewModel();

            viewModel.Height = 172;
            viewModel.Weight = 66;

            viewModel.Calc();

            Assert.That(viewModel.Bmi, Is.EqualTo(22.30));
        }

        [Test]
        public void プロパティの変更通知確認()
        {
            var viewModel = new BmiViewModel();
            String propertyName = "";

            viewModel.PropertyChanged += new PropertyChangedEventHandler((s, e) => { propertyName = e.PropertyName; });
            viewModel.Height = 172;
            viewModel.Weight = 66;

            Assert.That(propertyName, Is.EqualTo(""));

            viewModel.Calc();

            Assert.That(propertyName, Is.EqualTo("Bmi"));
        }
    }
}
正常()

BmiViewModelの変更(Calcメソッドの導入)にあわせて少し書き換えた程度です。これは特に問題ないと思います。

プロパティの変更通知確認()

Bmiプロパティが変更されたことがちゃんと通知されているかどうかをテストしています。
BmiViewModelのPropertyChangedイベントに「ローカル変数にイベントで渡されたプロパティ名をセットする」処理を割り当てています。
BmiViewModelから通知しているのはBmiプロパティが変更されたときのみなので、Calcメソッドが呼ばれるまではローカル変数propertyName は何も変更されません。
そして、Calcメソッドが呼ばれたときに初めてBmiViewModel.csのポイント2にあるRaisePropertyChanged("Bmi")が呼ばれますので、結果的にローカル変数propertyNameに"Bmi"がセットさます。

UIの変更

MainWindow.xaml

さて、BmiViewModelの変更とテストが終わりましたので、最後にUIの変更を行います。

<Window x:Class="MVVMSample.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:viewModels="clr-namespace:MVVMSample" Title="MainWindow" Height="237" Width="318">

    <Window.DataContext>
        <viewModels:BmiViewModel />
    </Window.DataContext>

    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="71*" />
            <ColumnDefinition Width="225*" />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="10*" />
            <RowDefinition Height="10*" />
            <RowDefinition Height="10*" />
            <RowDefinition Height="10*" />
        </Grid.RowDefinitions>
        <Label Content="身長" Name="label1" VerticalAlignment="Center" HorizontalAlignment="Center" />
        <Label Content="体重" Grid.Row="1" Name="label2" VerticalAlignment="Center" HorizontalAlignment="Center" />
        <Label Content="BMI" Name="label3" Grid.Row="2" VerticalAlignment="Center" HorizontalAlignment="Center" />
        <TextBox Name="heightText" Text="{Binding Path=Height}" Grid.Column="1" Margin="10" />
        <TextBox Name="weightText" Text="{Binding Path=Weight}" Grid.Row="1" Grid.Column="1" Margin="10" />
        <TextBlock Name="bmiText"  Text="{Binding Path=Bmi}" Grid.Row="2" Grid.Column="1" Margin="10" VerticalAlignment="Center" />
        <Button Content="計算" Grid.Row="3" Name="calcButton" Grid.ColumnSpan="2" Margin="50,10,50,10" Click="calcButton_Click" />
    </Grid>
</Window>
ポイント1
    <Window.DataContext>
        <viewModels:BmiViewModel />
    </Window.DataContext>

Viewが参照するDataContextを指定しています。ここでは、BmiViewModelのインスタンスを生成してMainWindowインスタンスのDataContextプロパティにセットしています。

ポイント2
        <TextBox Name="heightText" Text="{Binding Path=Height}" Grid.Column="1" Margin="10" />
        <TextBox Name="weightText" Text="{Binding Path=Weight}" Grid.Row="1" Grid.Column="1" Margin="10" />
        <TextBlock Name="bmiText"  Text="{Binding Path=Bmi}" Grid.Row="2" Grid.Column="1" Margin="10" VerticalAlignment="Center" />

各コントロールのTextプロパティとDataContextに割り当てられてるインスタンス(BmiViewModelクラス)のプロパティをバインドしています。

MainWindow.xaml.cs

今回の変更でコードビハインドは以下のようになりました。

using System.Windows;

namespace MVVMSample
{
    /// <summary>
    /// MainWindow.xaml の相互作用ロジック
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        private void calcButton_Click(object sender, RoutedEventArgs e)
        {
            var viewModel = (BmiViewModel) DataContext;
            viewModel.Calc();
        }
    }
}

だいぶすっきりしましたね。
XAMLで生成を指示したDataContextからViewModelインスタンスを取得し、Calcメソッドを呼び出しています。

まとめ

コードビハインドはだいぶすっきりしましたが、まだCalcメソッドを呼び出す一連の処理が残っています。
次回はこのメソッドもバインディングの仕組みを使ってコードビハインドから消してしまいましょう。

ソース

このエントリで作成したソースはこちら
UIの変更 · 9d2b285 · nakaji/MVVMSample · GitHub

*1:私が書くよりも遙かにわかりやすい

*2:この例ではMainWindow