公司动态

Unity游戏框架搭建实战:分层架构、事件中心与MVP模式详解

📅 2026/8/8 9:18:56
Unity游戏框架搭建实战:分层架构、事件中心与MVP模式详解
1. 项目概述为什么我们需要一个自己的游戏框架如果你在Unity里做过几个小项目或者参与过一个稍具规模的团队开发大概率会经历这样的场景项目初期大家凭着热情和直觉把脚本挂在GameObject上功能跑起来就行。但随着功能模块越来越多UI、角色、数据、网络、音效、配置表……各种脚本开始相互引用GameObject的层级结构变得一团乱麻。当你需要修改一个角色的移动逻辑时你发现它引用了UI管理器UI管理器又引用了背包系统背包系统又依赖一个全局的单例数据管理器。牵一发而动全身一个小小的改动就可能引发一连串的Bug。更头疼的是新来的同事要花好几天才能理清这个“意大利面条”式的代码结构团队协作效率直线下降。这就是我决定从零开始搭建一个Unity游戏框架的初衷。它不是一个要替代UniRx、Zenject这些成熟插件的庞然大物而是一个贴合自己团队习惯、解决实际项目痛点的“脚手架”。核心目标就三个解耦、可维护、易扩展。通过清晰的分层设计让数据、逻辑、表现各司其职让新功能可以像乐高积木一样“插”进来而不是“焊”进去。这次分享我会完整复盘我搭建这个框架的全过程重点不是展示一个完美的最终成品而是记录那些让我掉进去又爬出来的“坑”。我会附上完整的项目结构图并解释每一层为什么这么设计以及在实际项目中如何应用。无论你是刚入门Unity的新手还是正在为项目架构头疼的开发者希望这些踩坑经验能帮你少走弯路。2. 框架整体设计与核心思路拆解2.1 分层架构的核心理念关注点分离在动手写代码之前最重要的是想清楚架构的核心理念。我选择的是经典的分层架构其核心思想是“关注点分离”。简单来说就是让不同的代码只关心自己该做的事。想象一下餐厅的后厨采购员只负责买来新鲜的食材数据层切配工将食材处理成半成品逻辑/服务层而厨师负责将半成品烹饪成美味的菜肴并摆盘表现层。如果让厨师自己去买菜、算账后厨早就乱套了。游戏开发也一样我们需要明确的分工。在我的框架中主要分为以下四层数据层 (Data Layer)只负责数据的定义、存储、读取和序列化。它不关心数据怎么用也不关心屏幕上显示什么。比如玩家的金币数量、关卡配置表、物品属性等。逻辑层/服务层 (Logic/Service Layer)这是游戏的核心“大脑”。它包含所有的游戏规则和业务逻辑。例如计算伤害、判断胜负、管理游戏状态开始、暂停、结束。它从数据层获取数据进行计算并通知表现层更新。表现层 (Presentation Layer)负责一切与玩家感官交互相关的内容。包括UI的显示与交互、角色动画的播放、特效的生成、声音的触发。它监听逻辑层的事件并做出相应的视觉或听觉反馈同时将玩家的输入操作转化为事件传递给逻辑层。基础设施层/工具层 (Infrastructure/Tool Layer)为上面三层提供通用的支持服务。比如对象池管理、资源加载器、事件中心、日志工具、单例基类等。这一层通常与具体游戏逻辑无关可以被任何项目复用。注意分层不是越多越好。过度分层会导致简单的功能调用链过长增加理解成本。对于中小型项目将数据、逻辑、表现三层清晰地分开已经能解决80%的代码混乱问题。2.2 核心模块选型与设计考量确定了分层思想后接下来要决定每个层内部的关键模块如何实现。这里有几个关键选择每一个背后都有我踩坑的教训。1. 数据管理ScriptableObject vs. JSON/XML vs. 二进制ScriptableObjectUnity原生支持在编辑器内可视化编辑非常方便数据作为资源文件管理。坑点如果数据量巨大如成千上万个物品配置会导致项目资源目录臃肿影响编辑器打开和资源导入速度。且运行时动态修改并持久化保存比较麻烦。JSON/XML文本格式人类可读易于版本管理如Git。修改方便可以用Excel编辑后导出。坑点需要自己写解析和加载逻辑如果解析不当可能会有性能开销尽管通常可忽略。对于敏感数据如数值公式保密性差。二进制加载快体积小保密性好。坑点调试困难无法直接查看和修改需要专门的编辑和导出工具。我的选择采用“编辑器内用ScriptableObject发布后用JSON”的混合策略。策划在编辑器内使用ScriptableObject进行配置和调试享受可视化便利。在打包时通过一个自定义的导出工具将所有ScriptableObject数据序列化成一份或多份优化后的JSON文件。运行时框架从JSON加载。这样兼顾了开发效率和运行时性能。2. 通信机制直接调用 vs. 事件/消息中心 vs. 依赖注入直接调用GetComponentUIManager().UpdateScore(100);简单粗暴但耦合度最高是架构腐化的开端。事件/消息中心模块之间通过发布和订阅事件来通信。比如逻辑层发布一个OnScoreChanged事件表现层的UI模块订阅这个事件来更新分数显示。两者互不知晓对方存在实现了彻底解耦。依赖注入通过一个容器来管理所有服务的实例和生命周期并自动将依赖“注入”到需要它们的类中。功能强大但学习曲线较陡对于简单项目可能显得“杀鸡用牛刀”。我的选择实现一个轻量级的事件中心。这是解耦的利器也是本框架的核心枢纽。逻辑层和表现层几乎所有的交互都通过事件来完成。我避免了使用Unity自带的SendMessage或UnityEvent在跨模块通信时因为它们不够灵活且性能并非最优。自己实现一个基于C#event和Action/Func的通用事件系统更可控也更高效。3. 对象管理动态Instantiate/Destroy vs. 对象池频繁创建和销毁GameObject如子弹、敌人、UI弹窗是Unity性能的主要杀手之一。对象池是必须的。我的框架基础设施层包含一个通用的对象池管理器支持对任意类型的GameObject或纯C#对象进行池化管理。4. UI框架UGUI原生 vs. 封装MVP/MVVM直接使用UGUI的Button.onClick.AddListener会把业务逻辑和UI表现死死绑在一起。我借鉴了MVP模式为UI框架设计了三部分View继承自MonoBehaviour挂在UI预制体上。只持有UI组件的引用Text,Image,Button并提供设置文本、图片、绑定按钮事件等方法。View不包含任何游戏逻辑。Model即数据层提供UI需要显示的数据。Presenter/Controller负责协调View和Model。它从Model获取数据调用View的方法更新显示同时监听View传来的用户输入事件执行业务逻辑并更新Model。这样当UI需要改版时你只需要调整View当业务逻辑变化时你修改Presenter两者互不干扰。3. 核心模块详解与实现要点3.1 事件中心框架的“神经系统”事件中心是解耦的关键。我实现了一个叫EventCenter的单例类。它内部使用DictionaryEventType, Delegate来存储事件类型和对应的回调列表。// 定义事件类型可以用枚举也可以用字符串。枚举更高效。 public enum GameEvent { OnPlayerHpChanged, OnEnemyDefeated, OnSceneLoaded, // ... } public class EventCenter : SingletonEventCenter { private DictionaryGameEvent, Actionobject eventDic new DictionaryGameEvent, Actionobject(); // 注册事件 public void AddListener(GameEvent eventType, Actionobject callback) { if (!eventDic.ContainsKey(eventType)) { eventDic[eventType] null; } eventDic[eventType] callback; } // 移除事件 public void RemoveListener(GameEvent eventType, Actionobject callback) { if (eventDic.ContainsKey(eventType)) { eventDic[eventType] - callback; } } // 触发事件 public void TriggerEvent(GameEvent eventType, object eventData null) { if (eventDic.ContainsKey(eventType) eventDic[eventType] ! null) { eventDic[eventType]?.Invoke(eventData); } } // 清空所有事件通常在场景切换时调用 public void Clear() { eventDic.Clear(); } }使用示例逻辑层玩家生命值变化时// PlayerHealth.cs 在逻辑层 public void TakeDamage(int damage) { currentHp - damage; // 触发事件通知所有关心血量变化的模块 EventCenter.Instance.TriggerEvent(GameEvent.OnPlayerHpChanged, currentHp); }表现层UI血条// UIHealthBar.cs 在表现层 void Start() { // 注册监听事件 EventCenter.Instance.AddListener(GameEvent.OnPlayerHpChanged, OnHpChanged); } void OnDestroy() { // 务必在销毁时移除监听防止内存泄漏和空引用 EventCenter.Instance.RemoveListener(GameEvent.OnPlayerHpChanged, OnHpChanged); } private void OnHpChanged(object newHp) { int hp (int)newHp; // 更新血条UI的显示 healthSlider.value (float)hp / maxHp; healthText.text ${hp}/{maxHp}; }踩坑记录1事件监听的内存泄漏这是最容易被忽视的坑。如果一个对象如UI注册了事件监听但在销毁如关闭界面时没有移除监听那么事件中心依然持有对该对象的委托引用导致该对象无法被垃圾回收。务必在OnDestroy或相应的关闭方法中移除所有注册过的事件监听。踩坑记录2事件数据的类型安全上面的例子中TriggerEvent传递了一个object类型的参数在接收端需要进行强制类型转换。这有运行时出错的风险。一个更安全的做法是使用泛型事件或者为不同类型的事件定义不同的委托签名。但为了初版的简洁和易用性我选择了object并在团队内约定好每个事件传递的数据类型通过文档或命名来约束。3.2 数据管理层ScriptableObject与持久化数据层我设计了一个DataManager它负责统一加载、管理和提供所有静态配置数据以及动态运行时数据。静态配置数据如物品表、关卡数据在Resources或Addressables可加载的路径下存放由ScriptableObject导出的JSON文件。DataManager在游戏启动时或按需加载这些JSON文件并反序列化成对应的C#数据类对象。提供根据ID查询数据的方法如GetItemConfig(int id)。[System.Serializable] public class ItemConfig { public int id; public string name; public string iconPath; public int maxStack; // ... 其他属性 } public class DataManager : SingletonDataManager { private Dictionaryint, ItemConfig itemConfigDict new Dictionaryint, ItemConfig(); public void Init() { // 从JSON加载所有物品配置 TextAsset jsonText Resources.LoadTextAsset(Configs/ItemConfig); var configList JsonUtility.FromJsonItemConfigList(jsonText.text); // ItemConfigList是一个包装类 foreach(var config in configList.list) { itemConfigDict[config.id] config; } } public ItemConfig GetItemConfig(int id) { if(itemConfigDict.TryGetValue(id, out ItemConfig config)) { return config; } Debug.LogError($ItemConfig not found for id: {id}); return null; } }动态运行时数据如玩家存档定义一个PlayerSaveData类包含所有需要保存的字段。使用JsonUtility.ToJson或Newtonsoft.Json将其序列化为字符串。使用PlayerPrefs适用于小量数据或System.IO.File适用于复杂存档进行存储和读取。在DataManager中封装SaveGame()和LoadGame()方法。踩坑记录3ScriptableObject的运行时修改在编辑器模式下直接修改ScriptableObject资产文件修改会被保存。但在运行时打包后的修改是临时的游戏退出后就会丢失。如果你需要运行时修改配置并持久化必须将修改写回JSON文件或二进制存档并在下次启动时重新加载。切勿以为运行时修改了ScriptableObject实例就等于修改了源数据。3.3 UI框架基于MVP的封装我创建了一个基类BaseView和BasePanel用于管理整个UI界面。// UI面板基类 public abstract class BasePanel : MonoBehaviour { protected UIManager uiManager; // 持有UIManager引用用于打开/关闭其他面板 public virtual void Init(UIManager mgr) { uiManager mgr; } public virtual void OnOpen() { } // 面板打开时调用 public virtual void OnClose() { } // 面板关闭时调用 public virtual void OnUpdate() { } // 每帧更新 } // 某个具体面板例如主界面 public class MainPanel : BasePanel { // 持有View组件 [SerializeField] private Button startButton; [SerializeField] private Text playerNameText; private MainPanelPresenter presenter; public override void Init(UIManager mgr) { base.Init(mgr); presenter new MainPanelPresenter(this); startButton.onClick.AddListener(OnStartButtonClick); } public override void OnOpen() { presenter.OnPanelOpened(); } private void OnStartButtonClick() { presenter.OnStartGameRequested(); } // View提供接口给Presenter调用 public void UpdatePlayerName(string name) { playerNameText.text name; } } // Presenter纯C#类不继承MonoBehaviour public class MainPanelPresenter { private MainPanel view; private PlayerDataModel playerData; // 持有数据模型 public MainPanelPresenter(MainPanel panel) { view panel; playerData DataManager.Instance.PlayerData; } public void OnPanelOpened() { // 从Model获取数据更新View view.UpdatePlayerName(playerData.Name); } public void OnStartGameRequested() { // 处理业务逻辑例如通知逻辑层开始游戏 GameLogicManager.Instance.StartGame(); // 然后可以通知UIManager关闭主界面 // view.uiManager.ClosePanelMainPanel(); } }UIManager负责管理所有面板的栈用于返回功能、加载和实例化UI预制体通常通过对象池、以及面板的打开关闭生命周期。踩坑记录4UI组件的查找与绑定避免在Awake或Start中使用GameObject.Find、Transform.Find或GetComponentInChildren来动态查找子物体。这些操作在UI复杂时会有性能开销。我采用的方法是序列化字段手动拖拽在编辑器中将子物体拖拽到脚本的public或[SerializeField] private字段上。这是最直接、性能最好的方式。使用属性绑定工具对于动态生成的列表项如背包格子编写一个编辑器脚本自动根据命名约定为脚本绑定对应的子物体引用减少手动操作。代码生成更高级的做法是像FairyGUI那样通过工具自动生成UI组件绑定的代码。这对于大型UI项目能极大提升效率。4. 完整项目结构解析与搭建实操一个清晰的项目结构是框架的物理体现能让团队所有成员快速定位代码。以下是我最终采用的结构在Unity的Project视图中大致如下Assets/ ├── 3rdParty/ # 第三方插件 (如Dotween, Json.NET) ├── Art/ # 美术资源 (模型、纹理、动画等) │ ├── Materials/ │ ├── Models/ │ ├── Textures/ │ └── ... ├── Audio/ # 音效与音乐 ├── Editor/ # 编辑器扩展脚本 │ ├── Tools/ # 自定义工具如配置表导出工具 │ └── ... ├── Plugins/ # 原生插件 ├── Resources/ # 需随包体加载的资源谨慎使用会增大包体 │ └── Configs/ # JSON配置文件 ├── Scenes/ # 场景文件 ├── Scripts/ # 所有游戏脚本核心 │ ├── Runtime/ # 运行时脚本 │ │ ├── Core/ # 框架核心基础设施层 │ │ │ ├── Base/ # 基类 (Singleton, MonoSingleton, PoolableObject等) │ │ │ ├── EventSystem/ # 事件中心 │ │ │ ├── Manager/ # 管理器基类或通用管理器 (PoolManager, AudioManager) │ │ │ ├── Utils/ # 工具类 (扩展方法数学工具时间工具等) │ │ │ └── ... │ │ ├── Data/ # 数据层 │ │ │ ├── Models/ # 数据模型定义 (PlayerData, ItemData) │ │ │ ├── Configs/ # 静态配置对应的数据类 (ScriptableObject和C#类) │ │ │ ├── Services/ # 数据服务 (DataManager, SaveSystem) │ │ │ └── ... │ │ ├── Logic/ # 逻辑/服务层 │ │ │ ├── GameLogic/ # 核心游戏逻辑 (BattleManager, LevelManager) │ │ │ ├── Entity/ # 游戏实体逻辑 (PlayerController, EnemyAI) │ │ │ ├── Systems/ # 各种系统 (InventorySystem, SkillSystem) │ │ │ └── ... │ │ ├── Presentation/ # 表现层 │ │ │ ├── UI/ # UI框架相关 │ │ │ │ ├── Views/ # UI视图脚本 (挂预制体上) │ │ │ │ ├── Presenters/ # UI表现器 (可选纯逻辑) │ │ │ │ ├── Widgets/ # 通用UI组件 (自定义Button, ScrollView) │ │ │ │ └── UIManager.cs │ │ │ ├── Visual/ # 视觉表现 (特效控制器动画状态机) │ │ │ ├── Audio/ # 音效表现控制器 │ │ │ └── ... │ │ └── EntryPoint.cs # 游戏入口第一个执行的脚本 │ └── Editor/ # 仅编辑器使用的脚本 (如自定义Inspector) ├── Settings/ # 项目设置文件 (如Graphics Settings, Input Manager) ├── StreamingAssets/ # 流式资源可用于AssetBundle或动态加载 └── Tests/ # 单元测试 ├── PlayMode/ └── EditMode/搭建实操步骤创建基础文件夹结构按照上述结构在Assets/Scripts/Runtime下创建Core,Data,Logic,Presentation等文件夹。实现基础设施首先编写Core层。从SingletonT和MonoSingletonT基类开始确保全局管理器只有一个实例。接着实现EventCenter和PoolManager。搭建数据层骨架定义几个核心的数据模型类如GameConfig,PlayerSaveData。实现DataManager的雏形先完成本地JSON的加载和解析。创建第一个UI流程选择一个简单的界面如开始菜单创建UIManager实现BasePanel和MainPanel实践MVP模式。确保UI能通过事件与逻辑层通信。串联游戏流程在EntryPoint.cs可以挂在一个永不销毁的GameObject上中按顺序初始化各个管理器EventCenter-DataManager-UIManager-GameLogicManager。然后打开第一个UI界面。迭代与填充有了这个骨架后续开发新功能就是往对应的层里添加新的模块。例如做背包系统就在Logic/Systems下加InventorySystem在Data/Models下加Item数据类在Presentation/UI下加BagPanel和BagItemView。踩坑记录5命名空间的管理随着脚本增多类名冲突的可能性增加。务必使用命名空间来组织代码。例如所有Core层的代码放在YourGameName.Core命名空间下Data层的放在YourGameName.Data下。这不仅能避免冲突还能在IDE中提供更好的代码提示和组织。在Assembly Definition文件中设置好程序集引用可以显著改善编译速度。5. 常见问题、性能陷阱与排查技巧在实际使用这套框架开发项目的过程中我遇到了不少典型问题。这里列出一个速查表方便你遇到时快速定位。问题现象可能原因排查与解决思路UI点击无响应1. UI层级问题被其他全屏面板遮挡。2. Canvas的Graphic Raycaster被禁用或损坏。3. 按钮事件监听在Panel关闭时未正确移除新打开的Panel事件被旧回调干扰。1. 检查UI的渲染顺序和RectTransform的覆盖区域。2. 确保Canvas上有且只有一个有效的Graphic Raycaster。3.在BasePanel.OnClose中统一清理该面板所有的事件监听和回调。事件触发后接收方没反应1. 事件监听注册的时机太晚在事件触发之后。2. 事件监听在对象销毁前未移除但后续触发时对象已销毁委托被自动移除或引发空引用。3. 事件类型字符串或枚举值拼写错误。1. 确保监听在触发前注册如在Awake或Start中。2.严格遵守“谁注册谁移除”的原则在OnDestroy中移除。3. 使用常量或静态只读字段来定义事件类型避免拼写错误。场景切换后单例管理器数据丢失或报空1. 管理器GameObject在场景切换时被销毁。2. 单例实例在Awake中赋值但切换场景后新场景有同名GameObject导致冲突。1. 确保管理器的GameObject上有DontDestroyOnLoad。2. 在单例基类的Awake中实现“防重复创建”逻辑如果实例已存在则销毁自身。对象池取出的对象状态不对对象回池时没有重置状态。例如一个敌人被击败后回池血量还是0下次取出时就是死的。在对象回池的方法中必须将其所有状态重置为默认值。可以定义一个OnReset接口或虚方法在回池和取出时调用。JSON配置文件加载失败1. 文件路径错误。2. JSON文本格式错误如缺少逗号、引号。3. 数据类结构与JSON不匹配字段名、类型。1. 使用Application.dataPath等API打印完整路径进行核对。2. 使用在线JSON校验工具检查格式。3. 确保C#数据类是可序列化的[System.Serializable]且字段名与JSON键名完全一致或使用[JsonProperty]属性。在编辑器下运行正常打包后出错1.Resources.Load的路径在打包后发生变化或文件不在Resources文件夹内。2. 使用了编辑器特有的API如AssetDatabase。3. 代码中包含了仅在开发模式下运行的逻辑如#if UNITY_EDITOR。1. 检查打包后Resources文件夹的内容或考虑使用Addressables。2.将所有编辑器相关代码放在Editor文件夹下或用#if UNITY_EDITOR包裹。3. 仔细检查条件编译指令。项目越来越大编译时间超长1. 脚本数量过多且没有使用程序集定义。2. 存在循环依赖或过于复杂的依赖关系。3. 频繁修改经常被引用的核心脚本。1.使用Assembly Definition文件将代码分割成多个程序集如Core、Data、Logic、Presentation。这能实现增量编译。2. 梳理模块依赖确保依赖方向是单向的如Presentation依赖LogicLogic依赖DataData依赖Core。3. 将稳定的基础代码封装成DLL。性能优化心得避免在Update中做复杂查找比如每帧通过GameObject.Find或GetComponent查找对象。应该在Awake或Start中缓存引用。警惕匿名函数和闭包在事件注册或协程中使用匿名函数虽然方便但可能无意中捕获了外部变量导致预期外的内存引用阻碍GC。尽量使用具名方法。对象池是标配不仅是GameObject对于频繁创建的纯C#对象如网络消息包、寻路节点也可以使用对象池。分层架构本身带来的开销跨层调用需要通过接口、事件或消息这比直接调用方法多了一层间接性。对于性能极其敏感的代码如每帧执行数千次的战斗公式计算需要在保持架构清晰和追求极致性能之间做权衡有时允许一些“合理的耦合”。6. 框架的扩展与未来演进方向这个基础框架已经能够支撑起一个中小型Unity项目的开发。但随着项目复杂度的提升你可能会考虑引入更强大的设计模式或第三方库来增强它。引入依赖注入框架如 Zenject 或 VContainer。它们可以自动化管理所有服务Manager、System的创建和生命周期并自动解决构造函数依赖。这能让你的代码更加整洁测试也更加方便易于Mock。你可以将现有的XxxManager逐步改造成由DI容器管理的服务。状态管理对于复杂的UI或角色状态可以引入有限状态机模式。Unity的Animator就是一个状态机对于游戏逻辑状态可以自己实现一个轻量级的FSM系统放在Core层。资源管理升级从Resources切换到Addressables或AssetBundle。这是商业化项目的必经之路可以实现资源热更新和更精细的内存控制。可以在Core层抽象一个IAssetLoader接口底层分别实现ResourcesLoader和AddressablesLoader方便切换。网络层集成如果需要网络功能可以设计一个网络层通常放在Logic层或作为一个独立的Network层。它负责协议封装、消息发送接收、重连逻辑等并通过事件将网络消息分发给其他逻辑模块。单元测试得益于分层和解耦你的Logic层和Data层是纯C#类不依赖Unity的运行时环境非常适合编写单元测试。在Tests文件夹下为关键的业务逻辑编写测试能极大提升代码的健壮性。最后想说的是框架是为人服务的而不是束缚人的。我分享的这个结构和我踩过的坑是希望给你一个清晰的起点和避坑指南。在实际项目中你完全可以根据团队规模、项目类型和个人习惯进行调整。最重要的不是框架有多“高大上”而是它能否让你们的开发更有序、更高效、更快乐。当你在深夜加班修改一个功能发现只需要动一个文件夹里的几行代码而其他地方稳如泰山时你就会觉得前期在架构上的投入都是值得的。