連載Index(読む順・公開済リンクが最新): S00_門前の誓い_総合Index
このページは、トレイ常駐アプリの「隠す/戻す/終える」を混ぜないための最小設計をまとめる。
これが揃うと、×で隠して、通知で呼び戻して、必要なら静かに終える――常駐らしい体験が一気に作れる。
Slack/Teams/Zoom/ウイルス対策ソフトの“当たり前”を、自分のアプリでも再現する。
このページで手に入るもの(最短)
- ×は隠す、終了は終える:意味の切り分けを「実装に落ちる形」へ固定
- NotifyIconがトレイに残り続ける問題を、後始末で止める
- 常駐中に動く処理(Timer/監視/通知/ホットキー等)が残る問題を、終了手順で止める
- レビュー時にそのまま使えるチェック表
先に逆引き(症状→原因→対策)
| 症状 | だいたいの原因 | 対策(第一手) |
|---|---|---|
| ×でアプリが終わる | Closeを「終える」にしている | FormClosingでCancelしてHideへ寄せる |
| ×で隠れるが、終える手段が無い | 「終える」が用意されていない | トレイのメニューに「終了」を必ず置く |
| 終了したはずなのにトレイに残る | NotifyIconの後始末不足 |
Visible=false -> Dispose() を必ず通す |
| 終了が遅い/止まらない | 常駐中の処理停止が無い | Timer/監視/Token/HotKey等を終了時に止める |
| イベントや購読が残る | 購読解除の出口が無い | 終了手順に購読解除を入れる(必要な箇所だけ) |
| Close経路が複数で後始末が抜ける | 後始末が散っている | 後始末を1か所へ集約、保険で二重呼びに耐える |
0. まず切り分ける(ここが混ざると全部壊れる)
常駐アプリの基本動作は3つ。ここを混ぜない。
- 隠す: 画面を消すだけ。プロセスは生きる
- 戻す: トレイから画面を再表示する
- 終える: 後始末をしてプロセスを終える
×を「隠す」に寄せる運用はよくある。
ただし「終える」が別に無いと、プロセスは残り続ける。次回起動や多重起動と絡んで混乱する。
1. 今回揃えるルール(最小)
- ×は「隠す」へ寄せる(常駐継続)
- 「終える」はトレイのメニュー等に必ず用意する
- NotifyIconは
Visible=false -> Dispose()を必ず通す - 常駐中に動く処理(Timer/監視/通知/ホットキー等)は、終了時に止める手順を持つ
2. 先に表を作る(実装がぶれない)
| 操作 | 期待 | 実装の要点 |
|---|---|---|
| トレイから開く | 画面が出る | Show + 必要ならNormalへ |
| ×を押す | 画面だけ消える | FormClosingでCancelしてHide |
| トレイの終了 | プロセス終了 | 止める -> 後始末 -> Close |
表を作る意図は「Closeが何を意味するか」を先に決めるため。
3. 失敗例(×で隠すだけ)
「終える」が無い形。常駐のつもりが、ただの残骸になりやすい。
// 失敗例: ×で隠すだけ。終える操作が無い。
private void MainForm_FormClosing(object? sender, FormClosingEventArgs e)
{
e.Cancel = true;
Hide();
}
4. 最短で使うならこれ(Form基点)
狙いはこれ。
- ×はHideへ寄せる
- トレイの「終了」だけ、後始末してCloseを通す
- 後始末は1か所にまとめる(漏れない形に寄せる)
4-1. 実装(コメント込み)
public partial class MainForm : Form
{
private NotifyIcon? _tray;
private ContextMenuStrip? _menu;
// Exitが押されたときだけtrue
// ×(Close)はHideへ寄せるため、意味を分けるフラグ
private bool _exitRequested;
public MainForm()
{
InitializeComponent();
// トレイの右クリックメニュー: 「開く」と「終了」を用意
_menu = new ContextMenuStrip();
_menu.Items.Add("開く", null, (sender, e) => Restore());
_menu.Items.Add("-");
_menu.Items.Add("終了", null, (sender, e) => RequestExit());
// NotifyIconはIDisposable。Disposeしないと残りやすい
_tray = new NotifyIcon
{
Text = "MyApp",
Icon = this.Icon, // 例: FormのIconを流用
ContextMenuStrip = _menu,
Visible = true
};
// ダブルクリックで復帰。常駐の定番導線
_tray.DoubleClick += (sender, e) => Restore();
// 終了処理が散らばると抜けるため、イベントはここで握る
FormClosing += OnMainFormClosing;
FormClosed += OnMainFormClosed;
}
private void Restore()
{
// トレイから画面を戻す
Show();
// 最小化で戻すと見えないのでNormalへ
if (WindowState == FormWindowState.Minimized)
WindowState = FormWindowState.Normal;
// 前面化は運用次第。まずはActivateだけに留める
Activate();
}
private void RequestExit()
{
// 「終える」はここに集約する
_exitRequested = true;
Close();
}
private void OnMainFormClosing(object? sender, FormClosingEventArgs e)
{
if (!_exitRequested)
{
// ×は「隠す」に寄せる
e.Cancel = true;
Hide();
return;
}
// ここに来るのは「終える」だけ
StopBackgroundWorks(); // 常駐中の処理停止
UnsubscribeEvents(); // 必要な購読解除
DisposeTray(); // トレイの後始末
}
private void OnMainFormClosed(object? sender, FormClosedEventArgs e)
{
// Closeが別経路で進んでも後始末が通るよう、保険で呼ぶ
// 内部でnull化して二重Disposeを避ける
DisposeTray();
}
private void StopBackgroundWorks()
{
// 例:
// - Timer.Stop()
// - CancellationTokenSource.Cancel() -> Dispose()
// - RegisterHotKeyの解除
// - 監視スレッド/ループの停止
}
private void UnsubscribeEvents()
{
// 例:
// - _tray.DoubleClick -= ...
// - 外部イベント購読解除
// 常駐は購読が長生きしやすいので、必要な所だけ外す
}
private void DisposeTray()
{
if (_tray is null) return;
// トレイに残りやすいので、先にVisible=false
_tray.Visible = false;
// NotifyIconはIDisposable。Disposeしないと残りやすい
_tray.Dispose();
_tray = null;
// メニューもComponentなのでDispose対象
_menu?.Dispose();
_menu = null;
}
}
4-2. ここだけ見れば判断できるポイント
- ×はHide、終了はClose。混ぜない
- 終了時に
StopBackgroundWorks -> DisposeTrayが通る - NotifyIconは
Visible=false -> Disposeを必ず通す
4-3. 30秒テスト(動作確認の最短)
- 起動 → トレイに出る(アイコンが見える)
- × → 画面が消える(プロセスは残る)
- トレイの「開く」→ 画面が戻る(MinimizedならNormalへ)
- トレイの「終了」→ プロセスが消える(タスクマネージャで確認)
- 終了後、トレイにアイコンが残らない(
Visible=false -> Disposeが効く)
5. もう少し素直にしたい場合(ApplicationContext基点)
「最初から常駐で、画面は必要なときだけ出す」なら、寿命をApplicationContextで握る方が混線しにくい。
internal sealed class TrayAppContext : ApplicationContext
{
private readonly NotifyIcon _tray;
private readonly ContextMenuStrip _menu;
private MainForm? _form;
public TrayAppContext()
{
_menu = new ContextMenuStrip();
_menu.Items.Add("開く", null, (sender, e) => ShowForm());
_menu.Items.Add("-");
_menu.Items.Add("終了", null, (sender, e) => ExitApp());
_tray = new NotifyIcon
{
Text = "MyApp",
Icon = SystemIcons.Application,
ContextMenuStrip = _menu,
Visible = true
};
_tray.DoubleClick += (sender, e) => ShowForm();
}
private void ShowForm()
{
// 画面は必要なときだけ作る
_form ??= new MainForm();
_form.Show();
if (_form.WindowState == FormWindowState.Minimized)
_form.WindowState = FormWindowState.Normal;
_form.Activate();
}
private void ExitApp()
{
// 終了時の停止はここに集約する
StopBackgroundWorks();
// NotifyIconの後始末
_tray.Visible = false;
_tray.Dispose();
_menu.Dispose();
// 画面が出ているなら閉じる
_form?.Close();
_form?.Dispose();
_form = null;
// メッセージループを終える
ExitThread();
}
private void StopBackgroundWorks()
{
// 常駐中の処理停止をここに集約
}
}
// Program.cs
[STAThread]
static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new TrayAppContext());
}
6. よくある詰まり所(原因→手当て)
- Exitが無い
→ Hideしか無く、プロセスが残る。トレイに「終了」を必ず置く - Exitはあるが停止が無い
→ Timer/監視/通知/ホットキー等が動き続け、終了が遅くなる。終了手順に停止を集約 - NotifyIcon.Disposeを省く
→ トレイに残りやすい。Visible=false -> Disposeを必ず通す - 後始末が散る
→ どこかで抜ける。後始末を1か所へ集約し、保険で二重呼びに耐える(null化)
7. レビュー観点(そのままチェック表として使う)
| 観点 | 見るポイント |
|---|---|
| 操作の意味 | 隠す/戻す/終えるが混ざっていない |
| Exitの集約 | Exit操作が1か所にまとまっている |
| Stopの有無 | 常駐中に動く処理に止める手順がある |
| NotifyIcon | Visible=false -> Disposeが必ず通る |
| 後始末の集約 | 後始末が1か所にまとまっている |
8. 5問セルフチェック(実装のブレ止め)
- ×を押したとき、「終える」ではなく「隠す」に寄せる理由は何か
- 「終える」をトレイのメニューへ置く理由は何か
- NotifyIconで
Visible=falseを先に入れる理由は何か - 常駐中に動く処理の停止が無いと、どんな問題になりやすいか
- 後始末を散らさず1か所へ集約する狙いは何か
回答
- 常駐の体験は「画面=状態」ではない。×は表示だけを消し、プロセスは維持するため。
- 隠す/戻す/終えるを混ぜないため。終える操作が無いとプロセスが残り続ける。
- トレイに残りやすい挙動を避けるため。表示を先に落としてからDisposeを通す。
- 終了が遅い/止まらない、監視や通知が残る、外部資源が解放されない等につながりやすい。
- 後始末漏れを防ぐため。Close経路が複数でも、必ず後始末が通る形に寄せる。
関連トピック
- シリーズ総合Index(読む順・公開済リンクが最新)
- G11 メッセージループ入門(Application.RunとUIが固まる理由)
- G13 UIスレッドと非同期の実戦(SynchronizationContextとawait)
- E04 UIフリーズの正体(メッセージループ/Invoke/async)