バインドするデータのPropertyChangedを楽に実装するには?[Win 8/WP 8]:WinRT/Metro TIPS
データ・バインドは便利で強力だが、バインドするデータにはプロパティごとにPropertyChangedイベントを発火させるコードが必要になり、数が増えると面倒だ。これを楽に実装する方法を考える。
powered by Insider.NET
データ・バインドは便利で強力な仕組みだが、バインドするデータにはプロパティごとに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
バインドするプロパティは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
上のコードを見ると、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つのプロパティも同様に) ……
次に、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
なお、ジェネリックにすることを見越して、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つのプロパティも同様に) ……
これで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つのプロパティも同様に) ……
これでリファクタリングは完了だ。さらにカスタム属性を作って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
しかし実は、まさにそのような用途で使える親クラスがプロジェクト・テンプレートに用意されている。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
冒頭に載せたリファクタリング前のClockクラスと見比べてほしい。ずいぶんと簡潔な記述になったことが分かるだろう。
●まとめ
データ・バインドに使うデータのクラスは、BindableBaseクラスを継承して作成するとよい。本稿で示したリファクタリングの成果をまとめたものが、BindableBaseクラスなのだ。なお、WP8 のプロジェクトには含まれていないが、Windowsストア・アプリのものを移植すればよい。
Copyright© Digital Advantage Corp. All Rights Reserved.