写在前面
最近在做游戏项目的时候,遇到了一个让人头疼的问题。
项目初期,UI就那么几个界面,怎么写都行。但随着功能越加越多,问题就来了:主界面上弹个商店,商店里点击某个道具又弹出详情,详情里还能弹出购买确认框。这时候如果玩家点了返回键,该关哪个界面?如果这时候来了个系统公告,又该显示在哪一层?
更头疼的是,不同界面可能共用一个顶部的资源栏(显示金币、钻石之类的),这个资源栏该跟着哪个界面走?多个界面同时用怎么办?
这些问题不解决,代码很快就会变成一团乱麻。每次改UI都要小心翼翼,生怕影响到其他地方。于是我花了些时间,设计了一套窗口管理系统,这里分享一下思路。
核心思路:分层管理
解决复杂问题的第一步,往往是把问题拆解。
游戏里的UI,本质上是有优先级的。主界面是基础层,各种弹窗是普通层,二级弹窗是更高的层级,系统提示要在最顶层。既然如此,为什么不明确地把这些层级定义出来?
Base - 基础层(主界面、战斗界面等)
Normal - 普通层(背包、商店等功能界面)
Popup - 弹窗层(确认框、详情界面等二级弹窗)
Guide - 引导层(新手引导)
Top - 顶层(系统提示、跑马灯)
每一层就是一个独立的"窗口组",组内管理自己的窗口,组与组之间互不干扰。这样一来,架构就清晰了:
- 想在主界面上显示?注册到Base层
- 想弹个确认框?丢到Popup层
- 要显示系统公告?直接扔Top层
窗口的显示顺序,由它所在的层级决定,同一层内部再按打开顺序排序。这个设计简单直接,关键是好理解。
窗口的一生
一个窗口从创建到销毁,会经历几个关键的时刻:
初始化阶段 - 窗口第一次被创建,加载资源、初始化组件。这个只会发生一次。
显示阶段 - 窗口被打开显示给玩家。可能带着一些参数(比如显示哪个道具的详情)。
隐藏阶段 - 窗口被其他窗口盖住了,但还没关闭。这时候窗口可能需要暂停一些逻辑。
恢复显示 - 上层的窗口关闭了,这个窗口重新显示出来。这时候可能需要刷新一些数据。
关闭阶段 - 窗口被彻底关闭销毁,释放资源。
把这些时机暴露成生命周期钩子,让业务代码去实现:
onInit() // 窗口初始化
onShow(data) // 显示窗口
onHide() // 被其他窗口遮挡
onShowFromHide() // 从遮挡中恢复
onClose() // 关闭销毁
这样一来,开发者只需要在对应的钩子里写逻辑就行了。窗口管理系统负责在合适的时机调用这些钩子。
举个例子,背包界面被商店界面盖住了(onHide),这时候玩家在商店买了东西,商店关闭后背包重新显示(onShowFromHide),这时候背包就知道该刷新物品列表了。
窗口之间的关系处理
窗口之间不是孤立的,一个新窗口打开时,往往需要对其他窗口做一些处理。这是个很实际的问题。
举几个常见场景:
- 回到主界面:应该关闭所有其他界面,只保留主界面本身
- 道具详情界面:隐藏(而不是关闭)下面的窗口,用户关闭详情页后还能回到之前的界面
最初我想的是,让业务代码自己去关闭或隐藏其他窗口。但这样很容易写乱,而且容易忘记处理某些情况。
更好的做法是:让窗口在定义时就声明自己的"行为类型",系统自动处理其他窗口。
定义了几种常见的窗口类型:
Normal // 普通窗口,不影响其他窗口
CloseOne // 关闭上一个窗口
CloseAll // 关闭所有其他窗口
HideOne // 隐藏上一个窗口
HideAll // 隐藏所有其他窗口
使用起来很简单:
@uiclass("Popup", "Confirm", "Window")
class ConfirmWindow extends Window {
type = WindowType.HideAll; // 打开时隐藏所有其他窗口
}
@uiclass("Normal", "ItemDetail", "Window")
class ItemDetailWindow extends Window {
type = WindowType.CloseOne; // 打开详情时关闭背包
}
系统在显示窗口时,会根据类型自动处理:
-
CloseOne:关闭同一层级的上一个窗口,释放资源 -
CloseAll:关闭同一层级的所有其他窗口 -
HideOne:隐藏上一个窗口,但不销毁,等当前窗口关闭后会自动恢复显示 -
HideAll:隐藏所有其他窗口,关闭后恢复 -
Normal:不做处理
这个设计的好处是:
- 声明式 - 窗口的行为在定义时就确定了,不用在业务代码里到处写关闭逻辑
- 统一处理 - 所有窗口的显示关系由系统管理,不会出现遗漏或冲突
- 符合直觉 - 类型名称直接说明了行为,一看就懂
举个实际的例子:
主界面(Base层)
→ 打开背包(Normal层,Normal类型) - 主界面不受影响
→ 点击道具打开详情(Normal层,CloseOne类型) - 背包被关闭
→ 点击购买弹出确认框(Popup层,HideAll类型) - 详情被隐藏
→ 点击取消 - 确认框关闭,详情自动恢复显示
→ 点击确定 - 确认框关闭,详情恢复显示并刷新
整个流程很自然,开发者只需要定义每个窗口的类型,系统自动处理显示关系。
这里有个细节值得注意:HideOne和HideAll是隐藏而不是关闭,意味着窗口的状态保留着。用户取消操作返回时,不需要重新初始化界面,体验更好。而CloseOne和CloseAll是真正关闭,会释放资源,适合那些不会返回的流程。
资源管理:自动加载与释放
游戏包体大小是个永恒的话题。UI资源如果都打在一起,包体会很大;分包加载又容易出问题。
我的做法是:窗口自己声明需要哪些资源,打开时自动加载,关闭时自动卸载。
通过装饰器注册窗口信息:
@uiclass("Normal", "Shop", "MainWindow", ["CommonUI", "ItemUI"])
class ShopWindow extends Window {
// 这个窗口属于Normal层
// 界面资源在Shop包里,叫MainWindow
// 还依赖CommonUI和ItemUI两个包
}
这样做的好处是:
- 声明式 - 窗口需要什么资源,在类定义时就说清楚了,不用到处翻代码
- 自动化 - 打开窗口时系统自动加载这些资源包,关闭时自动判断是否还有其他窗口在用,没有就卸载
- 引用计数 - 多个窗口可能依赖同一个资源包,用引用计数管理,只有引用为0才真正卸载
这套机制让资源管理变得透明。开发者不用操心什么时候加载、什么时候释放,系统自动处理。
Header的共享问题
很多游戏的界面顶部都有个资源栏,显示金币、钻石、体力这些。多个界面可能共用同一个资源栏,这时候就有个问题:这个资源栏到底属于谁?
最简单的做法是每个界面都创建一份,但这样浪费内存,而且刷新起来麻烦(比如金币变了,要通知所有显示金币的地方)。
更好的做法是:让多个窗口共享同一个Header实例。
核心思路还是引用计数:
- 第一个窗口要用某个Header,创建它
- 第二个窗口也要用,引用计数+1,不重复创建
- 窗口关闭时引用计数-1,减到0才真正销毁
同时,Header需要知道当前显示在哪个窗口上:
- 多个使用同一个Header的窗口同时显示时,Header跟着最上层的窗口走
- 最上层窗口关闭了,Header自动切换到下一层的窗口上
这个逻辑实现起来有点绕,但对使用者来说很简单:
@uiclass("Normal", "Shop", "MainWindow")
class ShopWindow extends Window {
headerName = "CommonHeader"; // 声明要用哪个Header
headerUserdata = { coins: true, gems: true }; // Header需要用的自定义参数
}
窗口只需要声明自己要用哪个Header以及传什么数据,其他的系统自动处理。
性能优化的一些小心思
全局遮罩的复用
很多界面打开时,会在下面显示一个半透明的黑色遮罩。最直观的做法是每个窗口都创建一个遮罩,但这样会有很多重复的绘制,并且上层再显示一个遮罩时,还需要隐藏其下方的遮罩,否者多个遮罩叠加下层界面会变黑。
更好的做法是:全局共用一个遮罩,动态调整它的位置。
系统会从上到下找第一个需要遮罩的窗口,把遮罩放在它下面。窗口层级变化时,重新调整遮罩位置。这样既省内存,又省渲染开销。
Header的缓存优化
频繁查找"哪个窗口在最上层"是有开销的。所以系统做了个简单的缓存:
- 记住当前Header所在的窗口
- 新窗口打开时,如果层级更高就更新缓存
- 窗口关闭时,只有缓存的窗口关闭了才重新查找
这个优化让Header的层级调整从O(n)降到了O(1)。虽然代码复杂了一点,但在窗口频繁切换的场景下,性能提升还是很明显的。
设计原则的一些思考
回过头看,这套系统的核心就是几个原则:
1. 分层解决复杂度
不要把所有窗口都放在一个平面上管理,按层级分组。这样每一层的逻辑都很简单,组合起来就能处理复杂的情况。
2. 明确的生命周期
窗口的每个阶段都有对应的钩子,让业务代码知道什么时候该做什么事。这比在窗口里到处写判断要清晰得多。
3. 自动化的资源管理
开发者只需要声明依赖关系,系统自动加载和释放。这样既避免了手动管理的繁琐,又不容易出错。
4. 共享而非复制
能共享的资源就共享(Header、遮罩),用引用计数管理生命周期。这样既省资源,又方便统一管理。
5. 缓存但不过度
性能优化是必要的,但不能为了优化而把代码搞得太复杂。在关键路径上做优化,其他地方保持简单。
一些取舍
没有完美的设计,这套系统也有一些可以改进的地方。
比如资源加载是顺序进行的,为了保证稳定性。但如果改成并发加载,性能会更好。不过这样就要处理并发加载可能出现的各种问题,增加了复杂度。权衡之后还是选择了更保守的方案。
再比如窗口类型的枚举值,最初用了位掩码的写法(1 << 0, 1 << 1),想着也许以后能组合使用。但实际业务中根本没这个需求,代码里也没有用位运算。这就是典型的"预设未来需求"的过度设计,用简单的 0, 1, 2 就够了。
还有一些命名上的问题。比如onShow和onShowFromHide两个钩子,功能其实很接近,完全可以合并成一个钩子加个参数。当时是觉得分开更明确,但用起来确实有点啰嗦。
这些小问题在实际使用中影响不大,但确实让代码看起来没那么"干净"。不过也算是经验吧,下次设计的时候会更注意。
通用性思考
虽然这套系统是基于Cocos Creator + FairyGUI开发的,但核心思路其实和具体的引擎、UI库没什么关系:
- 分层管理 - 任何UI系统都需要管理层级
- 生命周期 - 任何UI都有创建、显示、隐藏、销毁的过程
- 资源管理 - 任何游戏都需要管理资源的加载和释放
- 共享机制 - 引用计数是通用的资源管理方法
如果你在用Unity + UGUI,或者Unreal的UMG,甚至Web前端的框架,这些思路都是可以借鉴的。具体实现会有差异,但核心理念是相通的。
写在最后
这套窗口管理系统在我的项目里用了有段时间了,基本满足需求。分享出来,希望能给遇到类似问题的朋友一些参考。
游戏开发没有银弹。每个项目的需求不一样,要根据实际情况调整。但有些基本原则是通用的:简单、清晰、够用。
过度设计和设计不足都是问题。找到平衡点,才能写出好维护的代码。
如果你有更好的想法,欢迎交流。

