☰
Unity框架实战:状态机、UI、场景与实体四模块全解析
2026/9/29 5:52:53 网站建设 项目流程

做 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 Settings:

  1. BootScene:启动场景,只放 Main 入口脚本。
  2. LoginScene:登录场景,实际可以是一个空场景,登录界面由 UI 模块动态创建。
  3. MainScene:主城场景,用于生成玩家实体。

这里有一个容易被忽略的原则:不要把框架入口脚本放在会被卸载的场景里。使用LoadSceneMode.Single加载场景时,当前场景会被整体卸载,入口脚本必须通过DontDestroyOnLoad保持存活,否则切一次场景整个流程就断掉了。

3. 核心模块设计思路拆解

3.1 状态机:流程控制的“大脑”

状态机(FSM,Finite 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 Dictionary<Type, FsmStateBase> _states = new Dictionary<Type, 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 ChangeState<T>() 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 Dictionary<string, UIFormBase> _openedForms = new Dictionary<string, UIFormBase>(); public UIManager(Transform uiRoot) { _uiRoot = uiRoot; } public T OpenForm<T>(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 CloseForm<T>() 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, Action<float> onProgress, Action onComplete) { if (_isLoading) { Debug.LogWarning("场景正在加载中,请勿重复调用"); return; } _isLoading = true; _owner.StartCoroutine(LoadSceneAsyncInternal(sceneName, onProgress, onComplete)); } private System.Collections.IEnumerator LoadSceneAsyncInternal( string sceneName, Action<float> 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 Dictionary<int, EntityBase> _entities = new Dictionary<int, EntityBase>(); private int _nextId = 1; public T ShowEntity<T>(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.Action<string, 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.ChangeState<ProcedureLogin>(); }); } 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.CloseForm<LoginForm>(); } Debug.Log("退出登录流程"); } private void HandleLogin(string account, string password) { Debug.Log($"开始登录,账号:{account}"); _uiManager.CloseForm<LoginForm>(); _sceneManager.LoadSceneAsync("MainScene", null, () => { Owner.ChangeState<ProcedureMain>(); }); } }

主城流程打开主界面,并通过实体管理器生成玩家实体。注意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.CloseForm<MainForm>(); _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.ChangeState<ProcedureLaunch>(); } private void Update() { _fsmManager.OnUpdate(Time.deltaTime); } }

运行步骤:

  1. 在 Unity 中打开 BootScene,将 Main.cs 挂到场景中的空物体上。
  2. 把 Canvas、LoginForm、MainForm、PlayerEntity 做成预制体,拖到 Main 组件对应字段中。
  3. 将 BootScene、LoginScene、MainScene 加入 Build Settings。
  4. 从 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 进度
实体频繁创建销毁卡顿未使用对象池实体模块内部实现池化复用
场景切换后入口脚本失效入口场景被卸载入口对象调用 DontDestroyOnLoad

6. 最佳实践与工程建议

6.1 状态机设计建议

状态机的核心不是代码写得多么复杂,而是状态划分是否清晰。建议每个流程状态只做一件事,例如启动流程只负责加载启动场景,登录流程只负责登录交互,不要出现一个状态里既加载场景又打开多个界面还生成角色的情况。

状态之间的切换参数尽量用显式类型传递,不要在状态间共享大量静态变量。状态切换顺序用ChangeState<T>这样的强类型方式表达,比到处比较字符串要安全得多。

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创建的,流程状态通过构造函数拿到管理器引用,这是一种简单直观的“依赖注入”。

项目继续扩大后,可以引入事件系统或消息中心。例如登录成功后发出LoginSuccessEvent,UI 模块、实体模块、任务模块各自监听,而不是在ProcedureLogin里挨个调用其他模块的公开方法。这样新增模块时不需要改动已有流程状态,扩展性会好很多。

7. 总结与学习路线

到这里,GGF 框架实战第三篇的核心内容就梳理完了。本文从概念入手,说明了 UI、场景、实体、状态机四个模块各自的职责和协作关系,给出了状态机基类、UI 管理器、场景管理器、实体管理器的简化实现,并用“启动 → 登录 → 主城 → 生成玩家”的完整流程把四个模块串联起来。

下一步可以继续学习的内容有三个方向:

  1. 事件系统:用 C# 事件或消息中心替换构造函数传参,进一步降低模块耦合。
  2. 对象池:给实体模块和 UI 模块补上复用机制,解决高频创建销毁带来的卡顿。
  3. 资源管理:理解 AssetBundle 与 Addressables 的差异,把资源加载统一收口到场景模块中。

实际项目中优先关注两类风险:一是流程状态切换时的资源清理,二是场景切换时入口对象的生命周期。这两类问题最容易在项目中期集中爆发,建议在框架设计阶段就做好约束。

框架代码不需要一步到位,先从本文的简化版开始跑通流程,再根据项目需要逐步增加对象池、事件系统、资源管理,这比直接照抄一个庞大框架要容易理解得多。遇到具体报错时,优先看控制台日志输出顺序,日志能帮你快速定位是哪个流程状态没有按预期执行。

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

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

立即咨询