July 2016
Volume 31 Number 7
データ バインディング - .NET でデータ バインディングを適切に実装する方法
データ バインディングは UI を開発するための優れた手法です。 ビュー ロジックとビジネス ロジックを簡単に分離でき、生成後のコードのテストが容易になります。データ バインディングは Microsoft .NET Framework に当初から含まれていました。注目され始めたのは、Windows Presentation Foundation (WPF) と XAML が登場した頃です。データ バインディングは、モデル - ビュー ビューモデル (MVVM: Model-View-ViewModel) パターンでビューとビューモデルを「結び付ける」役割を担います。
データ バインディングを実装する場合の問題点は、常に、鍵になる文字列と定型コードが必要になるところです。どちらも、変更をプロパティに反映し、UI 要素をプロパティに結び付ける目的で使用されます。長い年月を経て、さまざまなツールキットやテクニックが登場し、実装の手間は少なくなってきています。ですが、今回は、このプロセスをさらに簡略化することを目指します。
まず、データ バインディングの実装の基礎と、実装を簡略化する一般的なテクニックを確認します (既によくご存じの方は、読み飛ばしてください)。次に、見逃しがちなテクニック (「第 3 の方法」) を今回考案し、MVVM を使用してアプリケーションを開発する場合に遭遇する設計関連の問題に対するソリューションを紹介します。今回開発するフレームワークの完成版は、付属のコード サンプルから入手するか、SolSoft.DataBinding NuGet パッケージをプロジェクトに追加してください。
基礎 : INotifyPropertyChanged
オブジェクトを UI にバインドできるようにするには、INotifyPropertChanged を実装するのがお勧めです。INotifyPropertChanged は、PropertyChanged イベントを 1 つだけメンバーとして含む非常に簡単なインターフェイスです。このインターフェイスを実装するオブジェクトのバインド可能なプロパティが変更されると PropertyChanged イベントが発生します。これにより、プロパティ値に関連する表示を更新する必要があることをビューに通知します。
インターフェイスはシンプルですが、実装はシンプルではありません。イベントを発生するプロパティの名前をテキスト形式で手動でハードコードする方法は、スケール変換する場合もリファクタリングする場合も簡単には対応できません。 テキスト形式の名前とプロパティの名前を確実に同期するコードが必要になります。これではメンテナンスが大変です。以下に例を示します。
public int UnreadItemCount
{
get
{
return m_unreadItemCount;
}
set
{
m_unreadItemCount = value;
OnNotifyPropertyChanged(
new PropertyChangedEventArgs("UnreadItemCount")); // Yuck
}
}
このような状況を受け、適切に対応することを目的に考案されたテクニックがいくつかあります (たとえば、bit.ly/24ZQ7CY (英語) の Stack Overflow に関する質問をご覧ください)。こうしたテクニックの大半は、以下の 2 種類のいずれかに分類されます。
一般的なテクニック 1: 基底クラス
この状況を単純化する 1 つの方法として、一部の定型ロジックを再利用するために、基底クラスを使用します。こうすることで、プロパティ名をハード コードするのではなく、プログラムでプロパティ名を取得できるようにもなります。
式によるプロパティ名の取得: .NET Framework 3.5 では式が導入されました。この式により、コードの構造を実行時に検査できるようになります。LINQ は、この API を使用して優れた効果を生み出しています。たとえば、.NET LINQ クエリを SQL ステートメントに変換できます。企業開発者は、この API を利用してプロパティ名を検査することもできます。基底クラスを使用してこの検査を行う場合、上記のセッターを次のように書き換えることができます。
public int UnreadItemCount
...
set
{
m_unreadItemCount = value;
RaiseNotifyPropertyChanged(() => UnreadItemCount);
}
その結果、UnreadItemCount の名前を変更すると、式参照の名前も変わります。したがって、何も変えずにコードはそのまま機能します。RaiseNotifyPropertyChanged のシグネチャは次のようになります。
void RaiseNotifyPropertyChanged<T>(Expression<Func<T>> memberExpression)
memberExpression からプロパティ名を取得するには、さまざまなテクニックがあります。bit.ly/25baMHM (英語) の C# に関する MSDN ブログでは次のような簡単な例を紹介しています。
public static string GetName<T>(Expression<Func<T>> e)
{
var member = (MemberExpression)e.Body;
return member.Member.Name;
}
StackOverflow では、bit.ly/23Xczu2 (英語) でもっと詳しいリストを提示しています。ただし、いずれの場合も問題があります。 つまり、式の名前を取得する際にリフレクションを使用するため低速になります。プロパティの変更通知の数によってはこのパフォーマンスのオーバーヘッドが深刻になる可能性があります。
CallerMemberName によるプロパティ名の取得: C# 5.0 と .NET Framework 4.5 では、プロパティ名を取得する方法がさらに追加され、CallerMemberName 属性を使用できるようになっています (旧バージョンの .NET Framework でも、Microsoft.Bcl NuGet パッケージを利用すればこの属性を使用できます)。この場合、コンパイラがすべての作業を行うため、実行時にオーバーヘッドはかかりません。このアプローチにより、メソッドは以下のようになります。
void RaiseNotifyPropertyChanged<T>([CallerMemberName] string propertyName = "")
And the call to it is:
public int UnreadItemCount
...
set
{
m_unreadItemCount = value;
RaiseNotifyPropertyChanged();
}
この属性は、呼び出し元の名前 UnreadItemCount を、オプション パラメーター propertyName の値として入力するようコンパイラに指示します。
nameof によるプロパティ名の取得: CallerMemberName 属性は、今回のユース ケース (基底クラスでの PropertyChanged の発生) にはおそらく最適ですが、C# 6 では、さらに広い範囲に効果がある方法がコンパイラ チームによって提供されています。それが nameof キーワードです。nameof はさまざまな目的に有効です。今回は、式ベースのコードを nameof に置き換えれば、前と同じようにコンパイラがすべての作業を行います (実行時にオーバーヘッドがかかりません)。ただし、厳密にはコンパイラ版の機能で、.NET 版の機能ではありません。 このテクニックも依然として .NET Framework 2.0 をターゲットにできます。ただし、開発者 (とチーム メンバー全員) が Visual Studio 2015 以降を使用していることが条件です。nameof を使用すると、以下のようになります。
public int UnreadItemCount
...
set
{
m_unreadItemCount = value;
RaiseNotifyPropertyChanged(nameof(UnreadItemCount));
}
ただし、基底クラスの手法を使用する場合の一般的な問題は生じます。 つまり、俗に言う「基底クラスの焼き付き」が起こります。ビュー モデルを別のクラスに拡張しようとしても、その見込みはありません。プロパティの「依存関係」も処理されません (たとえば、FirstName と LastName を連結する FullName プロパティがあるとします。 このような場合、FirstName または LastName が変更されたら、FullName の変更もトリガーする必要があります)。
一般的なテクニック 2: アスペクト指向プログラミング
アスペクト指向プログラミング (AOP) とは、基本的には、実行時またはコンパイル後の手順として、コンパイル済みコードを「後処理」し、特定の動作 (「アスペクト」) を追加するテクニックです。通常、ログイン処理や例外処理など (いわゆる「横断的な懸念事項」)、反復的な定型コードを置き換えることが目的です。当然、INotifyPropertyChanged の実装もお勧めです。
このアプローチに利用できるツールキットがいくつかあります。PostSharp もその 1 つです (bit.ly/1Xmq4n2、英語)。このツールキットで依存関係プロパティ (たとえば、前述の FullName プロパティ) が適切に処理されるとわかったのは嬉しい驚きでした。「Fody」というオープン ソース フレームワークもあります (bit.ly/1wXR2VA、英語)。
これは魅力的なアプローチで、問題点はあるにせよ、重大なものではありません。実行時の動作がインターセプトされる実装もあります。その場合は、パフォーマンスが低下します。コンパイル後対応のフレームワークでは、実行時のオーバーヘッドが生じません。ただし、なんらかの実装や構成が必要になります。PostSharp は現在 Visual Studio の拡張機能として提供されています。無償の "Express" エディションでは、INotifyPropertyChanged アスペクトの使用が最大 10 クラスに制御されるため、金銭的コストが生じる可能性は高くなります。一方、Fody は無償の NuGet パッケージです。この点では魅力的な選択肢のように思えます。いずれにせよ、任意の AOP フレームワークを使用する場合は、作成したコードがそのまま実行されたり、作成したコードをそのままデバッグできるわけではない点に注意が必要です。
第 3 の方法
これに対処する別の方法として、オブジェクト指向の設計を利用する方法があります。 つまり、イベントの発生をプロパティ自体に任せます。 特に斬新な考え方ではありませんが、これまでこの方法を見たことはありません。最も基本的な形式は、以下のようになります。
public class NotifyProperty<T>
{
public NotifyProperty(INotifyPropertyChanged owner, string name, T initialValue);
public string Name { get; }
public T Value { get; }
public void SetValue(T newValue);
}
考え方としては、プロパティにプロパティの名前とその所有者への参照を渡し、このプロパティ自体が PropertyChanged イベントを発生できるようにするというものです。以下に例をしまします。
public void SetValue(T newValue)
{
if(newValue != m_value)
{
m_value = newValue;
m_owner.PropertyChanged(m_owner, new PropertyChangedEventArgs(Name));
}
}
問題は、これが実際には機能しないことです。 このように別のクラスからイベントを発生させることはできません。PropertyChanged イベントを発生させるには、所有元クラスとの間でなんらかのコントラクトが必要です。そこでインターフェイスの登場です。以下のインターフェイスを作成します。
public interface IRaisePropertyChanged
{
void RaisePropertyChanged(string propertyName)
}
このインターフェイスを用意したら、実際に NotifyProperty.SetValue を実装します。
public void SetValue(T newValue)
{
if(newValue != m_value)
{
m_value = newValue;
m_owner.RaisePropertyChanged(this.Name);
}
}
IRaisePropertyChanged の実装: プロパティの所有者がインターフェイスを実装する必要があるため、各ビューモデル クラスになんらかの定型コードが必要になります (図 1 参照)。最初のパートは、INotifyPropertyChanged を実装するすべてのクラスに必須です。パート 2 は新しい IRaisePropertyChanged に固有です。RaisePropertyChanged メソッドには汎用性を求めないので、明示的に実装しています。
図 1 IRaisePropertyChanged の実装に必要なコード
// PART 1: required for any class that implements INotifyPropertyChanged
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(PropertyChangedEventArgs args)
{
// In C# 6, you can use PropertyChanged?.Invoke.
// Otherwise I'd suggest an extension method.
var toRaise = PropertyChanged;
if (toRaise != null)
toRaise(this, args);
}
// PART 2: IRaisePropertyChanged-specific
protected virtual void RaisePropertyChanged(string propertyName)
{
OnPropertyChanged(new PropertyChangedEventArgs(propertyName));
}
// This method is only really for the sake of the interface,
// not for general usage, so I implement it explicitly.
void IRaisePropertyChanged.RaisePropertyChanged(string propertyName)
{
this.RaisePropertyChanged(propertyName);
}
この定型コードを基底クラスに置いて拡張してもかまいませんが、それでは前の説明に戻ってしまいます。結局、CallerMemberName を RaisePropertyChanged メソッドに適用するのであれば、基本的には最初のテクニックに戻るだけです。では、ポイントは何でしょう。 どちらの場合も、基底クラスから派生できなければ、他のクラスに定型コードをコピーするだけです。
基底クラスを使うテクニックと大きく異なるのは、こちらの場合、定型コード内に実際のロジックが存在しないことです。ロジックはすべて NotifyProperty クラスにカプセル化されています。イベントを発生する前にプロパティ値が変わっているかどうかをチェックするロジックは単純ですが、そうだとしてもコピーしない方が適切です。別の IEqualityComparer を使用してチェックすると、何が起こるでしょう。このモデルでは、NotifyProperty クラスのみを変更する必要があります。同じ IRaisePropertyChanged 定型コードを使用する複数のクラスがある場合も、各実装はコード自体を変更しないで、NotifyProperty を変更するメリットが得られます。導入する動作の変化がどのようなものであっても、IRaisePropertyChanged コードを変更する必要はほとんどありません。
テクニックの全容: これで、ビュー モデルが実装する必要のあるインターフェイスと、データ バインドされるプロパティに使用する NotifyProperty クラスが用意されました。最後の手順は、NotifyProperty の構築です。そのためには、何らかの形でプロパティ名を渡す必要があります。C# 6 を使用していれば、nameof 演算子を使って簡単に渡すことができます。使用していない場合は、拡張メソッドを使用するなど、式の助けを借りて NotifyProperty を作成します (残念ながら CallerMemberName はこのテクニックには役立ちません)。
public static NotifyProperty<T> CreateNotifyProperty<T>(
this IRaisePropertyChanged owner,
Expression<Func<T>> nameExpression, T initialValue)
{
return new NotifyProperty<T>(owner,
ObjectNamingExtensions.GetName(nameExpression),
initialValue);
}
// Listing of GetName provided earlier
このアプローチでは、まだリフレクションのコストがかかります。ただし、プロパティを変更するたびにかかるわけではなく、オブジェクトの作成時に限定されます。それでもコストが大きすぎると感じる (大量のオブジェクトを作成する) 場合は、GetName の呼び出しを常にキャッシュし、静的な読み取り専用値としてビュー モデル クラスに保持できます。C# 6 でもそれ以外でも使えるシンプルなビューモデルを 図 2 に示しています。
図 2 NotifyProperty を備えた基本ビューモデル
public class LogInViewModel : IRaisePropertyChanged
{
public LogInViewModel()
{
// C# 6
this.m_userNameProperty = new NotifyProperty<string>(
this, nameof(UserName), null);
// Extension method using expressions
this.m_userNameProperty = this.CreateNotifyProperty(() => UserName, null);
}
private readonly NotifyProperty<string> m_userNameProperty;
public string UserName
{
get
{
return m_userNameProperty.Value;
}
set
{
m_userNameProperty.SetValue(value);
}
}
// Plus the IRaisePropertyChanged code in Figure 1 (otherwise, use a base class)
}
バインディングと名前の変更: ここでは名前について考えながら、データ バインディングの別の問題点も取り上げます。文字列をハードコードしないで PropertyChanged イベントを発生できても、リファクタリングの問題はまだ解決していません。データ バインディング自体にも問題があります。バインドに使用しているプロパティの名前を XAML で変更しても、成功は保証されません (例、bit.ly/1WCWE5m、英語)。
そこで、分離コード ファイルでデータ バインディングを手動でコーディングします。以下に例を示します。
// Constructor
public LogInDialog()
{
InitializeComponent();
LogInViewModel forNaming = null;
m_textBoxUserName.SetBinding(TextBox.TextProperty,
ObjectNamingExtensions.GetName(() => forNaming.UserName);
// Or with C# 6, just nameof(LogInViewModel.UserName)
}
式の機能を利用するためだけに null オブジェクトを使うのはちょっと変わっていますが、機能します (nameof にアクセスできる場合は null オブジェクトは必要ありません)。
このテクニックは非常に便利ですが、メリットもデメリットもあります。メリットは、UserName プロパティの名前を変更すれば、リファクタリングが機能することに確信を持てることです。もう 1 つの大きなメリットは、[すべての参照の検索] が期待通りに機能することです。
デメリットは、XAML でバインドを行うほど簡単かつ自然なものには必ずしもならないことと、UI 設計の「独立性」を維持できなくなることです。 たとえば、コードを変更しないで Blend ツールから外観の設計だけを変えることはできません。また、データ テンプレートに対して機能しません。データ テンプレートをカスタム コントロールに取り出すことはできますが、それには多くの手間がかかります。
全体としては、「データ モデル」側の変更には柔軟性が生まれますが、「ビュー」側の柔軟性が犠牲になります。総合的に見て、これらのメリットがこのテクニックを使ってバインディングを宣言する正当な理由になるかどうかは、開発者しだいです。
「派生」プロパティ
PropertyChanged イベントを発生しても特に面倒な処理が残るシナリオ、つまりプロパティの値が他のプロパティに依存しているシナリオについて先ほど説明しました。FirstName と LastName と依存関係がある FullName プロパティを簡単な例として挙げました。このシナリオを実装する場合の目標は、このような基底 NotifyProperty オブジェクト (FirstName と LastName) と、ある特定の関数を受け取るようにすることです。この関数が、基底 NotifyProperty オブジェクトの派生値を計算 (例、FirstName.Value + " " + LastName.Value) し、結果の派生値を使用して残りを自動的に処理します。そのためには、先ほどの NotifyProperty にいくつか調整を加えます。
まず、NotifyProperty で別途 ValueChanged イベントを公開します。派生プロパティは、基になるプロパティのこのイベントをリッスンし、新しい値を計算することで応答します (さらに、適切な PropertyChanged イベントを発生します)。もう 1 つ、インターフェイス IProperty<T> を抽出し、NotifyProperty の機能全般をカプセル化します。これにより、他の派生プロパティから派生したプロパティを利用できるようになります。結果として得られるインターフェイスは単純で、以下のようになります (NotifyProperty の対応する変更は非常に簡単なので、ここでは示してません)。
public interface IProperty<TValue>
{
string Name { get; }
event EventHandler<ValueChangedEventArgs> ValueChanged;
TValue Value { get; }
}
DerivedNotifyProperty クラスの作成は簡単なように思えます。ただし、それも各要素をまとめようとするまでのことです。基本的な考え方は、基になるプロパティと、そのプロパティからなんらかの値を計算する関数を受け取るというものですが、すぐにジェネリックに起因する問題が起こります。以下のようにプロパティの多様な型に対応する実用的な方法はありません。
// Attempted constructor
public DerivedNotifyProperty(IRaisePropertyChanged owner,
string propertyName, IProperty<T1> property1, IProperty<T2> property2,
Func<T1, T2, TDerived> derivedValueFunction)
この問題の前半 (複数のジェネリック型を受け取る) 部分は、静的 Create メソッドを使用することで回避できます。
static DerivedNotifyProperty<TDerived> CreateDerivedNotifyProperty
<T1, T2, TDerived>(this IRaisePropertyChanged owner,
string propertyName, IProperty<T1> property1, IProperty<T2> property2,
Func<T1, T2, TDerived> derivedValueFunction)
ですが、派生プロパティでは、まだ各基底プロパティの ValueChanged イベントをリッスンする必要があります。これを解決するには 2 つの手順が必要です。まず、ValueChanged イベントを別のインターフェイスに抽出します。
public interface INotifyValueChanged // No generic type!
{
event EventHandler<ValueChangedEventArgs> ValueChanged;
}
public interface IProperty<TValue> : INotifyValueChanged
{
string Name { get; }
TValue Value { get; }
}
これにより、DerivedNotifyProperty で、ジェネリック IProperty<T> ではなく、非ジェネリック INotifyValueChanged を受け取ることができるようになります。次に、ジェネリックを使用しないで、新しい値を計算する必要があります。 元の derivedValueFunction は 2 つのジェネリック パラメーターを受け取りますが、この derivedValueFunction を受け取って、そこからパラメーターを必要としない新しい匿名関数を作成します。つまり、この匿名関数が、渡される 2 つのプロパティの値を代わりに参照します。言い換えると、クロージャを作成することになります。この処理は以下のコードで確認できます。
static DerivedNotifyProperty<TDerived> CreateDerivedNotifyProperty
<T1, T2, TDerived>(this IRaisePropertyChanged owner,
string propertyName, IProperty<T1> property1, IProperty<T2> property2,
Func<T1, T2, TDerived> derivedValueFunction)
{
// Closure
Func<TDerived> newDerivedValueFunction =
() => derivedValueFunction (property1.Value, property2.Value);
return new DerivedNotifyProperty<TValue>(owner, propertyName,
newDerivedValueFunction, property1, property2);
}
この新しい「派生値」関数は、パラメーターを受け取らない Func<TDerived> です。DerivedNotifyProperty では基になるプロパティの型情報が必要なくなったので、さまざまな型のプロパティから問題なく 1 つのクラスを作成できます。
もう 1 つの微妙な問題は、このような派生値関数をどのタイミングで実際に呼び出すかです。明示的に実装するのであれば、基になる各プロパティの ValueChanged イベントをリッスンし、プロパティが変化するたびに毎回関数を呼び出します。ただし、同じ操作で基になるプロパティが複数変化する場合はこれでは不十分です (フォームをクリアする [リセット] ボタンを想像してください)。適切なのは、求めに応じて値を作成 (およびキャッシュ) し、基になるプロパティのいずれかが変化した場合に値を無効化するという考え方です。これを実装するには、Lazy<T> が最適です。
図 3 に、DerivedNotifyProperty クラスの簡略化したリストを示します。このクラスは、リッスンするプロパティを任意の数受け取ります。つまり、基になる 2 つのプロパティの Create メソッドしか示していませんが、基になるプロパティを 1 つ受け取る場合や 3 つ受け取る場合はそれぞれ追加のオーバーロードを作成します。
図 3 DerivedNotifyProperty のコア実装
public class DerivedNotifyProperty<TValue> : IProperty<TValue>
{
private readonly IRaisePropertyChanged m_owner;
private readonly Func<TValue> m_getValueProperty;
public DerivedNotifyProperty(IRaisePropertyChanged owner,
string derivedPropertyName, Func<TValue> getDerivedPropertyValue,
params INotifyValueChanged[] valueChangesToListenFor)
{
this.m_owner = owner;
this.Name = derivedPropertyName;
this.m_getValueProperty = getDerivedPropertyValue;
this.m_value = new Lazy<TValue>(m_getValueProperty);
foreach (INotifyValueChanged valueChangeToListenFor in valueChangesToListenFor)
valueChangeToListenFor.ValueChanged += (sender, e) => RefreshProperty();
}
// Name property and ValueChanged event omitted for brevity
private Lazy<TValue> m_value;
public TValue Value
{
get
{
return m_value.Value;
}
}
public void RefreshProperty()
{
// Ensure we retrieve the value anew the next time it is requested
this.m_value = new Lazy<TValue>(m_getValueProperty);
OnValueChanged(new ValueChangedEventArgs());
m_owner.RaisePropertyChanged(Name);
}
}
基になるプロパティはさまざまな所有者から取得する可能性があります。たとえば、IsAddressValid プロパティを備えた Address ビューモデルがあるとします。また、請求先住所と配送先住所用に 2 つの Address ビューモデルを含む Order ビューモデルもあるとします。IsOrderValid プロパティは親の Order ビューモデルで作成し、この Order ビューモデルで子の Address ビューモデルの IsAddressValid プロパティを結合するのが妥当です。そうすれば、両方の住所が有効な場合にのみ注文を送信できます。これを行うには、Address ビューモデルでブール型の IsAddressValid { get; } と IProperty<bool> IsAddressValidProperty { get; } の両方を公開します。それにより、Order ビューモデルで子の IsAddressValidProperty オブジェクトを参照する DerivedNotifyProperty を作成できるようになります。
DerivedNotifyProperty の有用性
派生プロパティに関して挙げた FullName の例はかなり不自然なものですが、実際のユース ケースをいくつか取り上げ、それらを何らかの設計原則に結び付けます。1 つの例は先ほどの IsValid です。これは、たとえば、フォーム上の [Save] ボタンを無効にする非常にシンプルかつ強力な方法です。このテクニックの使用は UI ビューモデルのコンテキストに限定しなければならないわけではありません。ビジネス オブジェクトの検証にも使用できます。ビジネス オブジェクトには、IRaisePropertyChanged 実装することだけが必要です。
派生プロパティが非常に役立つ 2 つ目の状況は、「ドリルダウン」のシナリオです。簡単な例として、国を選択するコンボ ボックスを考えます。国を選択すると、都市の一覧が設定されます。この場合は SelectedCountry を NotifyProperty にします。GetCitiesForCountry メソッドを用意して、選択されている国が変わったときに自動的に同期を維持する AvailableCities を DerivedNotifyProperty として作成します。
NotifyProperty オブジェクトを使用する 3 つ目の用途は、オブジェクトが「ビジー」状態かどうかを示す場合です。 オブジェクトがビジーと見なされている間、特定の UI 機能を無効にし、必要に応じてユーザーに進行状況のインジケータを表示します。これは一見簡単なシナリオですが、微妙な問題が数多く影響します。
最初の問題は、オブジェクトがビジーかどうかを追跡することです。単純な場合は、ブール型の NotifyProperty を使用して実現できます。ただし、よくあるのは、複数の原因の 1 つによってオブジェクトが「ビジー」状態になる場合です。たとえば、データの複数の領域を、場合によっては並列に読み込んでいる場合です。全体の「ビジー」状態は、各アイテムのいずれかがまだ進行中かどうかによって変わります。これはほとんど派生プロパティの仕事のように思われます。ただし、これは (不可能ではないにしても) 不格好なものになります。 対象が進行中かどうかを追跡するために、起こり得る操作ごとにプロパティが必要になります。それでは大変なので、1 つの IsBusy プロパティを使用して、各操作で以下のようなことを行います。
try
{
IsBusy.SetValue(true);
await LongRunningOperation();
}
finally
{
IsBusy.SetValue(false);
}
これを実現するには、NotifyProperty<bool> を拡張する IsBusyNotifyProperty クラスを作成し、そこで「ビジー数」を管理します。 SetValue(true) でこのビジー数を増やし、SetValue(false) で減らすように SetValue をオーバーライドします。この数が 0 から 1 になるときのみ base.SetValue(true) を呼び出し、1 から 0 になるときのみ base.SetValue(false) を呼び出します。このようにして、未処理の操作を複数開始することで IsBusy が 1 回だけ true になるようにします。その後、すべての操作が完了したときにだけ再び false になります。この実装はコード サンプルで確認できます。
これで対処するのは「ビジー」状態の処理のみです。 「ビジー状態」を進行状況のインジケーターの表示にバインドします。ただし、UI を無効にする場合は、逆の処理も必要になります。「ビジー状態」が true の場合は、「UI 有効」を false にします。
XAML には、IValueConverter という概念があります。これは、値をディスプレイ表記に (またはディスプレイ表記を値に) 変換するものです。よく使われる例が BooleanToVisibilityConverter です。つまり、XAML では、要素の「可視性」はブール型ではなく、列挙値で表します。つまり、要素の可視性をブール型プロパティに直接結び付けることはできません (IsBusy と同様)。つまり、値をバインドして、コンバーターも使用する必要があります。以下に例を示します。
<StackPanel Visibility="{Binding IsBusy,
Converter={StaticResource BooleanToVisibilityConverter}}" />
「UI の有効」と「ビジー状態」は対局の関係になります。そのため、ブール型プロパティを反転する値コンバーターを作成するのが妥当です。このコンバーターは以下のように使用します。
<Grid IsEnabled="{Binding IsBusy,
Converter={StaticResource BooleanToInverseConverter}}" />
実際、DerivedNotifyProperty クラスを作成する前は、これが最も簡単な方法でした。しかし、プロパティを別途作成し、IsBusy を反転するように結び付け、適切な PropertyChanged イベントを発生させるのは非常に面倒でした。これが簡単になります。面倒だと感じるような人為的な障害もなく、IValueConverter を使用する適切な場所を正確に把握できます。
最終的には、ビューの実装方法を問わず (たとえば、WPF や Windows フォーム、コンソール アプリも一種のビューです)、ビューは基になるアプリケーションで行われていることを可視化 (「投影」) するもので、進行中のメカニズムとビジネス ルールの判断には責任を負いません。今回は、IsBusy と IsEnabled が図らずも互いに非常に密接に関連してますが、これは実装の中では些細なことです。UI を無効にすることや、アプリケーションのビジー状態の有無を明確に関連付けることは本質的なものではありません。
現状、これは微妙な領域だと考えられます。また、その実装に値コンバーターを使用することにしても、それに異を唱えるつもりはありません。ただし、別の要素をこの例に加えることにより、はるかに強固な主張を展開できます。ここで、ネットワークにアクセスできなくなった場合に、アプリケーションは UI を無効にする (さらに、状況を示すパネルを表示する) 必要があるとします。この場合、以下の 3 つの状況が生じます。 アプリケーションがビジー状態の場合は UI を無効にします (進行状況パネルも表示します)。アプリケーションからネットワークにアクセスできなくなった場合も、UI を無効にします (「接続できません」パネルも表示します)。3 つ目の状況は、アプリケーションがネットワークに接続され、ビジー状態でもなく、入力を受け取れる状態です。
個々の IsEnabled プロパティを使用しないでこれを実装しようとすると、わかりやすい処理にはなりません。MultiBinding を使用してもかまいませんが、やはり不格好になり、すべての環境でサポートされるわけでもありません。結局、この種の不格好さは、通常もっと優れた方法があることを意味します。もちろんあります。このロジックはビューモデル内で処理するのが適切です。2 つの NotifyProperty として IsBusy と IsDisconnected を公開後、DerivedNotifyProperty として IsEnabled を簡単に作成できます。この IsEnabled は、IsBusy と IsDisconnected がどちらも false の場合に true になります。
IValueConverter を使う方法で UI の有効状態を (それを反転するコンバーターを使用して) IsBusy に直接バインドする場合、非常に多くの作業が必要になります。代わりに、派生 IsEnabled プロパティを個別に公開すれば、この新しいロジックを追加することで作業量が大幅に減ります。また、IsEnabled のバインディング自体は変更する必要すらありません。これは、適切な方法を取っていることを示す良い兆しです。
まとめ
このフレームワークの設計はやや長い道のりになりました。ですがそのおかげで、反復的な定型コードも鍵になる文字列も使わずに、リファクタリングをサポートしたうえ、プロパティの変更通知を実装できるようになりました。今回のビューモデルには、特定の基底クラスにロジックを必要としません。それほど余計な手間をかけずに、適切な変更通知も発生する派生プロパティを作成できます。最後に、今回紹介したコードは運用中のコードです。また、オブジェクト指向設計による非常にシンプルなフレームワークを開発することすべてを実現しています。このフレームワークがプロジェクトの役に立つことを願っています。
Mark Sowul は、最初から熱心な .NET 開発者で、Microsoft .NET Framework と Microsoft SQL Server のアーキテクチャとパフォーマンスに関する豊富な専門知識を、自身が New York で設立したコンサルティング企業の SolSoft Solutions を通じて共有しています。連絡先は mark@solsoftsolutions.com (英語のみ) です。Mark の考え方に興味があり、彼のニュースレターの購読を希望する場合は、eepurl.com/_K7YD (英語) で登録してください。
この記事のレビューに協力してくれた技術スタッフの Francis Cheung (マイクロソフト) および Charles Malm (Zebra Technologies) に心より感謝いたします。
Francis Cheung は、Microsoft Patterns & Practices グループの主任開発者です。Francis は、Prism を含む多様なプロジェクトに携わっています。現在は、Azure 関連のガイダンスに注力しています。
Charles Malm は、ゲーム、.NET、および Web ソフトウェアのエンジニアで、RealmSource LLC の共同設立者です。