Unity UI 框架相关的一些思考

本文深入讨论了在Unity UI开发中,使用Monobehaviour子类与纯C#类控制逻辑的优缺点,组件式设计的必要性,接口易用性和一致性,以及焦点管理、背景逻辑、组件收集和生命周期管理的最佳实践。作者强调了组件化对UI开发效率提升和复用价值,同时关注了设计灵活性与团队协作挑战。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

 开源地址: 

GitHub - NRatel/NRFramework.UI: 基于 Unity UGUI 的 UI 开发框架基于 Unity UGUI 的 UI 开发框架. Contribute to NRatel/NRFramework.UI development by creating an account on GitHub.https://github.com/NRatel/NRFramework.UI

简介:

Unity UI 框架_NRatel的博客-优快云博客Unity UI 框架组件化、树状聚合设计提供面板创建/销毁/显隐藏接口、显示状态维护提供控件创建/动态逻辑绑定/销毁接口提供元素半自动收集、代码自动生成层级管理焦点管理内置自动添加背景,及背景点击响应逻辑内置自动播放打开/关闭动画、动画状态维护内置返回键回退逻辑其他(自定义组件、屏幕适配、多语言支持等)https://blog.youkuaiyun.com/NRatel/article/details/127902181

1、UI 类应该使用一个 Monobehaviour 子类进行逻辑控制,还是用纯 C# 类?

这两者的区别是:

前者创建:先创建 GameObjet 再给他挂一个控制它的脚本;
后者创建:先创建一个纯 C# 对象, 再在合适的时机创建 GameObject 交给它管理。

前者:
⑴、优点:符合Unity原本的 “go-comp” 思路。
⑵、缺点:逻辑脚本和预设资源绑在一起(至少不能以传统方式热更)。

后者:
⑴、优缺点:从类图上看,更符合控制引用关系、更便于代码设计;但需要自己维护 “引用 go”、“”“解除引用 go”、“销毁 go” 的逻辑;
⑵、优点:可为 go 挂一个通用的需操作元素收集组件,与逻辑类分开,并可随意组合复用。

2、为什么要组件式、树状聚合设计?

我之前的一篇文章里说过,游戏界面内容应该面向对象:每个界面作为一个游戏对象,界面中,较为独立的部分/重复出现的部分,可以单作为一个游戏对象,逐层嵌套,形成父子关系。 聚合/组合关系:1~1或1~N。好处是:

⑴、可以针对各层对象写逻辑,使逻辑各归其所,适合大规模建设。

⑵、对象可以复用。界面干净整洁、统一性好,不会出现有两处相同的东西,却长得不太一样

⑶、极大加快UI开发效率,设计界面、拼界面、写界面逻辑都是对 “对象化的零件” 进行拼凑组合。

比如:商店界面:商店界面 -> 货架 -> 货架的层 -> 货架层上的物体。
比如:编队界面:编队界面 -> 队伍槽位 -> 队伍中的英雄。
比如:英雄培养界面:英雄培养界面 -> 消耗材料槽(放材料图标和拥有/需求数量) -> 材料图标。
但是,这对策划(UE设计)和美术(UI效果图设计)的规划能力要求大幅增高了。
另外,也带给程序一些困惑,主要表现为:

⑴、若设计中各处针对性强 ,则难以复用。

⑵、设计复用需求不明确时,难以判断是否应按提出组件的方式来做。
(只出了UE,未出UI效果图就让程序先做功能,常常出现UE相似但最后UI不同的情况,导致返工)。

⑶、设计的需求调整可能不易,因为文件数变多了。
(只在本界面内重复出现的对象的逻辑类,可以定义在该界面的逻辑文件内,以减少文件数)

⑷、当有很多相似组件出现时,对组件的命名成了一个问题。

整体来说,利远大于弊。

3、接口支持度和易用性的思考

接口支持度和易用性,多少有点冲突,因为支持得越复杂意味着越不易用。

首先,在一个项目里,面对需求,框架不应该出现 “不能支持”的情况,

所以,只要扩展支持,就可能导致参数增多或细碎接口增多。

一个做事的上层方法,应该让用户自己决定怎么组合调用底层接口,而不是仅提供自己臆想出的 “某一个组合方式” 限制用户。

但是,如果一件事大部分情况下都是以 “某一个组合方式” 去做,每次都让用户组合,又未免过于麻烦。

以下几种手段可能解决这个问题:

⑴、将一些接口的参数改为默认形参 或 重载接口(UI框架中CreatePanel、CreateWidget等接口)

⑵、为一组细碎的接口提供默认的组合接口。

⑶、父类中提供接口的默认实现,但可在子类中重写(UI框架中播放打开/关闭Panel动画接口)。

4、操作/生命周期顺序的思考

⑴、父子类创建销毁的接口/生命周期顺序?

创建时先父后子;销毁时先子后父。

⑵、上下两层层界面的焦点焦点变化,获得焦点时,应该先触发谁的 OnFocus,丢失焦点时,应该先触发谁的 OnLostFocus?

出现时是先出现下层界面后出现上层界面;消失时是先消失上层界面后消失下层界面。

⑶、聚合/依赖关系,销毁的时候先解除引用还是先销毁自己?

创建:创建自身、建立引用;那么移除应该相反,即:解除引用、销毁自身。

⑷、对外提供接口的完成回调 和 对内完成事件的调用顺序?

对外提供接口的完成回调,应该晚于内部完成事件,即:先处理完内部,再处理外部。

5、复杂黑盒接口的异常处理思考

一个复杂的黑盒接口,传入非法参数时,让它报错还是返回 null?

尤其是封装成了 dll,别人不能查看源码时,无论报错还是光返回 null 都会让使用者感到疑惑。

仔细想想,异常,通常应该在外层调用处进行处理,那就应该把错误的情况列表并传出来。

可以为其定义一系列错误码,然后返回,若方法原来有返回值,改成 out 传出。如下:

static public class FindCompErrorCode
{
    //UIView中
    public const int OK = 0;
    public const int ERROR_CAST_TYPE = 1001;                //错误的组件转换类型
    public const int COMP_DEFINE_IS_NULL_OR_EMPTY = 1002;   //compDefine为null或""
    public const int NOT_EXIST_THIS_COMPONENT = 1003;       //View中不存在此组件定义
    public const int NOT_EXIST_ANY_CHILD_WIDGET = 1004;     //View中不存在任何子Widget(不存在此Widget)
    public const int WIDGETS_ID_IS_NULL_OR_EMPTY = 1005;    //widgetIds为null或""
    public const int NOT_EXIST_THIS_CHILD_WIDGET = 1006;    //View不存在此Widget

    //UIRoot
    public const int PANEL_ID_IS_NULL_OR_EMPTY = 1007;      //panelId为null或""
    public const int NOT_EXIST_THIS_PANEL = 1008;           //Root中不存在此Panel

    //UIManager中
    public const int NOT_EXIST_THIS_ROOT = 1009;            //UIManager中不存在此Root
    public const int VIEW_PATH_IS_NULL_OR_EMPTY = 1010;     //viewPath为null或""
    public const int VIEW_PATH_IS_TOO_SHORT = 1011;         //ViewPath应该至少包含一个rootId和一个panelId
}


public int FindComponentByPath<T>(string path, string compDefine, out T comp) where T : Component {}

6、界面显示及状态相关问题的思考

基本流程:
⑴、界面创建后,播放打开动画(若有)。
⑵、界面初始化时,注入或获取 Data 完成显示。
⑶、界面刷新时,注入或获取 Data 完成显示。
⑷、界面关闭时,播放关闭动画(若有)。
注意:
⑴、动画播放是异步的。动画一般都是创建时挂到预设上的,只操作预设初始节点,不依赖数据。
⑵、获取Data可能是异步的(现请求)。
⑶、某些组件的显示可能是异步的(如:为了优化脏标记异步更新)。
外部需求:
⑵、跳转连续打开多个界面时,不关心动画,但依赖数据(由数据决定是否可以依次打开,直至目标界面)。
⑶、功能解锁、红点、引导等上层系统需要能随时获取界面当前状态(如引导,要等界面完全准备好后才能执行)。
---------------------------------
其他问题:
⑴、异步请求数据,应放在 界面创建前 还是 界面创建后的初始化方法 中?
     建议后者,后者可以利用自身界面阻挡操作。但注意,必须处理好“创建后~初始化完成前”的显示。
⑵、界面显示和动画状态如何维护? 
     ①、只维护自身状态,不考虑子Widget。
     ②、但在外部读取时可以考虑计入自身及所有子Widget的状态(结合实际需求)。
     ③、初始化/刷新方法 完全由用户自定义(可能不是最终想法),如果是同步的,默认标记为Idle;如果是异步的,可以应该在初始化/刷新开始时将显示状态改为Initing/Refreshing,并在完成时将显示状态标记为Idle。
     ④、在Panel创建时默认调起打开动画,播放完成时将动画状态改为Idle。
     ⑤、在Panel关闭时调起关闭动画,播放完成时将动画状态改为Closed。  
⑶、是否将界面状态暴露到 Inspector中,便于调试?
     不确定是否有必要,待定。

7、维护焦点变化的一些思考

⑴、维护焦点变化,起到什么作用? 

界面失去焦点时,可选择性地“挂起”(暂停内部耗时Update类操作),并在重新获得焦点时恢复,以此优化。另外,还可在获得焦点时做一些事件触发,比如拍脸弹窗等。

⑵、关闭界面时是否触发 OnFoucusChanged(false)?

否,焦点在打开/关闭界面之后统一计算的。
无法触发已关闭界面的 OnFoucusChanged(false)。
这意味着,OnFoucusChanged 是不完全对称的。
如果在其中做了一些创建操作(尽量不要这样做),可能需要在 OnClosing 中善后清理。

⑶、界面的打开/关闭动画对焦点变化有什么影响?

界面在上层创建时,获得焦点应该是敏感的,即:只要创建就可能立刻获得焦点,此时下层界面丢失焦点也是立刻的。

界面在上层销毁时,下层界面获得焦点应该是迟钝的,即:要等到上层界面完全销毁,下层界面才能够获得焦点。

8、维护通用背景的一些思考

⑴、通用背景是否是单例的?

否,本来设想的是一个够用。但实际情况是当多层黑色半透明的弹窗叠加显示时,希望能看到背景变深。

⑵、通用背景是否总是出现在主要获得焦点的界面上?

不是,有些界面需要背景,但却不抢夺焦点(System类型)。

⑶、界面动画不应对预设根节点进行操作(缩放、旋转、位移)

因为背景是添加到预设根节点下的第一个物体,如果操作根节点,就会带着背景一起移动。

也是完全可以避免的:

①、PanelType 为 Underlay 的 界面,打开动画一般是 “子元素逐渐加入”(不会操作根节点)、“翻篇进入”(应该加入额外一个动画根节点)。

②、PanelType 为 Window 的 界面,打开动画一般是 “缩放、淡入淡出、飞入飞出等(应该加入额外一个动画根节点)”。

9、组件收集的一些实现问题解决和思考

⑴、每次支持新组件,都要改哪些地方?

①、支持原组件使用的图标, 
    修改 UIEditorUtility.GetIconByType 方法。
②、支持新组件脚本生成 生命周期、事件修改
    适当修改 UIView 或 为其增加 partial class,添加 “含事件组件” 的事件绑定、解除绑定和生命周期方法。
    适当修改 UIViewBehaviourEditor 的 canBindEventCompSet,增加 “含事件组件” 的组件名。
    适当修改 UIEditorUtility.kUITemporaryCode,增加事件生命周期方法。
③、支持组件推测。
    适当修改 SetAsUIOpElement。

⑵、TMP 的组件图标获取问题

①、TMP 是怎么做到脚本图标自定义的?

只要将图标资源按照其命名规则放在 Gizmos 目录中即可
可在 TMP 包的以下目录中找到:Packages/TextMeshPro/Edutor Resources/Gizmos/

②、那我要怎么才能加载?

访问包内资源的官方文档:https://docs.unity3d.com/cn/2020.3/Manual/upm-assets.html
(注意,路径中的包名不带版本号、空格、“-”都要保留)

(Texture2D)AssetDatabase.LoadAssetAtPath("Packages/com.unity.textmeshpro/Editor Resources/Gizmos/TMP - Input Field Icon.psd", typeof(Texture2D));

//实测,这样也可:
(Texture2D)EditorGUIUtility.Load("Packages/com.unity.textmeshpro/Editor Resources/Gizmos/TMP - Input Field Icon.psd")

⑵、在以下三种情况中,如何在 OnInspector 中准确获得当前关联预设的资源路径?

1点击预设时,2双击预设并选择预设时、3预设拖入Hierarchy时

解决:
双击预设并选择预设时、预设拖入Hierarchy时的 "Select" 是怎么做到的呢?
在Unity源码中全局搜索源码 "Select" ,找到 GameObjectInspector 了解具体情况。

PrefabAssetType singlePrefabType = PrefabUtility.GetPrefabAssetType(target);
PrefabInstanceStatus singleInstanceStatus = PrefabUtility.GetPrefabInstanceStatus(target);

在三种情境下测试(1点击预设时,2双击预设并选择预设时、3预设拖入Hierarchy时)
在其 OnInspectorGUI 中输出:
PrefabAssetType singlePrefabType = PrefabUtility.GetPrefabAssetType(target);
PrefabInstanceStatus singleInstanceStatus = PrefabUtility.GetPrefabInstanceStatus(target);
Debug.Log("singlePrefabType: " + singlePrefabType);
Debug.Log("singleInstanceStatus: " + singleInstanceStatus);
输出结果如下:
1、singlePrefabType: Regular;  singleInstanceStatus: NotAPrefab
2、singlePrefabType: NotAPrefab;  singleInstanceStatus: NotAPrefab
3、singlePrefabType: Regular;  singleInstanceStatus: Connected

发现一个方法,但是 internal 方法,不能用。。
GameObject prefabGo = PrefabUtility.GetOriginalSourceOrVariantRoot(targets[i]);   

但又发现一个调用了它的 public 方法:
GetPrefabAssetPathOfNearestInstanceRoot

在三种情境下测试(1点击预设时,2双击预设并选择预设时、3预设拖入Hierarchy时)
在其 OnInspectorGUI 中输出:
string prefabAssetPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(target);            
Debug.Log("prefabAssetPath: " + prefabAssetPath);
1、3,可以获得预设路径,2不行(输出为"")

全局搜索 "Canvas (Environment)" 找线索,发现以下调用堆栈:

PrefabStageUtility.GetOrCreateCanvasGameObject,
PrefabStageUtility.HandleUIReparentingIfNeeded
PrefabStageUtility.HandleReparentingIfNeeded
PrefabStage.LoadStage
PrefabStage.OpenStage
StageNavigationManager.SwitchToStage
PrefabStageUtility.OpenPrefabMode
PrefabStageUtility.OpenPrefab

可知:
点击预设 Inspector 右上角的 "Open"、鼠标右键点击预设再点击"Open"、双击预设等,
都是调用的 PrefabStageUtility.OpenPrefab

而我要的答案就是:
获取当前预设操作的Stage:PrefabStageUtility.GetCurrentPrefabStage() 或 PrefabStageUtility.GetPrefabStage(GameObject gameObject)
然后取其预设资源路径:pfabStage.prefabAssetPath(已弃用)或 prefabStage.assetPath(改为使用它)

⑶、如何在生成脚本后默认定位脚本位置

EditorGUIUtility.PingObject(AssetDatabase.LoadAssetAtPath<TextAsset>(scriptAssetPath));

⑷、为 操作元素Add(Set)、Delete(Remove) 增加快捷键

Alt+1:Add(Set)

Alt+2:Drelete(Remove)

<think>好的,用户让我用C#在Unity中编写一套UI框架。首先,我需要明确用户的需求。可能他们正在开发一个Unity项目,需要一个结构化的UI系统来管理各种界面,比如开始菜单、设置面板、游戏内HUD等。用户可能希望这个框架能方便地处理UI的显示隐藏、层级管理、事件响应,以及可能有动画效果和资源管理。 首先,我应该考虑Unity本身的UGUI系统,因为这是Unity内置的UI解决方案,用户可能已经在使用。接下来,常见的UI框架设计模式,比如MVC、MVP或者MVVM,但考虑到Unity的组件化特性,可能更适合使用基于组件的管理方式。 然后,用户提到的UI框架通常需要几个核心部分:一个UIManager单例来统一管理所有UI面板,每个UI面板作为独立的预制体,可能有基类如BasePanel,包含虚方法如OnEnter、OnExit等,供具体面板继承并实现具体逻辑。 另外,需要考虑UI的层级管理,比如不同面板可能处于不同的层级,比如背景层、普通层、弹出层、提示层等,这样在显示的时候可以正确排序。每个层级可能有不同的渲染顺序和遮挡关系。 事件系统方面,可能需要处理按钮点击、滑动等输入,Unity的EventSystem已经提供了基础,但可能需要封装以便于在框架内统一管理,比如使用委托、事件或者UnityEvent。 资源管理方面,需要考虑动态加载和卸载UI预制体,使用Resources.Load或者Addressables/AssetBundle,但为了简化示例,可能先使用Resources文件夹来加载预制体。 动画部分,可能需要为UI的显示和隐藏添加过渡效果,比如渐隐、缩放等,可以使用Unity的Animation或Dotween插件来实现。 接下来,我应该组织代码结构。UIManager作为核心,负责实例化、缓存、显示和隐藏面板。BasePanel作为所有面板的基类,定义生命周期方法。具体的面板如MainMenuPanel、SettingPanel继承BasePanel并实现具体逻辑。 然后,需要处理UI的层级,可能定义一个枚举UILayer,UIManager在实例化面板时将其放置到对应的Canvas下,并根据层级设置排序顺序。 另外,考虑单例模式的安全实现,确保UIManager在场景中唯一存在,并且方便其他脚本访问。 可能还需要考虑UI的输入控制,比如当弹出窗口出现时,禁止底层UI的交互,这可以通过使用CanvasGroup的interactable和blockRaycasts属性来控制。 测试方面,需要确保面板的显示隐藏正确,层级顺序符合预期,动画播放流畅,事件触发无误。 最后,可能需要提供一些扩展点,比如全局事件监听、多语言支持、分辨率自适应等,但根据用户的需求,可能先实现基础功能,再逐步扩展。 现在,我需要将这些思考整理成一个结构清晰的回答,分步骤介绍如何实现,并给出示例代码,同时指出可能的优化方向,比如对象池、异步加载等,供用户参考。</think>在Unity中开发UI框架时,建议采用分层架构设计。以下是一套可扩展的UI框架实现方案,包含核心模块和功能实现: 1. **框架结构设计** ```csharp // UI层级类型(可根据项目扩展) public enum UILayer { Background = 0, // 背景层(如加载界面) Normal = 1, // 常规层(主界面) Popup = 2, // 弹窗层(设置面板) Tips = 3, // 提示层(Toast提示) Loading = 4 // 加载层(转圈动画) } // UI基类(所有面板的父类) public abstract class BasePanel : MonoBehaviour { // 面板唯一标识 public string PanelKey { get; protected set; } // 生命周期方法 public virtual void OnCreate() {} // 首次创建时调用 public virtual void OnShow() {} // 显示时调用 public virtual void OnHide() {} // 隐藏时调用 public virtual void OnDestroy() {} // 销毁时调用 } ``` 2. **核心管理器实现** ```csharp public class UIManager : MonoBehaviour { private static UIManager _instance; public static UIManager Instance => _instance; // 面板缓存池 private Dictionary<string, BasePanel> panelPool = new Dictionary<string, BasePanel>(); // 层级容器 private Dictionary<UILayer, Transform> layerContainer = new Dictionary<UILayer, Transform>(); void Awake() { // 单例初始化 if (_instance != null) Destroy(gameObject); _instance = this; // 初始化层级容器 InitializeLayers(); } void InitializeLayers() { // 动态创建各层级Canvas foreach (UILayer layer in Enum.GetValues(typeof(UILayer))) { GameObject go = new GameObject(layer.ToString()); go.transform.SetParent(transform); Canvas canvas = go.AddComponent<Canvas>(); canvas.sortingOrder = (int)layer * 10; // 每层间隔10个排序单位 go.AddComponent<GraphicRaycaster>(); layerContainer.Add(layer, go.transform); } } } ``` 3. **面板管理功能** ```csharp // 在UIManager类中继续添加: public T ShowPanel<T>(UILayer layer, string panelPath) where T : BasePanel { string panelKey = typeof(T).Name; // 已存在则直接显示 if (panelPool.TryGetValue(panelKey, out BasePanel panel)) { panel.gameObject.SetActive(true); panel.transform.SetAsLastSibling(); panel.OnShow(); return (T)panel; } // 加载并实例化新面板 GameObject panelPrefab = Resources.Load<GameObject>(panelPath); if (panelPrefab == null) { Debug.LogError($"找不到面板预制体:{panelPath}"); return null; } GameObject panelObj = Instantiate(panelPrefab, layerContainer[layer]); T newPanel = panelObj.GetComponent<T>(); newPanel.PanelKey = panelKey; // 初始化组件 newPanel.OnCreate(); newPanel.OnShow(); // 加入缓存池 panelPool.Add(panelKey, newPanel); return newPanel; } public void HidePanel(string panelKey) { if (panelPool.TryGetValue(panelKey, out BasePanel panel)) { panel.OnHide(); panel.gameObject.SetActive(false); } } ``` 4. **使用示例**(主菜单面板实现) ```csharp public class MainMenuPanel : BasePanel { [SerializeField] private Button startButton; [SerializeField] private Button settingButton; public override void OnCreate() { PanelKey = "MainMenu"; startButton.onClick.AddListener(() => { Debug.Log("开始游戏"); SceneManager.LoadScene("GameScene"); }); settingButton.onClick.AddListener(() => { UIManager.Instance.ShowPanel<SettingPanel>( UILayer.Popup, "UI/SettingPanel"); }); } public override void OnShow() { // 显示时播放动画 GetComponent<Animator>().Play("FadeIn"); } } ``` 5. **优化建议** - **资源管理**:使用Addressables实现异步加载 ```csharp IEnumerator LoadPanelAsync<T>(string addressableKey) { var handle = Addressables.LoadAssetAsync<GameObject>(addressableKey); yield return handle; if (handle.Status == AsyncOperationStatus.Succeeded) { Instantiate(handle.Result); } } ``` - **动画系统**:集成DoTween实现平滑过渡 ```csharp public class PanelAnimator : MonoBehaviour { public float showDuration = 0.3f; public void PlayShowAnimation() { transform.localScale = Vector3.zero; transform.DOScale(Vector3.one, showDuration) .SetEase(Ease.OutBack); } } ``` - **事件系统**:创建全局事件中心 ```csharp public class EventCenter : MonoBehaviour { private static EventCenter _instance; public static EventCenter Instance => _instance; public UnityAction<string> OnPanelShow; void Awake() { if (_instance != null) Destroy(gameObject); _instance = this; } } // 面板显示时触发事件 UIManager.Instance.ShowPanel<MainMenuPanel>(...); EventCenter.Instance.OnPanelShow?.Invoke("MainMenu"); ``` 该框架具备以下特性: 1. 层级管理系统:通过动态创建的Canvas实现分层渲染 2. 对象缓存池:避免频繁实例化/销毁造成的性能消耗 3. 生命周期管理:规范化的面板状态控制 4. 扩展性强:支持无缝接入资源管理系统和动画系统 5. 事件驱动架构:通过事件中心实现模块间解耦 实际项目中可根据需求扩展以下功能: - 多语言支持:创建LocalizationManager - UI适配系统:通过CanvasScaler实现多分辨率适配 - 输入控制:使用InputSystem管理UI交互优先级 - 数据绑定:实现MVVM模式的数据驱动更新
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

NRatel

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值