ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity框架实战:状态机、UI、场景与实体四模块全解析

Unity框架实战:状态机、UI、场景与实体四模块全解析 做 Unity 客户端开发的时候很多人的项目都会经历这样一个过程刚开始只有一个场景、一个界面玩法逻辑全部堆在Update里怎么改都还算可控等到加了登录界面、主城、战斗、背包、设置之后场景跳转、界面打开关闭、角色生成销毁、流程切换就开始互相穿插改一处常常崩三处最后只能靠加各种if标志位来维护。本篇是 GGF 框架实战系列的第三篇围绕 UI、场景、实体和状态机四个模块展开。这四个模块基本覆盖了一款游戏“从启动到进入玩法”的主干链路状态机负责流程控制场景模块负责切换环境UI 模块负责表现交互实体模块负责场景中的动态对象。本文会先讲清楚每个模块解决什么问题再给出完整的简化实现和登录到主城的实战示例最后补充高频报错排查和工程化建议适合有一定 Unity 基础、想学习框架分层思路的同学阅读。1. 背景与核心概念1.1 GGF 框架的定位GGF 框架可以理解为一套面向 Unity 的轻量级模块化游戏框架它借鉴了主流游戏框架的分层思想把游戏开发中高频使用的功能拆成独立模块例如 UI、场景、实体、状态机、对象池、事件系统等。框架本身不写玩法它只负责提供一套可以复用的“骨架”让业务代码在固定的位置做自己该做的事。很多同学第一次接触框架时容易有一个误解框架是万能的脚手架写了框架就等于项目架构完成。实际上框架的价值在于约束它把“什么时候加载场景”“什么时候打开界面”“什么时候创建角色”这些流程问题统一收口让业务模块之间的依赖从“你调用我、我调用你”变成“都听调度中心安排”。GGF 这类框架最常见的组织方式是把核心逻辑拆成若干个 Manager例如UIManager、SceneManager、EntityManager、FsmManager再配合一个全局入口或者流程状态机来驱动。这样的好处是模块边界清晰测试和替换都容易项目规模大了之后尤其明显。1.2 四个模块各自的职责UI、场景、实体、状态机这四个词在游戏开发中都很常见但很多人不清楚它们之间的边界。下面用表格做一个快速区分。模块职责典型使用场景状态机管理游戏流程和对象状态的切换启动流程、登录流程、战斗流程、角色倒地/攻击切换场景模块负责场景资源加载、切换、进度反馈从登录场景切到主城场景从主城进入副本UI 模块负责界面加载、打开、关闭、层级管理登录界面、背包界面、弹窗提示实体模块负责场景内动态对象的创建、显示、回收玩家角色、NPC、怪物、子弹、特效简单来说状态机回答的是“现在游戏处于哪个阶段”场景模块回答的是“玩家在哪个环境里”UI 模块回答的是“玩家能看到哪些界面”实体模块回答的是“场景中有哪些可交互对象”。1.3 四个模块如何协作四个模块并不是孤立存在的实际项目中它们经常要配合工作。比如一个最常见的登录流程ProcedureLaunch启动流程 │ 加载登录场景 ▼ ProcedureLogin登录流程 │ 打开登录界面 ▼ 玩家点击登录按钮 │ 关闭登录界面加载主城场景 ▼ ProcedureMain主城流程 │ 打开主界面 ▼ EntityManager 生成玩家实体 │ 玩家进入主城 ▼ 状态机等待下一个流程切换可以看到状态机是流程的“大脑”它决定什么时候让场景模块加载场景、什么时候让 UI 模块打开界面、什么时候让实体模块生成角色。四个模块之间尽量通过调度者间接沟通避免直接互相持有强引用。2. 环境准备与版本说明2.1 推荐环境本文示例基于 Unity 开发涉及 UGUI 和场景管理相关的 API版本需要根据你的项目实际情况调整。笔者建议使用 Unity 2021.3 LTS 或更高版本LTS 版本稳定性更好示例中的代码不依赖特定新特性。项目推荐版本说明Unity2021.3 LTS 及以上示例代码在 2022 和 2023 版本下也能运行开发语言C#Unity 客户端主流语言IDEVisual Studio / Rider任选其一Rider 对 Unity 支持更顺手脚本后端Mono / IL2CPP编辑器调试用 Mono打包用 IL2CPP如果你使用的是更早的 Unity 版本SceneManager相关 API 从 Unity 5.3 之后就已经稳定整体思路不受影响个别属性名需要按版本调整。2.2 示例项目目录结构示例项目采用“框架代码 游戏业务代码”分目录的方式框架代码放在Assets/GGF下业务代码放在Assets/Game下。Assets/ GGF/ Fsm/ # 状态机核心 UI/ # UI 模块 Scene/ # 场景模块 Entity/ # 实体模块 Game/ Procedure/ # 游戏流程状态 UI/ # 具体界面 Entity/ # 具体实体 Main.cs # 入口脚本这种分层的目录结构在 Unity 项目中非常实用。框架目录不依赖业务目录后续如果要从 GGF 切换到其他框架业务层代码只需要替换模块调用方式不需要重写全部逻辑。2.3 场景与资源规划示例项目规划了三个场景需要手动加入 Build SettingsBootScene启动场景只放 Main 入口脚本。LoginScene登录场景实际可以是一个空场景登录界面由 UI 模块动态创建。MainScene主城场景用于生成玩家实体。这里有一个容易被忽略的原则不要把框架入口脚本放在会被卸载的场景里。使用LoadSceneMode.Single加载场景时当前场景会被整体卸载入口脚本必须通过DontDestroyOnLoad保持存活否则切一次场景整个流程就断掉了。3. 核心模块设计思路拆解3.1 状态机流程控制的“大脑”状态机FSMFinite State Machine是游戏开发中最常用也最容易上手的设计模式。它把复杂流程拆成若干个离散状态每个状态只关心自己负责的阶段通过切换函数在不同状态之间跳转。以登录流程为例可以拆成ProcedureLaunch、ProcedureLogin、ProcedureMain三个状态。每个状态内部定义清楚三件事进入时做什么、每帧做什么、离开时做什么。这其实就是很多网络资料里说的“三段式状态机”OnEnter、OnUpdate、OnExit。// 文件路径Assets/GGF/Fsm/FsmStateBase.cs namespace GGF.Fsm { public abstract class FsmStateBase { protected FsmManager Owner { get; private set; } public void SetOwner(FsmManager owner) { Owner owner; } public abstract void OnEnter(); public abstract void OnUpdate(float deltaTime); public abstract void OnExit(); } }状态切换时管理器需要先执行旧状态的OnExit再执行新状态的OnEnter。这个顺序不能颠倒因为旧状态可能持有需要释放的资源引用例如关闭登录界面、取消网络监听。// 文件路径Assets/GGF/Fsm/FsmManager.cs using System; using System.Collections.Generic; namespace GGF.Fsm { public sealed class FsmManager { private readonly DictionaryType, FsmStateBase _states new DictionaryType, FsmStateBase(); private Type _currentStateType; public FsmStateBase CurrentState { get { if (_currentStateType null) { return null; } return _states[_currentStateType]; } } public void RegisterState(FsmStateBase state) { Type type state.GetType(); if (_states.ContainsKey(type)) { throw new InvalidOperationException($状态已注册: {type.Name}); } state.SetOwner(this); _states.Add(type, state); } public void ChangeStateT() where T : FsmStateBase { Type type typeof(T); if (!_states.TryGetValue(type, out FsmStateBase nextState)) { throw new KeyNotFoundException($未找到状态: {type.Name}); } if (_currentStateType type) { return; } CurrentState?.OnExit(); _currentStateType type; nextState.OnEnter(); } public void OnUpdate(float deltaTime) { CurrentState?.OnUpdate(deltaTime); } } }ChangeState里判断当前状态类型是否相同这一步很关键。如果登录按钮被连续点击两次没有判断就会出现状态被重复刷新的问题界面反复开关逻辑也会重复执行。3.2 UI 模块界面生命周期与层级UI 模块要解决的核心问题不是“如何画一个按钮”而是“界面什么时候出现、什么时候关闭、遮住谁、被谁遮住”。实际项目中的 UI 通常分为底层、中层、弹窗层、顶层加载层不同层级对应不同 Canvas 或不同 Sort Order。为了让业务代码和 UI 模块解耦可以先抽象一个 UI 基类把打开、关闭、暂停这些生命周期方法统一起来。UGUI 的 Panel 本质上就是挂在 Canvas 下的一个 GameObject框架层只需要负责设置激活状态和父子关系。// 文件路径Assets/GGF/UI/UIFormBase.cs using UnityEngine; namespace GGF.UI { public abstract class UIFormBase : MonoBehaviour { public virtual void OnOpen(object userData) { gameObject.SetActive(true); } public virtual void OnClose(object userData) { gameObject.SetActive(false); } public virtual void OnPause() { gameObject.SetActive(false); } } }这里有一个容易被新手忽略的点OnClose不一定是销毁界面。实际项目中高频率使用的界面背包、商城更适合隐藏后缓存下次打开直接复用避免反复 Instantiate 造成的卡顿。真正要销毁的界面才进入销毁流程。UI 管理器负责维护一个“正在显示界面”的字典以界面类型名作为键。打开时实例化并调用OnOpen关闭时调用OnClose后再决定隐藏还是销毁。// 文件路径Assets/GGF/UI/UIManager.cs using System.Collections.Generic; using UnityEngine; namespace GGF.UI { public sealed class UIManager { private readonly Transform _uiRoot; private readonly Dictionarystring, UIFormBase _openedForms new Dictionarystring, UIFormBase(); public UIManager(Transform uiRoot) { _uiRoot uiRoot; } public T OpenFormT(T formTemplate, object userData null) where T : UIFormBase { string formName typeof(T).Name; if (_openedForms.ContainsKey(formName)) { Debug.LogWarning($界面已在打开状态: {formName}); return null; } // 简化实现每次都实例化实际项目可加入对象池复用 T form Object.Instantiate(formTemplate, _uiRoot); form.name formName; _openedForms.Add(formName, form); form.OnOpen(userData); return form; } public void CloseFormT() where T : UIFormBase { string formName typeof(T).Name; if (!_openedForms.TryGetValue(formName, out UIFormBase form)) { Debug.LogWarning($界面不在打开状态: {formName}); return; } form.OnClose(null); _openedForms.Remove(formName); Object.Destroy(form.gameObject); } public void CloseAll() { foreach (UIFormBase form in _openedForms.Values) { form.OnClose(null); Object.Destroy(form.gameObject); } _openedForms.Clear(); } } }3.3 场景模块切换与异步加载Unity 的SceneManager已经提供了最基本的场景加载能力框架要做的通常只是包一层加入加载中保护、统一进度回调、在加载完成时通知流程状态机。场景加载分为同步和异步两种。同步加载会阻塞主线程适合启动阶段的小场景异步加载适合资源较多的大场景加载过程中可以显示进度条和加载提示。// 文件路径Assets/GGF/Scene/SceneManagerEx.cs using System; using UnityEngine; using UnityEngine.SceneManagement; namespace GGF.Scene { public sealed class SceneManagerEx { private readonly MonoBehaviour _owner; private bool _isLoading; public SceneManagerEx(MonoBehaviour owner) { _owner owner; } public void LoadScene(string sceneName, Action onComplete null) { if (_isLoading) { Debug.LogWarning(场景正在加载中请勿重复调用); return; } _isLoading true; SceneManager.LoadScene(sceneName, LoadSceneMode.Single); _isLoading false; onComplete?.Invoke(); } public void LoadSceneAsync(string sceneName, Actionfloat onProgress, Action onComplete) { if (_isLoading) { Debug.LogWarning(场景正在加载中请勿重复调用); return; } _isLoading true; _owner.StartCoroutine(LoadSceneAsyncInternal(sceneName, onProgress, onComplete)); } private System.Collections.IEnumerator LoadSceneAsyncInternal( string sceneName, Actionfloat onProgress, Action onComplete) { AsyncOperation operation SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Single); operation.allowSceneActivation true; while (!operation.isDone) { onProgress?.Invoke(operation.progress); yield return null; } _isLoading false; onComplete?.Invoke(); } } }这里需要注意AsyncOperation.progress在资源加载未完成前会停在0.9只有场景真正切换完成后才会变成1。如果想做“进入新场景前的过渡黑屏”可以用中间变量但不要直接根据进度值强行判断加载完成否则可能提前执行场景切换逻辑。3.4 实体模块动态对象的创建与回收实体Entity和普通的GameObject有什么区别实体是场景中带有“身份”的对象比如玩家、NPC、怪物、子弹。框架给实体一个唯一EntityId方便通过 ID 查找和管理同时把创建、显示、隐藏、销毁这些操作统一收口。由于实体频繁创建销毁很容易造成卡顿和内存碎片成熟的框架都会给实体模块加上对象池。下面的简化实现没有写池化逻辑但保留了Init、OnShow、OnHide的生命周期后续加池子只需要把“销毁”改成“回收到空闲列表”。// 文件路径Assets/GGF/Entity/EntityBase.cs using UnityEngine; namespace GGF.Entity { public abstract class EntityBase : MonoBehaviour { public int EntityId { get; private set; } public string AssetName { get; private set; } public void Init(int entityId, string assetName) { EntityId entityId; AssetName assetName; OnInit(); } protected virtual void OnInit() { } protected virtual void OnShow(object userData) { } protected virtual void OnHide() { } } }实体管理器的核心是维持一个字典记录当前场景中所有存活的实体。生成实体时分配新的 ID隐藏实体时把 ID 从字典中移除。这样无论谁想找某个实体都可以通过EntityManager统一查找。// 文件路径Assets/GGF/Entity/EntityManager.cs using System.Collections.Generic; using UnityEngine; namespace GGF.Entity { public sealed class EntityManager { private readonly Dictionaryint, EntityBase _entities new Dictionaryint, EntityBase(); private int _nextId 1; public T ShowEntityT(string assetName, T entityTemplate, Vector3 position, Quaternion rotation) where T : EntityBase { T entity Object.Instantiate(entityTemplate, position, rotation); int entityId _nextId; entity.Init(entityId, assetName); _entities.Add(entityId, entity); entity.OnShow(null); return entity; } public void HideEntity(int entityId) { if (!_entities.TryGetValue(entityId, out EntityBase entity)) { return; } entity.OnHide(); Object.Destroy(entity.gameObject); _entities.Remove(entityId); } public void HideAllEntities() { foreach (EntityBase entity in _entities.Values) { entity.OnHide(); Object.Destroy(entity.gameObject); } _entities.Clear(); } } }4. 完整实战登录 → 主城的框架流程示例下面用一个完整的简化项目把四个模块串起来。项目流程是启动 BootScene加载 LoginScene打开登录界面点击登录后加载 MainScene打开主界面并生成一个玩家实体。4.1 编写状态机基础类第一步先实现状态机。将前面的FsmStateBase和FsmManager放入Assets/GGF/Fsm目录它们的职责已经在第 3 章说明。状态机是整个项目的调度中心后续流程状态都依赖它。4.2 编写 UI 管理器与登录界面登录界面继承UIFormBase内部持有“登录按钮”和两个输入框的引用。打开界面时给按钮注册点击事件关闭界面时移除事件避免重复注册。// 文件路径Assets/Game/UI/LoginForm.cs using UnityEngine; using UnityEngine.UI; using GGF.UI; public class LoginForm : UIFormBase { [SerializeField] private Button loginButton; [SerializeField] private InputField accountInput; [SerializeField] private InputField passwordInput; public System.Actionstring, string onLogin; public override void OnOpen(object userData) { base.OnOpen(userData); loginButton.onClick.AddListener(HandleLoginClicked); } public override void OnClose(object userData) { base.OnClose(userData); loginButton.onClick.RemoveListener(HandleLoginClicked); } private void HandleLoginClicked() { onLogin?.Invoke(accountInput.text, passwordInput.text); } }示例中使用的是 UGUI 基础组件InputField如果项目使用 TextMeshPro可以把字段类型替换为TMP_InputField逻辑完全一致。主界面比较简单只展示一个标题文本用于确认已经进入主城流程。// 文件路径Assets/Game/UI/MainForm.cs using UnityEngine; using GGF.UI; public class MainForm : UIFormBase { [SerializeField] private Text titleText; public void SetTitle(string title) { titleText.text title; } }4.3 编写场景加载管理器将第 3 章的SceneManagerEx放入Assets/GGF/Scene目录。这个类封装了同步加载和异步加载两种方式并加了_isLoading保护防止连续调用导致场景错乱。在实际项目里场景模块往往还要处理资源加载策略例如 AssetBundle 或 Addressables。本文示例直接使用 Unity 自带的SceneManager把重点放在流程串联上。4.4 编写实体管理器与玩家实体玩家实体继承EntityBase重写生命周期方法用于观察实体的创建和回收时机。// 文件路径Assets/Game/Entity/PlayerEntity.cs using UnityEngine; using GGF.Entity; public class PlayerEntity : EntityBase { protected override void OnInit() { Debug.Log($实体初始化id{EntityId}, asset{AssetName}); } protected override void OnShow(object userData) { base.OnShow(userData); Debug.Log(实体显示玩家已进入主城); } protected override void OnHide() { base.OnHide(); Debug.Log(实体隐藏); } }实体管理器依然复用第 3 章的EntityManager业务侧不直接Instantiate玩家对象而是通过管理器统一创建这为后续加入对象池和热更资源留了入口。4.5 串联三个流程状态流程状态依次是ProcedureLaunch、ProcedureLogin、ProcedureMain。每一个状态只做自己该做的事。启动流程加载登录场景加载完成后切换到登录流程。// 文件路径Assets/Game/Procedure/ProcedureLaunch.cs using GGF.Fsm; using GGF.Scene; public class ProcedureLaunch : FsmStateBase { private readonly SceneManagerEx _sceneManager; public ProcedureLaunch(SceneManagerEx sceneManager) { _sceneManager sceneManager; } public override void OnEnter() { Debug.Log(进入启动流程加载登录场景); _sceneManager.LoadSceneAsync(LoginScene, null, () { Owner.ChangeStateProcedureLogin(); }); } public override void OnUpdate(float deltaTime) { } public override void OnExit() { Debug.Log(退出启动流程); } }登录流程打开登录界面并监听登录事件。登录成功后关闭界面、加载主城场景然后切到主城流程。生产环境中这里需要接入服务器登录校验本文只看本地流程串联。// 文件路径Assets/Game/Procedure/ProcedureLogin.cs using GGF.Fsm; using GGF.Scene; using GGF.UI; using UnityEngine; public class ProcedureLogin : FsmStateBase { private readonly UIManager _uiManager; private readonly SceneManagerEx _sceneManager; private readonly LoginForm _loginFormTemplate; private LoginForm _loginForm; public ProcedureLogin(UIManager uiManager, SceneManagerEx sceneManager, LoginForm loginFormTemplate) { _uiManager uiManager; _sceneManager sceneManager; _loginFormTemplate loginFormTemplate; } public override void OnEnter() { Debug.Log(进入登录流程打开登录界面); _loginForm _uiManager.OpenForm(_loginFormTemplate); if (_loginForm ! null) { _loginForm.onLogin HandleLogin; } } public override void OnUpdate(float deltaTime) { } public override void OnExit() { if (_loginForm ! null) { _loginForm.onLogin - HandleLogin; _uiManager.CloseFormLoginForm(); } Debug.Log(退出登录流程); } private void HandleLogin(string account, string password) { Debug.Log($开始登录账号{account}); _uiManager.CloseFormLoginForm(); _sceneManager.LoadSceneAsync(MainScene, null, () { Owner.ChangeStateProcedureMain(); }); } }主城流程打开主界面并通过实体管理器生成玩家实体。注意OnExit里必须做清理关闭主界面、回收所有实体否则重复进入主城时会出现界面叠加和角色堆积。// 文件路径Assets/Game/Procedure/ProcedureMain.cs using GGF.Fsm; using GGF.Entity; using GGF.UI; using UnityEngine; public class ProcedureMain : FsmStateBase { private readonly UIManager _uiManager; private readonly EntityManager _entityManager; private readonly MainForm _mainFormTemplate; private readonly PlayerEntity _playerEntityTemplate; public ProcedureMain( UIManager uiManager, EntityManager entityManager, MainForm mainFormTemplate, PlayerEntity playerEntityTemplate) { _uiManager uiManager; _entityManager entityManager; _mainFormTemplate mainFormTemplate; _playerEntityTemplate playerEntityTemplate; } public override void OnEnter() { Debug.Log(进入主城流程); MainForm mainForm _uiManager.OpenForm(_mainFormTemplate); mainForm?.SetTitle(主城); _entityManager.ShowEntity( Player, _playerEntityTemplate, new Vector3(0f, 1f, 0f), Quaternion.identity); } public override void OnUpdate(float deltaTime) { } public override void OnExit() { _uiManager.CloseFormMainForm(); _entityManager.HideAllEntities(); Debug.Log(退出主城流程); } }4.6 挂入入口脚本并运行入口脚本挂在 BootScene 的一个空 GameObject 上。它负责创建各个管理器、注册流程状态并在Update中驱动状态机。这里使用DontDestroyOnLoad保证入口对象在场景切换后依然存活。// 文件路径Assets/Game/Main.cs using UnityEngine; using GGF.Entity; using GGF.Fsm; using GGF.Scene; using GGF.UI; public class Main : MonoBehaviour { [SerializeField] private Canvas uiRootTemplate; [SerializeField] private LoginForm loginFormTemplate; [SerializeField] private MainForm mainFormTemplate; [SerializeField] private PlayerEntity playerEntityTemplate; private FsmManager _fsmManager; private UIManager _uiManager; private SceneManagerEx _sceneManager; private EntityManager _entityManager; private void Awake() { DontDestroyOnLoad(gameObject); } private void Start() { Canvas uiRoot Instantiate(uiRootTemplate); DontDestroyOnLoad(uiRoot.gameObject); _uiManager new UIManager(uiRoot.transform); _sceneManager new SceneManagerEx(this); _entityManager new EntityManager(); _fsmManager new FsmManager(); _fsmManager.RegisterState(new ProcedureLaunch(_sceneManager)); _fsmManager.RegisterState(new ProcedureLogin(_uiManager, _sceneManager, loginFormTemplate)); _fsmManager.RegisterState(new ProcedureMain(_uiManager, _entityManager, mainFormTemplate, playerEntityTemplate)); _fsmManager.ChangeStateProcedureLaunch(); } private void Update() { _fsmManager.OnUpdate(Time.deltaTime); } }运行步骤在 Unity 中打开 BootScene将 Main.cs 挂到场景中的空物体上。把 Canvas、LoginForm、MainForm、PlayerEntity 做成预制体拖到 Main 组件对应字段中。将 BootScene、LoginScene、MainScene 加入 Build Settings。从 BootScene 启动游戏。预期输出控制台依次打印“进入启动流程加载登录场景”“进入登录流程打开登录界面”点击登录按钮后打印“开始登录账号xxx”随后切到 MainScene打印“进入主城流程”“实体初始化”“实体显示玩家已进入主城”。5. 常见问题与排查思路5.1 场景切换后 UI 不见了如果 UI 属于被卸载的场景那么场景切换后 UI 自然会被销毁。很多项目把 UI 直接放在 LoginScene 场景里切到 MainScene 之后登录界面消失了但主界面又没有正常打开表现就是 UI 丢失。解决思路是让 UI 挂在持久层对象上。入口脚本和 UI Root 都通过DontDestroyOnLoad保持存活界面由 UIManager 动态实例化不依赖具体场景。这样无论切到哪个场景UI 管理逻辑都不中断。5.2 UI 点击穿透弹窗打开后玩家点击弹窗背景结果底下的按钮响应了这是典型的 UI 穿透。原因通常是弹窗所在的 Canvas 没有正确拦截射线或者底层界面没有被置灰遮罩。解决思路是给弹窗类界面统一加一个全屏半透明遮罩遮罩上的Image必须勾选Raycast Target这样点击会被遮罩拦截。同时弹窗使用独立的 Canvas 或更高的 Sort Order保证渲染顺序在底层界面之上。5.3 状态机切换异常常见现象是点击按钮后流程反复进入同一个状态或者状态没有按预期切换。根本原因通常是ChangeState没有做相同状态判断或者RegisterState时重复注册了同一类型。排查顺序是先检查状态是否只注册一次再检查ChangeState里是否过滤了相同状态最后检查流程状态里是否在OnEnter中触发了新的ChangeState导致递归切换。第 3 章的状态机实现里已经加入了相同状态保护可以直接复用。5.4 实体频繁创建销毁导致卡顿战斗场景中怪物和子弹数量很多如果每次创建都用Instantiate、每次销毁都用Destroy会频繁触发内存分配和 GC表现就是帧率抖动甚至卡顿。解决思路是给实体模块加上对象池实体隐藏时放入空闲列表再次生成时从池中取出复用而不是反复实例化。实体模块只暴露创建和隐藏接口内部是否走池化对业务侧透明。5.5 高频排查速查表问题现象常见原因解决思路场景切换后 UI 丢失UI 挂在被卸载场景中UI 挂到常驻 UIRoot 下由 UIManager 管理UI 点击穿透遮罩没有拦截射线全屏遮罩开启 Raycast Target状态重复执行进入逻辑ChangeState 未过滤相同状态状态切换前判断当前状态类型异步场景加载提前完成错误判断 progress等待 isDone合理处理 0.9 进度实体频繁创建销毁卡顿未使用对象池实体模块内部实现池化复用场景切换后入口脚本失效入口场景被卸载入口对象调用 DontDestroyOnLoad6. 最佳实践与工程建议6.1 状态机设计建议状态机的核心不是代码写得多么复杂而是状态划分是否清晰。建议每个流程状态只做一件事例如启动流程只负责加载启动场景登录流程只负责登录交互不要出现一个状态里既加载场景又打开多个界面还生成角色的情况。状态之间的切换参数尽量用显式类型传递不要在状态间共享大量静态变量。状态切换顺序用ChangeStateT这样的强类型方式表达比到处比较字符串要安全得多。6.2 UI 工程化建议UI 统一命名规范非常必要例如Form表示全屏界面、Dialog表示弹窗、Toast表示轻提示。层级管理放在 UIManager 中统一处理业务层不要直接调整 Canvas 的Sort Order。高频率界面要缓存复用低频率界面关闭后直接销毁。所有界面按钮点击事件都要在OnClose时移除监听防止已经隐藏的界面重新被点击触发逻辑。UI 卡顿和热词中常见的“UI 界面卡顿”问题大部分原因就是频繁创建、销毁界面和过度使用SetActive池化和缓存可以显著缓解。6.3 场景与资源加载建议启动场景和正式主场景要分离启动场景只做初始化不要塞复杂资源。场景切换时先停掉旧的战斗逻辑再加载新场景避免新旧场景逻辑同时运行导致状态错乱。资源加载要尽量下沉到场景模块内部业务层不感知 AssetBundle 还是 Addressables。这样后续更换资源方案时只需要修改场景模块一个文件不需要改动所有流程状态。6.4 模块通信与扩展建议多个模块之间尽量不直接互相持有强引用。本文示例中UIManager、SceneManagerEx、EntityManager都是由入口脚本Main创建的流程状态通过构造函数拿到管理器引用这是一种简单直观的“依赖注入”。项目继续扩大后可以引入事件系统或消息中心。例如登录成功后发出LoginSuccessEventUI 模块、实体模块、任务模块各自监听而不是在ProcedureLogin里挨个调用其他模块的公开方法。这样新增模块时不需要改动已有流程状态扩展性会好很多。7. 总结与学习路线到这里GGF 框架实战第三篇的核心内容就梳理完了。本文从概念入手说明了 UI、场景、实体、状态机四个模块各自的职责和协作关系给出了状态机基类、UI 管理器、场景管理器、实体管理器的简化实现并用“启动 → 登录 → 主城 → 生成玩家”的完整流程把四个模块串联起来。下一步可以继续学习的内容有三个方向事件系统用 C# 事件或消息中心替换构造函数传参进一步降低模块耦合。对象池给实体模块和 UI 模块补上复用机制解决高频创建销毁带来的卡顿。资源管理理解 AssetBundle 与 Addressables 的差异把资源加载统一收口到场景模块中。实际项目中优先关注两类风险一是流程状态切换时的资源清理二是场景切换时入口对象的生命周期。这两类问题最容易在项目中期集中爆发建议在框架设计阶段就做好约束。框架代码不需要一步到位先从本文的简化版开始跑通流程再根据项目需要逐步增加对象池、事件系统、资源管理这比直接照抄一个庞大框架要容易理解得多。遇到具体报错时优先看控制台日志输出顺序日志能帮你快速定位是哪个流程状态没有按预期执行。
返回列表