Neutral Scent このページをアンテナに追加 RSSフィード Twitter

2014-12-02

[][][][]サンプルコードを見ながら理解するMVVMの基礎的な実装

f:id:kaorun:20141202213049p:image

WPFがWinFormsより敷居が高い?
そりゃ、最初に掛け違ってるからですよ、きっと。

というわけで、これはXAML Advent Calendar 2014の3日目、12月3日分のエントリーです。WPF? XAML? MVVM? そんなにムズカシクないよ? というお話。
もちろんWinRTのWindowsストアアプリやWindows Phoneアプリ等、XAMLベースのプラットフォームにもほとんどすべて共通した内容です。

ここではあえて理論とか観念は説明しません。とにかくコードを見ながら仕組みと動きを理解していきます。
俺は、コードが読める、長い説明エントリーなんてめんどくせぇ、と思ったら、コード部分だけを実際に動かしながら見ていくだけでも基本的な構造が十分に理解できるのではないか、と。

f:id:kaorun:20141202215300p:image:w240

サンプルプロジェクト
Download: SimpleMVVM.zip 直
github: https://github.com/kaorun/kaorun_samples/tree/master/SimpleMVVM

ビルドして実行すると表示されるこちらのインデックスから順にサンプルとして紹介していきます。


Simple Model Binding

まずは、おなじみのPersonクラスから。
Person.cs

public class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public int Age { get; set; }
}

まぁ、よくあるサンプルですよね。MVVMやMVCパターンで言うところのいわゆるModelです。

これをXAMLを使って表示すると、だいたいこんな実装になります。
SimpleModelPage.xaml

<StackPanel>
    <TextBlock Text="First Name:"/>
    <TextBox Text="{Binding FirstName}"/>
    <TextBlock Text="Last Name:"/>
    <TextBox Text="{Binding LastName}"/>
    <TextBlock Text="Age:"/>
    <TextBox Text="{Binding Age}"/>
</StackPanel>

こちらが表示側にあたる、いわゆるViewですね。
TextBoxコントロールのText属性を{Binding 〜}というマークアップ拡張構文でオブジェクトに紐づけています。
<TextBox Text="{Binding FirstName}"/>であればTextBoxコントロールのTextプロパティにFirstNameプロパティを紐づけ(Binding)するよ、という定義です。

このようにViewをXMLで定義して実装するためのマークアップ言語として開発されたのがXAMLです(実際には汎用的に様々なオブジェクトをXMLで定義することができるため、多くの用途にXAMLが利用されるようになっています)。
追記: いちおう.NET界隈でない人に説明しておくと、SimpleModelPage.xamlSimpleModelPage.xaml.csの2つはいわゆるpartialクラス定義です。XAMLはオブジェクト定義に変換されるので、クラスの定義を複数の構文に分割できるpertialクラスにより*.xamlと*.xaml.csで一つのViewオブジェクトクラスを定義しているわけですね。*.xaml.csはHTMLでいうところのscriptタグみたいなイメージで。

そのままでは出ないので、とりあえず、Pageのコンストラクターインスタンスを生成して、DataContextに突っ込んでいます。このViewのコンテキスト、つまりは表示するためのデータはこれ、というわけですね。これは、とりあえず、お約束ということで。
SimpleModelPage.xaml.cs

public partial class SimpleModelPage : Page
{
    public SimpleModelPage()
    {
        InitializeComponent();

        var p = new Person()
        {
            FirstName = "Tadafumi",
            LastName = "Iriya",
            Age = 17,
        };

        this.DataContext = p;
    }
}

入谷君をイジって楽しむよ(何)。

実行してみましたか? はい、出ましたね。
f:id:kaorun:20141202215115p:image:w320

  1. 表示するためのクラスを定義
  2. インスタンスを生成
  3. ページ(View)のDataContextに割り当て
  4. {Binding}構文を使ってコントロールのプロパティに割り当てる

これが、XAMLを利用したMVVMの礎の一つDataBindingの基本です。

しかし、これだけではとりあえず出るだけで、編集してもなにも起こりません。


Field Member Model Binding

それから、先のPersonクラス、なんでgetter/setterの付いたプロパティやねん? と思いませんでしたか? そう、シンプルなクラスだったら、フィールド変数なんじゃないの? と。

では、試してみましょう。
PersonFieldMember.cs

public class PersonFieldMember
{
    public string FirstName;
    public string LastName;
    public int Age;
}

どうでしょう? 表示されませんね。
f:id:kaorun:20141202215940p:image:w320

つまり、Bindingを使うためのDataContextとなるクラスのメンバ変数は常にgetter/setterを持ったプロパティでなければならないという点です。後々でてくる話に繋がるのですが、とりあえずまず、これは約束です。大概、わかっていても実装を進めていると後述するCommandプロパティなどをうっかりフィールド変数で定義してしまい、「よ...呼ばれない、なんで!?」などとうろたえることになります。
とにかくBindする変数はプロパティで。


Update Model Binding

さて、クラスオブジェクトをViewで表示しているのなら、これをコードで変更したらView側も更新して表示してくれんの? と思いませんか?

早速、ボタンを置いて、Ageを加算してみましょう。
ModelIncrementAgePage.xaml

<StackPanel>
    <TextBlock Text="First Name:"/>
    <TextBox Text="{Binding FirstName}"/>
    <TextBlock Text="Last Name:"/>
    <TextBox Text="{Binding LastName}"/>
    <TextBlock Text="Age:"/>
    <TextBox Text="{Binding Age}"/>
    <Button x:Name="cmdIncrementAge" Click="cmdIncrementAge_Click" >Age++</Button>
</StackPanel>

Personインスタンスをクラスメンバー化して、とりあえず、ボタンの処理はえいやでコードビハインドのClickイベントで実装です。
ModelIncrementAgePage.xaml.cs

public partial class ModelIncrementAgePage : Page
{
    private Person p;

    public ModelIncrementAgePage()
    {
        InitializeComponent();

        p = new Person()
        {
            FirstName = "Tadafumi",
            LastName = "Iriya",
            Age = 17,
        };

        this.DataContext = p;
    }

    private void cmdIncrementAge_Click(object sender, RoutedEventArgs e)
    {
        p.Age++;
    }
}

さて、実行してボタンを押してみます。
f:id:kaorun:20141202220508p:image:w320

うーん、何も起きませんね。デバッグしてみるとわかりますが、コードは実行されているはずです。つまりPersonクラスのAgeプロパティは増えているのに、View側の画面には反映されません。

WinFormsなどコードビハインドで実装していたら、

private void cmdIncrementAge_Click(object sender, RoutedEventArgs e)
{
    p.Age++;
    textAge.Text = p.Age.ToString()
}

などと実装するところですが、今このサンプルで更新するためのコードは書いてない、つまりDataContextに割り当てたオブジェクトの変更をViewがBindingで勝手に更新してくれたりはしないのです。


Update ViewModel Binding

では、どうするか?
クラス側で、どの変数が変更されたよ、という情報を通知するイベントを用意してやります。
PersonViewModel.cs

public class PersonViewModel : INotifyPropertyChanged
{
    private string firstNameVal;
    public string FirstName
    {
        get { return firstNameVal; }
        set
        {
            firstNameVal = value;
            NotifyPropertyChanged("FirstName");
        }
    }

    private string lastNameVal;
    public string LastName
    {
        get { return lastNameVal; }
        set
        {
            lastNameVal = value;
            NotifyPropertyChanged("LastName");
        }
    }

    private int ageVal;
    public int Age
    {
        get { return ageVal; }
        set
        {
            ageVal = value;
            NotifyPropertyChanged("Age");
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;
    private void NotifyPropertyChanged(String info)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(info));
        }
    }
}

実行すると、ボタンの押下で入谷君の年齢がどんどん上がっていきます。下げる機能はまだありません。
f:id:kaorun:20141202221427p:image:w320

さて変わったところはなんでしょう?
INotifyPropertyChangedというインターフェイスを継承して、各プロパティになにやらがしゃがしゃとコードが追加され、最後に見知らぬメンバーとしてイベントとプライベートメソッドが追加されています。

追加されたINotifyPropertyChangedインターフェイスとはなんでしょう? 定義を見てみます。

namespace System.ComponentModel
{
    // 概要:
    //     プロパティ値が変更されたことをクライアントに通知します。
    public interface INotifyPropertyChanged
    {
        // 概要:
        //     プロパティ値が変更されたときに発生します。
        event PropertyChangedEventHandler PropertyChanged;
    }
}

ようするに、クラスにイベントを一つ追加実装しなさいよ、という決まりです。

なにをやってるかはコードを読めばわかると思いますが、今どの変数が変更されたよ、という通知を送ってるわけですね。いわゆるObserverパターンのオブザーバー実装です。

シンプルなPersonクラスに変更通知機能が付いた、これがViewのためのModelオブジェクト、ViewModelの基礎の基礎です。実際のプロパティやNotifyPropertyChanged()実装では様々なテクニックや実装が使われることになりますが、基本的な動作としてはここがベースになります。


Edit Model Binding

さて、今度はよりViewModelらしい動きを実装するためにフォームの編集機能を追加します。FirstName, LastNameときたらFullNameですよね。ということで、まずは試しにModelクラスのPersonクラスにFullNameプロパティを実装してみます。
PersonFullName.cs

public class PersonFullName
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string FullName
    {
        get { return FirstName + " " + LastName; }
        set
        {
            var names = value.Split(new[] {' '});
            if (names.Length == 2)
            {
                FirstName = names[0];
                LastName = names[1];
            }
            else
            {
                FirstName = value;
                LastName = "";
            }
        }
    }
    public int Age { get; set; }
}

とうぜん、編集したところで更新されません。
f:id:kaorun:20141202230303p:image:w320


Edit ViewModel TwoWay Binding

ではViewModelでは?
PersonFullNameViewModel.cs

public class PersonFullNameViewModel : INotifyPropertyChanged
{
    private string firstNameVal;
    public string FirstName
    {
        get { return firstNameVal; }
        set
        {
            firstNameVal = value;
            NotifyPropertyChanged("FirstName");
            NotifyPropertyChanged("FullName");
        }
    }

    private string lastNameVal;
    public string LastName
    {
        get { return lastNameVal; }
        set
        {
            lastNameVal = value;
            NotifyPropertyChanged("LastName");
            NotifyPropertyChanged("FullName");
        }
    }

    public string FullName
    {
        get { return FirstName + " " + LastName; }
        set
        {
            var names = value.Split(new[] { ' ' });
            if (names.Length == 2)
            {
                FirstName = names[0];
                LastName = names[1];
            }
            else
            {
                FirstName = value;
                LastName = "";
            }
            NotifyPropertyChanged("FullName");
        }
    }

...
}

いよいよクラスがだいぶ長くなってきたので途中は割愛、先ほどのViewModelと基本的には同じ仕組み、変更されたら通知する、です。

FirstNameを変更したらFullNameの変更も通知しているのがキモですね。

それから、XAML側も実はいろいろ変わっています。
EditFullNamePage.xaml

<StackPanel>
    <TextBlock Text="Press [Tab] key or change focus to Update"/>
    <TextBlock Text="First Name:"/>
    <TextBox Text="{Binding FirstName,Mode=TwoWay}"/>
    <TextBlock Text="Last Name:"/>
    <TextBox Text="{Binding LastName,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"/>
    <TextBlock Text="Full Name:"/>
    <TextBox Text="{Binding FullName,Mode=TwoWay}"/>
    <TextBlock Text="Age:"/>
    <TextBox Text="{Binding Age,Mode=TwoWay}"/>
</StackPanel>

f:id:kaorun:20141202230443p:image:w320

{Binding FirstName}
から
{Binding FirstName,Mode=TwoWay}
へ、

{Binding LastName}
の方には、なにやらさらに長く、
{Binding LastName,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}
とついていますね。

Mode=TwoWayは、View側での変更をViewModelへ反映するよ、という属性です。規定値はMode=OneWayなんですね。
そして、UpdateSourceTrigger=PropertyChangedは、ViewModelへの反映をするタイミングの設定です。

試しに、FirstNameとLastNameをそれぞれ変更してみてください。FirstNameは入力してもすぐにはFullNameが変わらず、タブキーなどでFirstNameコントロールからフォーカスが外れると更新されます。LastNameコントロールは入力している傍から変更が反映されますね、そういう属性です。
Binding属性にはこれ以外にもStringFormatなど様々な設定が可能なので、色々変更して試してみるといいでしょう。


Command Binding

さて、View側のフォームを変更してViewModelに反映させることができるようになりました。

ただ、先ほどの加齢ボタンのように、コードビハインドを書いてView側で値を出し入れしたりするのはなんだかカッコ悪い、ボタンクリックなどのコマンドもViewModel側で実装してBindingできないの?
...というわけで、今度はCommandバインディングです。

f:id:kaorun:20141202230744p:image:w320

ViewModelがさらににぎやかになりました。

PersonCommandViewModel.cs

public class PersonCommandViewModel : INotifyPropertyChanged
{
    public ICommand IncrementAge { get; set; }
    public ICommand DecrementAge { get; set; }
    public ICommand Submit { get; set; }

    public PersonCommandViewModel()
    {
        IncrementAge = new RelayCommand(() => { Age++; });
        DecrementAge = new DecrementAgeCommand(this);
        Submit = new RelayCommand(SubmitNow);
    }

    private void SubmitNow()
    {
        MessageBox.Show(string.Format("User: {0}\nAge: {1}\nSubmitted!", FullName, Age));
    }
...
}

IncrementAge, DecrementAge, Submitコマンドが定義されました。謎のICommandインターフェイスインスタンスが定義されています。
また、View側のXAMLでは、ボタンのCommand属性にそれらのコマンドがBindingされています。

CommandPage.xaml

<StackPanel>
    <TextBlock Text="First Name:"/>
    <TextBox Text="{Binding FirstName,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"/>
    <TextBlock Text="Last Name:"/>
    <TextBox Text="{Binding LastName,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"/>
    <TextBlock Text="Full Name:"/>
    <TextBox Text="{Binding FullName,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"/>
    <TextBlock Text="Age:"/>
    <TextBox Text="{Binding Age,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"/>
    <StackPanel Orientation="Horizontal">
        <Button Command="{Binding IncrementAge}">Age++</Button>
        <Button Command="{Binding DecrementAge}">Age--</Button>
    </StackPanel>
    <Button Command="{Binding Submit}">Submit!</Button>
</StackPanel>

しかし、そのおかげで、CommandPage.xaml.csのコードビハインドはインスタンスの生成だけですっきりしました。最初のサンプルと同じですね。
CommandPage.xaml.cs

public partial class CommandPage : Page
{
    public CommandPage()
    {
        InitializeComponent();

        var p = new PersonCommandViewModel()
        {
            FirstName = "Tadafumi",
            LastName = "Iriya",
            Age = 17,
        };

        this.DataContext = p;
    }
}

ViewModelがICommandを実装して、そのプロパティがViewのCommand属性にBindingされると、ViewがViewModelを呼び出せるようになるわけです。

ICommandはちとややこしいので、3つのボタンに対してそれぞれ違う実装をしてみています。

public PersonCommandViewModel()
{
    IncrementAge = new RelayCommand(() => { Age++; });
    DecrementAge = new DecrementAgeCommand(this);
    Submit = new RelayCommand(SubmitNow);
}

一番最初のIncrementAgeコマンドは単純ですね、ラムダ式でAge++をしているだけです。割り当てているRelayCommandについては後述。

だったら、コードビハインドみたいに単にメソッドを渡せばいいんじゃないの? と思われるかもしれませんが、次のDecrementAgeコマンドに割り当てられているDecrementAgeCommandクラスの実装を見てみてください。

public class DecrementAgeCommand : ICommand
{
    private PersonCommandViewModel vm;

    public DecrementAgeCommand(PersonCommandViewModel viewmodel)
    {
        vm = viewmodel;
    }

    public bool CanExecute(object parameter)
    {
        return vm.Age > 0;
    }

    public event EventHandler CanExecuteChanged;

    public void Execute(object parameter)
    {
        vm.Age--;
    }
}

ICommandをシンプルに実装するとこういう感じになります。
単に、コマンドが実行された場合だけでなく、ボタンの状態が今有効かどうか(無効なら自動的にグレる)、そして(ここでは未実装ですが)その状態が変わったことをイベントとして通知する仕組みも持っているわけです。

このICommandを都度々実装するのがめんどくせぇ、というのがRelayCommandクラスなわけですが、なぜか、こいつは.NET Frameworkの標準クラスではありません。Commonフォルダに入っているRelayCommand.csの実装は、Windowsストアアプリテンプレートに自動的に入ってくるRelayCommandのほぼ丸写し実装です。この辺りがなんで標準でないかは今回は割愛。RelayCommandもわりと高性能なので、引数で有効無効の実装などもできます。


今回作成したサンプルは以上です。

出来上がったコードを見てみると、MVVMによるアプリ実装とは、表示するためのViewModelクラスを定義して、XAMLで定義したViewにBindingしていくことが基本である、ということがご理解いただけると思います。
そして、表示するオブジェクトがViewModelに集約され、動作の流れが見えてくると、手続き型のコードビハインド実装が生み出すスパゲティコードがどのように回避されるのかもなんとなくわかってくるのではないでしょうか?

ただのプロパティなのにNotifyPropertyChanged()等をいちいち実装するのがめんどくせぇ、と思うかもしれませんが、そこはIDEパワーでコードスニペットを定義して力技で行きましょう。

本当は、INotifyPropertyChangeによるViewModelとはまた別にDipendencyObject(依存関係オブジェクト)とDependencyProperty(依存関係プロパティ)による通知システムも存在したりするのですが、ひじょーに長くてめんどい話になりますので、これまた割愛いたします。
さぁWPFWindowsストアアプリの勉強を始めよう、と、最初にVisual StudioのデザイナでUIを作り、コードビハインドを止めていざMVVMへ、ViewModelやらBindingやらを実装しようとすると、誤ってそちらの密林に足を踏み込んでしまい、厄介なことになることもあるのではないかと思うわけです。

さて、以上で駆け足で実装してきた、とにかく基礎的なMVVMアプリの説明は終了です。え? ViewModelがあるならModelいらないんじゃ? ModelとViewModelはどう切り分けるの? いやいや、そのあたりまで突っ込んで解説すると怖い人から鉞(マサカリ)が飛んできますからね...(まぁ、ここまででも十分飛んできそうだけども)。
まずは、動きとそれぞれの役割を把握してから、理論や応用に関して理解を深めてみるのもよいのではないでしょうか?

そういうわけで、くだくだと長い説明になってしまいましたが、MVVMってなんじゃい? という基本的なイメージを実装から理解していただければ幸いです。

それでは、今年も残り少なくなりましたが、皆さんに良いXAMLライフが訪れますように。

まとめ:

  • ViewをXMLで定義して実装するためのマークアップ言語として開発されたのがXAML
  • XAMLで定義したViewのDataContextに、Bindingを利用してViewModelを紐づけるのが(Windowsプラットフォームにおける)MVVMの基本
  • Bindingする変数はフィールド変数ではなく必ずget/setのプロパティで実装すること、これ約束な
  • ModelにINotifyPropertyChangedによる通知機能を追加してViewModelに
  • プロパティの更新をViewに反映するためにPropertyChangedイベントで通知する
  • ViewModelとは、通知機能を持ったObserverパターンの実装
  • ICommandインターフェイスを実装して、クリックなどの操作イベントもViewModelにBinding

はてなユーザーのみコメントできます。はてなへログインもしくは新規登録をおこなってください。

トラックバック - http://d.hatena.ne.jp/kaorun/20141202/1417532472
Connection: close