タスクトレイ常駐アプリの実装 Tips&Tricks(その2)

(前回記事:その1は、こちらです)

タスクトレイアイコンの表示に使用するNotfyIconクラスですが、その中身は、

  • マウスイベントなどを受け取るための擬似的なウィンドウ
  • 表示中のアイコンなどの情報

を保持しているだけのクラスです。
トレイアイコン表示の制御は、Win32API(shell32.dll)の「Shell_NotifyIcon」関数を、必要な場合にのみ呼び出すことで実現しています。
実際のトレイアイコン表示はOS側、おそらくタスクバーをつかさどるExplorer.exeが行っています。

#具体的なソースコードは、Microsoftからダウンロードする(参照)か、こちらをご覧ください。

以降に述べるNotifyIconクラスの不具合を理解・回避するにあたっては、NotifyIconクラスの内部構造をある程度意識しておく必要があります。

1.アプリケーション起動時のPC負荷によっては、トレイアイコンが表示されない

スタートアップ起動に指定しているプログラムなどでは、タスクマネージャ上はexeが起動されているにもかかわらず、トレイアイコンが表示されないことがあります。
Shell_NotifyIcon 関数の仕様が原因です。

通知領域にアイコンが登録されないことがある
Shell_NotifyIcon 関数では、シェルがハングアップしている場合などを考慮し、シェルへの登録作業が 4 秒以内に完了しない場合には失敗したとみなして制御を戻します。この場合、 Shell_NotifyIcon 関数は FALSE を返し、GetLastError 関数の返す値は、1460 (ERROR_TIMEOUT) となります。

http://support.microsoft.com/kb/418138/ja

引用元のページには「エラー原因がタイムアウトの場合は、時間をおいて再度登録する」という対策方法が書かれているのですが、残念ながらNotifyIconクラスでは、このような対策は一切取り込まれていません。
それどころか、登録成功/失敗したかのチェックすら省略されています。
.NET Framework 2.0だけでなく、4.0でも直っていないようです)

このため、トレイアイコンが登録・表示されないまま後続の処理が動いてしまいます。
アイコン右クリックでContextMenuStripを出せなくなるので、タスクマネージャからプロセスを止めるしか終了方法がなくなってしまいます。

回避策

自前で Shell_NotifyIcon 関数を呼び出していれば、こちらのように対策すればよいのですが、NotifyIconクラス内部の挙動に手出しするのは困難です。
エラー原因がタイムアウトなのか「アイコンの登録に4秒以上かかったか」をもとに判断し、タイムアウトだった場合は再度トレイアイコンを登録することで、代替としましょう。。。。

  //トレイアイコン
  internal static NotifyIcon tray;
  //トレイアイコンの初期化メソッド
  void InitTray() {
    tray = new NotifyIcon();
    tray.ContextMenuStrip = cms; //右クリックメニューを登録
    tray.MouseDown = ClickEvent //左クリック時のイベントを登録
    tray.Text = (ツールチップ文字列を設定)
    tray.Icon = (初期表示させるアイコン画像を設定)
    while (true) //無限ループはあまり好ましくありませんが・・・・
    {
        int tickCount = Environment.TickCount;
        tray.Visible = true;
        tickCount = Environment.TickCount - tickCount;
        if (tickCount < 4000)
        {   //4秒以内に登録できていれば成功
            break;
        }
        //失敗した場合はVisibleをfalseにしてやりなおし
        tray.Visible = false;
    }
    :

NotifyIconのソースコード実装上は、Visible=trueの状態で再度Visibleにtrueを設定しても、 Shell_NotifyIcon 関数は呼び出されません。
いったんVisibleをfalseにしないとだめです。

補足

UPnPバイスを有効にしていたり、起動時にウィルススキャンを行う設定にしていると、4秒以内にアイコンが登録できないことがままあるようです。
スタートアップに登録したソフトがタスクトレイに表示されない - elderrisの日記

2.トレイアイコン右クリック後にAlt+F4を押すと、トレイアイコンだけが消えてしまう

右クリックメニューを表示させた状態でAlt+F4を押すと、プログラムは起動したままトレイアイコンの表示だけが消えてしまい、アプリケーションの操作ができなくなります。
これもタスクマネージャからプロセスを止めるしか終了方法がなくなってしまいます。

原因

NotifyIcon内の「マウスイベントなどを受け取るための擬似的なウィンドウ」が犯人です。

NotifyIconでタスクトレイに表示したアイコンがAlt+F4で消えてしまう - Visual Studio Development フォーラム


NotifyIcon は内部にウィンドウメッセージを処理するためのトップレベルウィンドウを持っていますが、これが曲者みたいですね。

コンテキストメニューを表示する際、このウィンドウをフォアグラウンドにしてからコンテキストメニューを表示しています(じゃないとほかのウィンドウがアクティブになったときコンテキストメニューの表示のキャンセルができない)。
フォアグラウンドになった結果、このウィンドウがキー入力を受け取るようになってしまいます。そして Alt+F4 に対するデフォルトの動作は「トップレベルウィンドウを閉じる」ですから、ウィンドウが閉じてしまいます。そして NotifyIcon は関連付けられたウィンドウが閉じられるとタスクトレイから自動的に削除される仕組みのようです。

http://social.msdn.microsoft.com/Forums/ja-JP/d8399049-71c6-4e55-a192-9dfd82eed626/notifyiconaltf4?forum=csharpgeneralja

右クリックメニュー表示時にアクティブになった擬似ウィンドウがAlt+F4キー操作を受け付けて、タスクトレイアイコン(および紐付いている擬似ウィンドウ)のみを終了、破棄してしまいます。
その際、NotifyIconクラスやアプリケーションへの通知は一切行われません。
NotifyIconのVisibleプロパティもtrueのままであり、通常のメソッド/プロパティからはトレイアイコンが消えてしまっているかどうか判別することはできません。

簡易的な回避方法(※タイミングによっては回避できません)

引用元のコメントにもありますが、NotifyIcon(の中の擬似ウィンドウ)がアクティブな状態でなければ、Alt+F4を押されても問題は起こらないわけです。
NotifyIconに設定したコンテキストメニューについて、開閉のタイミングでトレイアイコン以外のウィンドウをアクティブにすることで、ある程度対策できます。

    //ContextMenuStrip(cms)の初期化処理内で、以下のイベントを仕込んでおく
    cms.Closed += (sender, e) =>
    {
        SetForegroundWindow(GetDesktopWindow());
    };

    /// <summary>フォーカス制御用</summary>
    [System.Runtime.InteropServices.DllImport("user32")]
    static extern IntPtr GetDesktopWindow();
    /// <summary>フォーカス制御用</summary>
    [System.Runtime.InteropServices.DllImport("user32")]
    [return: MarshalAs(UnmanagedType.Bool)]
    static extern bool SetForegroundWindow(IntPtr hWnd);

このソースでは、コンテキストメニューが閉じられた際に、デスクトップをアクティブにしています。
Alt+F4キーが押されてもトレイアイコンが終了しなくなります。

ただしこの対策は完璧ではありません。
Altキーを押しっぱなしの状態でマウスを右クリックし、かつ瞬時にF4キーを押した場合などは、SetForegrowndWindow()呼び出し完了するよりもまえに擬似ウィンドウがAlt+F4キー操作を受け付けてしまい、トレイアイコンが消えてしまいます・・・・。

使用するイベントをいろいろ変えて試してみたのですが、Windows7(64bit)環境ではどうもうまくいきませんでした。
ContextMenuStripのClosed/Closingイベントだと、イベント処理の発動がやや遅れてしまうようです。
かといってOpening/Openedイベントでアクティブウィンドウを変更すると、今度は他のウィンドウをアクティブにしたときに右クリックメニューが開きっぱなしになってしまいます。
VisibleChangedイベントでも同様の問題が発生します。
(Webの情報を調べた限りでは、上記の対処で解決するらしいのですが・・・・)

このため、厳密に対策を行うのであれば、上記の回避策だけではなく「トレイアイコンが消えてしまった場合の復旧」も行う必要があります。
※2013.12.08 もっと良い回避方法が見つかったので、記事を起こしました。こちらを参照ください。
タスクトレイ常駐アプリの実装 Tips&Tricks(その3・Alt+F4キー対策) - hnx8 開発室

単純な復旧方法(※非UIスレッドからの操作では、右クリックメニューが効かなくなります・・・)

トレイアイコンの「表示」だけ復旧すればよいのであれば、NotifyIconのIconプロパティに指定する画像を差し替えれば、再度アイコンが表示されるようになります。

NotifyIcon.csのソースを読み解く限りでは、内部の擬似ウィンドウが破棄されたかどうかは、NotifyIcon内部のprivate変数をもとに判定できるようです。
アイコンを差し替える操作が行われた際に、擬似ウィンドウがない(破棄された)場合はその場で擬似ウィンドウを再作成し、差し替えるアイコン/擬似ウィンドウを Shell_NotifyIcon 関数で再登録する・・・という挙動となります。

    //細かいことを考えずにアイコン表示だけ復旧させたい場合
    tray.Icon = Program.DummyIcon; //なにか適当なアイコンを一旦指定する
    tray.Icon = Program.Icon;      //本来表示されるべきアイコンを指定しなおす

NotifyIconが「現在表示中のアイコン」として把握しているアイコンとは別のものを指定しないと、 Shell_NotifyIcon 関数の呼び出しが行われません。

ですがこれも厄介でして・・・・・この「擬似ウィンドウの再作成」の操作がタイマイベントなど非UIスレッドで行われてしまうと、そのトレイアイコンはUIスレッド上でのマウス操作を一切受け付けられなくなります。
したがって、右クリックメニューなどが開かなくなってしまい、これもタスクマネージャからプロセスを止めないと終了できない状態に陥ります。

また、トレイアイコンの復旧が不要なときにも表示アイコンの差し替えを行うことで、環境によっては微妙にトレイアイコンがチラつくという問題も発生します。
やはり擬似ウィンドウが閉じられてしまっているか調べた上で、必要な場合だけ復旧操作を行ったほうがよさそうです。

きっちりした復旧方法

完成形のソースを先に示します。タイマイベントなどで定期的に呼び出される処理を想定しています。

  //右クリックメニュー(あらかじめInvoke用にUIスレッドでHandleを確保しておくこと)
  internal static ContextMenuStrip cms;
  //トレイアイコン
  internal static NotifyIcon tray;
  //NotifyIcon内部の擬似ウィンドウを取り出すためのリフレクションと、取り出したウィンドウ
  FieldInfo fi = typeof(NotifyIcon).GetField("window", BindingFlags.NonPublic | BindingFlags.Instance);
  readonly NativeWindow window == null; 

  //トレイアイコンの表示を更新するタイマイベントメソッド
  private void Update(object dummy)
  {
    //※スレッドセーフではないタイマを使用する場合は、lockなどの排他制御も必要です。

    //表示するアイコンを決定
    Icon dispIcon = ・・・・(条件に応じ、表示内容を決定する);

    if (window == null)
    {   //擬似ウィンドウの取出しがまだだったら、取り出す
        window = fi.GetValue(tray) as NativeWindow;
    }
    if (window.Handle == IntPtr.Zero)
    {   //Alt+F4でアイコンが閉じられているようなので、復元のためにnativeWindowのHandleを再作成する
        cms.Invoke((MethodInvoker)(() =>
        {   //非UIスレッドからの呼び出しの場合はUIスレッド上で操作を行うこと
            window.CreateHandle(new CreateParams());
        }));
        //表示更新のため,いったんダミーのアイコンを指定しておく。if分岐終了後に再度正しいアイコンを指定
        tray.Icon = Program.DummyIcon;
    }
    //表示したいアイコンを設定する
    tray.Icon = dispIcon;
  }

NotifyIconクラス内のプライベート変数「window」が、擬似ウィンドウです。プライベート変数なのでリフレクションで取り出します。
この擬似ウィンドウのHandleプロパティを調べることで、擬似ウィンドウが存在しているか否かを確認できます。
未生成、もしくは閉じられた後の状態であれば、IntPtr.Zeroとなります。

閉じられているようであれば、擬似ウィンドウを(正確には、擬似ウィンドウ内部のウィンドウハンドル)を再作成し、確実に Shell_NotifyIcon 関数が呼び出されるよういったん別のアイコンを指定する小細工をいれておきます。
これで、再度アイコンが表示されるようになります。

そういえば、前回の記事では細かく説明していなかった気がしますが、NotifyIconクラスのIcon,Text,Baloontipのプロパティは、既にトレイアイコンが表示済みであれば、非UIスレッドから操作してもまったく問題は起こりません。
これらのプロパティの操作では、NotifyIconクラスの内部処理としては Shell_NotifyIcon 関数を呼び出すのみであり、またトレイアイコンの表示はタスクバーのExplorer.exeが行っているためだと思われます。
いっぽう、コントロールやウィンドウハンドルの作成といった処理は、UIスレッドで行わないとUI操作を受け付けられなくなるため、Invokeが必要です。
このサンプルではContextMenuStripのUIスレッドを使用する前提です。(前回の記事を参照願います)

根本的な回避方法

NotifyIconでタスクトレイに表示したアイコンがAlt+F4で消えてしまう - Visual Studio Development フォーラム


どうしても今の時点で解決しなければならないのなら、リフレクションでこの内部の NativeWindow を取り出して新たな NativeWindow で WM_SYSCOMMAND / SC_CLOSE を無視するようにすればいいでしょう。
Shell_NotifyIcon を直接使用することも視野に入れてもいいかもしれません。Win32 プログラミングに慣れているなら、NotifyIcon クラスと言うブラックボックスを通さない分理解しやすいと思います。コーディングは面倒ですけど。NotifyIcon クラスの実装が参考になるでしょう

http://social.msdn.microsoft.com/Forums/ja-JP/d8399049-71c6-4e55-a192-9dfd82eed626/notifyiconaltf4?forum=csharpgeneralja

引用元でもアドバイスがあいますとおり、根本対処を入れるのであればNotifyIconに手を加えるしかありません。
が、残念ながらNotifyIconクラスは継承不可です。
NativeWindowクラス(厳密には、NotifyIconクラス専用にカスタマイズしたNotifyIconNativeWindow)を自前でコーディングして差し替えるか、NotifyIconクラス自体を自前で作らなければならず、かなりの難行となります・・・。

補足

DLWアクセスランプでは、「簡易的な回避方法」+「きっちりした復旧方法」を併用して乗り切っています。
タイマイベントで定期的にアイコン表示を更新するアプリケーションであり、アイコンが消えてもほぼ瞬時に復旧することになるので、実用上の問題はほぼありませんが・・・・
タイマイベントを使用していない常駐型アプリケーションの場合は、「簡易的な回避方法」のみ採用し、二重起動制御(同じアプリケーションが2つ立ち上がった場合には、1つ目のアプリケーションをkillするか、1つ目のアプリケーション側でトレイアイコン表示をやり直す)で対策するほうが良いのかもしれません。
何かいいアイデアをお持ちの方、いらっしゃいましたらぜひ教えてください。

3.Windows7で、トレイアイコンの並べ替えができない/順番がおかしくなる

Windows7ではマウスドラッグでトレイアイコンの表示順を並べ替え・記憶できるようになりました。
どうやらNotifyIconのTextプロパティ(ツールヒント表示文字列)をトレイアイコンの識別に使っているようで、Textプロパティが同一のトレイアイコンは、マウス操作で並べ替えを行っても、マウス操作とは違った並び順で表示されてしまいます。

正しく並べ替えできるようにするには

Textプロパティにそれぞれ異なる文字列を設定してください。

4.トレイアイコンのツールヒント文字列の字数制限が厳しい(64文字未満)

NotifyIconのTextプロパティに設定可能な文字列の長さは、最大63文字です。
Shell_NotifyIcon 関数(の引数のNOTIFYICONDATA)の仕様としては、
Windows2000以降は最大127文字まで設定可能なはずですが、.NET Framework4.0でも字数制限は63文字のままです。

対策1(127文字まで設定できるようにする)

NotifyIconクラスは使わずに、 Shell_NotifyIcon 関数を使って自前でトレイアイコンを登録すれば、127文字まで設定可能です。
でも、直接Win32APIを呼び出すのはちょっとしんどいですよね・・・。

対策2(別のコントロールを使ってツールチップ相当の内容を表示する)

若干ツールチップとは挙動が異なりますが、Help.ShowPopup()を使う、という手もあります。
DLWアクセスランプでは、監視時間・アクセスバイト数の累計表示でHelp.ShowPopup()を使用しています。

  //右クリックメニュー(あらかじめInvoke用にUIスレッドでHandleを確保しておくこと)
  internal static ContextMenuStrip cms;

  void ShowPopup() {
    StringBuilder sb = (表示するテキスト内容を組み立てる)
    if (cms.Created == false)
    {   //cmsが一回も表示されてないとヘルプがタスクバーの裏側に隠れるので、一瞬だけ表示する
        cms.UseWaitCursor = true;
        try
        {
            MethodInfo mi = typeof(NotifyIcon).GetMethod("ShowContextMenu", BindingFlags.NonPublic | BindingFlags.Instance);
            mi.Invoke(tray[0], null);
        }
        catch (Exception) { }
    }
    Point p = Cursor.Position;
    if (p.X + 160 > Screen.PrimaryScreen.Bounds.Width)
    {   //タスクバーを右側配置していた場合に表示しきれるように
        p.X = Screen.PrimaryScreen.Bounds.Width - 160;
    }
    Help.ShowPopup(cms, sb.ToString(), p);
  }

このポップアップはタスクバーよりも手前側に表示できますが、なぜかContextMenuStripが一回も表示されていない場合に限っては、タスクバーの後ろに表示されます。
NotifyIconのプライベートメソッド「ShowContextMenu」をうまく使うことで対策できます。
TAB(\t)を使うとインデントも綺麗にそろいます。
ただし、日本語版Windows以外で全角文字(非Ascii文字)を使うと文字化けするので、

  internal static readonly bool isJP = (Encoding.Default.CodePage == 932 && Thread.CurrentThread.CurrentUICulture.LCID == 1041 ? true : false);

のようなコードで、OSの環境が日本語OS(LCID=1041)か、デフォルトの文字コードは日本語CP932(ShiftJIS)かを判定し、日本語OSではない場合には全角文字を使わないように気をつける必要があります。

5.アプリケーションを終了させてもタスクトレイのアイコンが消えない

FormのComponentsに紐付いていないNotifyIconは、表示したままアプリケーションを終了させると、タスクトレイにアイコンが残ったままになります(そのアイコンにマウスを乗せると、消えます)。
NotifyIconのDispose()が呼ばれていないのが原因です。
(アイコンの登録を解除するよう Shell_NotifyIcon 関数を呼び出さないと、アイコンが消えません。Dispose()メソッド内でこの処理を行っているようです)

回避策

アプリケーション終了時にきっちりDispose()を呼び出しましょう。
「終了」メニュー項目に組み込む、という手もありますが、Application.ApplicationExitイベントを仕込んでおくのが確実です。

class Program
{
  static volatile NotifyIcon tray;

  [STAThread]
  static void Main(string[] args)
  {
    Application.ApplicationExit += (sender, e) =>
    {
        if (tray != null) { tray.Dispose(); } //タスクトレイから確実にアイコンを消す
        //他のメンバ変数も、必要なものは全部Disposeで後始末したほうがいいです。
    };
    
    //右クリックメニューを設定
    cms = new ContextMenuStrip();
    cms.Items.AddRange(new ToolStripItem[]{
        :
        new ToolStripMenuItem("終了(&X)", null, (sender, e) => 
        {   //アプリケーションを終了⇒ApplicationExitイベントが呼ばれる
            Application.Exit();
        })
    });
    //トレイアイコンを初期化
    tray = initNotifyIcon();
    tray.ContextMenuStrip = Program.cms;
    //アプリケーション開始(ただしフォームは表示しない)
    IntPtr dummy = Program.cms.Handle; //ウィンドウハンドル確保
    Application.Run();
  }

ただし、VisualStudioの「デバッグの停止」から停止した場合、タスクマネージャーからプロセスを強制終了した場合は、ApplicationExitイベントは発生しません。

6.(詳細条件不明)C#で作られているアプリケーションのトレイアイコンが表示されなくなる

C#(.NET Framework)で作られている一部のタスクトレイ常駐アプリケーションにて、(長時間起動している/一旦PCをサスペンド・休止するなど)何らかの条件がそろうと

  • トレイアイコンの表示スペースは確保されているのに、アイコンが表示されなくなる
  • 問題を起こしているとおもわれるアプリケーション以外のトレイアイコンも巻き添えを喰らい、次第に同じように表示されなくなる

という事象を確認しています。
Windows8.1、Windows7(64bit版)で発生します。ただしWindows7は環境によっては発生しないようです。

原因

不明です・・・・C#(.NET Framework)で作られたアプリケーション共通で発生する問題なのか、特定のアプリケーション固有の問題なのかの切り分けすらできていません。
今後何か分かったら情報追記するかもしれません。



DLWアクセスランプを作ったときに嵌ったポイントについてメモを起こしたら、異様に長くなってしまいました・・・・。
タスクトレイ常駐アプリケーションを作るにあたっては、あともういっこ「二重起動制御」というトピックがあります。
気が向いたら、二重起動制御のやりかたについても記事を書くかもしれません。
(次回に続く・・?)