Unity3D C#之IL2CPP Windows端隐藏任务栏图标并添加至托盘

2021年11月6日 0 作者 老王

1 引言

这篇文章中,我们实现了点击最小化和关闭菜单将程序隐藏到任务栏的功能,但是这篇文章需要额外一个winform程序来处理任务栏的功能,有没有方法可以不需要依赖其他程序也能实现这个需求呢?当然有的,使用Windows系统提供的API就行了。
我们先来看看完全依靠调用Windows提供的API实现的效果。
实现效果

实现的效果包括:

  • 点击菜单的最小化和关闭按钮隐藏程序
  • 记录上次程序关闭时窗体的位置及大小,下次打开时恢复
  • 程序启动时生成任务栏图标,任务栏图标与.exe的图标相同
  • 程序隐藏时,双击任务栏图标弹出程序
  • 右键任务栏图标弹出可选菜单,点击菜单执行相应的动作

实现这个功能的时候遇到点坑,特此记录一下,以备以后查阅。

2 功能实现

2.1 查找窗体

Windows提供的API为user32.dll中的FindWindow,返回窗体的句柄值(IntPtr类型),以后控制窗体时都需要这个句柄值。
C#端的声明如下,注意需要设置字符集为Unicode,不然打包的窗体如果包含中文,该方法将无法找到窗体。
注意需要引入命名空间 System 和 System.Runtime.InteropServices。

using System;
using System.Runtime.InteropServices;
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern IntPtr FindWindow(string lpszClass, string lpszTitle);

public static IntPtr GetWindow(string titleOrClassname)
{
    IntPtr hWnd = FindWindow(null, titleOrClassname); ;
    if (hWnd == IntPtr.Zero)
    {
        hWnd = FindWindow(titleOrClassname, null);
    }

    return hWnd;
}

2.2 显示、隐藏、最大、最小化窗体

控制窗体显示、隐藏、最大、最小化的Windows API是user32.dll中的ShowWindow或ShowWindowAsync。
C#中的声明如下。

[DllImport("user32.dll")]
public static extern bool ShowWindow(IntPtr hwnd, int nCmdShow);
[DllImport("user32.dll")]
public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);

两个方法中第一个参数就是2.1中使用FindWindow获取到的窗体句柄值,第二参数为具体指令值,不同的值对应不同的效果。
下面只展示了部分值,完整的指令参见MSDN

public const int SW_HIDE = 0;                               // 隐藏窗口,大小不变,激活状态不变
public const int SW_MAXIMIZE = 3;                           // 最大化窗口,显示状态不变,激活状态不变
public const int SW_SHOW = 5;                               // 在窗口原来的位置以原来的尺寸激活和显示窗口
public const int SW_MINIMIZE = 6;                           // 最小化指定的窗口并且激活在Z序中的下一个顶层窗口
public const int SW_RESTORE = 9;                            // 激活并显示窗口。如果窗口最小化或最大化,则系统将窗口恢复到原来的尺寸和位置。在恢复最小化窗口时,应用程序应该指定这个标志

2.3 拦截窗体最小化、关闭事件

方式是user32.dll中的SetWindowLongPtr64或SetWindowLong32重新设置此窗体的WndProc方法。
(WndProc方法就是用来拦截窗体的各种消息的,将WndProc设置为我们自己的方法后,想怎么处理消息就怎么处理消息)
申明如下。

[DllImport("user32.dll", EntryPoint = "SetWindowLong")]
private static extern int SetWindowLong32(HandleRef hWnd, int nIndex, int dwNewLong);
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")]
private static extern IntPtr SetWindowLongPtr64(HandleRef hWnd, int nIndex, IntPtr dwNewLong);

第一个参数HandleRef hWnd,仍然为窗体的句柄值。2.1中咱们获取的句柄值是IntPtr格式的,所以需要转换一下,代码如下。

HandleRef handleRef = new HandleRef(null, intPtr);

第二个参数nIndex,设置WndProc固定为-4(表示GWL_WNDPROC,为窗口设定一个新的处理函数)。
其他值表示什么功能,参见MSDN
第三个参数dwNewLong,是新的WndProc方法的句柄值。
那么,C#中怎么获取到一个方法的句柄值呢?
首先,声明一个与系统WndProc方法签名完全相同的委托。

public delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);

然后,实例化上面申明的委托,并调用Marshal.GetFunctionPointerForDelegate方法即可获取到该方法的句柄值。

var newWndProc = new WndProcDelegate(WndProc);
var newWndProcPtr = Marshal.GetFunctionPointerForDelegate(newWndProc);

[MonoPInvokeCallback]
private static IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
{
    // TODO 处理消息
    return CallWindowProc(m_OldWndProcPtr, hWnd, msg, wParam, lParam);
}

看上面咱们自己的WndProc方法,有4点需要注意的地方:
①它一定静态static的,不然C++将无法调用
②它有一个MonoPInvokeCallback特性,虽然此特性其实是个空特性,但也是必须的,其定义如下

public class MonoPInvokeCallbackAttribute : Attribute
{
    public MonoPInvokeCallbackAttribute() { }
}

③处理完我们想特殊处理的消息后,需要调用CallWindowProc将其他消息继续传递出去。
CallWindowProc的声明如下。

[DllImport("user32.dll")]
public static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);

④WndProc方法中的参数msg表示此消息的类型,wParam和lParam为此消息带的参数值,不同的消息带有不同的参数,具体的消息值及消息带的什么参数得查MSDN了。
比如msg = 0x0112(WM_SYSCOMMAND)时,就表示是此消息用户点击窗体菜单的消息,通过wParam参数来明确用户到底点击了什么,如wParam = 0xF060(SC_CLOSE)就表示点击的是菜单栏的关闭按钮,具体见MSDN

完整的代码如下。

private HandleRef m_HMainWindow;
private static IntPtr m_OldWndProcPtr;
private IntPtr m_NewWndProcPtr;
private WndProcDelegate m_NewWndProc;

private void InitWndProc()
{
    m_HWnd = WinUser32.GetWindow(AppConst.AppName);
    m_HMainWindow = new HandleRef(null, m_HWnd);
    m_NewWndProc = new WndProcDelegate(WndProc);
    m_NewWndProcPtr = Marshal.GetFunctionPointerForDelegate(m_NewWndProc);
    m_OldWndProcPtr = WinUser32.SetWindowLongPtr(m_HMainWindow, -4, m_NewWndProcPtr);
}

private void TermWndProc()
{
    WinUser32.SetWindowLongPtr(m_HMainWindow, -4, m_OldWndProcPtr);
    m_HMainWindow = new HandleRef(null, IntPtr.Zero);
    m_OldWndProcPtr = IntPtr.Zero;
    m_NewWndProcPtr = IntPtr.Zero;
    m_NewWndProc = null;
}

[MonoPInvokeCallback]
private static IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
{
    if (msg == WinUser32.WM_SYSCOMMAND)
    {
        // 屏蔽窗口顶部关闭最小化事件
        switch ((int)wParam)
        {
            case WinUser32.SC_CLOSE:
                // 关闭
                return IntPtr.Zero;
            case WinUser32.SC_MAXIMIZE:
                // 最大化
                break;
            case WinUser32.SC_MINIMIZE:
                // 最小化
                return IntPtr.Zero;
        }
    }
    return WinUser32.CallWindowProc(m_OldWndProcPtr, hWnd, msg, wParam, lParam);
}

2.4 获取、设置窗体位置及大小

获取窗体位置及大小Windows提供的API为GetWindowRect。

[StructLayout(LayoutKind.Sequential)]
public struct RECT
{
    internal int Left;
    internal int Top;
    internal int Right;
    internal int Bottom;
}

[DllImport("user32.dll", SetLastError = true)]
public static extern bool GetWindowRect(IntPtr hwnd, out RECT lpRect);

此方法获取到RECT lpRect中的值与屏幕的关系如下图。
窗体的宽 = Right – Left。
窗体的高 = Bottom – Top。
GetWindowRect获取到的值与屏幕大小关系
设置窗体位置及大小的API为SetWindowPos。

[DllImport("user32.dll")]
public static extern bool SetWindowPos(IntPtr hWnd, int hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);

// An enumeration containing all the possible HWND values. Window handles (HWND) used for hWndInsertAfter
public enum HWND : int
{
   TOP = 0,                                // 在前面
   BOTTOM = 1,                             // 在后面
   TOPMOST = -1,                           // 在前面, 位于任何顶部窗口的前面
   NOTOPMOST = -2                          // 在前面, 位于其他顶部窗口的后面
}                                           

// And enumeration containing all the possible SWP values. SetWindowPos Flags
public enum SWP : uint                      
{                                          // 
   ASYNCWINDOWPOS = 0x4000,                // 
   DEFERERASE = 0x2000,                    // 
   FRAMECHANGED = 0x0020,                  // 强制发送 WM_NCCALCSIZE 消息, 一般只是在改变大小时才发送此消息
   HIDEWINDOW = 0x0080,                    // 
   NOACTIVATE = 0x0010,                    // 不激活
   NOCOPYBITS = 0x0100,                    // 
   NOMOVE = 0x0002,                        // 忽略 X、Y, 不改变位置
   NOOWNERZORDER = 0x0200,                 // 
   NOREDRAW = 0x0008,                      // 不重绘
   NOSENDCHANGING = 0x0400,                // 
   NOSIZE = 0x0001,                        // 忽略 cx、cy, 保持大小
   NOZORDER = 0x0004,                      // 忽略 hWndInsertAfter, 保持 Z 顺序
   SHOWWINDOW = 0x0040                     // 
}

SetWindowPos各个参数分别如下:

  • IntPtr hWnd 窗口句柄
  • int hWndInsertAfter 见HWND 枚举值,用于设置窗体的显示位置
  • int X, int Y 窗体左上角相对于屏幕左上角的位置
  • int cx, int cy 窗体的宽高
  • uint uFlags 见SWP枚举值,用于确定方法具体的指令
    可参考此篇文章MSDN
    有点需要注意的是,调用此方法时最好延迟两帧再执行。
    因为Unity的Screen.SetResolution也可以设置屏幕大小,但是此方法会在下一帧生效。为了让我们的SetWindowPos生效,所以最好延迟两帧再执行。
// 设置窗口位置及大小
private IEnumerator SetWindowPositionAndSize(IntPtr hWnd, int x, int y, int width, int height)
{
    // Screen.SetResolution会在下一帧执行
    //Screen.SetResolution(800, 800, false);

    // 延迟两帧再执行,防止本帧其他地方有调用Screen.SetResolution
    yield return new WaitForEndOfFrame();
    yield return new WaitForEndOfFrame();
    SetWindowPos(hWnd, 0, x, y, width, height, 0);
}

2.5 获取.exe的Icon

什么是.exe的Icon?
.exe的Icon
怎么获取呢,使用shell32.dll中的ExtractAssociatedIcon就可以提取到相应文件或文件夹的Icon的句柄值,注意字符集需设置为Unicode否则无法支持中文路径。

[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
public static extern IntPtr ExtractAssociatedIcon(IntPtr hInst, StringBuilder lpIconPath,
    out ushort lpiIcon);

第一个参数为窗体的句柄值,第二个参数为文件或文件夹的路径。
获取打包后程序.exe文件的Icon,完整代码如下。

DirectoryInfo assetData = new DirectoryInfo(Application.dataPath);
if (assetData.Parent == null)
    return;
var exeFilePath = $"{assetData.Parent.FullName}\\{AppConst.ExeName}.exe";
StringBuilder exeFileSb = new StringBuilder(exeFilePath);
IntPtr iconPtr = Shell_NotifyIconEx.ExtractAssociatedIcon(m_HWnd, exeFileSb, out ushort uIcon);

有同学会问,我想获取系统自带的Icon该怎么获取呢?
这时就需要使用另外一个API,user32.dll中LoadIcon了。

[DllImport("user32.dll")]
public static extern IntPtr LoadIcon(IntPtr hInstance, IntPtr lpIconName);

public enum SystemIcons
{
    IDI_APPLICATION = 32512,
    IDI_HAND = 32513,
    IDI_QUESTION = 32514,
    IDI_EXCLAMATION = 32515,
    IDI_ASTERISK = 32516,
    IDI_WINLOGO = 32517,
    IDI_WARNING = IDI_EXCLAMATION,
    IDI_ERROR = IDI_HAND,
    IDI_INFORMATION = IDI_ASTERISK,
}

其中,第二个参数为系统的图标类型,具体见SystemIcons枚举值。

2.6 创建任务栏图标

使用到的核心API为shell32.dll中的Shell_NotifyIcon方法,注意字符集一定要设置为Unicode,否则无法支持中文。
NOTIFYICONDATA结构体的字符集也一定要设置为Unicode

[DllImport("shell32.dll", EntryPoint = "Shell_NotifyIcon", CharSet = CharSet.Unicode)]
private static extern bool Shell_NotifyIcon(int dwMessage, ref NOTIFYICONDATA lpData);

// 注意一定要指定字符集为Unicode,否则气泡内容不能支持中文
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct NOTIFYICONDATA
{
    internal int cbSize;
    internal IntPtr hwnd;
    internal int uID;
    internal int uFlags;
    internal int uCallbackMessage;
    internal IntPtr hIcon;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
    internal string szTip;
    internal int dwState; // 这里往下几个是 5.0 的精华
    internal int dwStateMask;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
    internal string szInfo;
    internal int uTimeoutAndVersion;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
    internal string szInfoTitle;
    internal int dwInfoFlags;
}

Shell_NotifyIcon方法中的参数分别如下:

  • int dwMessage 具体的指令类型,如0x00(NIM_ADD)表示添加一个任务栏图标,0x01(NIM_MODIFY)表示修改任务栏图标的内容,0x02(NIM_DELETE)表示删除任务栏图标
  • ref NOTIFYICONDATA lpData 该任务栏图标的具体内容,其中包括任务栏图标的icon、提示内容等等

其中需要注意uCallbackMessage、uID,分别对应我们上面的WndProc方法中的msg及wParam。如果同一程序有多个任务栏图标,每个任务栏图标的uCallbackMessage、uID需要指定为不同的值。
创建一个NOTIFYICONDATA的代码如下。

private NOTIFYICONDATA GetNOTIFYICONDATA(IntPtr iconHwnd, string sTip, string boxTitle, string boxText)
{
    NOTIFYICONDATA nData = new NOTIFYICONDATA();
    // 结构的大小
    nData.cbSize = Marshal.SizeOf(nData);
    // 处理消息循环的窗体句柄,可以移成主窗体
    nData.hwnd = formTmpHwnd;
    // 消息的 WParam,回调时用
    nData.uID = uID;
    // 标志,表示由消息、图标、提示、信息组成
    nData.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO;
    // 消息ID,回调用
    nData.uCallbackMessage = WM_NOTIFY_TRAY;
    if (iconHwnd != IntPtr.Zero)
    {
        nData.hIcon = iconHwnd;
    }
    else
    {
        // 使用默认的程序图标
        nData.hIcon = LoadIcon(IntPtr.Zero, (IntPtr)SystemIcons.IDI_APPLICATION);
    }

    // 提示的超时值(几秒后自动消失)和版本
    //nData.uTimeoutAndVersion = 10 * 1000 | NOTIFYICON_VERSION; 
    // 类型标志,有INFO、WARNING、ERROR,更改此值将影响气泡提示框的图标类型
    nData.dwInfoFlags = NIIF_INFO;

    // 图标的提示信息
    nData.szTip = sTip;
    // 气泡提示框的标题
    nData.szInfoTitle = boxTitle;
    // 气泡提示框的提示内容
    nData.szInfo = boxText;

    return nData;
}

可参考此篇文章

2.7 创建任务栏菜单

先看创建菜单,用到的API如下。

[Flags]
public enum MenuFlags : uint
{
    MF_STRING = 0,
    MF_BYPOSITION = 0x400,
    MF_SEPARATOR = 0x800,
    MF_REMOVE = 0x1000,
}

// http://www.pinvoke.net/default.aspx/user32/CreatePopupMenu.html
[DllImport("user32")]
public static extern IntPtr CreatePopupMenu();

[DllImport("user32.dll", CharSet = CharSet.Auto)]
public static extern bool AppendMenu(IntPtr hMenu, MenuFlags uFlags, uint uIDNewItem, string lpNewItem);

具体步骤是先创建一个菜单菜单(CreatePopupMenu),然后添加菜单项AppendMenu。
AppendMenu的参数分别如下:

  • IntPtr hMenu 菜单的句柄值,CreatePopupMenu会返回新创建菜单的句柄值
  • MenuFlags uFlags 值为MF_STRING 表示字符内容,MF_SEPARATOR 表示分隔线
  • uint uIDNewItem 表示此菜单项的ID值,点击菜单时WndProc会被调用,此时msg = 0x0111(WM_COMMAND), wParam = 此菜单项的ID值,所以各菜单项的ID值不能重复
  • string lpNewItem 此菜单项显示的内容

菜单创建完成后,还需要调用user32.dll中的TrackPopupMenuEx将其弹出来。
需要注意的是,在调用TrackPopupMenuEx前一定要先调用SetForegroundWindow,将此窗体置为最前并激活,否则会出现鼠标点击其他地方弹出的菜单栏却无法被删除的问题,参考StackOverFlow。紧接着需要调用DestroyMenu将其销毁(TrackPopupMenuEx是阻塞式的,只用用户做了操作才会继续往下执行)。

[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern bool TrackPopupMenuEx(IntPtr hmenu, uint fuFlags, int x, int y,
   IntPtr hwnd, IntPtr lptpm);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static  extern bool DestroyMenu(IntPtr hMenu);

TrackPopupMenuEx的参数如下:

  • IntPtr hmenu 菜单的句柄值
  • uint fuFlags 菜单弹出的起始位置,如2就表示垂直右边弹出,其他值见MSDN
  • int x, int y 弹出的位置,相对为屏幕坐标(屏幕左上角为(0, 0), 右下角为(屏幕宽,屏幕高))
  • IntPtr hwnd 窗体句柄值
  • IntPtr lptpm 我们这里使用IntPtr.Zero,具体见MSDN

完整代码如下。

// 创建任务栏菜单
private static void CreateNotifyIconMenu()
{
    // 获取屏幕数量及宽高
    //int monitorCnt = WinUser32.GetSystemMetrics(WinUser32.SystemMetric.SM_CMONITORS);
    //var width = WinUser32.GetSystemMetrics(WinUser32.SystemMetric.SM_CXSCREEN);
    //var height = WinUser32.GetSystemMetrics(WinUser32.SystemMetric.SM_CYSCREEN);
    WinUser32.GetCursorPos(out var cursorPoint);
    IntPtr menuPtr = WinUser32.CreatePopupMenu();
    WinUser32.AppendMenu(menuPtr, WinUser32.MenuFlags.MF_STRING, MinimizeID, "最小化");
    WinUser32.AppendMenu(menuPtr, WinUser32.MenuFlags.MF_STRING, MaximizeID, "最大化");
    WinUser32.AppendMenu(menuPtr, WinUser32.MenuFlags.MF_SEPARATOR, 0, "");
    WinUser32.AppendMenu(menuPtr, WinUser32.MenuFlags.MF_STRING, QuitID, "退出");

    // 注意:调用SetForegroundWindow是为了鼠标点击别处时隐藏弹出的菜单,不能省略
    // https://stackoverflow.com/questions/4145561/system-tray-context-menu-doesnt-disappear
    WinUser32.SetForegroundWindow(m_HWnd);
    // 菜单点击时会发送WinUser32.WM_COMMAND消息,wParam为菜单的ID值
    WinUser32.TrackPopupMenuEx(
        menuPtr,
        2,
        cursorPoint.X,
        cursorPoint.Y,
        m_HWnd,
        IntPtr.Zero
    );
    WinUser32.DestroyMenu(menuPtr);
}

2.8 获取鼠标位置

这个API就比较简单了,如下。

[StructLayout(LayoutKind.Sequential)]
public struct POINT
{
    public int X;
    public int Y;

    public POINT(int x, int y)
    {
        this.X = x;
        this.Y = y;
    }
}

[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetCursorPos(out POINT lpPoint);

2.9 打包工具

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using UnityEditor;
using UnityEngine;

public class BuildPcTool
{
    private const string ProductNameName = "WindowsAPI测试软件";
    private const string AppName = "WindowsAPI测试";
    private const string ApplicationIdentifier = "com.laowangomg";
    private const string CompanyName = "laowang";
    private const string AppVersion = "0.0.0.1";                                // 软件版本号
    private const string Scene = "demo.unity";                                  // 入口场景

    [MenuItem("Build/生成Windows_X86_64_测试包", false, 1)]
    public static void BuildExe64Embedded()
    {
        Stopwatch sp = new Stopwatch();
        sp.Start();

        UpdatePcSetting(AppVersion);
        // 设置宏
        //PlayerSettings.SetScriptingDefineSymbolsForGroup(BuildTargetGroup.Standalone, "");
        var exeDirectory = Application.dataPath + "/../Build/Pc_x64/Test";
        if (Directory.Exists(exeDirectory))
        {
            Directory.Delete(exeDirectory, true);
        }
        Directory.CreateDirectory(exeDirectory);
        // 打包出的exe文件的名称
        var exePath = exeDirectory +"/{AppName}.exe";       
        BuildPipeline.BuildPlayer(CollectBuildScenePaths(), exePath, BuildTarget.StandaloneWindows64, BuildOptions.None);
        var dllPath = "{exeDirectory}/{AppName}_BackUpThisFolder_ButDontShipItWithYourGame";
        FileUtil.DeleteFileOrDirectory(dllPath);
        Application.OpenURL(exeDirectory.Replace('/', '\\'));

        UnityEngine.Debug.Log("打包用时: {FormatTime(sp.ElapsedMilliseconds)}");
        sp.Stop();
    }

    private static void UpdatePcSetting(string appVersion)
    {
        PlayerSettings.applicationIdentifier = ApplicationIdentifier;
        // Windows标题栏
        PlayerSettings.productName = ProductNameName;
        // AppData\LocalLow目录子文件夹名
        PlayerSettings.companyName = CompanyName;
        // 设置IL2CPP模式
        PlayerSettings.SetScriptingBackend(BuildTargetGroup.Standalone, ScriptingImplementation.IL2CPP);  
        PlayerSettings.displayResolutionDialog = ResolutionDialogSetting.Disabled;
        PlayerSettings.fullScreenMode = FullScreenMode.Windowed;
        PlayerSettings.defaultScreenWidth = 1280;
        PlayerSettings.defaultScreenHeight = 800;
        PlayerSettings.SplashScreen.show = false;
        PlayerSettings.runInBackground = true;
        PlayerSettings.resizableWindow = true;
        PlayerSettings.forceSingleInstance = true;
        PlayerSettings.bundleVersion = appVersion;
        AddSceneToBuildSetting(Scene);
    }

    private static string[] CollectBuildScenePaths()
    {
        var scenes = new string[EditorBuildSettings.scenes.Length];
        for (var i = 0; i  searchScenePaths = new List() { "Assets/Scenes" };
        string[] allGuids = AssetDatabase.FindAssets("t:Scene", searchScenePaths.ToArray());
        List scenes = new List();
        foreach (string guid in allGuids)
        {
            string sceneFullPath = AssetDatabase.GUIDToAssetPath(guid);
            string[] names = sceneFullPath.Split('/');
            if (names[names.Length - 1] == sceneName)
            {
                scenes.Add(new EditorBuildSettingsScene(sceneFullPath, true));
            }
        }

        EditorBuildSettings.scenes = scenes.ToArray();
    }

    // 毫秒转换为分秒格式
    public static string FormatTime(double milliseconds)
    {
        double getSecond = milliseconds * 1.0 / 1000;
        double getDoubleMinute = Math.Floor(getSecond / 60);
        string minuteTime = string.Empty;
        string secondTime = string.Empty;
        string resultShow = string.Empty;
        if (getDoubleMinute >= 1)
        {
            minuteTime = getDoubleMinute >= 10 ? "{getDoubleMinute}" :"0{getDoubleMinute}";
            double minute = getDoubleMinute * 60;
            double remainSecond = getSecond - minute;
            double second = Math.Floor(remainSecond);
            secondTime = "{(second >= 10 ? second.ToString() : "0" + second)}";
            resultShow ="{minuteTime}分{secondTime}秒";
        }
        else
        {
            secondTime = getSecond >= 10 ? getSecond.ToString() : ("0" + getSecond);
            resultShow = $"0分{secondTime}秒";
        }
        return resultShow;
    }
}

3 无法解决的问题

测试发现,有些版本的Unity打包出来后,频繁调用ShowWindow或ShowWindowAsync会输出Interal: JobTempAlloc has allocations that are more than 4 frames old – this is not allowed and likely a leak.的错误提示,目测是Unity自身的bug。
如果有报这个错,只有用不同的版本多测试一下咯

4 完整项目

链接:https://pan.baidu.com/s/1Zpvu4AkNh7LTF_pRcCMGrA
提取码:awax

5 参考文章