Unity轻量级框架QFramework:四层架构与命令事件驱动实战解析 📅 2026/7/13 5:02:31 1. 项目概述为什么我们需要一个轻量级的Unity框架如果你在Unity项目里摸爬滚打过一段时间尤其是参与过稍具规模的项目大概率会遇到这样的场景新来的同事想改个UI逻辑结果发现这个按钮的点击事件在三个不同的脚本里都有注册改了一处另外两处就崩了或者你想复用某个功能模块却发现它和场景里的特定GameObject、某个全局单例管理器深度耦合根本抽不出来。代码像一团乱麻牵一发而动全身这就是典型的“面条式代码”或“大泥球”架构。这时候一个清晰、轻量且约束良好的框架就显得尤为重要。QFramework正是为了解决这些问题而生的。它不是那种重型的、侵入性极强的企业级框架而是一套基于Unity的、以“四层架构”和“命令事件驱动”为核心思想的开发规范与工具集。它的目标不是让你写更多的代码而是通过一套约定俗成的规则让你的代码结构清晰、职责分明、易于测试和扩展。简单来说QFramework帮你把代码“分门别类”地放好。UI逻辑就放在UI层处理业务逻辑的放在逻辑层管理数据的放在数据层与外部服务打交道的放在服务层。层与层之间通过定义良好的接口或事件进行通信而不是直接互相引用。这样做的好处是当你需要修改一个功能时你能非常明确地知道该去哪里找代码当你需要替换一个模块比如把本地数据存储换成网络API时你只需要改动对应的那一层其他层几乎不受影响。2. 核心架构思想拆解四层架构与命令事件驱动在深入代码之前我们必须先理解QFramework赖以生存的两大核心思想。这就像盖房子前要先看懂设计图理解了原理后面的“搬砖”工作才会事半功倍。2.1 四层架构职责分离的艺术QFramework推荐的四层架构从上到下依次是表现层、系统层、数据层、工具层。这是一种经过简化和优化的分层模式非常适合Unity这种以表现游戏对象、UI为核心的环境。表现层这是离玩家最近的一层直接负责与用户交互和视觉呈现。在Unity里这通常对应着MonoBehaviour脚本、UI控件如Button, Image、动画控制器等。表现层的脚本应该“很薄”它只做两件事1. 接收用户输入点击、拖拽或系统事件2. 根据逻辑层提供的数据更新UI或游戏对象的状态。它不应该包含复杂的业务逻辑或数据计算。注意一个常见的错误是把大量的if-else判断、数值计算直接写在MonoBehaviour的Update里。这会导致逻辑与表现高度耦合极难维护和测试。在QFramework的规范里表现层脚本应该只负责“转发”事件和“应用”状态。系统层这是整个应用的大脑承载着核心的业务逻辑。例如处理玩家的攻击逻辑、计算伤害、管理游戏状态开始、暂停、结束、协调不同模块之间的协作等。系统层是纯C#类不继承MonoBehaviour因此它可以被轻松地进行单元测试。它通过监听命令或事件来驱动并操作数据层来改变游戏状态。数据层负责管理应用的所有状态数据。例如玩家的生命值、金币数量、背包物品列表、游戏设置等。数据层应该是“纯净”的只提供数据的存储、获取和简单的验证不包含任何业务逻辑。在QFramework中通常使用Model来封装数据层并通过属性变更事件来通知上层数据已更新。工具层提供通用的、与业务无关的基础服务。例如本地化系统、音频管理器、对象池、网络请求封装、扩展方法等。工具层是其他三层的支撑确保它们能专注于自己的核心职责。这四层的关系是单向依赖的表现层依赖系统层系统层依赖数据层所有层都可以依赖工具层。严格禁止下层直接调用上层或者跨层调用。这种单向依赖保证了代码的清晰度和可维护性。2.2 命令事件驱动从“轮询”到“响应”传统的过程式编程像是“轮询”你需要不断地检查某个条件是否满足比如在Update里判断if (hp 0)。而事件驱动编程则是“响应”你只需要声明“当生命值变为0时请执行阵亡逻辑”。后者更符合人类的思维模式也能让代码更解耦。QFramework将这一思想具象化为两个核心概念命令和事件。命令代表一个明确的“意图”或“动作”通常由用户输入或系统条件触发用于改变系统状态。例如“玩家点击了攻击按钮”这个意图可以封装为一个PlayerAttackCommand。命令对象本身包含了执行这个动作所需的数据如攻击目标、技能ID。系统层会定义对应的命令处理器来执行这个命令在处理器里进行伤害计算、播放音效、发送伤害事件等逻辑。使用命令的好处是它将“触发动作”和“执行动作”解耦了并且所有对系统的修改都通过命令进行便于记录、回放和撤销对于某些游戏类型非常有用。事件代表一个已经发生的“事实”或“状态变化”用于通知系统的其他部分。例如当PlayerAttackCommand执行后可能会发布一个OnPlayerAttackEvent事件或者当伤害计算完成后发布一个OnDamageCalculatedEvent事件。表现层或其他系统可以监听这些事件并做出响应如播放受击动画、更新血条UI。事件是广播式的发布者不需要知道谁在监听这进一步降低了模块间的耦合度。命令和事件共同构成了QFramework的数据流主干用户交互或内部逻辑产生命令- 系统层处理命令- 处理过程中或结束后发布事件- 表现层或其他系统响应事件更新状态。整个应用就像一台精密的机器各个零件通过命令和事件这根“总线”协同工作。3. 实战搭建从零构建一个简易背包系统理论讲得再多不如动手写一遍。我们用一个经典的“背包系统”作为例子来实战演练如何运用QFramework的四层架构和命令事件驱动。这个背包系统功能很简单显示背包物品列表点击物品可以使用它比如消耗品回血。3.1 项目结构与初始化首先你需要通过Package Manager或Git URL将QFramework导入你的Unity项目。之后我建议创建如下的文件夹结构来管理代码这能强迫你思考每段代码应该属于哪一层Scripts/ ├── Models/ // 数据层 ├── Systems/ // 系统层 ├── UI/ // 表现层 (UI相关) │ ├── Components/ // UI组件脚本 │ └── Views/ // UI面板预制体 ├── Commands/ // 命令定义 ├── Events/ // 事件定义 └── Utilities/ // 工具层接下来我们需要一个架构的入口点。在QFramework中这通常是一个继承自Architecture的类。我们在Scripts/根目录下创建GameArchitecture.csusing QFramework; // 游戏架构定义用于注册和管理所有Model、System、Utility等 public class GameArchitecture : ArchitectureGameArchitecture { protected override void Init() { // 注册数据层 this.RegisterModel(new BagModel()); // 注册系统层 this.RegisterSystem(new BagSystem()); // 可以在这里注册更多Model和System } }然后创建一个在任何场景中都会运行的初始化脚本GameStartup.cs挂载到一个永不销毁的GameObject上如GameControllerusing UnityEngine; using QFramework; public class GameStartup : MonoBehaviour { private void Awake() { DontDestroyOnLoad(this.gameObject); // 初始化QFramework框架 QFrameworkInit.Init(); // 初始化我们的游戏架构 GameArchitecture.Init(); Debug.Log(Game Architecture Initialized.); // 示例开局给玩家一些初始物品 this.SendCommand(new AddItemToBagCommand() { ItemId 1, // 血瓶 Amount 3 }); this.SendCommand(new AddItemToBagCommand() { ItemId 2, // 魔法药 Amount 2 }); } }3.2 数据层定义背包模型数据层是状态的持有者。我们创建BagModel来管理背包数据。using System.Collections.Generic; using QFramework; // 定义物品数据结构 public class BagItem { public int ItemId { get; set; } public string Name { get; set; } public int Amount { get; set; } // 其他属性如图标、描述、使用效果等 } // 背包数据模型 public class BagModel : AbstractModel { // 使用List存储背包物品。对于大量数据考虑使用Dictionary优化查找。 public ListBagItem Items { get; private set; } new ListBagItem(); // 提供一个属性变更事件当背包内容变化时通知外界 public EasyEvent OnBagItemsChanged new EasyEvent(); protected override void OnInit() { // 模型初始化可以在这里加载本地保存的背包数据 Debug.Log(BagModel Initialized.); } // 提供给外部主要是System层操作数据的方法 public void AddItem(int itemId, int amount) { var existingItem Items.Find(item item.ItemId itemId); if (existingItem ! null) { existingItem.Amount amount; } else { Items.Add(new BagItem { ItemId itemId, Amount amount, Name $Item_{itemId} }); } // 数据变更触发事件 OnBagItemsChanged?.Trigger(); } public bool UseItem(int itemId, int amount 1) { var item Items.Find(i i.ItemId itemId); if (item ! null item.Amount amount) { item.Amount - amount; if (item.Amount 0) { Items.Remove(item); } OnBagItemsChanged?.Trigger(); return true; // 使用成功 } return false; // 物品不足或不存在 } }实操心得AbstractModel是QFramework提供的基础类它天生被框架的IOC容器管理可以通过this.GetModelBagModel()在任何地方安全地获取到它的唯一实例。将数据操作封装在Model内部而不是暴露内部列表让外部直接修改这是保证数据一致性的关键。3.3 系统层处理背包业务逻辑系统层是真正的逻辑执行者。我们创建BagSystem来处理与背包相关的所有业务。using QFramework; // 背包业务系统 public class BagSystem : AbstractSystem { protected override void OnInit() { Debug.Log(BagSystem Initialized.); } // 系统可以提供一些公共方法供Command或其他System调用 // 但更推荐的方式是通过Command来驱动系统 }系统层本身可能看起来很简单因为它的大部分逻辑都被封装在命令处理器中。系统更像是一个逻辑模块的命名空间和容器。3.4 命令与事件定义通信协议现在我们来定义驱动背包工作的命令和事件。命令在Commands/文件夹下创建。using QFramework; // 添加物品到背包的命令 public class AddItemToBagCommand : AbstractCommand { public int ItemId { get; set; } public int Amount { get; set; } protected override void OnExecute() { // 命令的执行体获取Model调用其方法 var bagModel this.GetModelBagModel(); bagModel.AddItem(ItemId, Amount); // 可以在这里发布一个事件通知物品添加成功可选 this.SendEvent(new OnItemAddedEvent() { ItemId ItemId, FinalAmount bagModel.GetItemAmount(ItemId) }); } } // 使用背包物品的命令 public class UseItemCommand : AbstractCommand { public int ItemId { get; set; } protected override void OnExecute() { var bagModel this.GetModelBagModel(); bool success bagModel.UseItem(ItemId); if (success) { // 使用成功发布事件。这里可以扩展比如根据ItemId触发不同的效果回血、加蓝 this.SendEvent(new OnItemUsedEvent() { ItemId ItemId, Success true }); // 假设ItemId 1是血瓶使用后触发回血逻辑 if (ItemId 1) { // 这里可以发送另一个命令如PlayerHealCommand或者直接发布一个HealEvent // 为了解耦我们选择发布事件 this.SendEvent(new OnPlayerHealEvent() { HealAmount 50 }); } } else { // 使用失败也发布事件UI可以显示“物品不足”提示 this.SendEvent(new OnItemUsedEvent() { ItemId ItemId, Success false }); } } }事件在Events/文件夹下创建。事件是简单的数据结构。using QFramework; // 物品添加事件 public struct OnItemAddedEvent { public int ItemId; public int FinalAmount; } // 物品使用事件 public struct OnItemUsedEvent { public int ItemId; public bool Success; } // 玩家治疗事件由使用血瓶触发 public struct OnPlayerHealEvent { public int HealAmount; }3.5 表现层制作背包UI并响应事件最后我们创建UI来展示和交互。首先创建一个UGUI面板包含一个Scroll View用于列表和一个Item预制体包含图标、名称、数量文本和一个使用按钮。创建UI脚本UIBagPanel.cs它继承自QFramework的UIComponent这样可以方便地使用框架的UI工具。using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using QFramework; // UI背包面板 public class UIBagPanel : UIComponent { [SerializeField] private Transform m_ItemContent; // Item列表的父节点 [SerializeField] private GameObject m_ItemPrefab; // Item预制体 private ListUIBagItem m_SpawnedItems new ListUIBagItem(); private BagModel m_BagModel; protected override void OnBeforeDestroy() { // 取消监听 GlobalEventSystem.Get.UnRegisterOnItemAddedEvent(OnBagChanged); GlobalEventSystem.Get.UnRegisterOnItemUsedEvent(OnBagChanged); base.OnBeforeDestroy(); } // 初始化在UI打开时调用 public void InitPanel() { m_BagModel this.GetModelBagModel(); // 监听背包数据变更事件 m_BagModel.OnBagItemsChanged.Register(RefreshUI).UnRegisterWhenGameObjectDestroyed(gameObject); // 同时监听我们自定义的命令触发的事件双重保障根据情况选一种即可 GlobalEventSystem.Get.RegisterOnItemAddedEvent(OnBagChanged); GlobalEventSystem.Get.RegisterOnItemUsedEvent(OnBagChanged); // 首次刷新UI RefreshUI(); } private void OnBagChangedT(T e) where T : struct { // 当收到任何与背包相关的事件时刷新UI RefreshUI(); } private void RefreshUI() { // 清理旧Item foreach (var item in m_SpawnedItems) { Destroy(item.gameObject); } m_SpawnedItems.Clear(); // 根据Model数据生成新Item foreach (var bagItem in m_BagModel.Items) { var itemGo Instantiate(m_ItemPrefab, m_ItemContent); var uiItem itemGo.GetComponentUIBagItem(); uiItem.Init(bagItem, OnUseItemClicked); m_SpawnedItems.Add(uiItem); } } private void OnUseItemClicked(int itemId) { // UI点击事件发送使用物品命令 this.SendCommand(new UseItemCommand { ItemId itemId }); } } // 单个背包Item的UI组件 public class UIBagItem : MonoBehaviour { [SerializeField] private Text m_NameText; [SerializeField] private Text m_AmountText; [SerializeField] private Button m_UseBtn; private int m_ItemId; private System.Actionint m_OnUseCallback; public void Init(BagItem bagItem, System.Actionint onUseCallback) { m_ItemId bagItem.ItemId; m_OnUseCallback onUseCallback; m_NameText.text bagItem.Name; m_AmountText.text $x{bagItem.Amount}; m_UseBtn.onClick.RemoveAllListeners(); m_UseBtn.onClick.AddListener(() m_OnUseCallback?.Invoke(m_ItemId)); } }为了让UI能接收事件你需要在场景中有一个GlobalEventSystem的单例。QFramework通常会在初始化时自动创建。现在当你运行游戏初始化脚本会添加物品UI面板会监听BagModel的数据变更事件并刷新列表。点击物品的“使用”按钮会发送UseItemCommand命令修改Model数据并发布事件UI再次刷新形成完整的闭环。4. 深入原理QFramework的核心机制解析通过上面的实战我们已经体验了QFramework的基本工作流。但要真正用好它还需要理解其背后的几个核心机制。4.1 IOC容器依赖注入的枢纽IOC控制反转容器是QFramework的基石。它负责创建和管理所有注册的Model、System、Utility以及Command的单例实例。当你调用this.GetModelBagModel()时你并不是new了一个对象而是向容器请求获取已经注册好的那个唯一实例。这样做的好处解耦你不需要在代码里手动创建和管理这些核心组件的生命周期容器帮你做了。BagSystem不需要知道BagModel是怎么来的它只管向容器要。可测试性在单元测试中你可以轻松地用Mock对象替换掉真实的Model或System从而隔离测试。单一实例确保全局状态唯一比如游戏配置、玩家数据等有且只有一个来源。在GameArchitecture的Init方法中进行的RegisterModel和RegisterSystem操作就是在向这个全局容器注册类型。框架在需要的时候会自动实例化它们。4.2 事件系统松耦合通信的引擎QFramework提供了两种事件系统EasyEvent和GlobalEventSystem。EasyEvent轻量级、无参数的事件。我们之前在BagModel里用的OnBagItemsChanged就是这种。它适合在模块内部进行简单的通知。GlobalEventSystem功能更强大的全局事件系统支持带参数的事件结构体struct。我们用来监听OnItemAddedEvent的就是它。它使用C#的泛型和反射机制能够根据事件类型自动路由到对应的监听者。事件系统的工作流程注册监听在监听者如UI的代码中调用GlobalEventSystem.Get.RegisterT(callback)。触发事件在事件发布者如Command的代码中调用this.SendEventT(eventData)。自动调用事件系统会找到所有注册了对事件类型T的监听者并调用其回调函数。注意事项一定要记得在监听者销毁时如OnDestroy取消注册事件否则会导致内存泄漏因为事件系统持有对回调方法的引用阻止了垃圾回收。QFramework的UnRegisterWhenGameObjectDestroyed扩展方法可以方便地将事件监听与GameObject的生命周期绑定。4.3 命令模式封装变更意图命令模式将“请求”封装成一个对象从而使你可以用不同的请求对客户进行参数化支持请求的排队、记录、撤销等。在QFramework中AbstractCommand就是这一模式的实现。命令的执行流程创建命令new UseItemCommand { ItemId 1 }。发送命令通过this.SendCommand(command)发送。这个this可以是任何继承了IController接口的对象在QFramework中MonoBehaviour、UIComponent等默认支持。框架路由框架会处理这个命令对象。执行命令调用命令对象的OnExecute()方法。在这里你可以获取Model、System执行业务逻辑并发送事件。命令模式强制你将所有改变系统状态的操作都集中到命令处理器中这使得状态变化的源头变得清晰可追溯对于调试和实现“回放”、“撤销”功能非常有帮助。5. 进阶技巧与最佳实践掌握了基础用法后下面这些技巧能让你在项目中更游刃有余。5.1 使用Query替代直接获取Model数据有时你只是需要查询数据而不改变它。直接在UI里调用this.GetModelBagModel().Items虽然可以但破坏了分层原则表现层直接访问数据层。更好的做法是使用查询。你可以创建一个Query类专门用于获取数据public class BagItemsQuery : AbstractQueryListBagItem { protected override ListBagItem OnDo() { // 在Query内部访问Model return this.GetModelBagModel().Items; } }在UI中这样使用var items this.SendQuery(new BagItemsQuery());这样UI层只是发送了一个查询“意图”具体数据从哪里来Model、缓存、甚至网络被封装在Query内部未来数据源变更时UI层代码完全不用动。5.2 利用System管理复杂状态机对于复杂的游戏逻辑比如角色的战斗状态闲置、移动、攻击、受击、死亡非常适合在System层用状态机来管理。你可以使用QFramework提供的FSM有限状态机工具或者自己实现一个简单的状态模式。public class PlayerStateSystem : AbstractSystem { public enum State { Idle, Moving, Attacking, Hurt, Dead } public State CurrentState { get; private set; } State.Idle; public void TryChangeState(State newState) { if (CanChangeTo(newState)) { var oldState CurrentState; CurrentState newState; this.SendEvent(new OnPlayerStateChangedEvent { OldState oldState, NewState newState }); } } private bool CanChangeTo(State newState) { // 定义状态转换规则例如死亡后不能切换到其他状态 if (CurrentState State.Dead) return false; // ... 其他规则 return true; } }5.3 工具层的封装AudioManager示例工具层应该提供简单易用的静态接口。例如一个音频管理器using UnityEngine; using QFramework; // 标记为工具层在Architecture中注册为IUtility public interface IAudioManager : IUtility { void PlaySound(string clipName); void PlayMusic(string musicName, bool loop true); } public class AudioManager : IAudioManager { public void PlaySound(string clipName) { // 从Resources或Addressables加载并播放音效 Debug.Log($播放音效: {clipName}); // AudioSource.PlayClipAtPoint(...); } public void PlayMusic(string musicName, bool loop true) { Debug.Log($播放音乐: {musicName}, 循环: {loop}); // 背景音乐控制逻辑 } }在GameArchitecture中注册this.RegisterUtility(new AudioManager());在任何地方使用this.GetUtilityIAudioManager().PlaySound(Click);5.4 模块化与代码组织对于大型项目不要把所有Model、System都注册到一个GameArchitecture里。可以按功能模块划分成子架构。例如你可以有一个BattleArchitecture专门管理战斗相关的Model和System一个ShopArchitecture管理商店模块。它们可以通过父级架构进行通信也可以相对独立。6. 常见问题与避坑指南在实际使用QFramework的过程中我踩过不少坑也总结出一些经验。6.1 循环依赖与初始化顺序问题在Architecture.Init()中注册Model和System时如果A依赖BB又依赖A就会形成循环依赖导致初始化失败或空引用。解决仔细设计模块依赖关系确保是单向的。如果确有交叉依赖考虑提取公共部分到第三个模块或者使用事件进行间接通信。初始化顺序一般遵循“数据层 - 系统层 - 工具层”的原则。6.2 事件监听导致的内存泄漏问题在UI脚本中监听了全局事件但UI销毁时没有取消监听导致事件系统一直持有对已销毁UI对象方法的引用该对象无法被GC回收。解决首选使用UnRegisterWhenGameObjectDestroyed(gameObject)扩展方法将监听与GameObject生命周期绑定。手动管理在OnDestroy或OnDisable中调用GlobalEventSystem.Get.UnRegisterT(callback)。使用框架的UIComponent它提供了OnBeforeDestroy等生命周期是管理监听的好地方。6.3 Model数据与UI的频繁刷新问题任何微小的数据变化都触发OnBagItemsChanged事件导致UI整个列表重刷在物品很多时可能引起性能卡顿。解决差异化更新在事件中传递更详细的信息比如是“增加”、“减少”还是“更新”以及具体的Item索引。UI可以根据这些信息只更新对应的那一项而不是全部重绘。使用对象池对于动态列表像我们实战中那样复用UIBagItem的GameObject而不是频繁地Instantiate和Destroy。防抖如果一帧内可能触发多次数据变更可以考虑使用一个标记位在下一帧统一刷新一次UI。6.4 命令与事件的滥用问题事无巨细都用命令和事件导致系统中有大量细碎的命令和事件类难以管理。解决粒度把握命令代表“用户意图”或“重大操作”如AttackCommand,BuyItemCommand。事件代表“已发生的事实”如HealthChangedEvent,EnemyDiedEvent。对于模块内部简单的状态同步直接调用方法或使用EasyEvent可能更合适。分类与命名为命令和事件建立清晰的命名规范和文件夹结构。例如Commands/Player/,Events/Battle/。使用泛型事件对于结构相似的事件可以考虑使用泛型事件类如GameEventT但会牺牲一些类型安全性需权衡。6.5 与Unity现有生态的融合问题如何在使用QFramework的同时兼容Asset Store买来的插件或已有的代码解决适配器模式为第三方插件创建一层适配接口。例如一个复杂的对话系统插件你可以创建一个IDialogueSystem接口和对应的Adapter在QFramework的System层中通过这个接口来调用插件功能这样核心业务逻辑就与具体插件解耦了。渐进式重构不要试图一次性重写所有旧代码。可以从一个新的、独立的模块开始使用QFramework然后用事件或命令与旧系统通信。逐步地将旧模块重构并迁移到新架构下。7. 性能考量与调试技巧任何框架都会带来一定的开销QFramework很轻量但在极端情况下也需注意。性能事件系统的开销GlobalEventSystem基于反射频繁触发高粒度事件会有开销。对于性能关键的路径如每帧触发的战斗伤害事件可以考虑使用EasyEvent或直接委托回调。Model的序列化如果Model需要保存到磁盘确保其数据结构是可序列化的并且避免保存不必要的引用或循环引用。避免在Update中频繁SendCommand/Event这会导致每帧都有大量的对象分配和GC压力。对于连续的操作如长按应该在一个命令内处理循环逻辑或者使用一个标志位在Update中判断。调试使用QFramework的日志工具Log.I(),Log.W(),Log.E()提供了颜色分类比Debug.Log更清晰。命令与事件追踪在开发阶段可以创建一个简单的调试系统监听所有命令和事件的发送并打印日志这能帮你理清复杂的数据流。架构可视化在纸上或绘图工具中画出你的四层架构图标明Model、System、Command、Event的流向这对于理解复杂模块和向团队成员解释设计非常有帮助。我个人在多个中小型Unity项目中实践QFramework后最大的体会是它提供的不是一堆酷炫的黑科技而是一套纪律。它强迫你思考代码的归属强制你进行解耦。初期可能会觉得有些繁琐多写了不少类Command、Event但一旦项目规模稍微扩大或者需要多人协作、后续维护时这种前期的投入会带来巨大的回报。代码库会变得清晰、稳定新功能的添加更像是“拼乐高”——找到合适的位置放入新的命令、事件和处理器而不用担心会碰倒其他地方积木。