1. 项目概述与核心价值
最近在做一个Unity的PC端项目,客户提了个挺有意思的需求:他们不想要Windows系统默认的那个灰扑扑的标题栏,想换成自己设计的样式,最好还能把最大化、最小化、关闭这些按钮也换成游戏UI的风格,让整个窗口看起来更像一个“无边框”的定制化应用。这个需求在独立游戏、工具软件或者需要沉浸式体验的演示程序中其实挺常见的。Unity虽然能很方便地打包出.exe,但默认的窗口样式就是系统原生的,想要深度自定义,就得动点“手脚”。
这不仅仅是改个外观那么简单。隐藏默认标题栏意味着你需要自己接管窗口的移动、缩放、最大化最小化等一系列原本由系统处理的行为。这背后涉及到对Windows API(对于Windows平台)或相应桌面系统接口的调用,是Unity引擎封装之外的一次“越级”操作。做好了,应用质感提升一个档次;做不好,可能就是各种窗口行为错乱、性能拖累的灾难现场。接下来,我就结合自己的踩坑经验,把从隐藏默认标题栏到实现一套稳定、美观的自定义标题栏的完整方案拆解清楚。
2. 技术方案选型与原理剖析
要实现这个目标,市面上主要有几种技术路径,每种都有其适用场景和优缺点。理解它们的原理,是做出正确选择的前提。
2.1 方案一:使用-popupwindow启动参数(最简单,但功能有限)
这是最快捷的方法。在构建Player Settings的Resolution and Presentation面板中,勾选Fullscreen Mode为Windowed,然后在Standalone Player Options的Resolution部分,你会发现一个Run In Background之类的选项,其实更关键的是通过命令行启动。
原理:通过给生成的.exe添加-popupwindow启动参数,可以指示Unity以“弹出窗口”模式运行。这种模式下,窗口的边框样式会被设置为WS_POPUP,从而移除了标准的标题栏和厚边框,只保留一个极细的边框(在某些系统主题下甚至不可见)。
操作方法:
- 正常打包生成
YourGame.exe。 - 创建一个快捷方式。
- 右键快捷方式 -> 属性,在“目标”一栏的
.exe路径末尾加上-popupwindow。"C:\Path\To\YourGame.exe" -popupwindow - 运行该快捷方式。
优点:无需任何代码,一键实现“无标题栏”效果。缺点:
- 功能残缺:窗口无法通过鼠标拖拽标题栏来移动,也无法双击标题栏最大化。你彻底失去了对窗口的控制权。
- 兼容性:这个参数是Unity遗留的,并非所有Unity版本都表现一致,行为可能不稳定。
- 无法自定义:你只是隐藏了它,并不能用自己设计的UI去替换它。
注意:这个方案只适用于那些完全不需要用户移动、缩放,或者你打算用其他方式(如游戏内按钮触发传送)来替代窗口操作的特殊全屏演示场景。对于需要交互的窗口化应用,基本不可行。
2.2 方案二:使用UnityEditor命名空间下的Window类(仅限编辑器环境)
在Unity编辑器开发插件或工具窗口时,你会用到UnityEditor命名空间下的EditorWindow。它有一个titleContent属性可以设置图标和文字,但样式仍然是编辑器框架内的。对于运行时(Runtime)的玩家构建(Player Build),这个命名空间是不可用的。所以这个方案仅适用于编辑器扩展开发,不适用于发布的PC端程序,在此仅作辨析,防止走错路。
2.3 方案三:平台原生API调用(功能强大,推荐方案)
这是实现高度自定义的终极方案。核心思想是:在Unity运行时,通过C#的[DllImport]特性调用操作系统底层的API,直接操作我们游戏窗口的句柄(HWND),修改其样式(Window Style)。
原理剖析: 在Windows中,每个窗口都有一个称为“窗口样式(Window Styles)”的长整型标志位,例如WS_CAPTION表示包含标题栏,WS_THICKFRAME表示包含可调整大小的边框。当我们创建一个标准窗口时,系统会组合这些样式。要隐藏默认标题栏,我们需要:
- 获取当前Unity游戏窗口的句柄
HWND。 - 获取它当前的窗口样式
GWL_STYLE。 - 使用位操作,移除
WS_CAPTION、WS_THICKFRAME等我们不需要的样式标志。 - 设置新的窗口样式。
- (可选)为了保留窗口阴影或Aero效果,可能还需要调整
GWL_EXSTYLE(扩展样式)。
移除标题栏后,窗口将失去移动和缩放能力。因此,我们必须自己实现:
- 窗口移动:在自定义标题栏的UI区域,监听鼠标按下、拖拽事件,然后调用
ReleaseCapture()和SendMessage()发送WM_NCLBUTTONDOWN消息到窗口的HTCAPTION区域,欺骗系统认为用户在拖拽标题栏。 - 窗口缩放:在窗口边缘放置不可见的或具有特定样式的UI元素,监听鼠标事件,发送
WM_NCHITTEST消息来告诉系统鼠标处于窗口的哪个边缘(如HTLEFT,HTTOPLEFT),从而触发系统原生的缩放光标和功能。 - 最大化/最小化/关闭:为自定义的按钮绑定点击事件,调用
ShowWindow()API,传入SW_MAXIMIZE,SW_MINIMIZE,SW_RESTORE等命令。
优点:功能完整,可实现完全自定义的UI和交互逻辑,效果专业。缺点:实现复杂,涉及平台相关代码,需要处理多线程、消息循环等潜在问题,且通常需要为不同操作系统(Windows, macOS, Linux)编写不同的后端。
3. 基于原生API的完整实现流程
我们将采用方案三,并选择一个优秀的开源库来降低难度。这里我强烈推荐UnityRawInput的一个分支或专门处理窗口的库,但更通用和强大的是github.com/Elringus/UnityRawInput但我们需要的是窗口管理。实际上,社区有一个非常知名的插件叫UnityWindowController或开发者自己封装的类。为了教学清晰,我将展示核心的手动实现代码,你可以将其封装成自己的WindowController类。
3.1 第一步:创建窗口管理C#脚本
在Unity项目中创建一个新的C#脚本,命名为NativeWindowController.cs。
using UnityEngine; using System; using System.Runtime.InteropServices; public class NativeWindowController : MonoBehaviour { // 导入所需的Windows API函数 #if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN [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 GWL_EXSTYLE = -20; // 窗口样式标志 private const int WS_CAPTION = 0x00C00000; // 标题栏 private const int WS_THICKFRAME = 0x00040000; // 可调整大小的边框 private const int WS_MINIMIZEBOX = 0x00020000; // 最小化按钮 private const int WS_MAXIMIZEBOX = 0x00010000; // 最大化按钮 private const int WS_SYSMENU = 0x00080000; // 系统菜单(关闭按钮等) // 扩展窗口样式标志(保留阴影等效果) private const int WS_EX_DLGMODALFRAME = 0x00000001; private const int WS_EX_CLIENTEDGE = 0x00000200; private const int WS_EX_STATICEDGE = 0x00020000; // 窗口消息 private const uint WM_NCLBUTTONDOWN = 0xA1; private const uint WM_SYSCOMMAND = 0x0112; private const int HTCAPTION = 2; private const int SC_MAXIMIZE = 0xF030; private const int SC_MINIMIZE = 0xF020; private const int SC_RESTORE = 0xF120; private const int SC_CLOSE = 0xF060; // 用于SetWindowPos的常量 private static readonly IntPtr HWND_TOP = new IntPtr(0); private const uint SWP_NOSIZE = 0x0001; private const uint SWP_NOMOVE = 0x0002; private const uint SWP_FRAMECHANGED = 0x0020; private const uint SWP_SHOWWINDOW = 0x0040; private IntPtr _windowHandle; void Start() { // 获取当前窗口句柄 _windowHandle = GetActiveWindow(); if (_windowHandle == IntPtr.Zero) { Debug.LogError("Failed to get window handle."); return; } // 初始化时隐藏默认标题栏 HideDefaultTitleBar(); } /// <summary> /// 隐藏默认的窗口标题栏和边框 /// </summary> public void HideDefaultTitleBar() { if (_windowHandle == IntPtr.Zero) return; // 1. 获取当前窗口样式 int currentStyle = GetWindowLong(_windowHandle, GWL_STYLE); // 2. 移除标题栏、厚边框、系统菜单等样式 int newStyle = currentStyle; newStyle &= ~(WS_CAPTION | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_SYSMENU); // 3. 应用新样式 SetWindowLong(_windowHandle, GWL_STYLE, newStyle); // 4. 强制窗口重绘以应用样式更改(关键步骤!) SetWindowPos(_windowHandle, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_FRAMECHANGED | SWP_SHOWWINDOW); Debug.Log("Default title bar hidden."); } /// <summary> /// 恢复默认的窗口标题栏和边框 /// </summary> public void RestoreDefaultTitleBar() { if (_windowHandle == IntPtr.Zero) return; int currentStyle = GetWindowLong(_windowHandle, GWL_STYLE); int newStyle = currentStyle; newStyle |= (WS_CAPTION | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_SYSMENU); SetWindowLong(_windowHandle, GWL_STYLE, newStyle); SetWindowPos(_windowHandle, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_FRAMECHANGED | SWP_SHOWWINDOW); Debug.Log("Default title bar restored."); } #endif }3.2 第二步:实现窗口拖拽功能
隐藏标题栏后,我们需要让用户能通过拖拽我们自定义的标题栏UI来移动窗口。在NativeWindowController脚本中添加以下方法:
/// <summary> /// 开始拖拽窗口(应在自定义标题栏UI的鼠标按下事件中调用) /// </summary> public void BeginDragWindow() { #if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN if (_windowHandle == IntPtr.Zero) return; ReleaseCapture(); // 释放当前线程对鼠标的捕获 // 发送消息,模拟鼠标在窗口标题栏上的按下事件 SendMessage(_windowHandle, WM_NCLBUTTONDOWN, (IntPtr)HTCAPTION, IntPtr.Zero); #endif }在Unity UI中的使用:
- 在你的自定义标题栏GameObject(比如一个
Image或Panel)上添加EventTrigger组件。 - 为
EventTrigger添加一个PointerDown类型的事件。 - 将包含
NativeWindowController脚本的游戏对象拖入事件目标。 - 选择函数
NativeWindowController -> BeginDragWindow。
这样,当玩家在这个UI元素上按下鼠标时,整个窗口就可以被拖动了。
3.3 第三步:实现最大化、最小化、关闭按钮
我们需要调用系统命令来控制窗口状态。在NativeWindowController脚本中添加以下方法:
/// <summary> /// 最大化窗口 /// </summary> public void MaximizeWindow() { #if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN if (_windowHandle == IntPtr.Zero) return; SendMessage(_windowHandle, WM_SYSCOMMAND, (IntPtr)SC_MAXIMIZE, IntPtr.Zero); #endif } /// <summary> /// 最小化窗口 /// </summary> public void MinimizeWindow() { #if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN if (_windowHandle == IntPtr.Zero) return; SendMessage(_windowHandle, WM_SYSCOMMAND, (IntPtr)SC_MINIMIZE, IntPtr.Zero); #endif } /// <summary> /// 从最大化或最小化恢复窗口 /// </summary> public void RestoreWindow() { #if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN if (_windowHandle == IntPtr.Zero) return; SendMessage(_windowHandle, WM_SYSCOMMAND, (IntPtr)SC_RESTORE, IntPtr.Zero); #endif } /// <summary> /// 关闭窗口(退出应用) /// </summary> public void CloseWindow() { #if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN if (_windowHandle == IntPtr.Zero) return; SendMessage(_windowHandle, WM_SYSCOMMAND, (IntPtr)SC_CLOSE, IntPtr.Zero); #endif // 或者使用Application.Quit(),但SendMessage(SC_CLOSE)更符合Windows程序习惯。 // Application.Quit(); }同样,将这些方法绑定到你自定义的UI按钮的onClick事件上。
3.4 第四步:处理窗口边缘缩放(可选但建议)
为了让窗口仍然可以像普通窗口一样用鼠标拖拽边缘调整大小,我们需要处理WM_NCHITTEST消息。这比较复杂,通常需要设置一个钩子(Hook)。一个更简单但不完美的替代方案是:在窗口边缘放置一圈非常细的、对射线检测透明的Panel,当鼠标移动到这些Panel上时,使用SetCursorAPI改变鼠标光标,并在鼠标按下时模拟调整大小的操作。但这需要更复杂的逻辑来实际改变窗口大小。
一个相对折中的方案是,不完全移除WS_THICKFRAME样式。我们可以尝试只移除WS_CAPTION但保留WS_THICKFRAME,这样窗口就没有标题栏但保留了可调整大小的边框。然而,视觉效果上边框可能依然存在。我们可以通过设置窗口的扩展样式(GWL_EXSTYLE)来尝试美化边框,或者用UI覆盖它。
简化实现(保留缩放功能): 修改HideDefaultTitleBar方法,仅移除WS_CAPTION和系统按钮,但保留WS_THICKFRAME:
public void HideDefaultTitleBarButKeepResizeBorder() { if (_windowHandle == IntPtr.Zero) return; int currentStyle = GetWindowLong(_windowHandle, GWL_STYLE); int newStyle = currentStyle; // 只移除标题栏和系统菜单,保留可调整大小的边框 newStyle &= ~(WS_CAPTION | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_SYSMENU); // 注意:WS_THICKFRAME 被保留了 SetWindowLong(_windowHandle, GWL_STYLE, newStyle); SetWindowPos(_windowHandle, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_FRAMECHANGED | SWP_SHOWWINDOW); Debug.Log("Title bar hidden, but resize border kept."); }这样,用户就可以像平常一样拖拽窗口边缘进行缩放了。缺点是窗口四周会有一个系统默认的边框(通常很细,约1像素),你可能需要用全屏的背景UI来覆盖它。
4. 自定义标题栏UI设计与集成
技术底层搞定后,上层UI就自由了。你可以像设计任何游戏UI一样设计你的标题栏。
4.1 UI布局建议
- 层级:将自定义标题栏放在一个独立的
Canvas下,并确保其Sort Order高于游戏主内容,但低于可能存在的弹出菜单。 - 结构:通常是一个横向的
Panel,包含:- 左侧:应用图标和标题文本。
- 中间:留空或放置其他功能按钮(如设置、帮助)。
- 右侧:窗口控制按钮(最小化、最大化/还原、关闭)。关闭按钮应与其他按钮有明显视觉区分(如红色背景)。
- 样式:使用
Image组件设置背景(颜色、渐变或图片)。按钮可以使用Button组件,并自定义其Normal、Highlighted、Pressed状态。
4.2 与控制器脚本绑定
- 将
NativeWindowController脚本挂载到一个场景中常驻的GameObject上,比如叫“WindowManager”。 - 为你设计的“拖拽区域”(通常是整个标题栏背景)配置
EventTrigger,调用BeginDragWindow。 - 为“最小化”、“最大化/还原”、“关闭”按钮配置
Button的onClick事件,分别绑定到MinimizeWindow、MaximizeWindow/RestoreWindow、CloseWindow方法。
最大化/还原按钮的逻辑: 你需要一个状态来记录窗口当前是否最大化。可以在NativeWindowController中添加一个布尔变量_isWindowMaximized,并在点击最大化按钮时切换:
private bool _isWindowMaximized = false; public void ToggleMaximizeRestore() { if (_isWindowMaximized) { RestoreWindow(); } else { MaximizeWindow(); } _isWindowMaximized = !_isWindowMaximized; // 这里还可以更新按钮的图标(如从“口”变成“❐”) }然后将最大化按钮绑定到ToggleMaximizeRestore方法。
4.3 窗口状态变化与UI同步
窗口可能被用户通过系统菜单(Alt+Space)或其他方式最大化/最小化,我们需要监听这些状态变化来更新自定义按钮的图标(比如最大化后按钮应显示“还原”图标)。
这需要更高级的消息钩子来拦截WM_SIZE消息。一个简单的替代方案是在Update()中轮询窗口位置和大小,但这效率较低。对于大多数项目,如果只通过自定义按钮控制,可以暂时忽略外部状态变化,或者提供一个刷新按钮。
5. 跨平台考量与第三方插件推荐
上述代码使用了#if UNITY_STANDALONE_WIN预处理指令,意味着它只会在Windows平台编译和执行。如果你需要发布到macOS或Linux,需要为这些平台编写相应的原生接口(Cocoa for macOS, X11 for Linux)。
给实践者的建议:除非你的团队有很强的多平台原生开发能力,否则强烈建议使用成熟的第三方插件。它们封装了多平台的复杂性,提供了更稳定、功能更丰富的API。
- Unity Standalone Window Controller (USWC):这是一个在GitHub上可以找到的开源项目,封装了Windows和macOS的窗口操作,包括自定义标题栏、窗口透明度、点击穿透等高级功能。
- UniWindowController:Asset Store上也有类似的付费插件,通常有更好的文档和支持。
- Kino/WindowController:另一个在GitHub上活跃的开源方案。
使用这些插件,你往往只需要几行代码:
// 伪代码,示例插件API WindowController.HideTitleBar(); WindowController.SetCustomTitleBar(myTitleBarRectTransform); myCloseButton.onClick.AddListener(WindowController.Close);这能节省你大量研究和调试底层API的时间。
6. 常见问题、性能优化与避坑指南
在实际项目中,我遇到了不少坑,这里总结一下:
6.1 窗口闪烁或绘制异常
问题:调用SetWindowLong和SetWindowPos后,窗口内容可能闪烁,或者Unity渲染的内容与窗口边框不同步。解决:
- 确保在
Start()或Awake()中调用样式修改代码,避免在每帧更新中调用。 - 调用
SetWindowPos时,uFlags参数必须包含SWP_FRAMECHANGED,这是通知系统重绘窗口框架的关键。 - 如果问题依旧,可以尝试延迟一帧再修改样式,使用
StartCoroutine(DelayedHideTitleBar())。
6.2 鼠标事件穿透
问题:隐藏标题栏后,窗口边缘的鼠标消息(如调整大小)可能无法正确传递。解决:
- 如果使用了“保留边框”的方案,这个问题不存在。
- 如果完全移除了边框,需要自己实现复杂的
WM_NCHITTEST处理。这是自定义窗口最复杂的部分之一。使用第三方插件是更好的选择。
6.3 任务栏预览与Alt+Tab显示
问题:修改窗口样式后,在任务栏缩略图或Alt+Tab界面中,窗口可能显示异常(如黑边)。解决:这个问题与窗口的扩展样式(GWL_EXSTYLE)和DPI感知设置有关。可以尝试调整扩展样式,或者确保Unity Player Settings中的DPI Awareness设置正确(通常设置为“Per Monitor”或“Per Monitor v2”可以改善)。这个问题没有银弹,需要针对不同Windows版本进行测试。
6.4 性能影响
问题:频繁通过P/Invoke调用原生API可能带来轻微性能开销。优化:
- 所有窗口状态改变(如隐藏标题栏)只在初始化时执行一次。
- 拖拽窗口的
BeginDragWindow只在鼠标按下时调用一次,而不是在拖拽过程中持续调用。 - 避免在
Update()中轮询窗口状态。
6.5 编辑器与运行时差异
问题:在Unity编辑器中运行游戏视图时,GetActiveWindow()获取的可能是编辑器窗口的句柄,而不是游戏视图的句柄,导致代码无效或影响编辑器本身。解决:
- 使用
#if !UNITY_EDITOR将核心代码包裹起来,确保只在打包后的程序中执行。或者在编辑器模式下使用模拟逻辑。 - 有些插件提供了编辑器下的模拟行为,方便调试。
6.6 高DPI缩放支持
问题:在4K等高DPI显示器上,你的自定义标题栏UI可能变得很小或模糊。解决:
- 在Unity的Canvas Scaler组件中,将
UI Scale Mode设置为Scale With Screen Size,并设置合适的参考分辨率。 - 确保
NativeWindowController中计算鼠标位置和窗口大小时,考虑到了系统的DPI缩放因子。可以通过GetDpiForWindowAPI获取。UI拖拽通常使用屏幕坐标,受DPI影响较小,但如果你要精确绘制覆盖边框的UI,就需要计算DPI。
7. 完整示例与封装建议
最后,我提供一个更完整、封装性更好的脚本框架,它将核心功能与UI逻辑分离,并考虑了简单的编辑器兼容性。
// WindowManager.cs - 主管理器 public class WindowManager : MonoBehaviour { public RectTransform customTitleBar; // 拖拽你的自定义标题栏RectTransform进来 public Button minimizeButton; public Button maximizeRestoreButton; public Button closeButton; public Image maximizeRestoreIcon; // 用于切换图标 public Sprite maximizeSprite; public Sprite restoreSprite; private INativeWindowController _nativeController; private bool _isMaximized = false; void Start() { #if UNITY_STANDALONE_WIN || UNITY_EDITOR _nativeController = new WindowsNativeController(); // 工厂模式,未来可扩展macOS _nativeController.Initialize(); _nativeController.HideDefaultTitleBar(); #endif SetupUIEvents(); } void SetupUIEvents() { // 为标题栏添加拖拽 EventTrigger trigger = customTitleBar.gameObject.AddComponent<EventTrigger>(); EventTrigger.Entry entry = new EventTrigger.Entry(); entry.eventID = EventTriggerType.PointerDown; entry.callback.AddListener((data) => { BeginDragWindow(); }); trigger.triggers.Add(entry); // 按钮事件 minimizeButton?.onClick.AddListener(MinimizeWindow); maximizeRestoreButton?.onClick.AddListener(ToggleMaximizeRestore); closeButton?.onClick.AddListener(CloseWindow); } void BeginDragWindow() { _nativeController?.BeginDrag(); } void MinimizeWindow() { _nativeController?.Minimize(); } void CloseWindow() { _nativeController?.Close(); } void ToggleMaximizeRestore() { if (_isMaximized) { _nativeController?.Restore(); maximizeRestoreIcon.sprite = maximizeSprite; } else { _nativeController?.Maximize(); maximizeRestoreIcon.sprite = restoreSprite; } _isMaximized = !_isMaximized; } void OnDestroy() { #if UNITY_STANDALONE_WIN || UNITY_EDITOR _nativeController?.RestoreDefaultTitleBar(); // 退出前恢复,避免影响系统 #endif } } // INativeWindowController.cs - 接口 public interface INativeWindowController { void Initialize(); void HideDefaultTitleBar(); void RestoreDefaultTitleBar(); void BeginDrag(); void Minimize(); void Maximize(); void Restore(); void Close(); } // WindowsNativeController.cs - Windows实现(内容为之前DllImport的封装,略)这个架构将平台相关的代码隔离在具体的WindowsNativeController类中,WindowManager只负责UI逻辑,更清晰,也更容易未来支持macOS。
实现一个稳定、美观的自定义标题栏是提升PC端Unity应用专业度的有效手段。虽然涉及底层API调用,但通过清晰的步骤和合理的封装,完全可以驾驭。对于追求效率的团队,直接选用成熟的第三方插件是最稳妥的方案;而对于希望深入理解原理或需要高度定制功能的开发者,亲手实现一遍这个过程会让你对Unity与操作系统交互有更深的认识。关键是要充分测试,尤其是在不同Windows版本和DPI设置下的表现,确保给所有用户一致的体验。