バインドするデータのPropertyChangedを楽に実装するには?[Win 8/WP 8]WinRT/Metro TIPS

データ・バインドは便利で強力だが、バインドするデータにはプロパティごとにPropertyChangedイベントを発火させるコードが必要になり、数が増えると面倒だ。これを楽に実装する方法を考える。

» 2013年04月18日 14時14分 公開
[山本康彦BluewaterSoft]
WinRT/Metro TIPS
業務アプリInsider/Insider.NET

powered by Insider.NET

「WinRT/Metro TIPS」のインデックス

連載目次

 データ・バインドは便利で強力な仕組みだが、バインドするデータにはプロパティごとにPropertyChangedイベントを発火させるコードを書かねばならない。そのため、バインドするプロパティが10個・20個と増えてくると、その全てについてこのコードを書かねばならなくなる。この記述は嫌になる作業だ。何とかならないだろうか?

 そこで本稿では、前回で作成したClockクラスをリファクタリングして、PropertyChangedイベントを楽に実装する方法を考えてみることにする。なお、掲載しているコードはWindowsストア・アプリとWindows Phone 8で共通である。

事前準備

 Windows 8(以降、Win 8)向けのWindowsストア・アプリを開発するには、Win 8とVisual Studio 2012(以降、VS 2012)が必要である。これらを準備するには、第1回のTIPSを参考にしてほしい。本稿では64bit版Win 8 ProとVS 2012 Express for Windows 8を使用している。

 Windows Phone 8向けのアプリを開発するには、SLAT対応CPUを搭載したPC上の64bit版Win 8 Pro以上Windows Phone SDK 8.0(無償)が必要となる。

リファクタリング前のClockクラス

 まず、前回で作成したClockクラスを再掲する。バインドするプロパティが3つあり、PropertyChangedイベントを発火させるコードも3箇所ある。

public class Clock : INotifyPropertyChanged
{
  // 現在時刻を表すプロパティ
  public DateTimeOffset NowTime { get; private set; } // [A]

  // 秒が偶数のとき true
  public bool IsEven { get; private set; } // [B]

  // 秒が奇数のとき true
  public bool IsOdd { get; private set; } // [C]


  // NowTimeプロパティが変化したときに発生させるイベントの定義
  // なお、このプロパティはINotifyPropertyChangedインターフェイスの実装である
  public event PropertyChangedEventHandler PropertyChanged;

  public Clock()
  {
    Run(); // 時刻監視の無限ループを動かす
  }

  private async void Run()
  {
    DateTimeOffset lastTime;
    while (true)
    {
      await Task.Delay(10); // おおよそ10ミリ秒ごとにシステム時計をチェックする
      var nowTime = DateTimeOffset.Now;
      if (lastTime.Second != nowTime.Second)
      {
        // 秒が変わったら、プロパティに時刻をセットし、イベントを発火させる
        this.NowTime = nowTime;
        if (this.PropertyChanged != null)
          this.PropertyChanged(this,
                               new PropertyChangedEventArgs("NowTime")); // (1)

        bool isEvenSec = (nowTime.Second % 2 == 0);
        this.IsEven = isEvenSec;
        if (this.PropertyChanged != null)
          this.PropertyChanged(this,
                               new PropertyChangedEventArgs("IsEven")); // (2)
        this.IsOdd = !isEvenSec;
        if (this.PropertyChanged != null)
          this.PropertyChanged(this,
                               new PropertyChangedEventArgs("IsOdd")); // (3)

        lastTime = nowTime;
      }
    }
  }
}

Public Class Clock
  Implements INotifyPropertyChanged

  ' 現在時刻を表すプロパティ
  Private _nowTime As DateTimeOffset
  Public Property NowTime As DateTimeOffset ' [A]
    Get
      Return _nowTime
    End Get
    Private Set(value As DateTimeOffset)
      _nowTime = value
    End Set
  End Property

  ' 秒が偶数のとき True
  Private _isEven As Boolean
  Public Property IsEven As Boolean ' [B]
    Get
      Return _isEven
    End Get
    Private Set(value As Boolean)
      _isEven = value
    End Set
  End Property

  ' 秒が奇数のとき True
  Private _isOdd As Boolean
  Public Property IsOdd As Boolean ' [C]
    Get
      Return _isOdd
    End Get
    Private Set(value As Boolean)
      _isOdd = value
    End Set
  End Property


  ' NowTimeプロパティが変化したときに発生させるイベントの定義
  ' なお、このプロパティはINotifyPropertyChangedインターフェイスの実装である
  Public Event PropertyChanged(sender As Object, e As PropertyChangedEventArgs) _
                  Implements INotifyPropertyChanged.PropertyChanged

  Public Sub New()
    Run() ' 時刻監視の無限ループを動かす
  End Sub

  Private Async Sub Run()
    Dim lastTime As DateTimeOffset
    While (True)
      Await Task.Delay(10)  ' おおよそ10ミリ秒ごとにシステム時計をチェックする
      Dim nowTime = DateTimeOffset.Now
      If (lastTime.Second <> nowTime.Second) Then
        ' 秒が変わったら、プロパティに時刻をセットし、イベントを発火させる
        Me.NowTime = nowTime
        RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs("NowTime")) ' (1)

        Dim isEvenSec As Boolean = (nowTime.Second Mod 2 = 0)
        Me.IsEven = isEvenSec
        RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs("IsEven")) ' (2)
        Me.IsOdd = Not isEvenSec
        RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs("IsOdd")) ' (3)

        lastTime = nowTime
      End If
    End While
  End Sub
End Class

Clockクラス(上:C#、下:VB)
バインドするプロパティは3つある([A][B][C] )。PropertyChangedイベントを発火させるコードもプロパティに応じて3箇所ある((1)(2)(3))。

 上のコードではプロパティのsetアクセサ(=setter)(VBでは「Setプロパティ・プロシージャ」)がprivate/Privateになっているが、そうでないときは一般に次のコードのようにPropertyChangedイベントを発火させるコードをsetter内に記述する。setterに記述する際には、プロパティに変化がなければイベントを発火させてはいけないので、if文での切り分けも必要になる。

// 現在時刻を表すプロパティ
private DateTimeOffset _nowTime;
public DateTimeOffset NowTime
{
  get
  {
    return _nowTime;
  }
  internal set
  {
    if (_nowTime == value)
      return;

    _nowTime = value;
    if (this.PropertyChanged != null)
      this.PropertyChanged(this, new PropertyChangedEventArgs("NowTime"));
  }
}

// 秒が偶数のとき true
private bool _isEven;
public bool IsEven
{
  get
  {
    return _isEven;
  }
  internal set
  {
    if (_isEven == value)
      return;

    _isEven = value;
    if (this.PropertyChanged != null)
      this.PropertyChanged(this, new PropertyChangedEventArgs("IsEven"));
  }
}

// 秒が奇数のとき true
private bool _isOdd;
public bool IsOdd
{
  get
  {
    return _isOdd;
  }
  internal set
  {
    if (_isOdd == value)
      return;

    _isOdd = value;
    if (this.PropertyChanged != null)
      this.PropertyChanged(this, new PropertyChangedEventArgs("IsOdd"));
  }
}

…… 省略 ……

private async void Run()
{
  DateTimeOffset lastTime;
  while (true)
  {
    await Task.Delay(10);
    var nowTime = DateTimeOffset.Now;
    if (lastTime.Second != nowTime.Second)
    {
      this.NowTime = nowTime;

      bool isEvenSec = (nowTime.Second % 2 == 0);
      this.IsEven = isEvenSec;
      this.IsOdd = !isEvenSec;

      lastTime = nowTime;
    }
  }
}

' 現在時刻を表すプロパティ
Private _nowTime As DateTimeOffset
Public Property NowTime As DateTimeOffset
  Get
    Return _nowTime
  End Get
  Friend Set(value As DateTimeOffset)
    If (_nowTime = value) Then
      Return
    End If
    _nowTime = value
    RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs("NowTime"))
  End Set
End Property

' 秒が偶数のとき True
Private _isEven As Boolean
Public Property IsEven As Boolean
  Get
    Return _isEven
  End Get
  Friend Set(value As Boolean)
    If (_isEven = value) Then
      Return
    End If
    _isEven = value
    RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs("IsEven"))
  End Set
End Property

' 秒が奇数のとき True
Private _isOdd As Boolean
Public Property IsOdd As Boolean
  Get
    Return _isOdd
  End Get
  Friend Set(value As Boolean)
    If (_isOdd = value) Then
      Return
    End If
    _isOdd = value
    RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs("IsOdd"))
  End Set
End Property

…… 省略 ……

Private Async Sub Run()
  Dim lastTime As DateTimeOffset
  While (True)
    Await Task.Delay(10)
    Dim nowTime = DateTimeOffset.Now
    If (lastTime.Second <> nowTime.Second) Then
      Me.NowTime = nowTime

      Dim isEvenSec As Boolean = (nowTime.Second Mod 2 = 0)
      Me.IsEven = isEvenSec
      Me.IsOdd = Not isEvenSec

      lastTime = nowTime
    End If
  End While
End Sub

PropertyChangedイベントを発火させるコードをsetter内に移した(上:C#、下:VB)

 上のコードを見ると、setter内の記述はほとんど同じである。このような冗長なコーディングを毎回行うのは苦痛以外の何物でもないだろう。そこで以下では、Clockクラスをリファクタリングして、クリーンなコードにしていくことにする。

Clockクラスをリファクタリングする

 まず、PropertyChangedイベントを発火させるコードを、OnPropertyChangedメソッドに切り出してみよう。次のコードのようになる。なお、切り出したついでに、C#のコードでめったに起きるわけではないバグの修正も行った(コメント参照)。

private void OnPropertyChanged(string propertyName)
{
  var eventHandler = this.PropertyChanged;
  if (eventHandler != null)
  {
    // if文での判定後、次の文の実行前にイベント・ハンドラを切り離されても、
    //    eventHandler変数に保持してあれば大丈夫
    eventHandler(this, new PropertyChangedEventArgs(propertyName));
  }
}


// 現在時刻を表すプロパティ
private DateTimeOffset _nowTime;
public DateTimeOffset NowTime
{
  get
  {
    return _nowTime;
  }
  internal set
  {
    if (_nowTime == value)
      return;

    _nowTime = value;
    OnPropertyChanged("NowTime");
  }
}

…… 省略(残り2つのプロパティも同様に) ……

Private Sub OnPropertyChanged(propertyName As String)
  RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs(propertyName))
End Sub

' 現在時刻を表すプロパティ
Private _nowTime As DateTimeOffset
Public Property NowTime As DateTimeOffset
  Get
    Return _nowTime
  End Get
  Friend Set(value As DateTimeOffset)
    If (_nowTime = value) Then
      Return
    End If
    _nowTime = value
    OnPropertyChanged("NowTime")
  End Set
End Property

…… 省略(残り2つのプロパティも同様に) ……

PropertyChangedイベントを発火させるコードを、OnPropertyChangedメソッドに切り出した(上:C#、下:VB)

 次に、setterの中身を全部切り出してSetPropertyメソッドにしてみよう。まずNowTimeプロパティのsetterからコードを切り出すと次のようになる。

private void SetProperty(ref DateTimeOffset storage,
                         DateTimeOffset value, string propertyName)
{
  if (object.Equals(storage, value))
    return;

  storage = value;
  OnPropertyChanged(propertyName);
}

Private Sub SetProperty(ByRef storage As DateTimeOffset, _
                        value As DateTimeOffset, propertyName As String)
  If Object.Equals(storage, value) Then
    Return
  End If
  storage = value
  OnPropertyChanged(propertyName)
End Sub

NowTimeプロパティのsetterをSetPropertyメソッドに切り出した(上:C#、下:VB)
なお、ジェネリックにすることを見越して、if文でobject.Equalsメソッドを使っている。

 このSetPropertyメソッドは、ジェネリックを使って次のコードのように書き直せば、他のプロパティからも使えるようになる。これで似たコードを何度も記述する手間がかなり削減されるはずだ。

private void SetProperty<T>(ref T storage, T value, string propertyName)
{
  if (object.Equals(storage, value))
    return;

  storage = value;
  OnPropertyChanged(propertyName);
}

// 現在時刻を表すプロパティ
private DateTimeOffset _nowTime;
public DateTimeOffset NowTime
{
  get
  {
    return _nowTime;
  }
  internal set
  {
    SetProperty(ref _nowTime, value, "NowTime");
  }
}

…… 省略(残り2つのプロパティも同様に) ……

Private Sub SetProperty(Of T)(ByRef storage As T, value As T,
                                  propertyName As String)
  If Object.Equals(storage, value) Then
    Return
  End If
  storage = value
  OnPropertyChanged(propertyName)
End Sub

' 現在時刻を表すプロパティ
Private _nowTime As DateTimeOffset
Public Property NowTime As DateTimeOffset
  Get
    Return _nowTime
  End Get
  Friend Set(value As DateTimeOffset)
    SetProperty(_nowTime, value, "NowTime")
  End Set
End Property

…… 省略(残り2つのプロパティも同様に) ……

ジェネリックを使ってSetPropertyメソッドを書き直した(上:C#、下:VB)

 これでsetterの記述は1行だけになった。ただし、引数にプロパティ名を渡すところは、書き間違えてしまうことがありそうだ。そこで、CallerMemberName属性(System.Runtime.CompilerServices名前空間)を使うと呼び出し元のメンバ名が取得できるので、これを使うことで、プロパティ名を引数として渡さなくて済むようになる(次のコード)。なお、CallerMemberName属性を使うには既定値の指定が必要なので、ここではnull/Nothingとした。

private void SetProperty<T>(ref T storage, T value,
                            [CallerMemberName] string propertyName = null)
{
  if (object.Equals(storage, value))
    return;

  storage = value;
  OnPropertyChanged(propertyName);
}

// 現在時刻を表すプロパティ
private DateTimeOffset _nowTime;
public DateTimeOffset NowTime
{
  get { return _nowTime; }
  internal set { SetProperty(ref _nowTime, value); }
}

…… 省略(残り2つのプロパティも同様に) ……

Private Sub SetProperty(Of T)(ByRef storage As T, value As T,
                 <CallerMemberName> Optional propertyName As String = Nothing)
  If Object.Equals(storage, value) Then
    Return
  End If
  storage = value
  OnPropertyChanged(propertyName)
End Sub

' 現在時刻を表すプロパティ
Private _nowTime As DateTimeOffset
Public Property NowTime As DateTimeOffset
  Get
    Return _nowTime
  End Get
  Private Set(value As DateTimeOffset)
    SetProperty(_nowTime, value)
  End Set
End Property

…… 省略(残り2つのプロパティも同様に) ……

CallerMemberName属性を使って、メソッド名の引数をなくした(上:C#、下:VB)

 これでリファクタリングは完了だ。さらにカスタム属性を作ってsetterの記述を簡略化することも可能ではあろうが、そうすると呼び出しのオーバーヘッドが増えてしまうので、ここまででとどめておく。

BindableBaseクラスを使う

 これから作るデータ・クラスにも上記のリファクタリングの結果を利用するならば、次のメンバを切り出して継承元となるクラス(=親クラス)を作っておくとよい。

// PropertyChangedイベント・ハンドラ
public event PropertyChangedEventHandler PropertyChanged;

// SetPropertyメソッド
protected void SetProperty<T>(ref T storage, T value,
                  [CallerMemberName] string propertyName = null)
{
  …… 省略 ……
}

// OnPropertyChanged メソッド
protected void OnPropertyChanged(string propertyName)
{
  …… 省略 ……
}

' PropertyChangedイベント・ハンドラ
Public Event PropertyChanged(sender As Object, e As PropertyChangedEventArgs) _
                  Implements INotifyPropertyChanged.PropertyChanged

' SetPropertyメソッド
Protected Sub SetProperty(Of T)(ByRef storage As T, value As T,
                  <CallerMemberName> Optional propertyName As String = Nothing)
  …… 省略 ……
End Sub

' OnPropertyChanged メソッド
Protected Sub OnPropertyChanged(propertyName As String)
  …… 省略 ……
End Sub

データ・クラスを作るための親クラスに切り出すメンバー(上:C#、下:VB)

 しかし実は、まさにそのような用途で使える親クラスがプロジェクト・テンプレートに用意されている。Windowsストア・アプリの「グリッド アプリケーション (XAML)」テンプレートや「分割アプリケーション (XAML)」テンプレートのCommonフォルダに入っている「BindableBase」クラスがそれだ*1。WP 8のプロジェクト・テンプレートには入っていないが、Windowsストア・アプリのものを簡単に移植して利用できる。

*1 BindableBaseクラスが含まれていないプロジェクト・テンプレートを使うときは、前回で説明したように、そのプロジェクトに(新しいプロジェクト項目として)[基本ページ]を追加することでCommonフォルダに自動生成される。
  なお、BindableBaseクラスでは、SetPropertyメソッドがvoidではなくbool/Boolean型の値を返すようになっているが、基本的に同じである(プロパティに変化がありOnPropertyChangedが呼び出されればtrue/True、そうでなければfalse/Falseが返される)。


 前述のリファクタリングが完了したClockクラスを、BindableBaseクラスを継承するように書き直すと次のコードのようになる。

public class Clock : Common.BindableBase
{
  // 現在時刻を表すプロパティ
  private DateTimeOffset _nowTime;
  public DateTimeOffset NowTime
  {
    get { return _nowTime; }
    internal set { SetProperty(ref _nowTime, value); }
  }

  // 秒が偶数のとき true
  private bool _isEven;
  public bool IsEven
  {
    get { return _isEven; }
    internal set { SetProperty(ref _isEven, value); }
  }

  // 秒が奇数のとき true
  private bool _isOdd;
  public bool IsOdd
  {
    get { return _isOdd; }
    internal set { SetProperty(ref _isOdd, value); }
  }


  public Clock()
  {
    Run();
  }

  private async void Run()
  {
    DateTimeOffset lastTime;
    while (true)
    {
      await Task.Delay(10);
      var nowTime = DateTimeOffset.Now;
      if (lastTime.Second != nowTime.Second)
      {
        this.NowTime = nowTime;

        bool isEvenSec = (nowTime.Second % 2 == 0);
        this.IsEven = isEvenSec;
        this.IsOdd = !isEvenSec;

        lastTime = nowTime;
      }
    }
  }
}

Public Class Clock
  Inherits Common.BindableBase

  ' 現在時刻を表すプロパティ
  Private _nowTime As DateTimeOffset
  Public Property NowTime As DateTimeOffset
    Get
      Return _nowTime
    End Get
    Private Set(value As DateTimeOffset)
      SetProperty(_nowTime, value)
    End Set
  End Property

  ' 秒が偶数のとき True
  Private _isEven As Boolean
  Public Property IsEven As Boolean
    Get
      Return _isEven
    End Get
    Private Set(value As Boolean)
      SetProperty(_isEven, value)
    End Set
  End Property

  ' 秒が奇数のとき True
  Private _isOdd As Boolean
  Public Property IsOdd As Boolean
    Get
      Return _isOdd
    End Get
    Private Set(value As Boolean)
      SetProperty(_isOdd, value)
    End Set
  End Property


  Public Sub New()
    Run()
  End Sub

  Private Async Sub Run()
    Dim lastTime As DateTimeOffset
    While (True)
      Await Task.Delay(10)
      Dim nowTime = DateTimeOffset.Now
      If (lastTime.Second <> nowTime.Second) Then
        Me.NowTime = nowTime

        Dim isEvenSec As Boolean = (nowTime.Second Mod 2 = 0)
        Me.IsEven = isEvenSec
        Me.IsOdd = Not isEvenSec

        lastTime = nowTime
      End If
    End While
  End Sub
End Class

BindableBaseクラスを継承して書き直したClockクラス(上:C#、下:VB)

 冒頭に載せたリファクタリング前のClockクラスと見比べてほしい。ずいぶんと簡潔な記述になったことが分かるだろう。

まとめ

 データ・バインドに使うデータのクラスは、BindableBaseクラスを継承して作成するとよい。本稿で示したリファクタリングの成果をまとめたものが、BindableBaseクラスなのだ。なお、WP8 のプロジェクトには含まれていないが、Windowsストア・アプリのものを移植すればよい。

「WinRT/Metro TIPS」のインデックス

WinRT/Metro TIPS

Copyright© Digital Advantage Corp. All Rights Reserved.

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。