桌面常驻程序(如壁纸软件、后台工具、桌面宠物)通常需要一个系统托盘图标来接管主程序的呼出与退出。由于 Unity 引擎自身并未提供跨平台的系统托盘 API,我们需要引入 .NET Framework 的核心窗体组件,直接调用 Windows 原生组件来完成这一功能。

示例工程

关键前置准备:导入必要的 DLL 文件

Unity 默认的引用库中并不直接包含 Windows 窗体应用的核心库。在编写任何代码前,必须手动进行以下环境配置:

  1. 修改 API 兼容级别:
    打开 Unity 的 Edit -> Project Settings -> Player -> Other Settings,找到 Api Compatibility Level,必须将其修改为 .NET Framework(注意不要选 .NET Standard)。
  2. 提取系统原生 DLL:
    打开你的 Windows 系统的本地资源管理器,导航至以下目录:
    C:\Windows\Microsoft.NET\Framework64\v4.0.30319(32位系统则在 Framework 目录下)。
  3. 导入到 Unity 工程:
    在该目录中找到 System.Windows.Forms.dll 和 System.Drawing.dll 这两个文件。在 Unity 的 Assets 目录下新建一个名为 Plugins 的文件夹,将这两个 DLL 文件直接拖入该文件夹中。

完成以上三步,Unity 才能成功识别 NotifyIcon 和 Icon 等类。

系统托盘构建指南

实现系统托盘主要依赖于 NotifyIcon 类,结合 ContextMenu 构建右键交互菜单。

  1. 初始化右键菜单与事件绑定:
    创建一个 ContextMenu 实例,并通过 MenuItems.Add 添加如“显示游戏”与“退出游戏”等交互选项。接着,直接将自定义的处理逻辑(例如调用 Application.Quit())绑定到这些菜单项的点击事件上。

  2. 配置托盘图标 (NotifyIcon):
    实例化 NotifyIcon,设置其 Text 属性以提供鼠标悬停时的提示文字。将前面创建的右键菜单绑定到 ContextMenu 属性,并为 DoubleClick 事件挂载呼出主界面的逻辑。

  3. 生命周期管理(极其重要):
    托盘图标不会随 Unity 进程的结束而自动销毁,这会导致任务栏残留“幽灵图标”。必须在 MonoBehaviour 的 OnApplicationQuit 生命周期钩子中,将 NotifyIcon.Visible 设为 false,并调用 Dispose() 彻底释放资源。

完整实现代码

以下是 SystemTrayManager.cs 的完整源码,您可以直接挂载使用:

// ***********************************************************************************
// FileName: SystemTrayManager.cs
// Description: 为 Unity 游戏添加 Windows 系统托盘及右键菜单
// *************************************************************************************

using System;
using System.Collections;
using System.Drawing;
using System.IO;
using System.Windows.Forms;
using UnityEngine;
using UnityEngine.Rendering;
using Application = UnityEngine.Application;
using ContextMenu = System.Windows.Forms.ContextMenu;
using Debug = UnityEngine.Debug;

// ReSharper disable CheckNamespace
namespace OnyxCore.UnityCore.Runtime
{
    public class SystemTrayManager : MonoBehaviour
    {
        private NotifyIcon trayIcon;
        private ContextMenu trayMenu;

        /// <summary>
        /// Start 函数会在脚本实例被启用时调用,并且只在第一帧更新之前执行一次。
        /// </summary>
        private void Start()
        {
            if (!Application.isEditor)
            {
                Application.runInBackground = true;
                // 等待 Logo 播放完毕
                StartCoroutine(WaitSplashScreenAndApply());
            }
        }

        IEnumerator WaitSplashScreenAndApply()
        {
            // 1. 等待 Unity 启动动画结束
            while (!SplashScreen.isFinished)
            {
                yield return null;
            }

            // 2. 额外缓冲 0.5 秒,确保渲染管线稳定
            yield return new WaitForSeconds(0.5f);

            // 3. 初始化系统托盘
            AddSystemTrayIcon();
        }

        private void AddSystemTrayIcon()
        {
            // 1. 初始化右键菜单
            trayMenu = new ContextMenu();

            // 添加菜单项和点击事件
            trayMenu.MenuItems.Add("显示游戏", OnShowClicked);
            trayMenu.MenuItems.Add("退出游戏", OnExitClicked);

            // 2. 初始化托盘图标
            trayIcon = new NotifyIcon();
            trayIcon.Text = "胡须流浪者"; // 鼠标悬停时的提示文字

            // 加载 .ico 图标文件
            var iconPath = Path.Combine(Application.streamingAssetsPath, "tray_icon.ico");
            if (File.Exists(iconPath))
            {
                trayIcon.Icon = new Icon(iconPath);
            }
            else
            {
                Debug.LogError("找不到托盘图标文件!请检查 StreamingAssets 目录。");
            }

            // 绑定右键菜单
            trayIcon.ContextMenu = trayMenu;

            // 绑定双击事件
            trayIcon.DoubleClick += OnShowClicked;

            // 显示托盘图标
            trayIcon.Visible = true;
        }

        // 点击“显示游戏”或双击托盘时的逻辑
        private void OnShowClicked(object sender, EventArgs e)
        {
            // 这里可以补充呼出游戏主窗口的逻辑
            Debug.Log("显示游戏");
        }

        // 点击“退出”时的逻辑
        private void OnExitClicked(object sender, EventArgs e)
        {
            Application.Quit();
        }

        // 必须在程序退出时销毁托盘图标,否则它会一直残留在任务栏直到鼠标划过
        void OnApplicationQuit()
        {
            if (trayIcon != null)
            {
                trayIcon.Visible = false;
                trayIcon.Dispose();
                trayIcon = null;
            }
        }
    }
}

填坑指南:GameFramework 框架下的图标丢失问题

如果你在项目中使用 GameFramework (GF) 框架进行资源管理,在构建 AssetBundle 包时,GF 默认的 ResourceBuilder 会清空 StreamingAssets 文件夹,导致你的 .ico 图标丢失。

解决此问题的最佳实践是利用 Unity 的 IPostprocessBuildWithReport 接口编写一段 Editor 脚本。将优先级设置得较高(例如 100)以确保其在 GF 构建完成后执行,直接将保存在安全目录(如自定义的 CustomFiles)下的 .ico 文件,在打包后自动拷贝回最终输出目录下的 StreamingAssets 文件夹中。这样既不干扰框架的打包流程,又能确保托盘组件在运行时顺利读取到图标文件。