Silverlight MVVM の落とし穴

少し仕事のペースが落ち着いてきたので情報を流しておきます。

現在使っているフレームワークが SLExtensions の DLL を使っているので、なんじゃろ?という形で MVVM モデルってのがあるのことを知りました。以前から MVC モデルを利用しているし、.NET ラボの9月の勉強会(後ほど資料をアップする予定です)でも話しましたが、MVVM であろうと、MVC であろうと、「MV○」ってモデルには違いないので、最新って訳でもありません。
実装上はあれこれあるんですが、まぁ、タイトル通り「落とし穴」があるんです。

SLExtensionsを使ったM-V-VMパターン実装
http://d.hatena.ne.jp/coma2n/20090217/1234881006

を見ていくつかページを探っていきました。この方、業務用のアプリ作成に精通しているらしく MVC モデルの根本的なところを理解していらっしゃいます。VB 6.0 なんかが出てくるところが好感が持てます!
要は、
・ソフトウェア工学なところ(理学部的なところ)
・現場での実装/作業場の問題(工学部的なところ)
の問題があります。文系の方には、理学部と工学部の違いがよくわからないかもしれませんが、

「理論」と「実践」という比較で、理論が「理学部」、実践が「工学部」ってことです。
ソフトウェア工学の場合「工学」を名乗っているところもあるのですが、純粋に数学的なアルゴリズムの問題もあって、この理学/工学が混在しています。

画像認識の論文集を買ったのですが、理論的なところだけでなく、実装上の問題やスピードの問題も扱っているところが工学的ですね。収束スピードとか。

さて、MVVM パターンを実装した SLExtensions の DLL ですが、これが無くても MVVM パターンは実装可能です。というか個人的には SLExtensions を使わない方がコードがすっきりします。なんとなくですが、このあたり「理論としてのパターン」→「パターンをそのまま実装」している「理論」領域に留まっており、実際に作業量など(人的スキルも込み)を考慮した本来の「工学」に至っていない気がします。

# 余談ですが、Microsoft の実装面は、多少(?)ごちゃごちゃな面も含めて私は好きです。
# Java の整理されちゃったコードパターンよりも、C# のごちゃごちゃさはこの手のパターンを実装するときに、「パターン」≠「実装パターン」とすることが可能なのです。このあたり、C++ の template や #define マクロに通じるものがあるんですが。残念ながら C# はプリプロセッサを実装しないときたもんだ。

ええと、最初に言っておきますが、私としては MVVM モデルをこのような実装の仕方にするのは反対です。
というのも、ASP.NET や Silverlight の場合、既にVisual Studioの自動生成機能によって、コントロール自身がバインドされているので、Textプロパティ等に独自にバインディングする意味がありません。ViewとModelを分けたいのであれば、Silverlight に既に実装してあるものを利用するが分りやすいと思います。

ただし、データベースとの自動バインディングを推し進めた場合(いわゆるノンコーディングの場合)には、この実装が有効です。この話は別な機会に。というか、本当は.NETラボの勉強会で話したかったネタです。

と、やっとここから本題です。

■Textプロパティへのバインド

TextBoxコントロールに表示するTextプロパティのバインドを行います。
まず、xaml にバインドするポイントを記述します。

Page.xaml

<Grid x:Name="LayoutRoot" Background="White">
    <StackPanel>
        <TextBox Name="TextName" Width="100" Text="{Binding LastName, Mode=TwoWay}" />
        <Button Name="BtnSave" Width="100" Content="保存" Click="BtnSave_Click" />
        <TextBox Name="TextOut" Width="100" Text="{Binding OutName, Mode=TwoWay}" />
    </StackPanel>
</Grid>

Text=”{Binding LastName, Mode=TwoWay}”

のところがそれですね。「LastName」という名前でバインドされています。

さて、これが何処にバインドされるかというと、モデルクラスのプロパティになります。
バインドが双方向「TwoWay」なので、INotifyPropertyChangedインターフェースを実装します。INotifyPropertyChangedインターフェースは、お約束のコードがあるので、そのまま貼り付けます。
SLExtensions を使う場合は、ここのコードが既に実装済みです。

便宜上 Page.xaml.cs に入れていますが、どこにおいても構いません。

/// <summary>
/// モデルクラス
/// </summary>
public class Model : INotifyPropertyChanged
{
    /// <summary>
    /// 入力値
    /// </summary>
    private string _LastName = "";
    public string LastName
    {
        get { return _LastName; }
        set
        {
            if (_LastName != value)
            {
                _LastName = value;
                OnPropertyChanged("LastName");
            }
        }
    }

    /// <summary>
    /// 保存ボタン押下時の出力値
    /// </summary>
    private string _OutName = "";
    public string OutName
    {
        get { return _OutName; }
        set
        {
            if (_OutName != value)
            {
                _OutName = value;
                OnPropertyChanged("OutName");
            }
        }
    }
    #region INotifyPropertyChanged メンバ
    /// <summary>
    /// プロパティ変更時のイベント
    /// </summary>
    public event PropertyChangedEventHandler PropertyChanged;
    protected virtual void OnPropertyChanged(PropertyChangedEventArgs e)
    {
        if (PropertyChanged != null)
            PropertyChanged(this, e);
    }
    protected virtual void OnPropertyChanged(string name)
    {
        if (PropertyChanged != null)
            PropertyChanged(this, new PropertyChangedEventArgs(name));
    }
    #endregion
}

これで、ViewとModelの準備ができました。
ただ、このままではダメで、これを結び付ける処理を入れます。
Page.xaml.cs

public partial class Page : UserControl
{
    public Page()
    {
        InitializeComponent();
        this.DataContext = MyModel;
    }
    public Model MyModel = new Model();

データバインドは、InitializeComponent の前に入れるのか、後ろに入れるのか決まってはいませんが、一応、後ろに入れます。何かと InitializeComponent の中で色々やってくれるので。
MyModel フィールドが public になっているのは、UnitTest のためです。本来、Model は View や ViewModel と切り離すのが目的なので、普通ここに Model オブジェクトの実体は置きません。
実行を確認してはいませんが、SLExtensions を使う場合は、InitializeComponent の前に入れる必要があるようです。

さて、これで MVVM の実装が済みました。

動かすところは、保存ボタンを押したときで、


<Button Name="BtnSave" Width="100" Content="保存" Click="BtnSave_Click" />

の BtnSave_Click イベントが発生したときです。
実は、イベント自体もバインドが可能(SLExtensionsでは、独自にバインドします)なのですが、ここでは Page.xaml.cs のほうに記述します。

private void BtnSave_Click(object sender, RoutedEventArgs e)
{
    MyModel.OutName = MyModel.LastName;
}

これは MyModel の LastName の値(文字列)を OutName に代入しているところです。
普通は、

TextOut.Text = TextName.Text;

な感じでテキストボックスのTextプロパティを参照するところですが、ここではModelクラスのプロパティを利用します。
Textプロパティのほうが、分りやすいと言えば分りやすいのですが、ここはMVVMモデルの弊害ですね。Modelクラスを使うと直観的でないのが残念です。ただ、慣れると、

・Viewクラスの挙動を知らなくてよい。
・シリアライズ等のデータに関するメソッドを Model クラスに集約できる
・単体テストがしやすい

という利点があります。特に単体テストは便利です。
普通 Silverlight の画面のテストは、ひとが手でマウスをクリックしながらやると思うのですが、Model クラスに分離すると、通常の NUnit のようなテストツールが使えます。
もっとも、Silverlight 用のテストツールもあるので、それを利用するのもテです。

先のModelクラスを動かすと、画面のTextBoxを触っていないのに、ボタンをクリックするときちんと文字列が表示されます。これが「バインド」の効果で、先に xaml にした Binding と Model のプロパティが結びついているために、このように動きます。

■バインドの落とし穴

さて、ここからが本題です。
このバインドの機能を使った MVVM モデルですが、経験が浅い(?)と Model の作成に閉口します。
たぶん、Visual Studio で画面をいっぺんい作っていると画面とイベントの結びつけを意識しないので(意識しない造りになっているので)、このあたりを分離するのが困難なのだと思います。

そういう場合は、素直に MVVM モデルを諦めて、先の Page.xml.cs に直接コントロール経由で値を操作したほうが無難です。もともと Silverlight や ASP.NET が MVC モデルで出来ているので、あえて厳しい制限された形で MVVM モデルを導入する必要はありません。

が、現場の状況によりけりでして、どうしても Model を作らないと駄目になったときに、思わず「コントロールそのものをバインドしてしまおう」という発想になります。
これが落とし穴です。

では実際に、コントロールをバインドしてみましょう。

Page.xaml

<Grid x:Name="LayoutRoot" Background="White">
    <StackPanel>
        <TextBox Name="TextBoxIn" Width="100" DataContext="{Binding TBoxInName, Mode=TwoWay}" />
        <TextBox Name="TextBoxOut" Width="100" DataContext="{Binding TBoxOutName, Mode=TwoWay}" />
        <Button Name="BtnSave" Width="100" Content="保存" Click="BtnSave_Click" />
    </StackPanel>
</Grid>

上記のように DataContext にバインドしてしまいます。
Page.xaml.cs に記述するときは TextBoxIn コントロールを直接参照できるのですが、Model からは TextBoxIn が見れません。ですので、バインドしてしまうわけです。

/// <summary>
/// モデルクラス
/// </summary>
public class Model : INotifyPropertyChanged
{

    private TextBox _TBoxOutName = new TextBox();
    public TextBox TBoxOutName
    {
        get { return _TBoxOutName; }
        set
        {
            if (_TBoxOutName != value)
            {
                _TBoxOutName = value;
                OnPropertyChanged("TBoxOutName");
            }
        }
    }
    private TextBox _TBoxInName = new TextBox();
    public TextBox TBoxInName
    {
        get { return _TBoxInName; }
        set
        {
            if (_TBoxInName != value)
            {
                _TBoxInName = value;
                OnPropertyChanged("TBoxInName");
            }
        }
    }
   
}

Model クラスでは、TBoxOutName プロパティで受けます。
TextBox クラスになるので、「嬉しい」ことに

TBoxOutName.Text

なって形でTextプロパティにアクセスできます。
なので、保存ボタンをクリックしたときは以下のようになります。

private void BtnSave_Click(object sender, RoutedEventArgs e)
{
 MyModel.TBoxOutName.Text = MyModel.TBoxInName.Text;
}

一見すると、何も変わっていないように見えますが、テキストボックスのコントロールを直接参照しています。コントロールなので、Textプロパティを直接扱える、という利点があるのですが。
利点というか難点というか、MVVM モデル上のルール違反ですよね。これは。デザインに関わるところをごっそり持ってきて Model クラスで操作してしまう、ってのが間違いです。

で、これで動作させると、普通に動きます。先のTextプロパティにバインドしたときと同じように保存ボタンをクリックすると、画面のテキストボックスにバインドされます。
なので、どっちでもいいじゃん、めでたしめでたし(ルール違反は別として)に思えるのですが、実は違います。
このコントロールのバインド。一見、TBoxOutName.TextプロパティがテキストボックスのTextプロパティに直接バインドできているように思えますが「違います」。画面を表示しているときに、テキストボックスがTextプロパティに設定しているだけなのです。
これは、Silverlight の UnitTest をした時に判明します。

    model.TBoxInName.Text = "masuda";
    Assert.AreEqual("masuda", model.TBoxInName.Text);
    _page.TestBtnSaveClick();
    Assert.AreEqual("masuda", model.TBoxOutName.Text);
    // 即時バインドされない
    Assert.AreEqual("masuda", _page.TextBoxOut.Text); ★

実は★のところでエラーになります。
モデルのプロパティ(model.TBoxOutName.Text)は「masuda」が設定されていますが、直接テキストボックスのプロパティ(TextBoxOut.Text)を参照したときには空白が返ります。
しかし、画面で動かすと問題はありません。きちんと、TextBoxOutコントロールに「masuda」が表示されます。
なぜでしょうか?

■バインドは即時行われる

MVVM モデルの実装で使われる OnPropertyChanged メソッドですが、内部でキューなどを利用しているわけではなく(実装上、そういうバインドもありでしょうが)、Silverlight の場合は即時実行(関数コール型)になっています。

なので、シーケンスとしては、

1.Modelに値を設定する
2.ModelプロパティがOnPropertyChagnedを呼び出す。
3.ViewのProeprtyChangedイベントが実行される。
4.Viewのプロパティに値が設定される。

のようにシーケンシャルに動きます。

が、コントロールをバインドした場合には、

・コントロール自身の変更ではイベントが発生するのですが、
・コントロールのプロパティ(Textプロパティなど)ではイベントは発生しません。

当たり前といえば当たり前ですが、バインドしたオブジェクトに対してのみバインドが発生します。オブジェクトが持っているフィールドや子のオブジェクトを変更しても、イベントは発生しません。

ここが「落とし穴」です。

なので、コントロールに対してバインドを行った場合、一見、バインドがうまく動いているように見えますが、実は全然役に立っていません。

シーケンスとしては、

1.ModelのコントロールのTextプロパティに値を設定する。
2.この時点では、バインドが走らず、Viewのコントロールには古い値が残ったまま。
3.画面を再描画するイベントが走る(ボタンイベントなど)。
4.表示時にViewのコントロールがTextプロパティを参照する。
5.ModelのコントロールのTextプロパティの値を取得する。
6.ViewのコントロールのTextプロパティに値が設定される。
7.ViewのコントロールのTextプロパティが画面に表示される。

という順序になっており、UnitTest を動かしたときには、2で値を取得してNG。だが、画面は7の時点で表示されるからOKっていう具合なのかな、と。

まぁ、どっちにせよ、↓な形でコントロールを直接バインドするのはルール違反ですね。副作用も大きいし、避けておきたい落とし穴です。

 

        <TextBox Name="TextBoxIn" Width="100" DataContext="{Binding TBoxInName, Mode=TwoWay}" />
カテゴリー: 開発 パーマリンク