三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity PC应用窗口自定义:彻底摆脱播放器感,实现专业级无边框窗口

Unity PC应用窗口自定义:彻底摆脱播放器感,实现专业级无边框窗口

1. 项目概述:从播放器到专业应用的最后一步

如果你用Unity开发过PC游戏或者工具应用,大概率经历过这个阶段:在编辑器里跑得飞快,画面炫酷,交互流畅,一切都那么完美。然后你满怀期待地点击“Build”,生成一个.exe文件,双击打开——一个带着默认Windows标题栏、可能还有难看边框的窗口弹了出来。那一刻,感觉就像精心准备的晚宴,最后端上来的主菜却用了一次性塑料餐盒装着。项目的专业感瞬间崩塌。

这就是我们今天要啃的硬骨头:Unity打包PC端应用时,如何彻底摆脱那个“播放器感”十足的默认窗口,实现隐藏标题栏和深度自定义窗口外观。这不仅仅是“看起来更酷”,对于很多工具类、演示类、信息展示类应用来说,一个干净、无干扰、甚至形状独特的窗口,是产品体验的核心组成部分。我见过太多优秀的Unity项目,最终卡在了这“最后一公里”的交付体验上,要么是窗口无法置顶,要么是隐藏标题栏后窗口没法拖动,或者自定义边框带来了诡异的点击穿透问题。

网上的资料零散且坑多,有的只讲调用Windows API,却不提Unity不同渲染管线下的差异;有的实现了无边框,但窗口再也无法移动;更常见的是,代码在编辑器里运行良好,一打包就失效。基于我这些年踩过的坑和项目实战经验,这篇指南将系统性地拆解从标准“播放器窗口”到“专业级应用窗口”的完整路径。我们会深入原理,提供可直接复制粘贴的健壮代码,并重点分享那些官方文档不会告诉你的“避坑秘籍”。无论你是想做一个无边框的仪表盘、一个可自定义形状的创意工具,还是一个需要隐藏所有系统UI的Kiosk模式应用,这里都有你需要的答案。

2. 核心需求解析:为什么以及何时需要自定义窗口

在动手写任何一行代码之前,我们必须先厘清需求。盲目地隐藏标题栏或修改窗口样式,可能会引入意想不到的交互问题。通常,驱动我们进行窗口自定义的需求可以归结为以下几类,每一种都对应着不同的技术实现深度和潜在风险。

2.1 提升视觉沉浸感与产品专业度

这是最常见也是最直接的需求。Unity默认的窗口带有标准的Windows标题栏(包含图标、标题、最小化、最大化、关闭按钮)和可调整大小的边框。对于游戏而言,全屏模式可以解决这个问题。但对于非游戏应用,尤其是以下场景:

  • 数字标牌与信息展示屏:在商场、展厅、会议室,应用需要长时间全屏显示,任何系统UI都是干扰。
  • 创意工具与艺术装置:例如音乐可视化软件、交互式艺术投影,窗口本身需要成为画布的一部分,而非一个容器。
  • 模拟器与专业控制面板:追求极致的界面还原,模仿真实设备的屏幕,不允许有多余的边框。

在这些场景下,隐藏标题栏和边框是刚需。目标是将Unity渲染的内容直接作为“窗口”本身呈现给用户,实现应用与操作系统桌面环境的视觉剥离,让用户完全聚焦于内容。

2.2 实现特殊的窗口交互逻辑

标准窗口的交互是由Windows系统管理的。当你隐藏了标题栏,就意味着失去了系统提供的窗口移动、缩放、关闭等标准交互方式。这时,自定义需求就出现了:

  • 自定义拖拽区域:你可能希望点击应用的某个特定UI区域(比如一个自定义的顶部工具栏)来拖动窗口,而不是整个窗口客户区。
  • 实现非矩形窗口:通过透明通道和鼠标点击检测,创造出圆形、圆角矩形或其他任意形状的窗口。这在制作桌面宠物、个性化小部件时非常流行。
  • 禁用或改写系统菜单:右键点击标题栏弹出的系统菜单(还原、移动、大小、最小化、最大化、关闭)可能不符合应用逻辑,需要禁用或替换为自定义菜单。

这类需求要求我们不仅改变窗口的外观,还要接管一部分窗口消息处理逻辑,技术复杂度更高。

2.3 满足特定的部署与运行环境要求

在一些受限或特殊的部署环境中,对窗口有硬性规定:

  • Kiosk模式/信息亭模式:应用需要锁定前台,防止用户切换到其他程序。通常需要结合无边框、全屏、以及屏蔽系统快捷键(如Alt+F4, Alt+Tab)来实现。
  • 多显示器拼接与控制:在虚拟现实、环幕投影等系统中,应用可能需要跨多个显示器显示一个无缝的巨大画面,这就要求窗口能够无边框且精准定位。
  • 与其他桌面应用的窗口层级管理:需要确保你的应用窗口始终位于某些窗口之上或之下,这涉及到窗口置顶(Top-most)属性的管理,而自定义窗口样式是精细控制这些属性的前提。

理解你的核心需求属于哪一类,是选择正确技术方案的第一步。例如,如果仅仅是为了全屏沉浸,或许Screen.fullScreen = true就够了;但如果需要在窗口模式下无边框且可移动,就必须深入Windows API的层面。

3. 技术方案选型与原理剖析

Unity本身提供了一些基础的窗口设置,但对于深度自定义则力有不逮。这时,我们需要寻求外部力量的帮助,主要是通过平台原生插件(Native Plugin)来调用操作系统提供的API。对于Windows平台,这就是一系列来自user32.dlldwmapi.dll的函数。

3.1 Unity内置设置的能力与局限

首先,看看不写代码能做什么。在Player Settings>Resolution and Presentation(PC, Mac & Linux Standalone)下,有一些相关设置:

  • Fullscreen ModeExclusive Fullscreen(独占全屏)、Fullscreen Window(无边框窗口全屏)、Maximized Window(最大化窗口)、Windowed(窗口化)。Fullscreen Window模式会创建一个无边框、覆盖整个显示器的窗口,是实现无边框最简单的方式,但你失去了对窗口位置和尺寸的精确控制。
  • Allow Fullscreen Switch:允许通过Alt+Enter切换全屏。
  • Resolution:设置默认窗口尺寸。

在脚本中,你可以通过Screen类动态设置:

// 切换到无边框全屏窗口 Screen.SetResolution(1920, 1080, FullScreenMode.FullScreenWindow); // 获取当前窗口句柄(后续API调用需要) IntPtr hwnd = GetActiveWindow(); // 需要自己声明外部方法

局限:内置设置无法实现真正的、可移动的“无边框窗口化”模式。FullScreenWindow是全屏的,而Windowed模式一定有标题栏和边框。你无法创建一个在桌面任意位置、大小可调、且没有标题栏的窗口。这就是我们需要调用原生API的根本原因。

3.2 Windows API:我们的核心工具包

为了实现高级窗口控制,我们需要与Windows系统的窗口管理器直接对话。关键API函数来自user32.dll

  • SetWindowLongPtr:用于设置窗口的扩展样式(GWL_EXSTYLE)和普通样式(GWL_STYLE)。这是我们隐藏标题栏、边框、菜单的关键。
  • GetWindowLongPtr:获取当前的窗口样式。
  • SetWindowPos:设置窗口的位置、大小、Z序(置顶等)以及触发样式更新。
  • ReleaseCapture,SendMessage:用于实现无标题栏窗口的拖动。当用户在没有标题栏的区域点击并拖动时,我们需要向Windows发送WM_NCLBUTTONDOWN消息并附带HTCAPTION参数,欺骗系统认为用户正在拖动标题栏。
  • FindWindow/GetActiveWindow:用于获取当前Unity窗口的句柄(HWND)。句柄是操作系统识别每个窗口的唯一标识,所有API调用都离不开它。

一个关键概念:窗口样式(Window Styles)Windows窗口的外观和行为由一系列样式位标志控制。例如:

  • WS_CAPTION:包含标题栏。
  • WS_THICKFRAME:可调整大小的边框。
  • WS_SYSMENU:有关闭按钮的系统菜单。
  • WS_MAXIMIZEBOX/WS_MINIMIZEBOX:最大化/最小化按钮。 要隐藏它们,我们的思路是:先获取当前窗口的样式,然后用位操作(& ~)移除我们不需要的样式位,最后将新样式设置回去。

3.3 方案对比:从简单到复杂

根据需求复杂度,我们可以选择不同层级的方案:

  1. 方案A:简易无边框全屏
    • 方法:使用Unity内置FullScreenMode.FullScreenWindow
    • 优点:零代码,最简单。
    • 缺点:窗口必须铺满整个屏幕,无法窗口化运行。不适合需要同时查看其他应用的工具软件。
  2. 方案B:可移动的无边框窗口(主流需求)
    • 方法:在Windowed模式下,使用Windows API移除WS_CAPTIONWS_THICKFRAME样式,并自己实现窗口拖动逻辑。
    • 优点:实现了真正意义上的、可自由移动的“无边框窗口”,平衡了美观与实用性。
    • 缺点:需要编写并管理原生插件交互代码,实现拖动逻辑。
  3. 方案C:完全自定义的非客户区
    • 方法:在方案B的基础上,进一步处理WM_NCCALCSIZE消息,允许你完全定义窗口的非客户区(边框、标题栏区域),甚至可以绘制自定义的窗口控件(最小化、关闭按钮)。
    • 优点:自由度最高,可以实现任何视觉设计。
    • 缺点:复杂度急剧上升,需要处理大量的Windows消息,容易引入稳定性问题,且与Unity的UI系统整合困难。

对于90%的需求,方案B是最佳平衡点。接下来,我们将重点深入方案B的实现细节与避坑要点。

4. 核心实现:移除标题栏与实现窗口拖动

这是整个流程中最核心、也最容易出错的环节。我们将分步拆解,并提供经过项目验证的健壮代码。

4.1 第一步:声明与获取窗口句柄

首先,我们需要在C#脚本中声明将要使用的Windows API函数。建议创建一个单独的类,如WindowsWindowManager.cs

using System; using System.Runtime.InteropServices; using UnityEngine; public class WindowsWindowManager : MonoBehaviour { // 引入必要的Windows API函数 [DllImport("user32.dll")] private static extern IntPtr GetActiveWindow(); [DllImport("user32.dll")] private static extern int GetWindowLong(IntPtr hWnd, int nIndex); [DllImport("user32.dll")] private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); [DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); [DllImport("user32.dll")] private static extern bool ReleaseCapture(); [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); // 窗口样式索引 private const int GWL_STYLE = -16; // 窗口样式常量 private const int WS_CAPTION = 0x00C00000; private const int WS_THICKFRAME = 0x00040000; private const int WS_SYSMENU = 0x00080000; private const int WS_MAXIMIZEBOX = 0x00010000; private const int WS_MINIMIZEBOX = 0x00020000; // SetWindowPos 标志 private const uint SWP_FRAMECHANGED = 0x0020; private const uint SWP_NOMOVE = 0x0002; private const uint SWP_NOSIZE = 0x0001; private const uint SWP_NOZORDER = 0x0004; // 消息常量 private const uint WM_NCLBUTTONDOWN = 0xA1; private const int HTCAPTION = 2; private IntPtr _windowHandle; void Start() { // 获取当前Unity窗口的句柄 _windowHandle = GetActiveWindow(); if (_windowHandle == IntPtr.Zero) { Debug.LogError("Failed to get window handle."); return; } // 可以在此处调用移除标题栏的函数 // RemoveWindowTitleBar(); } }

注意1:32位与64位兼容性:上面的代码使用了GetWindowLongSetWindowLong。在64位系统上,更严谨的做法是使用GetWindowLongPtrSetWindowLongPtr,因为它们处理的是指针大小的数据。Unity的Mono/IL2CPP脚本后端在64位下是安全的,但为了绝对兼容,你可以使用IntPtr版本或条件编译。一个更简单的办法是,确保你的项目Player Settings中“Architecture”设置为x86_64,并使用GetWindowLong/SetWindowLong,在大多数情况下是可行的。

注意2:获取句柄的时机:在Start()Awake()中获取句柄通常是安全的。但有时,特别是窗口初始化的早期,句柄可能还未就绪。一个更稳健的做法是在OnApplicationFocus(true)时再执行样式修改,或者使用Invoke延迟一小段时间。

4.2 第二步:移除标题栏和边框

现在我们有了句柄,可以编写移除样式的核心函数。

public void RemoveWindowTitleBar() { if (_windowHandle == IntPtr.Zero) return; // 1. 获取当前的窗口样式 int currentStyle = GetWindowLong(_windowHandle, GWL_STYLE); // 2. 移除我们不需要的样式 // 移除标题栏(WS_CAPTION),它包含了WS_BORDER, WS_DLGFRAME, WS_SYSMENU, WS_THICKFRAME等的一部分 // 为了更精确控制,我们分别移除相关样式 int newStyle = currentStyle; newStyle &= ~WS_CAPTION; // 移除标题栏 newStyle &= ~WS_THICKFRAME; // 移除可调整大小的边框(同时会移除最大/最小化按钮) // newStyle &= ~WS_SYSMENU; // 谨慎:移除系统菜单会同时移除关闭按钮!通常保留。 // newStyle &= ~WS_MAXIMIZEBOX; // 移除最大化按钮 // newStyle &= ~WS_MINIMIZEBOX; // 移除最小化按钮 // 3. 应用新的窗口样式 SetWindowLong(_windowHandle, GWL_STYLE, newStyle); // 4. 关键步骤:强制Windows重新计算窗口的非客户区(边框、标题栏区域) // 如果不调用SetWindowPos,样式更改可能不会立即生效,或者窗口大小会出错。 SetWindowPos(_windowHandle, IntPtr.Zero, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER); Debug.Log("Window title bar and thick frame removed."); }

原理剖析:为什么需要SetWindowPos?当你修改GWL_STYLE后,Windows并不会自动重新绘制窗口的非客户区。SetWindowPos函数,即使不改变窗口位置和大小(通过SWP_NOMOVE | SWP_NOSIZE),只要携带SWP_FRAMECHANGED标志,就会通知系统“窗口框架变了,请重新计算和绘制”。这是让无边框效果生效的关键一步,很多教程遗漏了这一点,导致修改无效或窗口尺寸异常。

4.3 第三步:实现无标题栏窗口的拖动

移除了标题栏,窗口就无法拖动了。我们需要在Unity的UI或3D对象上监听拖动事件,然后发送Windows消息来模拟标题栏拖动。

// 在WindowsWindowManager类中添加此方法 public void DragWindow() { if (_windowHandle == IntPtr.Zero) return; // 释放Unity可能持有的鼠标捕获 ReleaseCapture(); // 发送消息,告诉系统“用户在标题栏上按下了鼠标左键并开始拖动” SendMessage(_windowHandle, WM_NCLBUTTONDOWN, (IntPtr)HTCAPTION, IntPtr.Zero); }

现在,你需要将这个拖动逻辑绑定到某个可交互的UI元素上。例如,你可以创建一个作为“自定义标题栏”的UI Panel。

  1. 在Unity中创建一个ImagePanel作为你的自定义标题栏。
  2. 为其添加Event Trigger组件。
  3. Event Trigger中添加PointerDown事件。
  4. WindowsWindowManager实例的DragWindow方法拖拽到事件回调中。

实操心得

  • 拖动区域设计:你的自定义标题栏最好有一定高度(如30像素),并放置在窗口顶部,符合用户习惯。
  • 双击最大化:如果你保留了最大化功能,可以尝试监听双击事件,然后调用SendMessage发送WM_NCLBUTTONDBLCLK消息。但实现起来更复杂,且与Unity UI事件可能冲突。一个更简单的替代方案是,自己写代码切换窗口大小。
  • 鼠标光标反馈:当鼠标悬停在你的自定义标题栏上时,光标不会自动变成“移动”形状。你可以通过Cursor.SetCursor来设置自定义光标,提升体验。

4.4 第四步:处理关闭、最小化、最大化按钮

移除了系统标题栏,你还需要提供替代的窗口控制按钮。

  • 关闭:最简单,直接调用Application.Quit()。但要注意,在编辑器模式下,这可能会停止播放。可以加条件编译:#if UNITY_EDITOR UnityEditor.EditorApplication.isPlaying = false; #else Application.Quit(); #endif
  • 最小化:使用APIShowWindow,传入SW_MINIMIZE参数。
  • 最大化/还原:这是最棘手的。你不能简单地移除WS_MAXIMIZEBOX然后自己实现,因为最大化/还原涉及到窗口状态管理和尺寸计算。一个相对稳定的方案是:
    1. 保留WS_MAXIMIZEBOX样式(即不把它从样式位中移除)。
    2. 隐藏系统提供的最大化按钮(通过将窗口客户区扩大,覆盖掉按钮,或者创建一个无标题栏但保留边框的样式变体,比较复杂)。
    3. 在自己的UI按钮中,调用ShowWindow,传入SW_MAXIMIZESW_RESTORE

由于最大化/还原的完全自定义实现非常复杂且容易出bug,对于许多应用,我建议直接禁用最大化功能(移除WS_MAXIMIZEBOX),或者使用“切换到一个预设的较大尺寸”来模拟最大化,而不是真正的系统最大化状态。

5. 高级主题与深度避坑指南

完成了基础功能,我们来看看那些容易让人栽跟头的高级问题和解决方案。

5.1 坑点一:DPI缩放与高分辨率屏幕

在高DPI(缩放比例 > 100%)的显示器上,Windows会进行DPI虚拟化。Unity 2017.4及以后版本对DPI感知有更好的支持,但结合自定义窗口API,可能会遇到坐标错乱的问题。

  • 现象:你调用SetWindowPos设置的窗口位置和大小,与实际显示的位置大小不符。你的自定义拖动区域,点击位置和实际拖动响应位置有偏移。
  • 原因:Windows API使用的坐标可能是“逻辑像素”,而Unity和屏幕是“物理像素”。DPI缩放因子在其中作祟。
  • 解决方案
    1. 确保Unity DPI感知:在Player Settings中,检查Resolution and Presentation下的Use DXGI Flip Model...DPI Awareness相关设置(不同Unity版本位置可能不同)。对于现代Unity版本,通常设置为“Per Monitor”或“Per Monitor v2”更好。
    2. API调用使用物理坐标:在获取鼠标位置用于拖动计算时,使用Input.mousePosition(屏幕坐标)是没问题的,因为Unity已经处理了DPI缩放。但在通过API(如SetWindowPos)设置窗口矩形时,如果你是从其他逻辑尺寸转换而来,需要小心。一个实用的方法是:在修改窗口样式和位置时,尽量使用相对值或基于当前屏幕分辨率的比例值,而非绝对像素值。对于拖动功能,由于我们只是发送HTCAPTION消息,系统会自己处理DPI,所以通常没有问题。

5.2 坑点二:与Unity UI(uGUI)的输入冲突

当你创建了一个覆盖全窗口的可点击UI作为拖动区域时,它可能会拦截掉其下方其他UI元素的点击事件。

  • 解决方案:确保你的“自定义标题栏”UI元素上,除了Event Trigger用于拖动,不要添加Graphic Raycaster组件,或者将其Raycast Target属性勾选掉。这样它就不会阻挡射线投射,下方按钮的点击事件就能正常触发。拖动事件依赖于IPointerDownHandler,这不需要Graphic Raycaster

5.3 坑点三:窗口阴影丢失

移除WS_THICKFRAME样式后,Windows 10/11为窗口添加的默认漂亮阴影也会消失,窗口看起来像一张“纸片”贴在桌面上,缺乏层次感。

  • 解决方案:使用DWM(Desktop Window Manager)API来为窗口重新添加阴影。这需要用到dwmapi.dll中的DwmExtendFrameIntoClientArea函数,但用法较为晦涩。一个更简单、跨Unity版本兼容性更好的方案是:创建一个比内容区域稍大的、半透明的、带模糊背景的UI作为窗口边框。但这会消耗额外的渲染开销。
  • 折中方案:对于很多应用,没有阴影是可以接受的。如果你需要阴影,可以搜索“Unity Windows drop shadow”寻找社区插件或更完整的原生插件解决方案。

5.4 坑点四:多平台兼容性与未来维护

本文所有代码都只针对Windows平台(.dll导入)。如果你的项目需要发布到macOS或Linux,这些代码会编译失败。

  • 解决方案:使用平台依赖编译。
    #if UNITY_STANDALONE_WIN [DllImport("user32.dll")] private static extern IntPtr GetActiveWindow(); // ... 其他Windows API声明 #endif public void RemoveWindowTitleBar() { #if UNITY_STANDALONE_WIN // Windows平台的实现代码 #else Debug.LogWarning("Custom window styling is only supported on Windows standalone builds."); #endif }
    对于macOS,自定义窗口需要使用Cocoa API,Linux则可能需要X11相关库,实现复杂度更高,通常需要专门的插件。

5.5 坑点五:杀毒软件或系统安全软件的误报

频繁调用底层Windows API修改窗口属性,尤其是涉及SetWindowLongSendMessage,有时会被一些过于敏感的安全软件标记为可疑行为。

  • 现象:打包后的exe运行时被拦截,或功能被禁用。
  • 应对措施
    1. 代码签名:为你的最终exe购买代码签名证书并签名,这能极大增加软件的可信度。
    2. 减少敏感API组合调用:避免在程序启动时立即进行大量窗口样式修改。可以考虑在用户进行某个操作(如点击“进入演示模式”)后再触发。
    3. 提交给安全软件厂商白名单:如果你的软件是正规商业软件,可以向各大安全软件厂商提交样本,申请加入白名单。
    4. 清晰的用户告知:在软件首次运行时或设置中,说明本软件会修改窗口外观以获得更好体验,如果安全软件报警请选择允许。

6. 完整示例与最佳实践整合

让我们将上面的知识点整合成一个更健壮、更易用的管理器类,并附上在Unity中的设置步骤。

6.1 完整的Windows窗口管理器脚本

// WindowsWindowManager.cs using System; using System.Runtime.InteropServices; using UnityEngine; using UnityEngine.EventSystems; public class WindowsWindowManager : MonoBehaviour { #if UNITY_STANDALONE_WIN [DllImport("user32.dll")] private static extern IntPtr GetActiveWindow(); [DllImport("user32.dll")] private static extern int GetWindowLong(IntPtr hWnd, int nIndex); [DllImport("user32.dll", SetLastError = true)] private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool ReleaseCapture(); [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); // 常量定义 private const int GWL_STYLE = -16; private const int WS_CAPTION = 0x00C00000; private const int WS_THICKFRAME = 0x00040000; private const uint SWP_FRAMECHANGED = 0x0020; private const uint SWP_NOMOVE = 0x0002; private const uint SWP_NOSIZE = 0x0001; private const uint SWP_NOZORDER = 0x0004; private const uint WM_NCLBUTTONDOWN = 0xA1; private const int HTCAPTION = 2; private IntPtr _windowHandle; private bool _isBorderless = false; #endif [Header("Settings")] [Tooltip("是否在启动时移除标题栏和边框")] public bool removeBorderOnStart = true; [Tooltip("用于拖动的自定义标题栏RectTransform(可选)")] public RectTransform dragHandleArea; void Start() { #if UNITY_STANDALONE_WIN // 延迟获取句柄,确保窗口已创建 Invoke(nameof(InitializeWindowHandle), 0.1f); #else Debug.Log("Windows window styling is only applicable in Windows standalone builds."); #endif } #if UNITY_STANDALONE_WIN private void InitializeWindowHandle() { _windowHandle = GetActiveWindow(); if (_windowHandle == IntPtr.Zero) { Debug.LogError("Could not retrieve window handle. Borderless mode disabled."); return; } if (removeBorderOnStart) { SetBorderlessWindow(true); } // 为拖动区域设置事件(如果已指定) SetupDragHandle(); } /// <summary> /// 设置或取消无边框窗口。 /// </summary> /// <param name="borderless">true为无边框,false为恢复默认。</param> public void SetBorderlessWindow(bool borderless) { if (_windowHandle == IntPtr.Zero || _isBorderless == borderless) return; int currentStyle = GetWindowLong(_windowHandle, GWL_STYLE); int newStyle = currentStyle; if (borderless) { // 移除标题栏和厚边框 newStyle &= ~WS_CAPTION; newStyle &= ~WS_THICKFRAME; } else { // 恢复标题栏和厚边框(注意:这不一定能完全恢复到初始默认状态) newStyle |= WS_CAPTION; newStyle |= WS_THICKFRAME; } SetWindowLong(_windowHandle, GWL_STYLE, newStyle); // 强制刷新窗口框架 SetWindowPos(_windowHandle, IntPtr.Zero, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER); _isBorderless = borderless; Debug.Log($"Window borderless set to: {borderless}"); } /// <summary> /// 使窗口可被拖动。应由UI事件触发。 /// </summary> public void StartDragWindow() { if (_windowHandle == IntPtr.Zero || !_isBorderless) return; ReleaseCapture(); SendMessage(_windowHandle, WM_NCLBUTTONDOWN, (IntPtr)HTCAPTION, IntPtr.Zero); } private void SetupDragHandle() { if (dragHandleArea != null) { EventTrigger trigger = dragHandleArea.gameObject.GetComponent<EventTrigger>(); if (trigger == null) trigger = dragHandleArea.gameObject.AddComponent<EventTrigger>(); EventTrigger.Entry entry = new EventTrigger.Entry(); entry.eventID = EventTriggerType.PointerDown; entry.callback.AddListener((data) => { StartDragWindow(); }); trigger.triggers.Add(entry); // 重要:确保拖动区域不阻挡其下UI的点击 var graphic = dragHandleArea.GetComponent<UnityEngine.UI.Graphic>(); if (graphic != null) graphic.raycastTarget = true; // 需要接收事件,所以设为true // 其下的UI元素应正常设置raycastTarget } } #endif // 提供给UI按钮的简单控制方法 public void CloseApplication() { #if UNITY_EDITOR UnityEditor.EditorApplication.isPlaying = false; #else Application.Quit(); #endif } }

6.2 Unity场景中的设置步骤

  1. 创建管理器:在场景中创建一个空的GameObject,命名为“WindowManager”。
  2. 挂载脚本:将WindowsWindowManager脚本挂载上去。
  3. 创建自定义标题栏
    • 在Canvas下创建一个ImagePanel,命名为“CustomTitleBar”。
    • 将其锚点(Anchors)设置为顶部拉伸(Top Stretch),调整高度(如30)。
    • 设置一个醒目的背景色以便调试。
  4. 连接引用
    • 将“CustomTitleBar”的RectTransform拖拽到WindowsWindowManager组件的Drag Handle Area字段。
  5. 创建控制按钮(可选):
    • 在“CustomTitleBar”内创建“关闭”按钮。
    • 为按钮的OnClick()事件添加监听,选择WindowManager->WindowsWindowManager.CloseApplication方法。
  6. 测试
    • 在编辑器中运行,由于API只在Windows Standalone生效,你可能看不到变化。但可以测试按钮点击事件。
    • 关键的一步构建项目(Build Settings -> PC, Mac & Linux Standalone -> Target Platform: Windows)。运行生成的.exe文件,你将看到标题栏和边框已经消失,并且可以通过顶部的自定义标题栏拖动窗口。

6.3 构建设置检查清单

在打包前,请确认以下设置:

  • Player Settings > Resolution and Presentation:
    • Fullscreen Mode: 设置为Windowed。我们的API修改是基于窗口模式的。
    • Width&Height: 设置你期望的初始窗口分辨率。
    • Allow Fullscreen Switch: 根据需求勾选。如果启用,用户按Alt+Enter会切换到真正的全屏,可能会覆盖我们的无边框效果。
  • Player Settings > Other Settings:
    • Api Compatibility Level*: 保持.NET Standard 2.1.NET Framework(根据你的需求)。
    • Scripting Backend: Mono或IL2CPP均可。IL2CPP更推荐用于发布。
  • 构建路径:确保输出路径没有中文或特殊字符。

7. 常见问题排查与调试技巧

即使按照指南操作,你可能还是会遇到一些问题。这里是一个快速排查清单。

7.1 问题:打包后运行,窗口样式没有任何变化。

  • 可能原因1:脚本中的平台编译指令#if UNITY_STANDALONE_WIN没有生效。
    • 检查:确保你在构建时选择的目标平台是“Windows”。在编辑器中运行不会调用这些API。
  • 可能原因2:获取窗口句柄失败。
    • 调试:在InitializeWindowHandle函数中,添加Debug.Log($"Window Handle: {_windowHandle}");。打包后运行,查看日志文件(通常位于%USERPROFILE%\AppData\LocalLow\[CompanyName]\[ProductName]\output_log.txt)。如果句柄是0,说明获取失败。尝试将初始化延迟更长的时间,例如在Start()中用Invoke(nameof(InitializeWindowHandle), 0.5f)
  • 可能原因3SetWindowPos没有携带SWP_FRAMECHANGED标志。
    • 检查:确认调用SetWindowPos时包含了SWP_FRAMECHANGED

7.2 问题:窗口可以无边框,但无法拖动。

  • 可能原因1:拖动区域UI的Raycast Target未开启,或EventTrigger设置不正确。
    • 检查:确认自定义标题栏的Image组件Raycast Target已勾选。确认EventTrigger的PointerDown事件已正确绑定到StartDragWindow方法。
  • 可能原因2ReleaseCapture()SendMessage()调用失败。
    • 调试:在StartDragWindow方法开始处添加Debug.Log("Dragging...");,打包后测试,看日志是否有输出。确保_windowHandle有效且_isBorderless为true。

7.3 问题:窗口位置或大小在移除边框后变了。

  • 可能原因:移除WS_CAPTIONWS_THICKFRAME后,窗口的客户区(Client Area)大小计算方式变了。SetWindowPos调用时,如果之前窗口是标准样式,系统会基于包含标题栏的窗口矩形来定位。移除后,同样的矩形参数可能导致窗口显示位置偏移。
  • 解决方案:在调用SetBorderlessWindow(true)之前,先记录当前窗口的位置和大小(可以通过Screen.widthScreen.height,但这是整个窗口的尺寸)。然后在设置无边框后,再用SetWindowPos将其设置回原来的位置和大小。或者,更简单的方法是,在窗口初始就是无边框的状态下启动。可以在Start()中立刻调用SetBorderlessWindow,这样窗口创建时就是无边框的,位置由Player Settings中的设置决定。

7.4 问题:在无边框窗口下,如何实现窗口缩放?

这是一个高级功能。标准做法是处理WM_NCHITTEST消息,当鼠标移动到窗口边缘时,返回HTLEFT,HTRIGHT,HTTOP,HTTOPLEFT等值,告诉系统可以在此处调整大小。但这需要在C++插件中实现复杂的消息循环钩子,并将其传递给Unity,实现门槛很高。

  • 实用替代方案:在你的应用界面角落放置几个透明的、可拖动的UI元素。当用户拖动这些元素时,通过SetWindowPos动态改变窗口的宽度和高度。这需要你手动计算鼠标移动的偏移量并转换为窗口尺寸的变化。虽然不如系统原生的边缘缩放流畅,但可以实现基本功能。

7.5 调试建议

  1. 使用日志文件:在关键函数入口添加Debug.Log。打包后,日志会写入到output_log.txt文件。这是排查运行时问题最重要的工具。
  2. 分步测试:不要一次性实现所有功能。先确保能成功移除边框,再添加拖动功能,最后再加控制按钮。
  3. 备份原始样式:在SetBorderlessWindow函数中,可以在修改前将currentStyle保存下来。这样你可以在运行时提供一个“恢复默认”的按钮,方便调试和用户选择。
  4. 考虑使用现有插件:如果你觉得直接操作Windows API太复杂,或者需要更稳定、功能更全的解决方案(如完整的窗口缩放、阴影、跨平台支持),可以考虑Asset Store上的成熟插件,如“Standalone Window Manager”、“Borderless Window”等。它们封装了底层细节,并提供更友好的编辑器接口。但理解本文的原理,能帮助你更好地使用和调试这些插件。

从Unity播放器到拥有自定义外观的独立exe,这最后一步的打磨,是区分业余作品与专业产品的关键细节之一。它要求开发者跳出舒适区,与操作系统底层进行交互。这个过程虽然充满挑战,但当你看到自己的应用以完全符合设计意图的形态呈现在桌面上时,那种成就感是巨大的。希望这篇指南不仅能提供代码,更能帮你理解背后的“为什么”,让你在遇到问题时能自己找到答案。记住,在PC端,窗口就是你的应用给用户的第一张脸,值得你花心思把它打扮好。

← 返回列表