連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index
WinFormsが不安定になる典型は、初期化と後始末がイベントに散って、順序が崩れることにある。
Loadで重い処理を抱えると初回表示が固まり、Closingで止め切れないと終了が詰まり、Disposeで漏れが出ると常駐系が残る。
このページは Form のライフサイクルを ctor → Load → Shown → Closing → Dispose の順で揃え、責務の置き場を言語化する。
0. このページの使い方
困った場面から逆引きする場合、次の順で十分。
- 初回表示が固まる: 「Load/Shown」へ進む(同期I/O、.Result/.Wait の混入を点検)
- 終了が詰まる: 「Closing」へ進む(停止要求・キャンセル・常駐抑止の有無を点検)
- 常駐系が残る: 「Dispose」へ進む(Timer/NotifyIcon/event 解除漏れを点検)
- 2回目表示だけ壊れる: 「再表示/トレイ常駐」へ進む(Shownは1回、毎回更新の置き場を点検)
1. 用語
1-1. ctor とは何か
ctor は constructor(コンストラクタ)の略称。
WinFormsでは public MainForm() のように書く部分が ctor になる。
1-2. ここでの「軽い」とは何か
- UIスレッドを長時間占有しない(初回表示が詰まらない)
- 外部I/Oを開始しない(ファイル/DB/HTTP 等の待ちが出ない)
- 失敗時の扱いが明確(例外が握り潰されず、落としどころがある)
2. ライフサイクル全景
イベント名が散ると「どこに何を置くべきか」が崩れる。
まずは全体の順序を頭に置く。
3. 責務の置き場
| 位置 | タイミング | 置く責務 | 置かない責務 |
|---|---|---|---|
| ctor | new の生成過程 | InitializeComponent、依存注入、軽い初期値、イベント配線 | 外部I/O、重い計算、待ちが出る処理 |
| Load | 表示直前 | 初期表示に必要なUI状態、レイアウト準備、初期値反映 | .Result/.Wait、長時間処理、重いI/O |
| Shown | 初回表示直後 | 重い初期化の開始、非同期起動、例外と縮退の整理 | UIスレッドでの長時間同期処理 |
| Closing | 閉じる直前 | 停止要求、キャンセル、未保存確認、終了可否 | 「Dispose任せ」で停止しない形 |
| Dispose | 破棄時 | event解除、Timer停止、NotifyIcon/Handle解放、資源解放の保険 | 主要ロジックの本体、判断の中心 |
4. ctor は「軽くすべき」
4-1. ctor を重くすると困る理由
- ここが詰まると「起動しない」に見える(初回表示まで進まない)
- 例外が出るとフォーム生成自体が失敗する(回復ルートが作りにくい)
- デザイナ(Visual Studio)が ctor を通すため、重い処理で設計時が壊れる
4-2. ctor に置いてよいもの
InitializeComponent()- DIの受け取り、フィールド初期化
- 「軽い」イベント配線(Timer開始などの起動はここに置かない)
- 画面の静的な見た目(固定値のプロパティ設定)
public partial class MainForm : Form
{
private readonly ILogger _logger;
// ★長寿命の停止トークン。Closing で Cancel して、Dispose で解放する。
private readonly CancellationTokenSource _lifeCts = new();
public MainForm(ILogger logger)
{
InitializeComponent(); // ★コントロール生成はここが責務
_logger = logger;
// ★配線はここで完了させる(Start/Stop は別責務)
this.Load += MainForm_Load;
this.Shown += MainForm_Shown;
this.FormClosing += MainForm_FormClosing;
// ★軽い初期値(見た目・フラグ)
this.Text = "Main";
}
}
4-3. ctor と Load の境界が揺れる場面
「コントロール初期化を ctor に置くか Load に置くか」で迷う場面は多い。
判断軸は次の2つに寄せるとブレが減る。
- 固定値で決まるか: 端末/設定/データで変わらないなら ctor 側が成立しやすい
- Handle/サイズ/レイアウトに依存するか: 依存するなら Load 側が成立しやすい
例: DataSource の差し替えや動的列生成など、「初回描画前に揃えたい」変更は Load へ寄せる。
4-4. Handle 未作成の罠
ctor では Handle が未作成のことが多い。
この段階で BeginInvoke や CreateGraphics、一部のコントロール操作を行うと、Handle が意図せず作られたり、順序が変わることがある。
Handle 依存の処理は Load 以降へ寄せる方が安全寄り。
4-5. .NET Framework 4.8 補足
.NET Framework 4.8 でも考え方は同じ。差が出やすいのは「設計時の落ち方」。
WinFormsデザイナは ctor を通すため、I/Oや環境依存を ctor に置くと設計時が壊れやすい。
設計時を避けたい場合、DesignMode だけに頼らず、用途モードも併用する方が安全寄り。
private static bool IsDesignTime(Control c)
{
// ★DesignMode は ctor 直後だと false になりやすい
if (LicenseManager.UsageMode == LicenseUsageMode.Designtime) return true;
// ★Site が取れる段階なら DesignMode が効く
if (c.Site?.DesignMode == true) return true;
return false;
}
5. Load は「初期表示を作る」
Load は「初回描画に必要なUI状態」を揃える場所。
重い処理を置くと初回表示が詰まるため、揃えるだけに寄せる。
5-1. 失敗パターン: Load で同期I/O
現象は「固まる」「起動しない」に見える。
private void MainForm_Load(object? sender, EventArgs e)
{
// ★ここで待ちが出ると初回表示が詰まる
var json = File.ReadAllText(_path);
_settings = JsonSerializer.Deserialize<AppSettings>(json);
}
5-2. 正攻法: Load は軽く、重い処理は Shown で開始
Load は「表示できる状態」を作る。
重い処理は Shown で起動し、例外と縮退の落としどころを作る。
private void MainForm_Load(object? sender, EventArgs e)
{
// ★初期表示に必要な最低限(プレースホルダ、状態)
statusLabel.Text = "initializing...";
progressBar.Visible = true;
// ★Load では開始しない(開始は Shown へ)
}
6. Shown は「重い初期化の開始点」
Shown は初回表示の後に呼ばれる。
ここで非同期初期化を起動すると、「表示は先に出す」「後で詰まったら縮退する」が取りやすい。
6-1. 典型形: 初期化を起動し、Closing でキャンセルする
private CancellationTokenSource? _initCts;
private async void MainForm_Shown(object? sender, EventArgs e)
{
// ★イベントは async void が避けにくい。try/catch を必ず置く。
_initCts = new CancellationTokenSource();
try
{
await InitializeAsync(_initCts.Token); // ★重い処理はここで開始
statusLabel.Text = "ready";
}
catch (OperationCanceledException)
{
// ★終了経路。ログは必要に応じて。
statusLabel.Text = "closing...";
}
catch (Exception ex)
{
// ★初期化失敗の落としどころ(ログ+縮退)
_logger.LogError(ex, "Initialize failed");
statusLabel.Text = "failed (degraded)";
}
finally
{
progressBar.Visible = false;
}
}
private void MainForm_FormClosing(object? sender, FormClosingEventArgs e)
{
// ★Closing は「止める」を担当する(Dispose 任せにしない)
_initCts?.Cancel();
}
6-2. InitializeAsync の中身(重い処理の置き場)
Shown 側が「起動」、InitializeAsync 側が「実体」と分けるとレビューが通りやすい。
例はファイル読み+UI反映。UI反映は await 後に行う(UIスレッドへ戻る前提)。
private async Task InitializeAsync(CancellationToken ct)
{
// ★重いI/Oはここへ寄せる(Load/ctor へ置かない)
// .NET 8: File.ReadAllTextAsync が使える
// .NET Framework 4.8: StreamReader.ReadToEndAsync や Task.Run で代替する場面が多い
var json = await Task.Run(() => File.ReadAllText(_path), ct);
ct.ThrowIfCancellationRequested(); // ★キャンセル点を明示
_settings = JsonSerializer.Deserialize<AppSettings>(json);
// ★UI反映は UI スレッドで行う(await 後は戻る設計を前提にする)
ApplySettingsToControls(_settings);
}
6-3. Shown は 1 回、毎回更新は別イベントへ
Shown は基本的に「初回だけ」に寄る。
Hide→Show のような再表示で毎回更新したい場合、VisibleChanged や Activated へ寄せる方が安定する。
protected override void OnVisibleChanged(EventArgs e)
{
base.OnVisibleChanged(e);
if (!this.Visible) return;
// ★再表示ごとに必要な更新(表示文字、状態同期)
RefreshUiSnapshot();
}
7. Closing は「止める・止め方を決める」
FormClosing は「止める要求」と「終了可否」の判断点。
ここで止めないと「閉じたのに動く」「終了が詰まる」へ繋がる。
7-1. 常駐(トレイ)で Close を抑止する形
private bool _exitRequested;
private void exitMenuItem_Click(object? sender, EventArgs e)
{
// ★明示的な終了要求だけ通す
_exitRequested = true;
this.Close();
}
private void MainForm_FormClosing(object? sender, FormClosingEventArgs e)
{
if (!_exitRequested)
{
// ★Close を抑止して常駐へ回す
e.Cancel = true;
this.Hide();
return;
}
// ★本当の終了: ここで停止要求を出す
_initCts?.Cancel();
StopAllBackgroundSources();
}
7-2. Close 前に Hide を入れる場面
終了時に「後処理でUIがちらつく」「フォーカスが荒れる」などが出る場合、
Hide → 後処理 → Close の順へ寄せると落ち着くことがある(常駐系や入力系と同居する場合に効きやすい)。
8. Dispose は「解除と解放の保険」
Dispose は「解除漏れの最後の砦」。
Closing に主要な停止を置き、Dispose では idempotent(何度呼ばれても同じ結果)を狙う。
8-1. 解除漏れが出やすい対象
-
Timer(System.Windows.Forms.Timer/System.Threading.Timer) NotifyIcon- 外部イベント購読(ホットキー、外部通知のコールバック)
CancellationTokenSource-
IDisposableなクライアント(例: 長寿命HttpClientを保持する場合)
private System.Windows.Forms.Timer? _uiTimer;
private NotifyIcon? _tray;
protected override void Dispose(bool disposing)
{
if (disposing)
{
// ★解除: 参照が残ると常駐系が生き残る
if (_uiTimer is not null)
{
_uiTimer.Stop();
_uiTimer.Tick -= UiTimer_Tick;
_uiTimer.Dispose();
_uiTimer = null;
}
if (_tray is not null)
{
_tray.Visible = false;
_tray.Dispose();
_tray = null;
}
_initCts?.Dispose();
_initCts = null;
_lifeCts.Cancel();
_lifeCts.Dispose();
}
base.Dispose(disposing);
}
9. 再表示・外部イベントが同居する場合の分割
タイマー、外部通知、ホットキー、トレイ常駐が同居すると、Form が「責務の集積所」になりやすい。
Start/Stop を持つコンポーネントへ寄せ、Closing で停止要求を一本化するのが安定しやすい。
public sealed class BackgroundSources : IDisposable
{
private readonly System.Windows.Forms.Timer _timer;
public BackgroundSources()
{
_timer = new System.Windows.Forms.Timer { Interval = 1000 };
_timer.Tick += (_, __) => OnTick?.Invoke();
}
public event Action? OnTick;
public void Start() => _timer.Start(); // ★開始
public void Stop() => _timer.Stop(); // ★停止
public void Dispose()
{
_timer.Stop();
_timer.Dispose();
}
}
Form 側は「開始は Shown、停止は Closing、解除は Dispose」へ揃える。
10. レビュー観点
「知っていれば防げる」を「機械的に潰せる」へ落とす。
見つけ方は grep・検索ワード前提で整理する。
10-1. ctor
-
File./SqlConnection/HttpClient/WebRequestなど外部I/Oの開始がない -
Task.Runで重い処理を走らせていない(成功/失敗/キャンセルの扱いが迷子になりやすい) - 設計時に壊れる要因がない(環境変数、レジストリ、ネットワーク依存など)
- Handle 依存APIが ctor に無い(
BeginInvoke、CreateGraphics、ホットキー登録など)
10-2. Load
-
.Result/.Wait()/Task.WaitAllが無い -
Thread.Sleep/DoEventsが無い - 初期表示に必要なUI状態だけが置かれている(重い初期化は Shown 側)
10-3. Shown
-
async voidにtry/catchがあり、ログと縮退がある - キャンセル手段がある(
CancellationTokenSource) - 初期化失敗時に UI が壊れない(表示上の落としどころがある)
10-4. Closing
- 停止要求がある(Cancel/Stop)
- 常駐抑止(トレイ運用)が明示されている(Close の抑止条件がある)
- 終了処理が詰まる場合の最短切り分けが残っている(ログ・タイムアウト方針)
10-5. Dispose
- Timer/event/NotifyIcon の解除がある
- 解除が idempotent になっている(null 化、二重解除で落ちない)
11. 関連トピック
- 止血と計測(ログ/タイムアウト/計測の型): E04
- UIスレッドの構造理解(メッセージループ/帰還先): G11
- 非同期の実戦整理(待ち方/合成/デッドロック回避): G13
- 規約化(レビュー観点へ落とす): R06
連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index