UIFramework 实现细节与使用说明
本文档梳理 Assets/MMOGame/Scripts/Framework/UIFramework 的实现机制,以及它和当前项目业务 UI 的关系。
需要先明确一点:项目里存在一套通用的 UIFramework,但当前业务 UI 主线大量使用的是另一套更轻的 UIRoot + UIControllerBase + ViewBase + WindowBase。两者思想相近,但调用链不同。新增业务 UI 时通常应优先沿用现有业务链路;只有准备统一改造 UI 管理方式时,才考虑让通用 UIFramework 接管窗口/面板。
目录与核心类
通用 UI 框架位于:
text
Assets/MMOGame/Scripts/Framework/UIFramework
核心结构:
text
UIFrame
├── PanelUILayer 管理面板 Panel,适合常驻 UI:血条、小地图、HUD
└── WindowUILayer 管理窗口 Window,适合互斥窗口、队列窗口、弹窗
主要类职责:
| 类 | 职责 |
|---|---|
UIFrame |
UI 框架门面,对外提供显示、隐藏、注册接口 |
UILayer<TScreen> |
基础 Layer,维护 screenId -> controller 注册表 |
PanelUILayer |
面板层,直接显示/隐藏,可多面板同时存在 |
WindowUILayer |
窗口层,维护当前窗口、队列、历史栈、弹窗遮罩和转场阻断 |
UIScreenController<TProps> |
所有框架 UI 控制器的基类,统一属性、动画、生命周期 |
WindowController |
窗口控制器基类,提供窗口行为属性和关闭请求 |
PanelController |
面板控制器基类,提供面板优先级 |
AniComponent |
UI 动画抽象,FadeAni、AnimationView 是已有实现 |
UISettings |
ScriptableObject,用于从 UIFrame prefab 和 screen prefab 列表创建 UI 实例 |
UIFrame:框架入口
UIFrame 挂在 UI 根节点上,初始化时会从子节点中查找:
csharp
panelLayer = gameObject.GetComponentInChildren<PanelUILayer>(true);
windowLayer = gameObject.GetComponentInChildren<WindowUILayer>(true);
初始化流程:
- 找到并初始化
PanelUILayer。 - 找到并初始化
WindowUILayer。 - 订阅窗口层的
RequestScreenBlock/RequestScreenUnblock。 - 获取主 Canvas 的
GraphicRaycaster。
窗口转场时,WindowUILayer 会通知 UIFrame 临时关闭或恢复点击:
csharp
private void OnRequestScreenBlock() {
graphicRaycaster.enabled = false;
}
private void OnRequestScreenUnblock() {
graphicRaycaster.enabled = true;
}
这能避免窗口动画播放中重复点击导致状态错乱。
常用接口:
csharp
uiFrame.ShowPanel("HpPanel");
uiFrame.HidePanel("HpPanel");
uiFrame.OpenWindow("BackpackWindow");
uiFrame.CloseWindow("BackpackWindow");
uiFrame.CloseCurrentWindow();
uiFrame.HideAll();
uiFrame.RegisterScreen("SomeWindow", controller, controller.transform);
screenId 使用字符串。注册时会写入 controller 的 ScreenId。
UIScreenController:所有框架界面的生命周期基类
UIScreenController<TProps> 是窗口和面板的共同基类:
csharp
public abstract class UIScreenController<TProps> : MonoBehaviour, IScreenController
where TProps : IScreenProperties
它统一处理四类事情。
1. 属性系统
每个 screen 可以有序列化属性:
csharp
[SerializeField]
private TProps properties;
显示界面时也可以传入运行时属性:
csharp
screen.Show(properties);
Show 会检查传入属性类型是否匹配 TProps。类型不匹配时会报错并中止显示。
2. 显示/隐藏动画
每个 screen 可以配置进入动画和退出动画:
csharp
[SerializeField] private AniComponent animIn;
[SerializeField] private AniComponent animOut;
显示时,如果配置了 animIn,会播放动画;否则直接 SetActive(true)。
隐藏时,如果配置了 animOut,会播放动画;否则直接 SetActive(false)。
3. 生命周期钩子
可在子类中按需重写:
csharp
protected virtual void AddListeners() {}
protected virtual void RemoveListeners() {}
protected virtual void OnPropertiesSet() {}
protected virtual void WhileHiding() {}
protected virtual void HierarchyFixOnShow() {}
含义:
| 方法 | 调用时机 | 典型用途 |
|---|---|---|
AddListeners |
Awake |
注册按钮点击、事件监听 |
RemoveListeners |
OnDestroy |
解绑事件 |
OnPropertiesSet |
Show 设置属性之后 |
根据打开参数刷新 UI |
WhileHiding |
Hide 时 |
清理临时状态 |
HierarchyFixOnShow |
Show 前 |
调整层级 |
WindowController 会重写 HierarchyFixOnShow,打开窗口时把自己放到最后一个 sibling:
csharp
protected override void HierarchyFixOnShow() {
transform.SetAsLastSibling();
}
4. 事件回调
UIScreenController 暴露以下回调给 Layer 使用:
csharp
Action<IScreenController> InTransitionFinished;
Action<IScreenController> OutTransitionFinished;
Action<IScreenController> CloseRequest;
Action<IScreenController> ScreenDestroyed;
这些事件用于:
- 通知转场结束。
- 窗口请求关闭。
- 对象销毁时自动反注册。
UILayer:注册表与按 id 操作
UILayer<TScreen> 维护一个注册表:
csharp
protected Dictionary<string, TScreen> registeredScreens;
注册 screen:
csharp
RegisterScreen(string screenId, TScreen controller)
内部会:
- 设置
controller.ScreenId = screenId。 - 加入
registeredScreens。 - 订阅
controller.ScreenDestroyed。
销毁时会自动反注册:
csharp
private void OnScreenDestroyed(IScreenController screen) {
if (!string.IsNullOrEmpty(screen.ScreenId)
&& registeredScreens.ContainsKey(screen.ScreenId)) {
UnregisterScreen(screen.ScreenId, (TScreen) screen);
}
}
按 id 操作:
csharp
ShowScreenById(string screenId)
ShowScreenById<TProps>(string screenId, TProps properties)
HideScreenById(string screenId)
如果 id 未注册,会直接打印错误。因此使用这套框架时,必须先注册 screen。
PanelUILayer:面板层
PanelUILayer 适合常驻或并存 UI,例如:
- 血条
- 小地图
- 主界面 HUD
- 教程提示
- 遮挡层
它没有窗口队列和历史栈。显示/隐藏逻辑很直接:
csharp
public override void ShowScreen(IPanelController screen) {
screen.Show();
}
public override void HideScreen(IPanelController screen) {
screen.Hide();
}
面板优先级
面板优先级定义:
csharp
public enum PanelPriority {
None = 0,
Prioritary = 1,
Tutorial = 2,
Blocker = 3,
}
PanelUILayer.ReparentScreen 会根据面板的 Priority 把它放到不同父节点:
csharp
ReparentToParaLayer(ctl.Priority, screenTransform);
这些父节点由 PanelPriorityLayerList 配置。渲染顺序取决于这些父节点在 Hierarchy 中的顺序。
WindowUILayer:窗口层
WindowUILayer 负责更复杂的窗口管理,它维护:
csharp
public IWindowController CurrentWindow { get; private set; }
private Queue<WindowHistoryEntry> windowQueue;
private Stack<WindowHistoryEntry> windowHistory;
private HashSet<IScreenController> screensTransitioning;
含义:
| 字段 | 含义 |
|---|---|
CurrentWindow |
当前前台窗口 |
windowQueue |
等待当前窗口关闭后显示的窗口队列 |
windowHistory |
窗口历史栈,当前窗口关闭后可回到上一个窗口 |
screensTransitioning |
正在播放转场动画的窗口集合,用于阻断点击 |
打开窗口流程
调用:
csharp
uiFrame.OpenWindow("SomeWindow");
最终进入:
csharp
WindowUILayer.ShowScreen<TProp>(IWindowController screen, TProp properties)
流程:
text
把传入 properties 转成 IWindowProperties
判断是否应该入队
是:加入 windowQueue
否:立即 DoShow
是否入队由 ShouldEnqueue 决定:
text
如果当前没有窗口,也没有排队窗口
直接显示
如果运行时 properties.SuppressPrefabProperties == true
用运行时 properties.WindowQueuePriority 判断
否则
用 prefab 上 controller.WindowPriority 判断
不是 ForceForeground
入队
否则
直接显示
窗口优先级:
csharp
public enum WindowPriority {
ForceForeground = 0,
Enqueue = 1,
}
ForceForeground:立即显示到前台。Enqueue:如果已有窗口,排队等待。
DoShow 流程
真正显示窗口在 DoShow 中:
text
如果当前窗口就是要打开的窗口
打 warning
否则如果当前窗口存在,并且当前窗口 HideOnForegroundLost == true,并且新窗口不是 popup
隐藏当前窗口
把新窗口压入 windowHistory
记录转场 AddTransition
如果新窗口 IsPopup
显示黑色遮罩
调用 windowEntry.Show()
CurrentWindow = 新窗口
这意味着窗口层默认是"当前窗口优先"的模型,而不是任意多个普通窗口并存的模型。
关闭窗口流程
关闭窗口最终进入:
csharp
WindowUILayer.HideScreen(IWindowController screen)
它只允许关闭当前窗口:
text
如果 screen == CurrentWindow
从 windowHistory 弹出当前窗口
播放隐藏动画
CurrentWindow = null
如果 windowQueue 有窗口
显示队列下一个
否则如果 windowHistory 还有窗口
回到上一个历史窗口
否则
打错误日志并忽略
所以不要用 WindowUILayer 随意关闭后台窗口。它的模型是:只关闭前台窗口,然后恢复队列或历史窗口。
WindowProperties:窗口行为属性
窗口属性定义在 WindowProperties:
csharp
public class WindowProperties : IWindowProperties
关键字段:
| 字段 | 含义 |
|---|---|
HideOnForegroundLost |
当前窗口被其他非 popup 窗口置前时,自己是否隐藏 |
WindowQueuePriority |
打开策略:立即前台或排队 |
IsPopup |
是否弹窗,弹窗会进入优先窗口层并显示黑色遮罩 |
SuppressPrefabProperties |
运行时属性是否覆盖 prefab 上配置的窗口行为 |
SuppressPrefabProperties 容易误解。WindowController.SetProperties 的逻辑是:
csharp
if (!props.SuppressPrefabProperties) {
props.HideOnForegroundLost = Properties.HideOnForegroundLost;
props.WindowQueuePriority = Properties.WindowQueuePriority;
props.IsPopup = Properties.IsPopup;
}
Properties = props;
结论:
SuppressPrefabProperties == false:保留 prefab 上配置的窗口行为,只用运行时属性携带业务数据。SuppressPrefabProperties == true:运行时传入的属性可以覆盖HideOnForegroundLost、WindowQueuePriority、IsPopup。
可以把它理解为"是否压制/忽略 prefab 上的属性"。
弹窗遮罩 WindowParaLayer
WindowParaLayer 是弹窗的优先层。它负责:
- 接收
IsPopup == true的窗口。 - 管理黑色背景
darkenBgObject。 - 有 popup 激活时显示遮罩。
- 没有 popup 激活时关闭遮罩。
注册 screen 时,如果窗口是 popup:
csharp
priorityParaLayer.AddScreen(screenTransform);
打开 popup 时:
csharp
priorityParaLayer.DarkenBG();
隐藏动画结束后:
csharp
priorityParaLayer.RefreshDarken();
遮罩不是窗口自己控制的,而是 WindowUILayer + WindowParaLayer 统一控制。
动画系统
动画抽象:
csharp
public abstract class AniComponent : MonoBehaviour {
public abstract void Animate(Transform target, Action callWhenFinished);
}
已有实现:
| 类 | 实现方式 |
|---|---|
FadeAni |
通过 CanvasGroup.alpha 做淡入/淡出 |
AnimationView |
使用 Unity Legacy Animation 播放 AnimationClip,支持反向播放 |
如果 screen 没有配置动画组件,UIScreenController 会直接切换激活状态。
注意:FadeAni 在动画结束后会把 canvasGroup.alpha 设置成 1f,即使是淡出也是这样。由于隐藏流程随后会 SetActive(false),通常不影响表现,但复用该组件时需要知道这个行为。
UISettings 与编辑器工具
UISettings 是一个 ScriptableObject:
csharp
[CreateAssetMenu(fileName = "UISettings", menuName = "UI/UI Settings")]
配置项:
| 字段 | 含义 |
|---|---|
templateUIPrefab |
UIFrame 预制体 |
screensToRegister |
要注册的窗口/面板 prefab 列表 |
deactivateScreenGOs |
实例化后是否隐藏 screen GameObject |
调用:
csharp
UIFrame frame = uiSettings.CreateUIInstance();
内部流程:
text
实例化 UIFrame prefab
遍历 screensToRegister
实例化 screen prefab
获取 IScreenController
用 prefab 名称作为 screenId 注册
根据配置决定是否 SetActive(false)
编辑器工具菜单:
text
Assets/Create/UI/UI Frame in Scene
Assets/Create/UI/UI Frame Prefab
它会创建如下结构:
text
UIFrame
├── UICamera
├── EventSystem
├── PanelLayer
├── WindowLayer
├── PriorityPanelLayer
├── PriorityWindowLayer
│ └── DarkenBG
└── TutorialPanelLayer
Canvas 会被设置为 ScreenSpaceCamera,参考分辨率是 1920x1080。
通用 UIFramework 的使用流程
1. 创建 UIFrame
可以通过菜单创建:
text
Assets/Create/UI/UI Frame Prefab
或:
text
Assets/Create/UI/UI Frame in Scene
2. 创建窗口或面板 prefab
窗口示例:
csharp
using UIFramework;
public class BackpackWindowController : WindowController
{
protected override void AddListeners()
{
// 注册按钮事件
}
protected override void RemoveListeners()
{
// 解绑按钮事件
}
protected override void OnPropertiesSet()
{
// 根据 Properties 刷新 UI
}
public void OnCloseButtonClick()
{
UI_Close();
}
}
面板示例:
csharp
using UIFramework;
public class HpPanelController : PanelController
{
protected override void OnPropertiesSet()
{
// 刷新血条
}
}
3. 配置 prefab 属性
窗口可以配置:
Hide On Foreground LostWindow Queue PriorityIs PopupAnim InAnim Out
面板可以配置:
Panel PriorityAnim InAnim Out
4. 注册 screen
手动注册:
csharp
uiFrame.RegisterWindow("BackpackWindow", backpackController);
uiFrame.RegisterPanel("HpPanel", hpPanelController);
或统一入口:
csharp
uiFrame.RegisterScreen("BackpackWindow", backpackController, backpackController.transform);
如果使用 UISettings,则把 prefab 填进 screensToRegister,由 CreateUIInstance() 自动实例化并注册。
5. 显示与隐藏
窗口:
csharp
uiFrame.OpenWindow("BackpackWindow");
uiFrame.CloseWindow("BackpackWindow");
uiFrame.CloseCurrentWindow();
面板:
csharp
uiFrame.ShowPanel("HpPanel");
uiFrame.HidePanel("HpPanel");
带属性打开窗口:
csharp
var props = new WindowProperties {
WindowQueuePriority = WindowPriority.Enqueue,
HideOnForegroundLost = true,
IsPopup = false,
SuppressPrefabProperties = true
};
uiFrame.OpenWindow("BackpackWindow", props);
当前项目实际业务 UI 链路
当前项目业务 UI 主要走这条链路:
text
UIRoot
└── LoginUIController / SelectRoleUIController / MainCityUIController
└── ViewBase
└── WindowBase
对应目录:
text
Assets/MMOGame/Scripts/UI
核心类:
| 类 | 作用 |
|---|---|
UIRoot |
业务 UI 根节点,加载 LoginView、SelectRoleView、MainCityView |
UIControllerBase |
View 的控制器基类,提供 ShowWindow、UpdateUI 等接口 |
ViewBase |
维护 WindowType -> WindowBase 字典 |
WindowBase |
具体窗口基类,默认直接 SetActive 和 UpdateUI |
这套业务 UI 不依赖 UIFrame 注册表,不使用窗口队列、历史栈、弹窗遮罩和 AniComponent。它更简单,靠 WindowType 和字典切换窗口。
ViewBase
ViewBase 维护窗口字典:
csharp
protected Dictionary<WindowType, WindowBase> windowDic;
初始化:
csharp
public virtual void InitView()
{
windowDic = new Dictionary<WindowType, WindowBase>();
}
显示某个窗口:
csharp
public virtual void ShowWindow(WindowType windowType, object obj = null)
{
if (windowDic == null || windowDic.Count == 0) return;
foreach (var window in windowDic.Keys)
{
windowDic[window].Show(window == windowType, obj);
}
}
这意味着基础实现会显示目标窗口,同时隐藏其他窗口。
刷新 UI:
csharp
public virtual void UpdateUI(WindowType windowType, object obj)
{
windowDic[windowType].UpdateUI(obj);
}
WindowBase
WindowBase 默认很轻:
csharp
public virtual void Show(bool isShow, object obj = null)
{
gameObject.SetActive(isShow);
if (obj != null && isShow)
{
UpdateUI(obj);
}
}
具体窗口通过重写:
csharp
public override void InitWindow()
public override void UpdateUI(object obj)
实现自己的初始化和刷新。
UIControllerBase
UIControllerBase 包装 View:
csharp
public virtual void ShowWindow(WindowType windowType, object obj = null)
{
if (!_view.gameObject.activeSelf)
{
_view.ShowView(true);
}
_view.ShowWindow(windowType, obj);
}
控制器负责:
- 注册 View 中按钮回调。
- 注册网络协议回调。
- 发送网络请求。
- 收到协议返回后调用
UpdateUI或ShowWindow。
注意:UIControllerBase.Dispose() 当前直接抛 NotImplementedException,不要在未实现前调用。
业务 UI 示例:登录界面
UIRoot.InitLoginView 加载登录 UI prefab:
csharp
ResourceManager.Instance.LoadPrefabAsync("UIPrefabs/View/LoginView", (go) =>
{
LoginView loginView = go.GetComponent<LoginView>();
go.transform.SetParent(_canvas.transform, false);
LoginUIController = new LoginUIController(loginView);
LoginUIController.ShowWindow(WindowType.LoginWindow);
});
LoginUIController 构造时:
csharp
_synchronizationContext = SynchronizationContext.Current;
_loginView = view as LoginView;
_loginView.InitView();
RegistCommand();
LoginView.InitView 将 Inspector 里拖入的窗口注册到字典:
csharp
windowDic.Add(WindowType.LoginWindow, _loginWindow);
windowDic.Add(WindowType.RegistWindow, _registWindow);
windowDic.Add(WindowType.ResetPwdWindow, _resetPwdWindow);
windowDic.Add(WindowType.GameServerWindow, _gameServerWindow);
windowDic.Add(WindowType.ServerListWindow, _serverListWindow);
控制器收到登录成功后,会切换窗口:
csharp
ShowWindow(WindowType.GameServerWindow, ret.GameServer);
业务 UI 示例:主城界面
MainCityView.InitView 注册窗口:
csharp
windowDic.Add(WindowType.RoleInfoWindow, _roleInfoWindow);
windowDic.Add(WindowType.ChatWindow, _chatWindow);
windowDic.Add(WindowType.MinMapWindow, _minMapWindow);
windowDic.Add(WindowType.SkilIInfoWindow, _skillInfoWindow);
windowDic.Add(WindowType.BackpackWindow, _backpackWindow);
windowDic.Add(WindowType.TalkWindow, _talkWindow);
windowDic.Add(WindowType.ShopWindow, _shopWindow);
windowDic.Add(WindowType.RoleAttributeWindow, _roleAttributeWindow);
windowDic.Add(WindowType.InputWindow, _inputWindow);
主城重写了 ShowWindow,实现"点一次打开,再点一次关闭"和互斥窗口:
csharp
if (windowDic[windowType].IsActive())
{
CameraManager.Instance.SetCursorVisible(false);
windowDic[windowType].Show(false);
}
else
{
CameraManager.Instance.SetCursorVisible(true);
windowDic[windowType].Show(true, obj);
foreach (var item in _exclusionWindows)
{
if (item.WindowType == windowType)
{
foreach (var window in item.windowTypes)
{
windowDic[window].Show(false);
}
}
}
}
所以主城 UI 不是基础 ViewBase 的"打开一个、隐藏全部"模式,而是允许 HUD、窗口、互斥窗口按业务规则共存。
两套 UI 的差异
| 项目 | 通用 UIFramework | 当前业务 UI |
|---|---|---|
| 核心入口 | UIFrame |
UIRoot |
| screen 标识 | string screenId |
WindowType enum |
| 基类 | UIScreenController<TProps> |
WindowBase / ViewBase |
| 注册方式 | RegisterScreen/RegisterWindow/RegisterPanel |
View.InitView() 手动 windowDic.Add |
| 窗口队列 | 有 | 无 |
| 历史栈 | 有 | 无 |
| 弹窗遮罩 | 有 | 无,业务自己处理 |
| 动画系统 | AniComponent |
默认直接 SetActive |
| 属性传递 | 强类型 IWindowProperties/IPanelProperties |
弱类型 object obj |
| 当前使用程度 | 框架代码存在,但业务 UI 没大量继承它 | 主力业务方案 |
新增业务 UI 的推荐做法
如果是给当前项目新增业务窗口,例如好友、任务、邮件等,建议沿用现有业务 UI 链路。
1. 增加枚举
在 EnumDefine.cs 的 WindowType 中增加:
csharp
FriendWindow
2. 创建窗口脚本
csharp
public class FriendWindow : WindowBase
{
public override void InitWindow()
{
// 初始化按钮、列表等
}
public override void UpdateUI(object obj)
{
// 根据 obj 刷新好友列表
}
}
3. 在对应 View 中声明字段
例如在 MainCityView:
csharp
[SerializeField]
private FriendWindow _friendWindow;
4. 在 InitView 注册
csharp
windowDic.Add(WindowType.FriendWindow, _friendWindow);
5. 在 Controller 中控制显示和刷新
csharp
ShowWindow(WindowType.FriendWindow);
UpdateUI(WindowType.FriendWindow, friendList);
6. 配置互斥关系
如果该窗口需要和其他窗口互斥,在 MainCityView 的 _exclusionWindows Inspector 配置中增加规则。
什么时候使用通用 UIFramework
适合使用通用 UIFramework 的情况:
- 准备重新统一 UI 管理入口。
- 需要窗口队列、历史栈、弹窗遮罩、转场阻断这些能力。
- 想用强类型
Properties替代object obj。 - 想通过
UISettings批量注册 UI prefab。
不建议混用的情况:
- 同一个业务模块里,一部分窗口由
UIRoot/ViewBase管,一部分窗口由UIFrame管。 - 同一个窗口既出现在
WindowType字典中,又注册到UIFrame中。
混用会导致显隐状态、层级、输入阻断和生命周期入口不一致。
当前实现中的注意点
-
UIControllerBase.Dispose()未实现,当前调用会抛异常。 -
ViewBase.ShowWindow(WindowType, object)会隐藏其他所有窗口,适合登录页,但不适合主城 HUD。主城已经通过重写解决。 -
WindowBase.UpdateUI(object obj)是弱类型,调用方和窗口之间依赖约定。修改时要确认传入对象的真实类型。 -
通用
WindowUILayer.HideScreen只能关闭当前窗口,不能随意关闭后台窗口。 -
通用
WindowProperties.SuppressPrefabProperties会影响运行时属性和 prefab 属性的优先级,使用前要确认期望行为。 -
FadeAni淡出完成后会把 alpha 设回1f,通常因为对象被隐藏而无影响,但复用时要注意。
结论
项目中有完整的通用 UIFramework:它支持 screen 注册、Panel/Window 分层、窗口队列、历史栈、弹窗遮罩、属性传递和动画转场。
但当前 MMORPG 业务 UI 的主线是:
text
UIRoot -> UIControllerBase -> ViewBase -> WindowBase
控制器负责网络消息和业务回调,View 负责窗口集合和互斥显示,Window 负责具体界面刷新。
新增普通业务 UI 时,优先沿用当前业务链路;只有当需求明确需要队列、历史栈、弹窗遮罩或强类型属性系统时,再考虑接入或改造通用 UIFramework。