Unity C# 简易UI框架 — 流程梳理与原理说明
2026/7/21 22:58:51 网站建设 项目流程

说明:以实际代码为准。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:双字典设计

字典作用KeyValue
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

新增界面时,只需:

  1. UIPanelType枚举中添加类型

  2. 在 JSON 中添加配置

  3. 创建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 抽取所有共性逻辑,子类只需关注自身特殊行为。新增界面只需改枚举、加配置、写子类,核心管理器零改动。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询