0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

G19 【外伝】×で終わらない常駐アプリの作り方 NotifyIconトレイ常駐の作法(隠す/戻す/終えるを分ける)

Posted at

連載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問セルフチェック(実装のブレ止め)

  1. ×を押したとき、「終える」ではなく「隠す」に寄せる理由は何か
  2. 「終える」をトレイのメニューへ置く理由は何か
  3. NotifyIconで Visible=false を先に入れる理由は何か
  4. 常駐中に動く処理の停止が無いと、どんな問題になりやすいか
  5. 後始末を散らさず1か所へ集約する狙いは何か
回答
  1. 常駐の体験は「画面=状態」ではない。×は表示だけを消し、プロセスは維持するため。
  2. 隠す/戻す/終えるを混ぜないため。終える操作が無いとプロセスが残り続ける。
  3. トレイに残りやすい挙動を避けるため。表示を先に落としてからDisposeを通す。
  4. 終了が遅い/止まらない、監視や通知が残る、外部資源が解放されない等につながりやすい。
  5. 後始末漏れを防ぐため。Close経路が複数でも、必ず後始末が通る形に寄せる。

関連トピック


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?