|
本サイトは移転しました。旧アドレスからのリダイレクトは2025年03月31日(月)まで有効です。
|
🛈 | ✖ |
ウィンドウズシャットダウン時のwxWidgetsアプリケーション挙動について考察し、コーディング指針を定める。
シャットダウンはシステム停止してパソコンの電源を落とし、サインアウトはユーザーの使用を終了してサインイン画面に戻る。シャットダウン時あるいはサインアウト時にアプリケーションが起動しているとウィンドウズは共通の方法で終了を指示する。本記事は最初にウィンドウズのシャットダウン/サインアウトにおける挙動を説明し、続いてwxWidgetsライブラリを利用して作成したウィンドウズデスクトップアプリケーション(以下、wxWidgetsアプリケーション)の挙動を説明し、最後にwxWidgetsアプリケーションにおけるコーディングの指針を説明する。
本記事は"シャットダウン"をサインアウトを含めて参照し、サインアウトの場合は適切に読み替えるものとする。サインアウトを除く本来のシャットダウンは"狭義のシャットダウン"として参照する。
ウィンドウズのシャットダウン時に起動しているアプリケーションにはWM_QUERYENDSESSIONとWM_ENDSESSIONが送信される。
アプリケーションがWM_QUERYENDSESSIONにFALSEを返せばシャットダウンをキャンセルできるが、ウィンドウズはユーザーに再確認を求める。フルスクリーン画面にシャットダウンUIを表示してユーザーは[強制的にシャットダウン]あるいは[キャンセル]を選択し、前者ならアプリケーションを"強制終了"してシャットダウンし、後者ならキャンセルしてデスクトップ画面に戻る。WM_QUERYENDSESSIONにTRUEを返せばウィンドウズはWM_ENDSESSIONを送信して、アプリケーションはこれを処理して"正常終了"する。
本記事で"正常終了"とはアプリケーションがWM_ENDSESSIONの処理を完了して(WM_ENDSESSIONハンドラを抜けてメッセージループへ戻ろうとして)終了する事、"強制終了"とは処理を完了せずに終了する事と定義する。"正常終了"はアプリケーションの終了手順によらずウィンドウズが"強制"するものでもあるが、本記事の文脈ではあくまでもWM_ENDSESSIONハンドラから抜ける事である。"強制終了"はWM_ENDSESSIONハンドラから抜ける前にアプリケーションを終了する事で、WM_QUERYENDSESSION/WM_ENDSESSIONメッセージ処理中にアプリケーションが手順に沿って"正常"に終了する場合を含む。アプリケーションが"正常終了"/"強制終了"しても他のアプリケーションがシャットダウンをキャンセルするかもしれない。
ウィンドウズの推奨動作はWM_QUERYENDSESSIONの処理にTRUEで即応して、時間のかかる処理はWM_ENDSESSIONの処理に委ねる。本サイトはWM_QUERYENDSESSIONの処理に5秒以内でTRUEを返し、WM_ENDSESSIONの処理を5秒以内で行う事を標準動作と定義する。
テストプログラムで確認した詳細をUMLアクティビティ図に示す。動作はシャットダウンUIへのユーザー入力の他、WM_QUERYENDSESSION/WM_ENDSESSIONハンドラの処理時間などにも依存する。狭義のシャットダウンならPC電源オフとキャンセル後に表示されるかもしれないロック画面は明確に区別できるが、サインアウトはサインイン画面とロック画面が共通で見かけによる区別が難しい。
各動作におけるアプリケーション終了位置をまとめる。WM_ENDSESSIONを処理するアプリケーションは必ず終了する。(*)はハンドラ終了と競合して処理後の結果となる可能性がわずかにあると思う(未確認)。(**)はWM_QUERYENDSESSION受信時に実行中の別メッセージ処理が残る場合である。WM_QUERYENDSESSIONとWM_ENDSESSIONの処理は連続し、つまりWM_ENDSESSION側に別メッセージの処理が残る事はほとんど無い(WM_QUERYENDSESSIONハンドラがメッセージポストすれば競合となる可能性は残る)。(***)でWM_QUERYENDSESSIONの処理はTRUEを返すかもしれないが、それでも強制終了となってWM_ENDSESSIONは送信されない。
条件 | UI表示 | UI解除理由 | ユーザー入力 | 動作 | アプリケーション終了位置 |
---|---|---|---|---|---|
標準動作 | - | - | - | 正常終了 | WM_ENDSESSION処理後 |
WM_ENDSESSION 処理が5秒以上 | 1分以内 | ユーザー入力 | 強制的にシャットダウン | 強制終了 | WM_ENDSESSION処理中(*) |
キャンセル | デスクトップ復帰 | WM_ENDSESSION処理後 | |||
ハンドラ終了 | - | 正常終了 | WM_ENDSESSION処理後 | ||
1分以上 | - | - | ロック画面表示 | WM_ENDSESSION処理後 | |
WM_QUERYENDSESSION にFALSEを返した | 1分以内 | ユーザー入力 | 強制的にシャットダウン | 強制終了 | WM_QUERYENDSESSION処理後 |
キャンセル | デスクトップ復帰 | - | |||
1分以上 | - | - | ロック画面表示 | - | |
WM_QUERYENDSESSION 処理が5秒以上 | 1分以内 | ユーザー入力 | 強制的にシャットダウン | 強制終了 | WM_QUERYENDSESSION処理中(*)/処理前(**) |
キャンセル | デスクトップ復帰 | - | |||
ハンドラ終了 | - | 強制終了 | WM_QUERYENDSESSION処理後(***) | ||
1分以上 | - | - | ロック画面表示 | - |
"強制終了"のアプリケーション終了位置は確定できない。(**)を排除できるのであれば、シャットダウン時に必要な処理を最も確実に行えるのはWM_QUERYENDSESSION処理の冒頭となる。
wxWidgetsアプリケーションのシャットダウン時のデフォルト挙動を説明する。ウィンドウズアプリケーションとしてWM_QUERYENDSESSION/WM_ENDSESSIONを処理するのは同じだが、wxWidgetsライブラリにおける解釈を明確にする。wxWidgetsアプリケーションの通常の(シャットダウン時でない)終了動作を説明してから、WM_QUERYENDSESSION/WM_ENDSESSIONのデフォルト処理方法を説明して挙動としてまとめる。
各実装を説明目的で大きく簡略化するが実際は非常に複雑で、例えばアプリケーション実装クラスはwxAppクラスを継承するが、wxAppはwxApp→wxAppBase→wxAppConsole→wxAppConsoleBaseと継承してメンバ定義は各基底に分散する。説明は必要最低限をwxAppクラス内定義に一括して、そのため宣言順は矛盾するかもしれない。wxApp以外も同様に簡略化する。検討はwxWidgetsバージョン3.2.2.1のソースコードに依った。
アプリケーションはwxAppの継承として実装し、そのインスタンスをグローバルなポインタ変数のwxTheAppに保持する。エントリ関数をwxEntryとして簡略を示す。wxTheAppを構築し、OnInit、OnRun、OnExitメンバ関数を順次コールして、終了すればwxTheAppを解体する。wxTheAppの構築/解体はwxEntryStart/wxEntryCleanup自由関数が行う。
メッセージループ実装クラスをwxEventLoopとして、wxAppとwxEventLoopの簡略を示す。グローバル変数としてwxTopLevelWindowsとwxPendingDeleteを利用する。wxList<T>はヘルプドキュメントのみに現れる仮想のクラステンプレートで、要素を(Tでなく)T*で保持するダブルリンクリストとしてstd::list<T*>に互換するインターフェースを併せ持つ。説明はこの仮想クラステンプレートを用いるが、実際はマクロ定義から生成されるクラスである。wxTopLevelWindowsはトップレベルウィンドウ(以下、TLW)のリストで、wxPendingDeleteはアイドル処理まで解体遅延されたwxObjectである。本記事の範囲で解体遅延されたwxObjectとはwxTopLevelWindow::Destroyメンバ関数をコールしたTLWであるが、その他のwxObjectがそれぞれの理由で解体遅延されるかもしれない。アプリケーションのほとんどで親を持たないTLWはメインウィンドウ一つ(以下、メインTLW)だが、それを複数持つ場合もある。
wxTheAppのwxApp::OnRunメンバ関数はメッセージループを構築してwxApp::m_eventLoopに保持する。メッセージループはウィンドウ標準のWM_QUITポスト(ウィンドウズAPIの::PostQuitMessage関数)でも停止するが、標準手順はwxEventLoop::Exitメンバ関数からのwxEventLoop::m_heventWakeカーネルイベントオブジェクト(以下、m_heventWake)のシグナルによる。m_heventWakeはウェイクアップ一般に用いられ、区別するためwxEventLoop::m_shouldExitフラグを併用する。WM_QUITを用いない理由は他に起動されている可能性のあるメッセージループとの干渉を避けるためとされる。wxApp::ExitMainLoopメンバ関数はwxEventLoop::Exitメンバ関数でwxApp::m_eventLoopを停止し、wxApp::OnRunを抜けてアプリケーションは終了する。
wxTheApp::GetTopWindowメンバ関数から解体遅延されるTLWを除く処理はバージョン3.1.1で追加された(※1)。解体遅延されるTLWを親として生成したダイアログが、親と無関係なTLWとしてアイドル処理で解体されてしまうのを防ぐためとされる。
TLWを実装するwxTopLevelWindowクラスはwxFrame(フレーム)とwxDialog(ダイアログ)の基底でデスクトップに独立表示される。コンストラクタの処理を実装するCreateメンバ関数でwxTopLevelWindowsリストに自らを登録し、デストラクタで登録を解除する。wxEVT_CLOSE_WINDOW(あるいはEVT_CLOSE)イベントのデフォルトハンドラOnCloseWindowメンバ関数はDestroyメンバ関数をコールし、DestroyはwxPendingDeleteリストに登録して解体遅延する。最後のTLW(多くの場合メインTLW)が解体されるとデストラクタはwxApp::ExitMainLoopメンバ関数をコールしてアプリケーションを終了する。
TLWでないウィンドウのDestroyメンバ関数はwxPendingDeleteリストに登録せず即座に解体する。TLWは次のアイドル処理まで解体遅延するが、ある特殊な場合でメッセージループがアイドル処理を通過しないとして、バージョン3.1.4でウェイクアップが追加された(※2)。本記事の範囲ではWM_QUERYENDSESSION/WM_ENDSESSIONの処理がこれに該当する。
まとめればアプリケーションは以下のように終了する。
wxTopLevelWindowにWM_QUERYENDSESSION/WM_ENDSESSION処理を追記する。wxTopLevelWindow::MSWHandleMessageメンバ関数はウィンドウプロシージャからコールされてこれらのメッセージを処理する。実際はwxWindow基底クラスで実装されて他の多数のメッセージと共に処理されるが、WM_QUERYENDSESSION/WM_ENDSESSIONに限ればトップレベルウィンドウへ送信されるのでこの解釈でも問題ない。実際はソースコード(wxWidgetsのメッセージハンドラ実装)を参照のこと。MSWHandleMessageはWM_QUERYENDSESSION/WM_ENDSESSIONをwxEVT_QUERY_END_MESSAGE/wxEVT_END_MESSAGEイベントとしてwxTheAppにイベント処理を委ねる。wxEVT_QUERY_END_MESSAGEで渡されるwxCloseEventをwxCloseEvent::Vetoコールする(以下、Vetoする)とWM_QUERYENDSESSIONのハンドラはFALSEを返してシャットダウンをキャンセルできる。
wxAppにwxEVT_QUERY_END_SESSION/wxEVT_END_SESSIONデフォルトハンドラを追記する。
wxApp::OnQueryEndSessionメンバ関数はwxEVT_QUERY_END_SESSIONのデフォルトハンドラで、wxApp::GetTopWindowコールが返すTLW(通常はメインTLW)のwxTopLevelWindow::Closeメンバ関数を実引数!event.CanVeto()でコールしてfalseが戻る(TLWがwxEVT_CLOSE_WINDOWイベントでVetoする)とVetoする。event.CanVeto()はwxTopLevelWindow::HandleQueryEndSessionコールでtrueとしてwxEVT_CLOSE_EVENTハンドラに渡されるwxCloseEventインスタンスのCanVeto()もtrueを返し、イベントがセッション終了なのか通常のウィンドウを閉じる操作なのか区別できない。
wxApp::OnEndSessionメンバ関数はwxEVT_END_SESSIONのデフォルトハンドラで、TLWを全解体してからwxApp::OnExitコールで解体遅延を実行して、wxEntryCleanup自由関数でwxTheAppを解体して、C言語標準ライブラリexit関数でアプリケーションを終了する。メッセージディスパッチ中に全てのウィンドウハンドル(HWND)を破壊(ウィンドウズAPIの::DestroyWindow関数)するとアプリケーションはそこで終了するため(これを確認するドキュメントは見つからない)、事前にTLW一つをwxWindow::SetHWND(0)してHWNDとの関連付けを破りリーク覚悟でHWNDを残している。exitで終了するのは静的ストレージオブジェクトの解体が保証されるためで、このためメッセージループには戻らない。なおメッセージループに戻ろうとしてもWM_ENDSESSIONの処理完了したとしてシステムは勝手にアプリケーションを終了するし、仮に戻れたとしてもHWNDの関連付けが破られていて正常な処理は期待できない。
TLW全解体はOnExitコール時にTLWが残ると通常の終了動作と異なるため、バージョン3.1.1で加えられた(※3)。
まとめればwxAppはシャットダウンメッセージを以下にデフォルト処理する。
wxWidgetsアプリケーションのシャットダウン時の挙動をカスタマイズするには、二つの観点から注意を必要とする。
デフォルト挙動はバージョン3.1.1(※1)(※3)および3.1.4(※2)で変更されていて、比較としてバージョン3.0.5、3.1.2、3.2.2.1で確認した。以降、それぞれを3.1.0以前、3.1.1~3.1.3、3.1.4以降として参照するが、その他の変更を見落としているかもしれない。最初にデフォルト挙動のバージョン依存性を確認し、次にバージョン依存性を解決できるWM_ENDSESSIONメッセージ利用の欠点を指摘する。最後に、バージョン依存性を解決しながらウィンドウズが期待に沿った挙動となるコーディングの指針を説明する。
何のカスタマイズも加えない挙動を説明する。TLWのwxEVT_CLOSE_WINDOW(あるいはEVT_CLOSE)ハンドラもデフォルトのままとする。説明を単純化するためTLWはメインTLWのみと仮定する。図中でwxTopLevelWindowはwxTLWと略した。本記事の文脈で"正常終了"とはアプリケーションがWM_ENDSESSIONを処理完了して終了する事、"強制終了"とは処理完了せずに終了する事であることを確認しておこう。
wxWidgetsバージョンに依存してメインTLWデストラクタコールの有無が異なる。3.1.0以前はコールされ、3.1.1~3.1.3は(※1)の追加でコールされない。やっかいなのは3.1.4以降で、競合条件が発生してコール有無が一定とならない。本記事の範囲ではシステムが送信するWM_QUERYENDSESSION/WM_ENDSESSIONメッセージとアプリケーションが自らを終了するm_heventWakeがメッセージループを駆動するが、(※2)の追加でWM_ENDSESSIONとm_heventWakeがループ内の::MsgWaitForMultipleObjectsウィンドウズAPI関数で競合条件となる場合がある。WM_ENDSESSIONを先に処理すれば3.1.1~3.1.3同様にコールせず、m_heventWakeを先に処理すれば通常のアプリケーション終了処理となってコールする。後者はwxEventLoop::Exitメンバ関数がm_heventWakeを再シグナルしてもう一度競合条件が発生するが結果に影響しない。
メインTLWのwxEVT_CLOSE_WINDOWハンドラをカスタマイズしてVetoする、あるいはwxTheAppのwxEVT_QUERY_END_SESSIONハンドラをカスタマイズしてVetoすればシャットダウンはキャンセルできる。その場合システムはシャットダウンUIを表示して、ユーザーが[キャンセル]を選択すればデスクトップに復帰してアプリケーションを継続実行する。サンプルコードのように状況依存でメインTLWクローズをダイアログでユーザー確認するアプリケーションは多いが、そういったアプリケーションはシャットダウン時にもダイアログを表示してTLWクローズをキャンセルすればシャットダウンもキャンセルする。ドキュメントはこれら全てを推奨しないが、特にダイアログはシャットダウンUIに重複してユーザー入力を求めることになる。wxEVT_CLOSE_WINDOWハンドラがイベントのCanVeto()を確認しているのなら、wxEVT_QUERY_END_SESSIONハンドラでイベントをSetCanVeto(false)すればダイアログ表示を抑止することはできる。
wxEVT_QUERY_END_SESSIONハンドラをカスタマイズしてDestroyメンバ関数でメインTLWを解体すればwxEVT_CLOSE_WINDOWハンドラを省略できるが、wxWidgetsバージョン依存でデストラクタコールの有無が異なるのは変わらない。delete演算子でメインTLWを即解体してしまえばデストラクタコールは常に保証されるが、残余イベントが解体後のウィンドウに送信される可能性があり避けるべきとされる。
WM_ENDSESSIONハンドラでメインTLWをクローズすればバージョン依存性は解決できるが大きな問題を抱える。wxAppを継承するTMyAppクラスで説明する。TMyAppのwxEVT_QUERY_END_SESSIONハンドラ(TMyApp::OnQueryEndSessionメンバ関数)は何もせず、wxEVT_END_SESSIONハンドラ(TMyApp::OnEndSessionメンバ関数)はwxApp::OnQueryEndSession相当の処理(wxTopLevelWindow::Closeコール)を行ってからイベントをwxEvent::SkipコールしてwxAppのハンドラ(wxApp::OnEndSessionメンバ関数)へ渡す。
wxTopLevelWindows::Destroyメンバ関数より先にwxTopLevelWindows::HandleEndSessionメンバ関数をコールするので、(※1)の有無に関係なくwxApp::GetTopWindowメンバ関数はメインTLWを返してTMyApp::OnEndSessionメンバ関数をコールする。(※2)はメッセージループをウェイクアップするが、WM_ENDSESSIONハンドラがメッセージループに戻ることはないので有無は結果に影響しない。(※3)はメインTLWの解体を他の解体遅延されたwxObjectより先に行うことを保証するだけで、その有無もまた挙動を大きく変えない。TLWデストラクタは解体遅延されたwxObjectリストから自らを削除して、リストに残って二重解体される心配も不要だ。このように挙動はwxWidgetsバージョンに依存せず競合条件も発生しない。全てのバージョンでメインTLWデストラクタをコールしてexitコールで強制終了する。なおwx_EVT_END_SESSIONイベントスキップせずwxApp::OnEndSessionメンバ関数をコールしないとすると全てのバージョンでメインTLWは解体遅延されたままWM_ENDSESSIONハンドラを抜けて、正常終了してメインTLWデストラクタはコールされない。
WM_ENDSESSIONでメインTLWをクローズすればwxWidgetsバージョンに依存しない処理ができるわけだが大きな欠点がある。メインTLWがwxEVT_CLOSE_WINDOWハンドラをカスタマイズしてVetoしてもシャットダウンしてしまう。メインTLWが状況依存でクローズをダイアログ確認する場合はさらに悪く、シャットダウンUIを表示して[キャンセル]を選択すればデスクトップに復帰するが、さらにダイアログでクローズをキャンセルしてもアプリケーションは終了してしまう。どちらもWM_ENDSESSIONを処理するアプリケーションは必ず終了するという事を思い出せば当然の結果であるが、特に後者はユーザーから見て直感に反する。つまりWM_ENDSESSIONハンドラはアプリケーション終了をキャンセルできないがシャットダウンはキャンセルできるという、ウィンドウズの不可解な仕様をあぶり出す。
このようにWM_ENDSESSIONでのメインTLWクローズはユーザーの直感に反する挙動となるのでやるべきでない。Destroyメンバ関数ならwxEVT_CLOSE_WINDOWハンドラを省略してそのようなことはないが、それならデフォルトのwxApp::OnEndSessionメンバ関数だけで必要十分だろう。
ドキュメントはウィンドウズの期待する挙動を以下とする。
wxWidgetsアプリケーションのデフォルト挙動はWM_QUERYENDSESSIONでメインTLWクローズしてWM_ENDSESSIONまで解体遅延するが、WM_ENDSESSIONでメインTLWの解体を含むクリーンアップが実行されるかどうかはwxWidgetsバージョンと競合条件発生に依存する。メインTLWがwxEVT_CLOSE_WINDOWハンドラで表示するダイアログはシャットダウン時にも表示されるが、ユーザーはシャットダウンUIで[キャンセル]を選択してから再度ダイアログに応えなければならない。
これに対して本サイトがベストと考えるwxWidtgetsアプリケーションのシャットダウン時処理をまとめる。
WM_QUERYENDSESSIONのメインTLWクローズは応答時間に影響するほどの実行コストは無く問題ないが、WM_ENDSESSIONのバージョン依存/競合条件による挙動差の原因となるため避ける。メインTLWがwxEVT_CLOSE_WINDOWハンドラで表示するダイアログは表示できなくなるが、必要であれば同等の条件判断をwx_QUERY_END_SESSIONハンドラで行い、クローズ/シャットダウンをキャンセルする場合は::ShutdownBlockReasonCreate関数に理由を与えてVetoする。ユーザーがシャットダウンUIで[キャンセル]すればデスクトップに復帰してアプリケーションは実行継続するが、[強制的にシャットダウン]すればtaskkillコマンド同等で終了してクリーンアップは実行されない。つまりバージョン依存/競合条件による挙動差を排除してもクリーンアップが実行されない条件が一つ残る。WM_ENDSESSIONはデフォルト動作wxApp::OnEndSessionメンバ関数で必要十分でカスタマイズせず、つまりメインTLW(および全てのTLW)解体はこのメンバ関数に委ねる。
クリーンアップが実行されない条件で何らかの終了処理を確実に実行するにはwx_QUERY_END_SESSIONハンドラで無条件に事前実行する(強制終了がWM_QUERYENDSESSION処理前(**)となる場合までは面倒見れないが)。もちろん応答時間に影響するほどの実行コストとなる処理は避けなければならない。この終了処理は何らかの状態を変化させてはならない。仮に状態変化を伴うとしてVetoすれば、シャットダウンUIで[強制的にシャットダウン]ならそのまま終了処理後の状態を維持し、[キャンセル]ならアプリケーションを実行継続するため状態変化を戻して終了処理のされていない状態とする。しかしシャットダウンUIはアプリケーションが関与できず、そのように処理を切り替えることが不可能なのだ。こういった理由でライブラリと静的ストレージオブジェクトのクリーンアップは事前実行できず(特に後者はアプリケーション終了による解体なので絶対に事前実行できない)成り行きに任せるしかない。事前実行できるのはあなたがコーディングする終了処理で状態変化を伴わないもので、例えばレジストリデータ保存や編集済みデータ退避などである。そういった終了処理の多くはメインTLWのデストラクタにコーディングされるので、メインTLWがwxTopLevelWindowを継承するTMyTLWクラスであるとして説明する。
wxEVT_QUERY_END_SESSIONでVetoする可能性のないアプリケーションに終了処理の事前実行は不要でwxApp::OnEndSessionに全て委ねても良いが、一つ留意すべき点がある。wxApp::OnEndSessionは最初に一つTLWをwxWindow::SetHWND(0)して次にdelete演算子で全てのTLWを解体するが、SetHWND(0)されたTLW(多くの場合メインTLW)デストラクタでwxWindow::GetHWNDメンバ関数からHWNDを得ることができず、必要なら事前に記憶しておく必要がある。例えば本サイトに掲げるアプリケーション(サンプルコード、自作ツール、公開ソフトウェア)のメインTLWはデストラクタの終了処理としてウィンドウのサイズ/位置をレジストリに保存するが、その取得にウィンドウズAPIの::GetWindowPlacement関数を利用してHWNDを必要とする。終了処理を事前実行しておけば、こういったHWNDの記憶は不要になる。