MFCのMDIアプリでWM_MDINEXTが捕捉できない原因と、確実にフックする実装ガイド
「CMainFrame に ON_MESSAGE(WM_MDINEXT, &CMainFrame::OnMdiNext) を書いたのに、Ctrl + Tab で子ウィンドウ(ドキュメント)を切り替えてもハンドラが呼ばれない」――MDI(Multiple Document Interface)でしばしば遭遇する悩みです。本記事では、この現象が起きる根本原因と、最も安全で再利用性の高い対処法を、コード断片とあわせて詳しく解説します。合わせて、タブ付きMDI(Tabbed MDI)利用時の注意点、代替アプローチ(コマンドハンドラ/キー捕捉/MDIメッセージの別ルート)も整理し、実装チェックリストやデバッグの勘所まで網羅します。
結論(最短の答え)
- WM_MDINEXT は MDI クライアント ウィンドウ(
m_hWndMDIClient)に送られるため、CMainFrameには届きません。よってON_MESSAGEで待っても反応しないのは仕様どおりです。 - 確実に受け取るには、MDIクライアントをサブクラス化して処理します。Windows共通コントロールの
SetWindowSubclass/RemoveWindowSubclassを用いるのが安全で後始末も容易です。 - 標準のウィンドウ切替(次へ/前へ、タブ移動など)を壊さないために、最終的に
DefSubclassProcを呼ぶことが重要です。
まずは現象の理解:MDIメッセージの行き先
MDIでは、ユーザー操作(メニューの「次/前のウィンドウ」、Ctrl+Tab、Ctrl+F6 等)により、MDIクライアントが子ウィンドウのアクティブ切替を司ります。このとき投げられるのが WM_MDINEXT であり、宛先はフレームではなく「MDIクライアント」です。MFC であれば CMDIFrameWnd / CMDIFrameWndEx のメンバー m_hWndMDIClient がそれにあたります。
| メッセージ/コマンド | 主なトリガー | 本来の宛先 | フレームで受けられるか | 推奨フック箇所 |
|---|---|---|---|---|
WM_MDINEXT | Ctrl+Tab / Ctrl+F6、メニュー「次/前のウィンドウ」 | MDIクライアント(m_hWndMDIClient) | いいえ | MDIクライアントのサブクラス |
WM_MDIACTIVATE | MDI子のアクティブ変更 | MDI子(アクティブ/非アクティブ) | 間接的(子側) | MDI子(CMDIChildWnd(Ex))側でハンドル |
ID_WINDOW_NEXT / ID_WINDOW_PREV | メニュー/アクセラレータ | コマンドルート(MFCコマンドルーティング) | はい | フレームの ON_COMMAND |
再現コード(うまくいかない例)
次のようなメッセージマップを CMainFrame に書いても、WM_MDINEXT は反応しません。
BEGIN_MESSAGE_MAP(CMainFrame, CMDIFrameWndEx)
ON_MESSAGE(WM_MDINEXT, &CMainFrame::OnMdiNext) // ← 反応しない
END_MESSAGE_MAP()
理由は前述の通り、メッセージの宛先がフレームではないからです。
解決策A:SetWindowSubclass で MDI クライアントをサブクラス化(おすすめ)
Win32のサブクラスAPIを使う方法です。MFC内部のクラス階層に干渉しにくく、後述の RemoveWindowSubclass による解除も確実で、タブ付きMDIを含む近年のMFCでも安定しやすいのが利点です。
1) ヘッダー宣言
// MainFrm.h
#pragma once
#include <commctrl.h> // SetWindowSubclass, RemoveWindowSubclass
class CMainFrame : public CMDIFrameWndEx
{
DECLARE_DYNAMIC(CMainFrame)
public:
static LRESULT CALLBACK MDIClientProc(
HWND hwnd, UINT msg, WPARAM wp, LPARAM lp,
UINT_PTR id, DWORD_PTR ref);
```
// ...(通常の宣言)...
```
protected:
DECLARE_MESSAGE_MAP()
};
2) サブクラスのセットアップ(OnCreate)
// MainFrm.cpp
#pragma comment(lib, "Comctl32.lib") // 忘れがち:リンカに追加
int CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct)
{
if (CMDIFrameWndEx::OnCreate(lpCreateStruct) == -1)
return -1;
```
// ここで MDI クライアントにサブクラスを仕掛ける
// 42 は一意なら何でもよい ID(ジョークで「究極の答え」を使う例)
if (!::SetWindowSubclass(m_hWndMDIClient, &CMainFrame::MDIClientProc,
42, reinterpret_cast<DWORD_PTR>(this)))
{
TRACE(_T("SetWindowSubclass 失敗\n"));
// 必須ではないが、ここで return -1; してもよい(以降の処理を止めたい場合)
}
return 0;
```
}
3) サブクラスプロシージャ本体
LRESULT CALLBACK CMainFrame::MDIClientProc(
HWND hwnd, UINT msg, WPARAM wp, LPARAM lp,
UINT_PTR id, DWORD_PTR ref)
{
// フレーム this を取り出す(必要ならメンバーへアクセス)
CMainFrame* pThis = reinterpret_cast<CMainFrame*>(ref);
```
if (msg == WM_MDINEXT)
{
// lParam==0: 次へ、非0: 前へ
const bool prev = (lp != 0);
TRACE(_T("WM_MDINEXT 受信: %s\n"), prev ? _T("前へ") : _T("次へ"));
// ここに独自処理(ログ、統計、切替抑止、UI更新など)
// 例:最近使った順のスタック更新、ステータスバー表示など
// pThis->UpdateStatusForMdiSwitch(prev);
// 既定動作を維持(ここが重要)
return ::DefSubclassProc(hwnd, msg, wp, lp);
}
else if (msg == WM_NCDESTROY)
{
// 後始末(メモリリーク防止)
::RemoveWindowSubclass(hwnd, &CMainFrame::MDIClientProc, id);
}
return ::DefSubclassProc(hwnd, msg, wp, lp);
```
}
ポイントと注意事項
DefSubclassProcを必ず呼ぶ:呼ばないと標準の次/前切替が無効になったり、MDIタブの挙動が壊れます。- サブクラスID(例の 42)は一意なら何でもOK:重複すると解除できなくなる可能性があります。
RemoveWindowSubclassはWM_NCDESTROYで行うのが定石。アプリ終了や再作成時のリーク/クラッシュを防ぎます。commctrl.hのインクルードと、Comctl32.libのリンカ指定を忘れないでください。- タブ付きMDI(
CMDIFrameWndEx+EnableMDITabs)でも有効:内部的にMDIクライアントを使うため、この方法が最も相性が良いです。
解決策B:MFCの CWnd::SubclassWindow を使う「純MFC版」
Win32 API ではなく MFC のサブクラス化を用いる手もあります。既存の MFC コードスタイルに寄せたい場合に有効です。ただし、Feature Pack 以降のタブ付きMDIやドッキング周りと二重にサブクラスが絡むと干渉するケースがあり、近年は 解決策A を推奨します。
// MDIクライアント専用の軽量ラッパ
class CMyMdiClient : public CWnd
{
public:
BOOL Subclass(HWND hWnd) { return SubclassWindow(hWnd); }
protected:
afx_msg LRESULT OnMdiNext(WPARAM wp, LPARAM lp)
{
const bool prev = (lp != 0);
TRACE(_T("WM_MDINEXT 受信(Pure MFC): %s\n"), prev ? _T("前へ") : _T("次へ"));
// 既定処理:CWnd::Default() で元プロシージャへ
return Default();
}
DECLARE_MESSAGE_MAP()
};
BEGIN_MESSAGE_MAP(CMyMdiClient, CWnd)
ON_MESSAGE(WM_MDINEXT, &CMyMdiClient::OnMdiNext)
END_MESSAGE_MAP()
// MainFrm.h / MainFrm.cpp
CMyMdiClient m_wndMdiClient; // CMainFrame のメンバーに追加
int CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct)
{
if (CMDIFrameWndEx::OnCreate(lpCreateStruct) == -1)
return -1;
if (!m_wndMdiClient.Subclass(m_hWndMDIClient))
{
TRACE(_T("MDIクライアントのサブクラス化に失敗\n"));
}
return 0;
}
この方法でも受信は可能ですが、RemoveじゃなくUnsubclassのタイミング管理や、タブ付きMDIと組み合わさったときの相性に注意が必要です。
代替アプローチ:WM_MDINEXT にこだわらない設計
要件によっては、WM_MDINEXT を直接フックしなくても目的を達せられる場合があります。以下の選択肢も検討してみてください。
1) コマンドハンドラで拾う(実用度◎)
MFC のコマンドルーティングには、MDIの「次/前のウィンドウ」に対応するコマンドIDが用意されています。アクセラレータ(Ctrl+Tab / Ctrl+F6 等)がこれらにマップされていれば、CMainFrame で ON_COMMAND を使って確実に捕捉できます。
BEGIN_MESSAGE_MAP(CMainFrame, CMDIFrameWndEx)
ON_COMMAND(ID_WINDOW_NEXT, &CMainFrame::OnWindowNext)
ON_COMMAND(ID_WINDOW_PREV, &CMainFrame::OnWindowPrev)
END_MESSAGE_MAP()
void CMainFrame::OnWindowNext()
{
// 独自処理(ログ、アナリティクス、UI更新など)
TRACE(_T("ID_WINDOW_NEXT\n"));
// 既定動作へ(必要なら送出)
::SendMessage(m_hWndMDIClient, WM_MDINEXT, 0, 0);
}
void CMainFrame::OnWindowPrev()
{
TRACE(_T("ID_WINDOW_PREV\n"));
::SendMessage(m_hWndMDIClient, WM_MDINEXT, 0, 1);
}
利点は「MFCの既定アクセラレータやメニュー操作を包括的に拾える」こと。アプリに独自のショートカットが存在しても、メニュー/アクセラの定義をこのIDに揃えるだけで収束できます。
2) PreTranslateMessage でキーを監視(要配慮)
Ctrl+Tab などの特定のキー操作だけを契機にしたい場合には、フレームの PreTranslateMessage で軽くフックする選択肢もあります。ただし、「既定の処理を潰さない」ことが最重要です。
BOOL CMainFrame::PreTranslateMessage(MSG* pMsg)
{
if (pMsg->message == WM_KEYDOWN && pMsg->wParam == VK_TAB)
{
if (::GetKeyState(VK_CONTROL) & 0x8000)
{
const bool prev = (::GetKeyState(VK_SHIFT) & 0x8000) != 0;
TRACE(_T("Ctrl+%sTab を検出\n"), prev ? _T("Shift+") : _T(""));
// ここに「検出した」ことに対する副作用の少ない処理だけを書く
// (既定アクションはベースへ流す)
}
}
return CMDIFrameWndEx::PreTranslateMessage(pMsg); // 既定へ委譲
}
この方法は「検出して何か表示する」程度なら有効ですが、強く介入するとアクセラレータ解決やタブ切替のUXを壊す恐れがあります。
よくある誤解と落とし穴
- 「
ON_MESSAGE(WM_MDINEXT, ...)をフレームに書けば届くはず」:いいえ。WM_MDINEXT の宛先は MDICLIENT で、フレームとは別のウィンドウです。 - 「タブ付きMDIだと WM_MDINEXT は使われない?」:内部実装はMFCのバージョンや設定により異なりますが、MDIクライアントの存在は不変で、サブクラス化アプローチ(解決策A)は概ね安定して動作します。
- 「
CMDIChildWnd(Ex)のWM_MDIACTIVATEで十分?」:WM_MDIACTIVATEは「どの子がアクティブになったか」を知るには最適ですが、「次/前の指示そのもの」を捕捉したい(切替前に介入したい)用途には合いません。 - 「
SetWindowLongPtrの古典的サブクラスでよい?」:使えますが、SetWindowSubclassは多重サブクラスや解除の安全性、再入を考慮した仕組みがあり、近年はこちらを推奨します。
WM_MDINEXT の仕様メモ(実装時に役立つ豆知識)
WPARAM:基準となる MDI 子ウィンドウのハンドル。通常はNULL(現在のアクティブ子が基準)。LPARAM:0なら「次へ」、非0なら「前へ」。- 「前へ」は
Ctrl+Shift+Tabなどで送られがち。Ctrl+F6/Ctrl+Shift+F6も同種。
実運用での活用アイデア
- 最近使用順(MRU)切替の実装:
WM_MDINEXTやWM_MDIACTIVATEをトリガーに、独自のMRUスタックを更新。Ctrl+Tabで VS風の「最近使った順」切替を実現。 - 切替抑止/フィルタ:ドキュメントの編集中は「未保存ならスキップ」などの条件分岐を挟む。標準動作を阻害しないよう、処理の最後に既定へ委譲。
- 監査ログ/テレメトリ:ユーザーの画面遷移を匿名統計として記録し、UI改善に活かす。
導入から確認まで:チェックリスト
- ビルド設定:
<commctrl.h>をインクルードし、Comctl32.libをリンカへ追加。 - サブクラス化のタイミング:
CMDIFrameWndEx::OnCreate成功後、m_hWndMDIClientが有効になってからSetWindowSubclass。 - 解除:
WM_NCDESTROYでRemoveWindowSubclass。異常終了や再作成のケースでも漏れない構造に。 - 動作検証:
Ctrl+Tab、Ctrl+Shift+Tab、メニューの「次/前」、Ctrl+F6/Ctrl+Shift+F6の全パスでトレース発火を確認。 - 回帰影響:タブ付きMDIのタブドラッグ/タブグループ分割、ドッキング操作に影響がないかを実機確認。
トラブルシューティング
| 症状 | 原因の可能性 | 対処 |
|---|---|---|
| ハンドラが一切発火しない | サブクラス化が失敗/タイミング早すぎ/m_hWndMDIClient 無効 | OnCreate 内で SetWindowSubclass の戻り値をチェック。CMDIFrameWndEx::OnCreate 成功後に行う。 |
| 切替が効かなくなった | DefSubclassProc を呼んでいない/早期 return | 最後に必ず DefSubclassProc を返す。条件分岐でも抜け道を作らない。 |
| 終了時にクラッシュ | 解除漏れ/二重サブクラス | WM_NCDESTROY での RemoveWindowSubclass を徹底。IDの一意性も確認。 |
| 一部のキー操作で発火しない | アクセラレータが別IDに割り当て/独自処理が先に消費 | ID_WINDOW_NEXT/ID_WINDOW_PREV の併用、PreTranslateMessage での早期消費をやめる。 |
実装テンプレート(そのまま貼れる最小構成)
// MainFrm.h
#pragma once
#include <commctrl.h>
class CMainFrame : public CMDIFrameWndEx
{
DECLARE_DYNAMIC(CMainFrame)
public:
static LRESULT CALLBACK MDIClientProc(
HWND, UINT, WPARAM, LPARAM, UINT_PTR, DWORD_PTR);
protected:
afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct);
DECLARE_MESSAGE_MAP()
};
// MainFrm.cpp
#include "pch.h"
#include "framework.h"
#include "MainFrm.h"
#pragma comment(lib, "Comctl32.lib")
#ifdef _DEBUG
#define new DEBUG_NEW
#endif
IMPLEMENT_DYNAMIC(CMainFrame, CMDIFrameWndEx)
BEGIN_MESSAGE_MAP(CMainFrame, CMDIFrameWndEx)
ON_WM_CREATE()
END_MESSAGE_MAP()
int CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct)
{
if (CMDIFrameWndEx::OnCreate(lpCreateStruct) == -1)
return -1;
```
// MDIクライアントにサブクラスをセット
if (!::SetWindowSubclass(m_hWndMDIClient, &CMainFrame::MDIClientProc,
42, reinterpret_cast<DWORD_PTR>(this)))
{
TRACE(_T("SetWindowSubclass failed\n"));
}
return 0;
```
}
LRESULT CALLBACK CMainFrame::MDIClientProc(
HWND hwnd, UINT msg, WPARAM wp, LPARAM lp,
UINT_PTR id, DWORD_PTR ref)
{
CMainFrame* pThis = reinterpret_cast(ref);
```
if (msg == WM_MDINEXT)
{
const bool prev = (lp != 0);
TRACE(_T("WM_MDINEXT: %s\n"), prev ? _T("Prev") : _T("Next"));
// 任意の処理:例)ステータスバー更新など
// if (pThis) pThis->GetStatusBar().SetPaneText(0, prev ? _T("前へ") : _T("次へ"));
return ::DefSubclassProc(hwnd, msg, wp, lp);
}
else if (msg == WM_NCDESTROY)
{
::RemoveWindowSubclass(hwnd, &CMainFrame::MDIClientProc, id);
}
return ::DefSubclassProc(hwnd, msg, wp, lp);
```
}
「サブクラスIDの 42」について
サンプル中の 42 は単なる一意な整数で、値は何でも構いません。有名なSF小説のジョークにちなむ数字を例示しているだけです。プロダクションでは、重複しない管理方針(enum や名前付き定数)で扱うと安全です。
実装後の検証観点(回帰リスクを未然に)
- ドキュメント数が多い状態(10以上)でループ切替しても不具合が出ない。
- タブグループ(水平/垂直分割)にしても挙動が安定している。
- アクセラレータ定義(Ctrl+Tab、Ctrl+F6 等)を変更してもハンドラが確実に発火。
- マウス操作(タブクリック/ホイール)での切替でも副作用がない。
- アプリ終了時のクラッシュやハングが発生しない(
RemoveWindowSubclass確認)。
応用:切替前に確認ダイアログを出す(安全な介入例)
未保存ドキュメントがある場合にだけ「本当に切り替えますか?」を出したい、といった要件では、WM_MDINEXT の受信時点で 状態を判定して通知だけ行い、既定動作は殺さない 方針がUXを損ねません。どうしても阻止が必要な場面は、該当条件のときだけ DefSubclassProc を呼ばずに 0 を返す、という選択もありますが、タブ付きMDIの内部挙動に影響する恐れがあるため、十分なテストが必須です。
まとめ
WM_MDINEXTをフレームで受けられないのは仕様。MDIクライアントをサブクラス化して捕捉するのが正攻法。- 実装は
SetWindowSubclass/RemoveWindowSubclassを用いた方法が堅牢でおすすめ。必ずDefSubclassProcを呼び、標準動作を維持する。 - 用途によっては
ID_WINDOW_NEXT/ID_WINDOW_PREVのコマンドハンドラや、軽量なキー検出(PreTranslateMessage)も有効。 - 導入後は、全経路の切替操作(ショートカット/メニュー/マウス)で副作用がないか丁寧に検証する。
付録:ショートカットと挙動の早見表
| ショートカット | 既定の意味 | 典型的に送られるもの | 捕捉のおすすめ |
|---|---|---|---|
| Ctrl+Tab | 次のMDI子(タブ)へ | WM_MDINEXT(lParam=0) | MDIクライアントのサブクラス(A) |
| Ctrl+Shift+Tab | 前のMDI子(タブ)へ | WM_MDINEXT(lParam≠0) | MDIクライアントのサブクラス(A) |
| Ctrl+F6 | 次のMDI子へ | WM_MDINEXT or ID_WINDOW_NEXT | サブクラス or コマンド(A/B) |
| Ctrl+Shift+F6 | 前のMDI子へ | WM_MDINEXT or ID_WINDOW_PREV | サブクラス or コマンド(A/B) |
最後に:なぜこの実装が「保守しやすい」のか
MDI周りはフレーム、クライアント、子ウィンドウ、タブコントロール、ドッキングマネージャなど複数のレイヤーが絡みます。将来のMFCアップデートや設定変更(タブ有無、アクセラレータの再割り当て)にも耐える設計の鍵は、メッセージが本来届くウィンドウで受けることと、既定動作を壊さないこと。ここで示したサブクラス化パターンは、その2点を満たす素直な実装であり、長期運用でも破綻しづらいのが最大のメリットです。
実装要点のクイックリファレンス
- 受信先:
WM_MDINEXTはm_hWndMDIClient(MDIクライアント)。 - 推奨実装:
SetWindowSubclass(OnCreate後に設定、WM_NCDESTROYで解除)。 - 互換性:タブ付きMDI/従来MDIどちらでもOK。
- 代替:
ID_WINDOW_NEXT/ID_WINDOW_PREVのON_COMMAND、または軽量なキー監視。 - 定数 42:単なる一意ID。値は任意で良い。
備考:本記事のサンプルは CMDIFrameWndEx をベースに記述していますが、CMDIFrameWnd でも同様の考え方で実装できます。プロジェクトのMFCバージョンやタブ設定(EnableMDITabs 等)に応じて、検証範囲を少し広めに取ると安心です。
回答・解決策の要約
- メッセージが届くウィンドウが違う:
WM_MDINEXTは MDI クライアントに送られるため、CMainFrameのON_MESSAGEでは受け取れない。 - MDI クライアントをサブクラス化:
SetWindowSubclassを使い、WM_MDINEXTを確実に受信。処理の最後にDefSubclassProc。 ON_MESSAGE(WM_MDINEXT, ...)は不要: MDIクライアントで受け止める設計に切り替える。- サブクラスID(例の 42): 任意の一意な整数。ジョークに由来する例示に過ぎない。
- 補足: 標準の切替動作を残すには
DefSubclassProcを必ず呼ぶ。OnCreate完了後にサブクラス化し、全経路での発火を確認。
以上で、「WM_MDINEXT を捕捉できない」問題の原因と、壊れにくい実装パターンを整理できました。既存プロジェクトに段階的に導入し、テストしながら安定運用へ移行していきましょう。

コメント