R07:【掟・判例】WinForms寿命管理 閉じたのにまだプロセスがいるじゃねぇか ― ライフサイクル Dispose
連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index
アプリを閉じたつもりが、もう一度起動すると二重起動チェックで弾かれたり、
タスクマネージャを見るとプロセスが残っていたり、メモリは使用中のままだったりする。
(まれに) Visual Studioのデザイナ(デザイン画面)を開くだけで落ちる。
抜けやすいのは、Load/Shown/FormClosing/FormClosed/Disposeに「始める」「止める」「外す」を置く場所が揃っていないこと。
このページでは、ライフサイクルごとの役割を分け、開始と停止を対にし、回収はDisposeへ寄せる。
このページのゴール
- WinFormsのライフサイクル(Load/Shown/Closing/Closed/Dispose)の役割を分けて説明できる
- 初期化をどこに置くと後で困りにくいか判断できる
- CloseとDisposeの関係が説明できる
- タイマー、イベント購読、バックグラウンド処理の止め所を決められる
- カスタムコントロールでDisposeの書き方を揃えられる
- デザイナで落ちる要因を避けられる(.NET 8/Framework 4.8どちらでも)
このページの登場人物(最低限)
- ライフサイクルイベント: Load/Shown/FormClosing/FormClosed/Dispose
- Dispose: 管理対象外リソースではなくても「やめる」「外す」「解放する」をまとめて行う出口
- components(IContainer): デザイナが生成する部品箱。Disposeで中身が破棄される
- デザイナ: Visual Studioのデザイン画面。実行時とは文脈が違う
ライフサイクル早見表(役割の切り分け)
| タイミング | 典型的に起きること | 置く処理の目安 | 置かない方がよい処理 |
|---|---|---|---|
| コンストラクタ | フィールド初期化、InitializeComponent | 依存注入、軽い初期値、イベントハンドラ接続(必要最小) | 外部I/O、重い初期化、画面依存のレイアウト確定 |
| Load | 画面が生成され、表示直前 | 画面データの読み込み準備、UI初期状態の反映(軽量) | 長時間処理、ブロッキングI/O |
| Shown | 初回表示が終わる | 重い初期化の開始、非同期開始、初回フォーカス調整 | UIスレッドを塞ぐ処理 |
| FormClosing | 閉じる直前(キャンセル可能) | 未保存確認、停止要求の発行、キャンセル判断 | 重い破棄処理、待ち合わせで固める |
| FormClosed | 閉じた後 | タイマー停止、イベント解除、バックグラウンド終了合図 | UI操作(もう画面は無い) |
| Dispose | 破棄(回収) | components破棄、購読解除、Timer/Handle破棄 | UIの状態参照(破棄順序で揺れる) |
補足として、Disposeは「Closeの代わり」ではない。
Closeは閉じる導線、Disposeは資源回収の導線になる。
参考: Form.Close / Control.Dispose
掟: 寿命の区切りを決め、開始と停止を対にする
-
掟1: 重い初期化はコンストラクタに置かない
理由: デザイナ文脈でも実行される経路があり、設計時例外の原因になる。表示まで遅れ、画面が固まったように見えることもある。 -
掟2: 表示後に始める処理はShownへ寄せる
理由: 初回描画を先に通すと、UIの反応が残る。Loadに詰め込むと、表示が遅れた原因の切り分けが面倒になる。 -
掟3: 終了は2段階(停止要求→回収)で分ける
理由: FormClosingはキャンセル判断の場で、回収の場ではない。停止要求だけ出し、回収はFormClosed/Disposeで行うと順序が読みやすい。 -
掟4: 画面の外に生きるもの(タイマー/購読/Task)は必ず止め所を持つ
理由: 画面が閉じても動き続けると、プロセス残留、メモリ未回収、例外ログ増加につながる。 -
掟5: Disposeでは「破棄するものの集合」を揃える
理由: 破棄漏れは発生箇所が散りやすい。Disposeに寄せると探す場所が1つになる。
どう困るのか(よくある形)
形1: 閉じたのにプロセスが残る
- タイマーが生きている
- バックグラウンドスレッドが止まっていない
- NotifyIconなど画面外の部品が生きている
対処は「止め所を決める」だけになる。FormClosedかDisposeに寄せる。
形2: デザイナで落ちる
- コンストラクタで設定ファイルを読む
- コンストラクタでDIコンテナを引く
- コントロール生成時にDBやネットワークへ触れる
設計時は「画面の形を作る」だけの文脈で、実行時前提の処理が混ざると落ちやすい。
処理の置き場所をShown/Loadへ移すか、設計時判定で分岐する。
形3: 閉じた後にUIを触って例外になる
- Shownで開始した非同期処理が、終了後にUI更新しようとする
- Close後にTimer Tickが走り続ける
止め所(キャンセル/停止/購読解除)が無いのが原因になる。
最短の実装パターン(揃える型)
パターンA: Shownで開始し、FormClosedで止める(画面の寿命に合わせる)
private readonly System.Windows.Forms.Timer _timer = new();
private CancellationTokenSource? _cts;
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
_cts = new CancellationTokenSource();
_timer.Interval = 500;
_timer.Tick += Timer_Tick;
_timer.Start();
_ = StartBackgroundAsync(_cts.Token);
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
_timer.Stop();
_timer.Tick -= Timer_Tick;
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
base.OnFormClosed(e);
}
private void Timer_Tick(object? sender, EventArgs e)
{
// UI更新など
}
private async Task StartBackgroundAsync(CancellationToken ct)
{
// 長い処理はキャンセル可能にする
await Task.Delay(1000, ct);
}
ポイントは「開始と停止が対」になっていること。
止める場所が決まると、調査はそこだけ見ればよくなる。
パターンB: Disposeに回収を寄せる(探す場所を1つにする)
FormやControlはDisposeを持つ。回収の中心はここに置ける。
protected override void Dispose(bool disposing)
{
if (disposing)
{
// イベント解除、Timer停止、購読解除など
// componentsはデザイナが生成する場合がある
components?.Dispose();
}
base.Dispose(disposing);
}
設計時安全(デザイナで落ちないための作法)
-
コンストラクタでは外部I/Oをしない
設定読み込み、DB接続、ネットワークアクセスは表示後に寄せる。 -
設計時判定が必要なら、判定方法を揃える
Control.DesignModeはタイミングによってfalseになり得るため、判定として弱い。
実務ではLicenseManager.UsageModeを使う方が安定しやすい。
using System.ComponentModel;
private static bool IsDesignTime()
{
return LicenseManager.UsageMode == LicenseUsageMode.Designtime;
}
-
設計時に触れてよいのはUI内部の軽い初期値だけ
画面の形(サイズ/色/初期テキスト)に限る。動的な解決は表示後へ寄せる。
判例(OK/NG)
| 観点 | OK例 | NG例 | 理由(困ること) | レビューで見る所 |
|---|---|---|---|---|
| 重い初期化の場所 | Shownで開始 | コンストラクタで開始 | デザイナで落ちる/表示が遅れる | コンストラクタの処理量 |
| 終了の段取り | FormClosingで停止要求、FormClosed/Disposeで回収 | FormClosingで回収まで全部やる | 閉じる操作が重い/順序が読めない | FormClosingの中身 |
| タイマー | StartとStopを対にする | Startだけで放置 | Close後も動き続ける | Stop/購読解除があるか |
| イベント購読 | 購読解除をDisposeに寄せる | 購読解除が無い | GCされず残りやすい |
+= の対応する -=
|
| 非同期 | キャンセル経路を持つ | 画面寿命と無関係に走らせる | Close後のUI更新/ログ増加 | CancellationTokenの有無 |
| 設計時安全 | 外部I/Oは表示後 | コンストラクタでI/O | デザイナが落ちる | 設計時判定/処理場所 |
レビュー観点
| 観点 | ありがちな見落とし | 困り方 | 指摘コメント例(直球禁止) |
|---|---|---|---|
| コンストラクタ肥大 | 初期化が全部集まる | デザイナで落ちる/表示が遅れる | 「設計時にも通り得るので、外部I/OはShown以降へ寄せると安定する」 |
| 破棄漏れ | Timer/購読/Taskの停止が無い | Close後も動く/プロセス残留 | 「開始したものの停止位置が見えない。FormClosedかDisposeへ寄せると追いやすい」 |
| FormClosingの過積載 | 破棄と待ち合わせが混ざる | 閉じる操作が重い | 「閉じる判断と回収を分けると順序が読みやすい」 |
| DesignMode頼み | 判定が不安定 | デザイナだけ落ちる | 「DesignModeはタイミングで揺れるので、UsageModeで揃える方が安全」 |
| componentsの扱い | Disposeを書き換えるがcomponentsを忘れる | 部品が残る/リーク | 「Disposeでcomponentsの破棄が維持されているか確認したい」 |
禁書庫A: 逆引き(症状→原因→対策)
| 症状 | ありがちな原因 | 切り分け(見る場所) | 最短の対処 | 再発防止(ルール化) |
|---|---|---|---|---|
| 閉じたのにプロセスが残る | Timer/購読/バックグラウンドが生きている | FormClosed/Disposeに停止処理があるか | Stop/購読解除/Cancelを追加 | 開始と停止を対にし、停止位置をFormClosedかDisposeへ寄せる |
| 設計時に落ちる | コンストラクタで外部I/O/DI解決 | コンストラクタ/フィールド初期化 | 重い処理をShownへ移動 | コンストラクタは軽量にする。設計時判定が必要ならUsageModeで揃える |
| Close後に例外ログが増える | 非同期がUI更新を続ける | Shown開始処理と終了処理 | Cancelと終了ガードを入れる | 画面寿命に合わせてCancellationTokenを持つ |
| メモリが戻らない | イベント購読解除が無い |
+= 箇所とDispose |
-= をDisposeに追加 |
購読解除をDisposeへ寄せ、レビューで対を確認する |
| 閉じる操作が重い | FormClosingで待ち合わせ/破棄 | FormClosingの中身 | 停止要求だけにする | 停止要求と回収を分け、回収はFormClosed/Disposeへ寄せる |
禁書庫B: チートシート(決め打ちで読む)
- コンストラクタ: InitializeComponentと軽い初期値だけ
- Load: 軽いUI初期状態(重いI/Oは置かない)
- Shown: 重い初期化を開始する場所
- FormClosing: キャンセル判断と停止要求
- FormClosed: Timer停止、購読解除、終了合図
- Dispose: 回収の集約点(components破棄を維持する)
.NET Framework 4.8: 併記(差分が有意な箇所だけ)
WinFormsのライフサイクルの考え方自体は同じ。
差分として出やすいのは「設計時判定」と「非同期開始の位置」になる。
- 設計時判定は
LicenseManager.UsageModeを使うと揺れにくい。 - asyncを使う場合でも、終了合図(CancellationToken)を持たせるのが基本。