Unity C# 简易UI框架 — 流程梳理与原理说明

📅 2026/7/21 22:58:53
Unity C# 简易UI框架 — 流程梳理与原理说明
说明以实际代码为准。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 ListUIPanelInfo infoList字段名完全一致Unity才能正确反序列化。注意JSON根节点是infoList对应UIPanelTypeJson类中的字段名。步骤2定义 UIPanelType 面板类型枚举public enum UIPanelType { TaskPanel, BagPanel, ItemMessagePanel, MainMeunPanel, ShopPanel, SkillPanel, SystemPanel }为什么先写这个因为枚举是整个框架的身份证系统。后面所有地方——JSON配表、字典Key、反射找类、打开界面传的参数——都用它来做标识。先定好枚举后续代码才能围绕这个强类型来写而不是用容易写错的字符串。步骤3UIPanelInfo 数据模型定义数据模型 反序列化自动转换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 ListUIPanelInfo infoList; // 名称必须和JSON根节点一致 }为什么第二步写这个这是数据层。JSON文件里的字符串需要转成C#对象这个类就是桥梁。ISerializationCallbackReceiver接口的作用是在反序列化完成后自动把字符串转成枚举这样后面代码里直接拿panelType枚举用不用每次手动Parse。为什么不直接让JSON存枚举Unity的JsonUtility不支持直接把JSON字符串反序列化成枚举所以先以字符串读进来再自己转。前三个步骤用JSON 配置文件 枚举 反序列化来替代UIType是配置化路径的思路步骤4定义面板基类 BasePanelusing 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.GetComponentCanvasGroup(); } ​ // 懒加载关闭按钮 if (closeTrans null) { closeTrans transform.Find(closeBtn); if (closeTrans ! null) { closeBtn closeTrans.GetComponentButton(); 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.LoadGameObject(UIPrefab/Canvas) ).transform; } return canvasTrans; } } ​ // 数据字典 // 存储「面板类型 → 预制体路径」 private DictionaryUIPanelType, string panelInfoDic new DictionaryUIPanelType, string(); ​ // 存储「面板类型 → 已实例化的面板对象」保证只实例化一次 private DictionaryUIPanelType, BasePanel panelDic new DictionaryUIPanelType, BasePanel(); ​ // 栈管理层级关系 private StackBasePanel panelStack new StackBasePanel(); ​ // 解析JSON配置文件 void ParseJson() { TextAsset ta Resources.LoadTextAsset(Config/UIPanelType); UIPanelTypeJson jsonObj JsonUtility.FromJsonUIPanelTypeJson(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.LoadGameObject(path), CanvasTrans ); ​ // 2. 动态挂载对应脚本 AddScripts(panelType, instancePanel); ​ // 3. 获取挂载的BasePanel组件并缓存 panel instancePanel.GetComponentBasePanel(); 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用于启用/禁止点击 GetAndAddComponentCanvasGroup(go); } ​ // 泛型工具获取或添加组件 public T GetAndAddComponentT(GameObject obj) where T : Component { if (!obj.GetComponentT()) { obj.AddComponentT(); } return obj.GetComponentT(); } ​ // 界面入栈打开新界面 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各子界面继承 BasePanelTaskPanel任务界面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).GetComponentButton(); 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懒加载 Canvaspublic Transform CanvasTrans { get { if (canvasTrans null) { canvasTrans GameObject.Instantiate( Resources.LoadGameObject(UIPrefab/Canvas) ).transform; } return canvasTrans; } }第一次需要实例化面板时才创建 Canvas后续所有面板都挂在这个 Canvas 下避免游戏启动时一次性加载过多资源造成卡顿原理3双字典设计字典作用KeyValuepanelInfoDic存储配置数据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); // 动态挂载 GetAndAddComponentCanvasGroup(go); // 确保有CanvasGroup }命名约定UIPanelType.TaskPanel→ 类名TaskPanel→ 脚本文件TaskPanel.cs新增界面时只需在UIPanelType枚举中添加类型在 JSON 中添加配置创建XxxPanel.cs继承BasePanelUIManager 一行代码都不用改。原理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()恢复交互完美支持多界面嵌套背包 → 详情 → 提示框原理6CanvasGroup 统一控制// 打开看得见 点得着 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 抽取所有共性逻辑子类只需关注自身特殊行为。新增界面只需改枚举、加配置、写子类核心管理器零改动。