说明:以实际代码为准。UIManager是纯C#单例(非MonoBehaviour),通过new UIManager()创建,在构造函数中解析JSON。Canvas也是动态加载的。
一、整体流程步骤
步骤1:创建 JSON 配置文件
{ "infoList": [ { "panelTypeString": "TaskPanel", "path": "UIPrefab/TaskPanel" }, { "panelTypeString": "BagPanel", "path": "UIPrefab/BagPanel" }, { "panelTypeString": "ItemMessagePanel", "path": "UIPrefab/ItemMessagePanel" }, { "panelTypeString": "MainMeunPanel", "path": "UIPrefab/MainMeunPanel" }, { "panelTypeString": "ShopPanel", "path": "UIPrefab/ShopPanel" }, { "panelTypeString": "SkillPanel", "path": "UIPrefab/SkillPanel" }, { "panelTypeString": "SystemPanel", "path": "UIPrefab/SystemPanel" } ] }配置是从游戏文件中读取的数据
是"变化最频繁的东西"。策划可能今天改路径、明天加界面。把路径从代码里抽出来,以后改配置就行,不用重新编译代码。而且JSON可以在线校验格式,不容易写错。
为什么字段名叫infoList?因为要和UIPanelTypeJson类里的public List<UIPanelInfo> infoList字段名完全一致,Unity才能正确反序列化。
注意:JSON根节点是infoList,对应UIPanelTypeJson类中的字段名。
步骤2:定义 UIPanelType 面板类型枚举
public enum UIPanelType { TaskPanel, BagPanel, ItemMessagePanel, MainMeunPanel, ShopPanel, SkillPanel, SystemPanel }为什么先写这个?
因为枚举是整个框架的"身份证系统"。后面所有地方——JSON配表、字典Key、反射找类、打开界面传的参数——都用它来做标识。先定好枚举,后续代码才能围绕这个强类型来写,而不是用容易写错的字符串。
步骤3:UIPanelInfo 数据模型,定义数据模型 + 反序列化自动转换
using System; using System.Collections.Generic; using UnityEngine; [Serializable] public class UIPanelInfo : ISerializationCallbackReceiver { public UIPanelType panelType; // 代码中使用的枚举(反序列化后自动赋值) public string panelTypeString; // JSON中的面板字符串 public string path; // 预制体路径 public void OnBeforeSerialize() { throw new NotImplementedException(); } // 反序列化完成后自动调用:字符串 → 枚举 public void OnAfterDeserialize() { panelType = (UIPanelType)Enum.Parse(typeof(UIPanelType), panelTypeString); } } [Serializable] public class UIPanelTypeJson { public List<UIPanelInfo> infoList; // 名称必须和JSON根节点一致 }为什么第二步写这个?
这是数据层。JSON文件里的字符串需要转成C#对象,这个类就是桥梁。ISerializationCallbackReceiver接口的作用是在反序列化完成后自动把字符串转成枚举,这样后面代码里直接拿panelType(枚举)用,不用每次手动Parse。
为什么不直接让JSON存枚举?Unity的JsonUtility不支持直接把JSON字符串反序列化成枚举,所以先以字符串读进来,再自己转。
前三个步骤用JSON 配置文件 + 枚举 + 反序列化来替代UIType,是配置化路径的思路
步骤4:定义面板基类 BasePanel
using System; using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class BasePanel : MonoBehaviour { protected CanvasGroup canvasGroup; protected Transform closeTrans; protected Button closeBtn; public virtual void Start() { } // 界面打开 public virtual void OnOpen() { // 懒加载CanvasGroup if (canvasGroup == null) { canvasGroup = this.GetComponent<CanvasGroup>(); } // 懒加载关闭按钮 if (closeTrans == null) { closeTrans = transform.Find("closeBtn"); if (closeTrans != null) { closeBtn = closeTrans.GetComponent<Button>(); closeBtn.onClick.AddListener(OnClickCloseBtn); } } // 显示界面 + 允许交互 canvasGroup.alpha = 1; canvasGroup.blocksRaycasts = true; } // 关闭按钮点击回调 protected virtual void OnClickCloseBtn() { UIManager.Instance.PopPanel(); } // 界面暂停交互(被上层界面盖住时调用) public virtual void OnPause() { canvasGroup.blocksRaycasts = false; } // 界面恢复交互(上层界面关闭后调用) public virtual void OnResume() { canvasGroup.blocksRaycasts = true; } // 界面关闭 public virtual void OnClose() { canvasGroup.alpha = 0; canvasGroup.blocksRaycasts = false; } }为什么第三步写基类?
这是"找共同点"。所有界面都有打开、关闭、暂停交互、恢复交互这些行为,只是具体表现不同。先抽出共性,后面每个子界面继承它,只需要写自己特殊的地方。
为什么用虚函数而不是抽象函数?因为不是所有界面都需要重写所有方法。比如任务界面不需要暂停交互,那它就不用重写OnPause(),直接用父类的空实现就行。如果用抽象函数,子类被迫全部实现,没意义。
步骤5:创建 UIManager(纯C#单例,核心管理器)
using System; using System.Collections.Generic; using UnityEngine; /* * 序列化:对象转换为二进制 * 反序列化:二进制转换为对象 */ public class UIManager { // ========== 单例 ========== private static UIManager instance; public static UIManager Instance { get { if (instance == null) instance = new UIManager(); return instance; } } // ========== 构造函数:解析JSON ========== private UIManager() { ParseJson(); } // ========== 画布(懒加载) ========== private Transform canvasTrans; public Transform CanvasTrans { get { if (canvasTrans == null) { canvasTrans = GameObject.Instantiate( Resources.Load<GameObject>("UIPrefab/Canvas") ).transform; } return canvasTrans; } } // ========== 数据字典 ========== // 存储「面板类型 → 预制体路径」 private Dictionary<UIPanelType, string> panelInfoDic = new Dictionary<UIPanelType, string>(); // 存储「面板类型 → 已实例化的面板对象」(保证只实例化一次) private Dictionary<UIPanelType, BasePanel> panelDic = new Dictionary<UIPanelType, BasePanel>(); // ========== 栈:管理层级关系 ========== private Stack<BasePanel> panelStack = new Stack<BasePanel>(); // ========== 解析JSON配置文件 ========== void ParseJson() { TextAsset ta = Resources.Load<TextAsset>("Config/UIPanelType"); UIPanelTypeJson jsonObj = JsonUtility.FromJson<UIPanelTypeJson>(ta.text); foreach (UIPanelInfo info in jsonObj.infoList) { panelInfoDic.Add(info.panelType, info.path); } } // ========== 获取面板(核心:只实例化一次) ========== private BasePanel GetPanel(UIPanelType panelType) { BasePanel panel; panelDic.TryGetValue(panelType, out panel); if (panel == null) // 面板不存在,需要实例化 { string path; panelInfoDic.TryGetValue(panelType, out path); if (path != null) { // 1. 从Resources加载预制体并实例化到Canvas下 GameObject instancePanel = GameObject.Instantiate( Resources.Load<GameObject>(path), CanvasTrans ); // 2. 动态挂载对应脚本 AddScripts(panelType, instancePanel); // 3. 获取挂载的BasePanel组件并缓存 panel = instancePanel.GetComponent<BasePanel>(); panelDic.Add(panelType, panel); return panel; } else { Debug.LogError("没有找到对应的面板" + panelType); return null; } } else { return panel; // 已存在,直接复用 } } // ========== 动态挂载脚本(反射实现) ========== void AddScripts(UIPanelType panelType, GameObject go) { // 枚举名转字符串 string str = Enum.GetName(panelType.GetType(), panelType); // 通过反射获取类型 Type scriptType = Type.GetType(str); // 避免重复挂载 if (!go.GetComponent(scriptType)) { go.AddComponent(scriptType); } // 确保每个面板都有CanvasGroup,用于启用/禁止点击 GetAndAddComponent<CanvasGroup>(go); } // ========== 泛型工具:获取或添加组件 ========== public T GetAndAddComponent<T>(GameObject obj) where T : Component { if (!obj.GetComponent<T>()) { obj.AddComponent<T>(); } return obj.GetComponent<T>(); } // ========== 界面入栈(打开新界面) ========== public void PushPanel(UIPanelType panelType) { // 1. 暂停当前栈顶界面的交互 if (panelStack.Count > 0) { BasePanel topPanel = panelStack.Peek(); topPanel.OnPause(); } // 2. 获取/实例化目标面板 BasePanel panel = GetPanel(panelType); // 3. 执行打开逻辑 panel.OnOpen(); // 4. 入栈 panelStack.Push(panel); } // ========== 界面出栈(关闭当前界面) ========== public void PopPanel() { if (panelStack.Count <= 0) return; // 1. 关闭当前栈顶界面 BasePanel topPanel = panelStack.Pop(); topPanel.OnClose(); // 2. 恢复新的栈顶界面交互 if (panelStack.Count <= 0) return; BasePanel newTopPanel = panelStack.Peek(); newTopPanel.OnResume(); } }为什么第四步才写管理器?
因为管理器依赖前面所有东西:
依赖
UIPanelType枚举做Key依赖
UIPanelInfo解析JSON依赖
BasePanel做栈的元素类型依赖反射动态挂载脚本(脚本类名要和枚举名一致)
为什么是纯C#单例而不是MonoBehaviour?
不需要挂在场景物体上,随时可用
构造函数里自动解析JSON,保证第一次访问时数据已就绪
Canvas用属性懒加载,避免游戏启动时卡顿
为什么用双字典?
panelInfoDic:只读配置,启动时解析一次panelDic:运行时缓存实例,保证每个面板只Instantiate一次
为什么用栈?UI交互天然是"后进先出":打开任务→打开背包→关闭背包→回到任务。栈顶永远只有一个,正好对应"永远只和最上层界面交互"。
步骤6:各子界面继承 BasePanel
TaskPanel(任务界面)
using System.Collections; using System.Collections.Generic; using UnityEngine; using DG.Tweening; public class TaskPanel : BasePanel { public override void OnOpen() { base.OnOpen(); // 先移到屏幕外 transform.localPosition = new Vector3( 3000, transform.localPosition.y, transform.localPosition.z ); // DOTween跳跃动画进入屏幕中心 transform.DOLocalJump(Vector3.zero, 100, 3, 1); // 其他动画效果可选: // transform.DOLocalMove(Vector3.zero, 1f); // transform.localScale = Vector3.zero; // transform.DOScale(Vector3.one, 1f); } }BagPanel(背包界面)
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class BagPanel : BasePanel { private Button bagSlot; public override void Start() { base.Start(); // 查找背包格子按钮并监听点击 bagSlot = transform.Find("bagSlot/bagItem").GetComponent<Button>(); bagSlot.onClick.AddListener(OnClickBagSlotBtn); } private void OnClickBagSlotBtn() { // 点击物品,弹出详细信息面板 UIManager.Instance.PushPanel(UIPanelType.ItemMessagePanel); } }为什么最后写子类?
因为子类依赖基类和管理器。框架搭好了,子类只需要:
继承
BasePanel重写需要自定义的方法(如
OnOpen加动画)写自己特有的逻辑(如背包格子点击)
新增界面时,UIManager一行代码不用改,这就是框架的价值。
设计顺序的底层逻辑
第1步:定身份(枚举) → 给每个界面发身份证 第2步:定数据结构(UIPanelInfo)→ 身份证怎么对应到路径 第3步:写配置(JSON) → 身份证和路径的映射关系外置 第4步:抽共性(BasePanel) → 所有界面都有的行为 第5步:搭管理器(UIManager) → 用身份证去加载、缓存、管理层级 第6步:写个性(子类) → 每个界面自己的特殊逻辑
核心设计思想:
配置与逻辑分离:路径变不用改代码
共性抽取到基类:子类只写特殊逻辑
单例统一管理:所有界面的生命周期一处控制
栈管理交互层级:永远只操作最上层
反射+约定:枚举名=类名,新增界面零改动管理器
二、核心原理统一说明
原理1:纯C#单例模式
┌─────────────────────────────────────┐ │ UIManager(纯C#类,非MonoBehaviour)│ │ ───────────────────────────────── │ │ private static UIManager instance │ │ public static UIManager Instance │ │ → get { if null → new } │ │ private UIManager() { ParseJson(); }│ └─────────────────────────────────────┘ │ ▼ 第一次访问 Instance 时触发 构造函数自动解析JSON配置为什么不用 MonoBehaviour 单例?
不需要挂在场景物体上,更轻量
不依赖场景生命周期,随时可访问
构造函数中完成初始化(解析JSON),保证数据就绪
原理2:懒加载 Canvas
public Transform CanvasTrans { get { if (canvasTrans == null) { canvasTrans = GameObject.Instantiate( Resources.Load<GameObject>("UIPrefab/Canvas") ).transform; } return canvasTrans; } }第一次需要实例化面板时才创建 Canvas
后续所有面板都挂在这个 Canvas 下
避免游戏启动时一次性加载过多资源造成卡顿
原理3:双字典设计
| 字典 | 作用 | Key | Value |
|---|---|---|---|
panelInfoDic | 存储配置数据 | UIPanelType枚举 | 预制体路径字符串 |
panelDic | 存储实例化后的面板 | UIPanelType枚举 | BasePanel组件引用 |
为什么要两个字典?
panelInfoDic:只读配置,启动时从JSON解析一次,永不变动panelDic:运行时缓存,第一次实例化后存入,后续直接复用
原理4:反射动态挂载脚本
void AddScripts(UIPanelType panelType, GameObject go) { string str = Enum.GetName(panelType.GetType(), panelType); // "TaskPanel" Type scriptType = Type.GetType(str); // 找到 TaskPanel 类 if (!go.GetComponent(scriptType)) // 避免重复挂载 go.AddComponent(scriptType); // 动态挂载 GetAndAddComponent<CanvasGroup>(go); // 确保有CanvasGroup }命名约定:UIPanelType.TaskPanel→ 类名TaskPanel→ 脚本文件TaskPanel.cs
新增界面时,只需:
在
UIPanelType枚举中添加类型在 JSON 中添加配置
创建
XxxPanel.cs继承BasePanel
UIManager 一行代码都不用改。
原理5:栈管理界面层级
操作顺序:
1. 游戏启动,某处调用: UIManager.Instance.PushPanel(UIPanelType.MainMeunPanel); Stack: [MainMeunPanel] ← 栈顶=主界面(可交互) 2. 点击任务按钮: UIManager.Instance.PushPanel(UIPanelType.TaskPanel); Stack: [MainMeunPanel, TaskPanel] ← 主界面.OnPause(), 任务界面.OnOpen() 3. 点击背包按钮(从任务界面跳转): UIManager.Instance.PushPanel(UIPanelType.BagPanel); Stack: [MainMeunPanel, TaskPanel, BagPanel] ← 任务界面.OnPause(), 背包界面.OnOpen() 4. 点击背包内物品,弹出详情: UIManager.Instance.PushPanel(UIPanelType.ItemMessagePanel); Stack: [MainMeunPanel, TaskPanel, BagPanel, ItemMessagePanel] ← 背包界面.OnPause(), 详情界面.OnOpen() 5. 关闭详情(点击关闭按钮 → PopPanel()): Stack: [MainMeunPanel, TaskPanel, BagPanel] ← 详情界面.OnClose(), 背包界面.OnResume() 6. 关闭背包: Stack: [MainMeunPanel, TaskPanel] ← 背包界面.OnClose(), 任务界面.OnResume() 7. 关闭任务: Stack: [MainMeunPanel] ← 任务界面.OnClose(), 主界面.OnResume()核心规则:
只有栈顶界面
blocksRaycasts = true(可交互)新界面入栈 → 原栈顶
OnPause()(屏蔽交互)界面出栈 → 新栈顶
OnResume()(恢复交互)完美支持多界面嵌套(背包 → 详情 → 提示框)
原理6:CanvasGroup 统一控制
// 打开:看得见 + 点得着 canvasGroup.alpha = 1; canvasGroup.blocksRaycasts = true; // 暂停:看得见 + 点不着(被上层界面盖住) canvasGroup.blocksRaycasts = false; // 关闭:看不见 + 点不着 canvasGroup.alpha = 0; canvasGroup.blocksRaycasts = false;为什么不用gameObject.SetActive(false)?
SetActive(false)会彻底禁用物体,无法做渐隐动画alpha = 0只是透明,配合 DOTween 可以做淡入淡出blocksRaycasts一键控制整个界面及其所有子物体的交互
原理7:基类抽取共性
| 功能 | BasePanel 实现 | 子类是否需要关心 |
|---|---|---|
| 查找/挂载 CanvasGroup | ✅ OnOpen() 中懒加载 | ❌ 不需要 |
| 查找/绑定关闭按钮 | ✅ OnOpen() 中自动处理 | ❌ 不需要 |
| 显示/隐藏(alpha) | ✅ OnOpen() / OnClose() | ❌ 不需要 |
| 交互开关(blocksRaycasts) | ✅ OnOpen() / OnPause() / OnResume() / OnClose() | ❌ 不需要 |
| 点击关闭 → PopPanel | ✅ OnClickCloseBtn() | ❌ 不需要 |
| 打开动画(跳跃/缩放/移动) | ❌ 基类不管 | ✅ 子类重写 OnOpen() |
| 背包格子点击弹详情 | ❌ 基类不管 | ✅ BagPanel 自己实现 |
设计哲学:基类处理「所有界面都一样的事」,子类只写「这个界面特殊的事」。
三、新增界面的完整流程(以抽奖界面为例)
JSON配置
{ "infoList": [ // ... 原有配置 ... { "panelTypeString": "LotteryPanel", "path": "UIPrefab/LotteryPanel" } ] }第1步:添加枚举
public enum UIPanelType { TaskPanel, BagPanel, ItemMessagePanel, MainMeunPanel, ShopPanel, SkillPanel, SystemPanel, LotteryPanel // ← 新增 }第2步:创建脚本
using UnityEngine; using DG.Tweening; public class LotteryPanel : BasePanel { public override void OnOpen() { base.OnOpen(); // 自定义打开动画:从下方滑入 transform.localPosition = new Vector3(0, -2000, 0); transform.DOLocalMove(Vector3.zero, 0.5f); } }第3步:预制体命名
制作LotteryPanel.prefab,放入Resources/UIPrefab/文件夹。
完成!
UIManager.cs 一行代码都不用改。这就是框架的价值。
四、一句话总结
UIManager 作为纯C#单例,在构造函数中解析JSON建立「类型→路径」映射;通过懒加载创建Canvas;利用反射按「枚举名=类名」的约定动态挂载脚本;用双字典分别缓存配置数据和实例对象;用栈管理界面层级,保证永远只有最上层可交互;BasePanel 抽取所有共性逻辑,子类只需关注自身特殊行为。新增界面只需改枚举、加配置、写子类,核心管理器零改动。