ET 框架“一切皆实体”设计解析:树状实体架构与组件生命周期
2026/9/15 15:43:12 网站建设 项目流程

ET 框架“一切皆实体”设计解析:树状实体架构与组件生命周期

【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET

ET(Unity3D Client And C# Server Framework)在吸收 ECS 思想后并未照搬传统 ECS,而是提出了“一切皆实体(Everything is Entity)”的树状数据架构:Entity 既是数据单元、又可作为组件挂载、还可拥有孩子节点。本文以 Book/3.3一切皆实体.md 为主线,结合 cn.etetet.core 包中的实体源码,完整讲解 ET 实体树的组织方式、组件创建与释放流程,以及 InstanceId 在对象池与异步场景中的关键作用,帮助你真正理解 ET 数据层的设计与使用。

ECS 热潮之下,ET 的取舍

近几年 ECS(Entity-Component-System)设计非常流行,主要得益于《守望先锋》的成功引爆了这项技术。守望先锋采用状态帧网络技术,客户端会进行预测,预测不准时需要回滚;由于组件式的设计,回滚时可以只回滚某些组件,这正是组件化的巨大收益之一。

ECS 最重要的设计是逻辑与数据的完全分离:EC 是纯数据,System 实际就是逻辑,由数据驱动逻辑。所谓“数据驱动逻辑”很简单——通过 Update 检测数据变化,通过事件机制订阅数据变化,这就是数据驱动。

但 ET 作者认为,ECS 的其他卖点(如缓存命中)在编写逻辑上并不太重要:现代游戏都用脚本,连脚本的性能都能容忍,怎么会在乎缓存命中那点性能提升?因此 ET 在设计时吸收了 ECS 的思想,但并不完全照搬,最终形成了自己的特色。

传统 ECS 的缺陷与 ET 的树状实体设计

传统扁平式 ECS 的问题

传统 ECS 写逻辑存在不少缺陷:

  • 组件过度拆分:为了复用,数据必然要拆成非常小的颗粒,导致组件数量爆炸;
  • 协作成本高:游戏是多人合作开发的,每个人基本上只熟悉自己的模块,扁平式结构最后可能造成组件大量冗余;
  • 难以定位组件:常见的 ECS 是扁平式的,Entity 与 Component 只有一层。组件一多,开发功能时不知道该使用哪些 Component。

作者用一个非常形象的公司管理类比来解释这个问题:最大的是老板,老板手下带几百个人,老板不可能认识所有的人,完成一项任务,老板没法挑出自己需要的人。合理的做法是老板手下有几个经理,每个经理手下有几个主管,每个主管管理几个工人,形成树状的管理结构才容易管理。

ET 的树状 Entity 结构

这类似 ET 的做法:Entity 可以管理 Component,Component 可以管理 Entity,甚至 Component 还可以挂载 Component。例如:人由头、身体、手、脚组成,而头又由眼睛、耳朵、鼻子、嘴巴组成:

Head head = human.AddComponent<Head>(); head.AddComponent<Eye>(); head.AddComponent<Mouth>(); head.AddComponent<Nose>(); head.AddComponent<Ear>(); human.AddComponent<Body>(); human.AddComponent<Hand>(); human.AddComponent<Leg>();

注:原文档示例中的Mouse应指 Mouth(嘴),此处按语义更正;AddComponent 的详细实现见后文源码分析。

从源码看,这套树状结构由 Entity.cs 中的两套挂载机制支撑:

  • 组件挂载ComponentParent设置父节点并把自身放入父节点的Components集合,同时把IsComponent置为 true;
  • 孩子挂载Parent设置父节点并把自身放入父节点的Children集合,IsComponent置为 false。

两者的核心约束一致:父节点不能为 null、不能是自己、父节点必须已经在数据树上(value.IScene == null会直接抛异常)。这保证了整个实体树始终挂在某个 Scene 之下。

一切皆 Entity:数据放置的哲学

ET 中,所有数据都是 Entity,包括 Entity 本身。Entity 既可以当成组件使用,也可以当作其它 Entity 的孩子。这就引出数据放置的判断标准:

  • 通用数据放在 Entity 身上作为成员;
  • 不太通用的数据作为组件挂在 Entity 身上。

比如物品的设计,所有物品都有配置 Id、数量、等级的字段,这些字段没有必要做成组件,放在 Entity 身上使用会更加方便:

class Item : Entity { // 道具的配置Id public int ConfigId { get; set; } // 道具的数量 public int Count { get; set; } // 道具的等级 public int Level { get; set; } }

这种设计使 ET 的数据呈现树状结构,非常有层次,能非常轻松地理解整个游戏的架构:

  • 顶层是Game.Scene,不同模块的数据都挂载在Game.Scene上面;
  • 每个模块自身下面又可以挂载很多数据;
  • 每开发一个新功能,不用过多思考类该怎么设计、数据放在什么地方、挂载在这里会不会导致冗余。

例如:玩家需要做道具系统,设计一个ItemsComponent挂在Player身上即可;需要技能,开发一个SpellComponent挂在Player身上;全服需要做活动,搞一个活动组件挂在Game.Scene上面。这种设计使任务分派变得很简单,十分模块化。

源码印证:Scene 与 Fiber 的层级

在 Scene.cs 中,Scene继承自Entity并实现IScene接口,持有Fiber(纤程)与SceneType。每个 Scene 构造时就会通过fiber.NewInstanceId()获得自己的 InstanceId 并注册进实体系统:

public Scene(Fiber fiber, long id, int sceneType, string name) { this.Id = id; this.Name = name; this.InstanceId = fiber.NewInstanceId(); this.SceneType = sceneType; this.IsNoDeserializeSystem = true; this.Fiber = fiber; this.IScene = this; this.IsRegister = true; }

配合 EntityHelper.cs 提供的扩展方法(Zone()Scene()Root()Fiber()等),任何挂在树上的 Entity 都能一路回溯到自己的 Scene 与 Fiber,这正是“挂在树上”之后才能被事件系统驱动的前提。

组件的一些细节

1. 组件的创建

原文档指出:组件的创建不要自己去new,应该统一使用ComponentFactory创建。ComponentFactory提供了三组方法:CreateCreateWithParentCreateWithId

  • Create是最简单的创建方式,做了几个处理:a. 根据组件类型构造一个组件;b. 将组件加入事件系统,并且抛出一个 AwakeSystem;c. 决定是否启用对象池。
  • CreateWithParentCreate的基础上提供了一个 Parent 对象,设置到Component.Parent字段上。
  • CreateWithId用来创建ComponentWithId或其子类,在Create的基础上可以自己设置一个 Id。
  • 三类工厂方法都带有一个fromPool参数,用于控制是否使用对象池。

与当前源码的对应:在 ET 最新源码中,这套能力已整合进Entity的方法族(见 Entity.cs):

  • AddComponent<T>(bool isFromPool = false)及带参版本AddComponent<T, P1, P2...>:创建组件、设置 Id 与父节点(ComponentParent)、然后调用EntitySystemSingleton.Instance.Awake(component)触发 AwakeSystem;
  • AddComponentWithId<K>(long id, ...):对应原CreateWithId语义,显式指定组件 Id;
  • AddChild<T>(...)/AddChildWithId<T>(...):创建孩子实体并触发 AwakeSystem。

底层创建统一走ObjectPool.Fetch(type, isFromPool)(见 Entity.cs 中的Create私有方法),随后设置IsFromPoolIsNoDeserializeSystem标志并挂到父节点上。因此“组件不要自己 new,统一走工厂创建”的原则在当前版本依然成立,只是入口从ComponentFactory迁移到了Entity.AddComponent/AddChild系列方法。需要说明的是,当前源码中AddComponentisFromPool默认值为false,与原文档所述“默认 true”略有差异,以当前仓库实现为准。

挂载时若父节点已有同类型组件,Entity.CreateComponent会直接抛出异常(entity already has component),从源码层面杜绝了重复挂载。

2. 组件的释放

组件都继承了IDisposable接口。需要注意,组件拥有非托管资源,删除一个组件必须调用该接口。该接口做了如下操作:

  • a. 抛出 Destroy System;
  • b. 如果组件是使用对象池创建的,在这里会放回对象池;
  • c. 从全局事件系统(EventSystem)中删除该组件,并且将 InstanceId 设为 0。

如果组件挂在 Entity 身上,那么 Entity 调用Dispose时,会自动调用身上所有 Component 的Dispose方法。

源码印证:Entity.cs 中的Entity.Dispose()完整流程为:

  1. 通过InstanceId == 0判断是否已释放(IsDisposed),已释放则直接返回;
  2. IsRegister = falseInstanceId = 0
  3. 递归清理children集合中所有孩子并逐个Dispose()
  4. 递归清理components集合中所有组件并逐个Dispose()
  5. 若自身实现了IDestroy接口,触发EntitySystemSingleton.Instance.Destroy(this)抛出 Destroy System;
  6. 从父节点的Children(或Components)集合中移除自己;
  7. 保存IsFromPool标志、重置 status,最后调用基类Dispose()

基类 DisposeObject.cs 的Dispose最终执行ObjectPool.Recycle(this),把对象放回对象池。也就是说,释放一个父 Entity 会沿着实体树级联释放整棵子树,这正是树状结构带来的“一键清理”能力。

3. InstanceId 的作用

任何组件都带有一个InstanceId字段,这个字段会在组件构造、或组件从对象池取出的时候重新设置,它标识这个组件的身份。在 Entity.cs 中,InstanceId在设置IScene时若为 0 则通过iScene.Fiber.NewInstanceId()生成;而 Fiber.cs 中NewInstanceId()是一个从 1 开始递增的长整型生成器,保证了同一 Fiber 内 InstanceId 的全局唯一性。

为什么需要这样一个字段?主要有两个原因:

原因一:对象池下的异步安全

对象池的存在意味着组件未必会被真正释放,而是回到对象池中复用。在异步调用中,很可能这个组件已经被释放了,然后又从对象池中被重新利用起来,此时需要一种方式区分“之前的组件对象”是否已经被释放。例如下面这段ActorLocationSender的异步更新逻辑:

public static async ETVoid UpdateAsync(this ActorLocationSender self) { try { long instanceId = self.InstanceId; while (true) { if (self.InstanceId != instanceId) { return; } ActorTask actorTask = await self.GetAsync(); if (self.InstanceId != instanceId) { return; } if (actorTask.ActorRequest == null) { return; } await self.RunTask(actorTask); } } catch (Exception e) { Log.Error(e); } }

while (true)中是一段异步方法,await self.GetAsync()之后,ActorLocationSender对象很可能已经被释放了,甚至有可能这个对象又被其它逻辑从对象池中再次利用了起来。此时就可以通过 InstanceId 的变化来判断这个对象是否已被释放:只要self.InstanceId != instanceId,就说明对象已经经历了一次“释放→重新分配”,后续逻辑立即返回,避免对已失效对象进行操作。

原因二:全局唯一且携带位置信息

InstanceId 是全局唯一的,并且带有位置信息,可以通过 InstanceId 找到对象的位置、将消息发给对象。这个设计会在 Actor 消息机制中利用到(如 ActorLocation 定位),属于 ET 分布式通信的基础设施之一。

总结

ET 的“一切皆实体”不是对 ECS 的生搬硬套,而是一套面向游戏开发协作的务实设计:

  • 树状实体结构替代扁平式 ECS,解决组件冗余与“不知道该用哪些组件”的协作难题;
  • 用“通用数据放成员、专用数据放组件”的准则简化数据放置决策,以Game.Scene为顶层组织整棵数据树;
  • 通过统一的组件创建入口(当前源码中的Entity.AddComponent/AddChildEntitySystemSingleton.Instance.Awake)确保 AwakeSystem 正确抛出、对象池正确启用;
  • 通过IDisposable级联释放保证实体树可以被整体回收,Destroy System、对象池回收到位;
  • 通过InstanceId为对象池复用场景下的异步逻辑提供失效检测,也为 Actor 消息定位打下基础。

对于想要深入源码的读者,建议重点阅读 Entity.cs(树结构、AddComponent/AddChild 与 Dispose 实现)、Scene.cs(Scene 与 Fiber 的关系)以及 EntitySystemSingleton.cs(Awake/Destroy 等 System 的调度入口),再配合 IAwakeSystem.cs 与 IDestroySystem.cs 理解系统回调的挂接方式。

【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询