Win32APIアプリケーションでデータバインディングしてみる


Win32APIアプリケーションでデータバインディングしてみる

C# + XAML の世界では、MVVMというフレームワークに基いて設計を行うことが当たり前らしいです。けど、データバインディングしようとすると、いちいち通知登録処理を書かなきゃいけなかったりと思ったよりも面倒で、もっと言語環境側でMVVMを手厚く標準サポートしてくれてもいいのになあ、と感じました。

さて、Win32APIアプリケーション、それもMFCでもATL/WTLでもないGUIアプリケーションで、データバインディング的な事をやってる実装例ってあるのかなあ、と気になったわけです。軽く探して見た感じ見つからなかったので、ちょいと試しに作ってみることにしました。

方針

Win32APIでフォームの外観を定義するのはリソースファイル( *.rc ) になるんだけど、もちろんこの中でバインディングするプロパティを指定するような真似はできません。 なのでコントロールID( IDC_* のような定数値) と、何らかの変数をバインディングするようなイメージを目指しました。

たとえば変数iAを整数型と仮定して、

  • エディットボックス IDC_EDIT_A の文字列変更 --> 変数 iA の値を更新
  • 変数 iA に値を代入 --> エディットボックス IDC_EDIT_A 中の文字列に反映

といった処理が出来るだけ少ないコードで実現できるようにしたいわけです。

あと、ボタンにCommandオブジェクト的なもの(有り体に言えばstd::function) をバインディング出来たりすると、なんかMVVMぽくて格好いい気がします。はたしてそれがMVVMなのかどうかは疑問ですが(たぶん違う)。

出来たもの

himo という名前にしました。縛るので。

実装例

この例では、エディットボックスとラベルに1つのstring型変数 (BoundData<string>クラス) がバインドされています。なので、任意のエディットボックスで文字列を編集するとバインドされている全てのエディットボックスとラベルに反映されます。
"Append"ボタンには上記のstringに一文字"1"を追記する関数(BoundCommandクラス)がバインドされています。また非同期実行も可能にしてあり、文字の追加前に待機時間を設けているのですが、その間ボタンコントロールを無効化しています。
"Toggle"ボタンは、あるBoundData<bool>インスタンスの値を反転させるBoundCommandがバインドされています。このBoundData<bool> は各エディットボックスにバインドされており、コントロール有効・無効として反映されるようになっています。

実装コード

変数宣言

以下は himo 名前空間に定義された各種クラス使用時の宣言例です。

// value bound to HWND of window control
static himo::BoundData<HWND, string> bound_string("");
static himo::BoundData<HWND, BOOL> bound_boolean(TRUE);
// command bound to HWND of button mainly
static himo::BoundCommand<HWND> command_append, command_toggle;
// as a facade for window message handler procedure
static himo::Binder<HWND> binder;

コントロールIDは別のウィンドウに行くと通用しないただの整数値なので、かわりにウインドウハンドルHWND型をバインドの対象にしました。(template引数にすることでポインタ、ハンドル、参照型であればいちおう何でもバインドできるので、汎用的使用の可能性をなんとなく残しています。)
BinderBoundData, BoundCommandを登録しておくことで、各コントロールからの通知 WM_COMMAND, WM_NOTIFY の窓口となるクラスです。

バインド操作 (初期化処理内)

以下ではstring型変数を各コントロールにバインドしています。

// Bind string value to show as window text
bound_string.AttachSetter(
    [](HWND h, string str) {
    ::SetWindowText(h, str.c_str());
});
bound_string.AttachGetter(
    [](HWND h) {
    wchar_t buf[256];
    ::GetWindowText(h, buf, sizeof(buf));
    return string(buf);
});
bound_string.AttachComparator(
    [](string a, string b) {
    return (a == b) ? 0 : 1;
});
binder.Bind(&bound_string, ::GetDlgItem(hWnd, IDC_EDIT1));
binder.Bind(&bound_string, ::GetDlgItem(hWnd, IDC_EDIT2));
// ... (以下略)

まず bound_stringにsetter, getter, comparatorという関数オブジェクトをアタッチしています。それぞれ、

  • バインドされた HWND に string の値を反映させる方法
  • HWND から string の値を取得する方法
  • 2つの string を同値と見なす条件(更新不要と判断する基準)

に相当します。
なぜこのようにしたかというと、例えば BoundData が整数型だったとして、文字列として表示する際のフォーマット(基数とか桁数とか単位とか)をそのプロパティの意味するところに応じて変更したりすることが出来るからです。

HWNDとやり取りする方法を与えられた bound_stringbinder によって 各コントロールのHWNDとバインドされます。

次に以下では、BOOL型でコントロールの有効・無効を切り替えるようなバインディングを行います。
WINAPI関数のシグネチャが揃っているため、こちれでは setter, getter のアタッチがよりシンプルに記述できます。

// Bind booleaan value to enable bound controls
bound_boolean.AttachGetter(&::IsWindowEnabled);
bound_boolean.AttachSetter(&::EnableWindow);
bound_boolean.AttachComparator([](BOOL a, BOOL b) { return a ^ b; });
binder.Bind(&bound_boolean, ::GetDlgItem(hWnd, IDC_EDIT1));
binder.Bind(&bound_boolean, ::GetDlgItem(hWnd, IDC_EDIT2));
// ... (以下略)

更に、BoundCommandとボタンコントロールのバインドは以下のようにします。

// lambda formula to cast standartd bool into BOOL
auto func_enwin = [](HWND h, bool en) { ::EnableWindow(h, (BOOL)en); };
// Asynchronous command
command_append.AttachEnabler(func_enwin );
command_append.AttachAction(
    [](HWND) { ::Sleep(1000); bound_string = (string)bound_string + "1"; },
    true /* async */
);
binder.Bind(&command_append, ::GetDlgItem(hWnd, IDC_BUTTON1));
// Synchronous command
command_toggle.AttachEnabler(func_enwin );
command_toggle.AttachAction(
    [](HWND) { bound_boolean = !bound_boolean; }
);
binder.Bind(&command_toggle, ::GetDlgItem(hWnd, IDC_BUTTON2));

まず enabler なる関数オブジェクトがアタッチされていますが、これはコマンドが実行可能・不能になった際にバインドされた HWND に対して行う操作を定義しています。上の例では、ボタンの有効・無効を切り替えているだけです。
アタッチされている action はボタンを押した際(WM_COMMAND受信時)に同期または非同期で実行される処理です。
command_append は「1秒待機後 bound_string に1文字追加」、command_toggle は「bound_booleanの値を反転」という action が指定されています。

メッセージ処理部

ウィンドウプロシージャ関数に以下のような記述を追加することで binder に登録されたバインディング操作が行われます。

case WM_COMMAND:
{
    HWND wndCtrl = (HWND)lParam;
    // Excecute some actions bound to some values or commands
    binder.OnCommand(wndCtrl);
    break;
}
case WM_NOTIFY:
{
    int idCtrl = (int)wParam;
    HWND hwndCtrl = ::GetDlgItem(hWnd, idCtrl);
    // Notify some changes to some values or commands
    binder.OnNotify(hwndCtrl);
    break;
}

実際には WM_COMMAND, WM_NOTIFY ともに対象となるコントロールIDないしHWND以外にも、より詳細なパラメータ情報が付加されているのですが、ここでは無視しています。
「なにがあったか知らないが(単にクリックされただけかも)とりあえず内容を確認して更新があれば通知する」というスタンスを取っています。
また、BoundDataWM_COMMAND, WM_NOTIFY どちらにも反応しますが、BoundCommand は名前の通り WM_COMMAND にしか反応しないようにしてあります。

導入のメリット

  • プロシージャ関数がひじょうにスッキリする
  • プロパティとコントロールの関係が多対多のような状況を管理しやすい
  • 複数のダイアログにまたがってコントロールの値どうしが連動していたりする場合には見通しがよくなるかも

デメリット

  • プロシージャ関数から実際のメッセージ処理が呼び出されるまでの仮定が隠蔽されてデバッグしづらい
  • プロパティとコントロールの関係が常に1対1だとすると単にわかりにくくなるだけかも