在这篇文章中,我们实现了点击最小化和关闭菜单将程序隐藏到任务栏的功能,但是这篇文章需要额外一个winform程序来处理任务栏的功能,有没有方法可以不需要依赖其他程序也能实现这个需求呢?当然有的,使用Windows系统提供的API就行了。
我们先来看看完全依靠调用Windows提供的API实现的效果。
实现的效果包括:
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;
}
控制窗体显示、隐藏、最大、最小化的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; // 激活并显示窗口。如果窗口最小化或最大化,则系统将窗口恢复到原来的尺寸和位置。在恢复最小化窗口时,应用程序应该指定这个标志
方式是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);
}
获取窗体位置及大小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。
设置窗体位置及大小的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各个参数分别如下:
// 设置窗口位置及大小
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);
}
什么是.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枚举值。
使用到的核心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方法中的参数分别如下:
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;
}
可参考此篇文章。
先看创建菜单,用到的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的参数分别如下:
菜单创建完成后,还需要调用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的参数如下:
完整代码如下。
// 创建任务栏菜单
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);
}
这个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);
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 < scenes.Length; i++)
{
scenes[i] = EditorBuildSettings.scenes[i].path;
}
return scenes;
}
private static void AddSceneToBuildSetting(string sceneName)
{
List<string> searchScenePaths = new List<string>() { "Assets/Scenes" };
string[] allGuids = AssetDatabase.FindAssets("t:Scene", searchScenePaths.ToArray());
List<EditorBuildSettingsScene> scenes = new List<EditorBuildSettingsScene>();
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;
}
}
测试发现,有些版本的Unity打包出来后,频繁调用ShowWindow或ShowWindowAsync会输出Interal: JobTempAlloc has allocations that are more than 4 frames old - this is not allowed and likely a leak.的错误提示,目测是Unity自身的bug。
如果有报这个错,只有用不同的版本多测试一下咯。
博主本文博客链接。
链接:https://pan.baidu.com/s/1Zpvu4AkNh7LTF_pRcCMGrA
提取码:awax