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;
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)
{
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。 设置窗体位置及大小的API为SetWindowPos。
[DllImport("user32.dll")]
public static extern bool SetWindowPos(IntPtr hWnd, int hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
public enum HWND : int
{
TOP = 0,
BOTTOM = 1,
TOPMOST = -1,
NOTOPMOST = -2
}
public enum SWP : uint
{
ASYNCWINDOWPOS = 0x4000,
DEFERERASE = 0x2000,
FRAMECHANGED = 0x0020,
HIDEWINDOW = 0x0080,
NOACTIVATE = 0x0010,
NOCOPYBITS = 0x0100,
NOMOVE = 0x0002,
NOOWNERZORDER = 0x0200,
NOREDRAW = 0x0008,
NOSENDCHANGING = 0x0400,
NOSIZE = 0x0001,
NOZORDER = 0x0004,
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)
{
yield return new WaitForEndOfFrame();
yield return new WaitForEndOfFrame();
SetWindowPos(hWnd, 0, x, y, width, height, 0);
}
2.5 获取.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);
[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;
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;
nData.uID = uID;
nData.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO;
nData.uCallbackMessage = WM_NOTIFY_TRAY;
if (iconHwnd != IntPtr.Zero)
{
nData.hIcon = iconHwnd;
}
else
{
nData.hIcon = LoadIcon(IntPtr.Zero, (IntPtr)SystemIcons.IDI_APPLICATION);
}
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,
}
[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()
{
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, "退出");
WinUser32.SetForegroundWindow(m_HWnd);
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);
var exeDirectory = Application.dataPath + $"/../Build/Pc_x64/Test";
if (Directory.Exists(exeDirectory))
{
Directory.Delete(exeDirectory, true);
}
Directory.CreateDirectory(exeDirectory);
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($"<color=#00ff00ff>打包用时: {FormatTime(sp.ElapsedMilliseconds)}</color>");
sp.Stop();
}
private static void UpdatePcSetting(string appVersion)
{
PlayerSettings.applicationIdentifier = ApplicationIdentifier;
PlayerSettings.productName = ProductNameName;
PlayerSettings.companyName = CompanyName;
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;
}
}
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 参考文章
|