做游戏框架选型的时候,我一直在找一个能让团队不再被“面条式代码”折磨的架构。后来把目光落在Entitas上,用了几个原型项目之后,心里那句“这才是做游戏该有的结构”越来越清晰。这篇内容就围绕Entitas这个基于ECS架构的框架展开,说说它是怎么帮我理清游戏逻辑、降低耦合、提升性能的,也聊聊踩过的坑和值得注意的边界。如果你是做Unity开发、想引入ECS但还在观望的开发者,或者已经被传统MonoBehaviour的继承链和全局状态搞得头疼,这篇文章应该能给你一份可落地的参考。
先交代一下背景:Entitas是一个轻量但非常完整的ECS框架,核心思路是把游戏里的“对象”拆分成Entity、Component、System三个维度,用数据组合代替类的继承,用系统扫描处理代替对象各自调用。我最早看到它的代码生成器时还觉得多此一举,等真正用上之后才发现,这一层自动生成的代码大幅降低了手动编写样板代码的痛苦。这篇文章不会停留在概念层面,我会结合一个典型的“物体移动并销毁”Demo,拆解Entitas的核心概念、组件定义、System编写、代码生成、事件响应、性能优化、常见坑点,以及我个人的使用建议。无论你是刚接触ECS,还是已经在用其他ECS方案想对比一下,都能从中找到对你有用的部分。
1. 为什么放弃传统MonoBehaviour,转向Entitas
先聊一件让我印象非常深的事。之前在某公司做一个策略型游戏原型,最开始所有人都在用MonoBehaviour挂脚本:敌人挂EnemyBehaviour,玩家挂PlayerBehaviour,子弹挂BulletBehaviour。角色多了之后,我需要让“会飞的敌人”“会被冰冻的敌人”“会爆炸的敌人”同时存在,继承树一下子就乱了。简单算一下就知道,如果有5种敌人基类、5种可叠加状态、3种攻击方式,用继承组合出来的类数量会在几十个以上,而且绝大多数类只有几行代码,纯粹是为了“凑一个名字”存在。
1.1 继承链崩塌的那一刻
传统OOP的游戏架构往往从Actor基类开始,然后派生出Enemy、Player、NPC,再往下派生出不同兵种。这套思路在小项目里没什么问题,但规模一旦上来,痛点会非常集中:
- 功能冲突:一个角色既是“飞行单位”又是“近战单位”,还要“可以治疗队友”,在继承树里很难优雅表达,只能到处用abstract override或者接口组合。
- 状态难管理:角色的Buff、减速、眩晕、隐身等状态如果都写在基类字段里,一个几十种状态的基类会很臃肿,而且每个子类都会继承一堆自己用不到的字段。
- 调试困难:运行时的父类虚方法调用链很长,经常要在五六个子类里跳来跳去才能找到真正修改逻辑的地方。
- 多人协作易冲突:改一个基类会影响所有子类,团队并行开发时冲突不断。
1.2 ECS架构到底在解决什么问题
ECS的核心思路是“组合优于继承”。它把对象拆成了三个部分:
- Entity:一个只携带ID的容器,不写任何逻辑。
- Component:纯粹的数据,比如坐标、速度、血量、标签。
- System:处理数据的逻辑,比如移动系统、攻击系统、渲染系统。
这样做之后,想给角色加一个新能力,不需要新建类并继承谁,只需要添加一个Component,然后写一个System去处理它。比如“被冰冻”这个效果,我给目标实体加一个FrozenComponent,再让FrozenSystem负责减速逻辑,原本的类结构完全不用动。
这个模式还有一个隐藏优势:数据可以批量处理。因为Component是连续存储的,系统在遍历某个组件组时,可以非常高效地访问内存,CPU缓存友好性远高于随机访问一堆MonoBehaviour。
1.3 Entitas和网上其他ECS框架相比的差异
市面上的ECS方案不止一个,Unity官方有Entities包,社区里还有LeopotamGroup.Ecs等方案。我选择Entitas,主要看中这几点:
- 代码生成器成熟:它可以根据你定义的组件自动生成Entity类、Context类和Matcher类,手写样板代码的量少到可以忽略。
- 与Unity生命周期融合自然:可以自由控制Update频率,也可以和MonoBehaviour并存,不需要整体重写项目。
- 事件驱动好用:ReactiveSystem能够在组件添加、替换、移除时自动触发,天然适合做“玩家血量降到0就死亡”这类逻辑。
- 可视化调试工具顺手:运行时可以直观看到所有实体、组件和系统执行顺序。
当然,Unity官方Entities的Job System和Burst Compiler性能上限更高,更适合超大规模单位群,但学习成本和团队改造难度也更高。如果你想要一个更轻量、上手门槛更低、马上能用进现有项目的方案,Entitas是很务实的选择。
2. 先搞懂四个核心概念:Entity、Component、System、Context
网上聊ECS的文章很多,但大多堆术语。我用大白话拆解一遍Entitas里的核心概念,重点说清楚“它们到底负责什么”以及“为什么不这样设计就转不起来”。
2.1 Entity只是一张身份证
我第一次接触ECS时走了一个误区,下意识认为Entity像GameObject,应该带很多功能。但Entitas里的Entity真的很轻,它就是一个整数ID以及一堆Component的持有者。
打个比方:Entity就像医院里的病历号,它自己不生病、不治疗,只是用来关联某位患者的各种检查记录。有了这个ID,系统可以通过“查找带有位置组件的所有实体”来操作它,而不需要知道它究竟代表玩家还是怪物还是子弹。
Entitas生成的GameEntity类会提供AddPosition()、ReplacePosition()、RemovePosition()这类方法,这些方法都由代码生成器根据组件定义自动生成。你手动写逻辑时一般不会直接new一个Entity,而是通过Context创建。
2.2 Component是数据容器,别在里面写游戏逻辑
很多刚上手的人会习惯性在Component里加方法,我一开始也犯了这个问题,比如写了position.MoveTo()。这是典型的OOP思维残留。Component应当只承担数据存储:
[Game] public sealed class PositionComponent : IComponent { public Vector3 Value; }Component里的字段越多,被多个系统同时访问时就越容易出现隐式耦合。我的经验是尽量让组件小而专一,一个组件只描述一个事实。“坐标”就是坐标,“速度”就是速度,“阵营”就是阵营。如果一个组件既放速度又放攻击力又放皮肤ID,那它和传统的“God Class”也没什么区别了。
还有一个容易忽略的细节:Entitas的代码生成器要求Component实现IComponent接口,并且用public字段。因为代码生成器拿这些字段去生成Entity的访问方法。如果你写了属性而不用字段,部分版本生成出来的代码会不完整。
2.3 System是真正干活的角色
System负责读取Component数据并修改它们。Entitas里有几种常用System基类:
- IExecuteSystem:每帧执行,适合持续处理移动、刷新等逻辑。
- ReactiveSystem :在匹配到某些组件事件时批量执行,适合响应式逻辑。
- IInitializeSystem:初始化时执行一次。
- ICleanupSystem:清理时执行。
- ITearDownSystem:整个Context销毁时执行。
我在Demo里最常见的搭配是:FeetPositionSystem每帧更新坐标,RemoveOutOfBoundsSystem响应“越界”事件。System之间尽量不去直接调用另一个System的方法,而是通过修改组件数据来相互影响,这样系统的执行顺序依赖变得很少。
2.4 Context负责打包整个逻辑宇宙
Context是Entitas里的根容器。一个典型的Entitas项目,会按上下文拆成几个Context,比如GameContext负责游戏世界逻辑,InputContext负责输入,UiContext负责界面状态。每个Context里存放的都是这个“世界”对应的所有Entity。
多个Context的好处是逻辑隔离。输入和游戏逻辑混在一起时,输入状态的变动会让游戏世界系统产生很多无谓遍历;拆开之后,输入系统只管生成输入事件实体,游戏系统只消费这些事件,互不污染。
实际项目里,我一般会建立一个Contexts单例来统一管理这些Context,在系统初始化时传入Contexts对象,这样系统可以方便获取自己关心的上下文。
3. 从零搭建Entitas项目:组件的定义与代码生成
Entitas的上手体验和很多框架不太一样,它依赖代码生成,所以先用一个最简单的项目走通流程非常重要。这一节我会写完整步骤,并解释每一步背后的逻辑。
3.1 环境准备与版本选择
我当时准备引入时最关心的问题是:Entitas能不能和我当前Unity版本兼容。官方建议在不同Unity版本上使用对应的Entitas Releases版本,我建议先到官方Release页面找与你的Unity大版本匹配的releases。下载得到的通常是一个UnityPackage或者源码文件夹。
导入Unity项目后,推荐把整个Assets/Entitas目录放在一个统一管理插件的地方。这里有个小建议:不要随手拖到Assets根目录随意分布,否则多人协作时覆盖冲突非常痛苦。我习惯用Packages或Plugins目录单独放第三方代码,但Entitas本身也支持源码级接入,放到Assets下也可以,关键要保持整洁。
安装后,Unity菜单栏会出现Tools/Entitas相关入口,比如Show Entitas Visual Debugger、Update Code Generated等。到这里环境基本就绪。
3.2 写第一个PositionComponent
在Assets/Sources(这是我习惯的自定义目录)里新建一个组件脚本:
using Entitas; using UnityEngine; [Game] public sealed class PositionComponent : IComponent { public Vector3 Value; }注意这里的[Game]特性标明了这个组件属于哪个Context。这个写法是Entitas特有的约定,代码生成器看到它之后,会为GameContext生成对应的GameEntity访问接口。
有人会问:为什么需要这个上下文标签,而不是让所有组件归到同一个Context?原因是隔离。我的输入组件只希望被输入系统看到,如果不加区分全部塞到一个Context,所有系统都要遍历不相干的实体,复杂度会不必要地提高。
3.3 用Attribute标记组件,让生成器帮你干活
Entitas的代码生成引擎不只会看到[Game],还会识别其他几个常用的标记:
- [Game]、[Input]、[Ui] 等:指定上下文。
- [Unique]:表示这个组件在一个Context里最多只能有一个实体持有,常用于全局唯一标记,如当前玩家、当前关卡状态。
- [Event(EventTarget.Self)]:为某个组件生成事件接口,组件变化后能主动通知外部。
我刚开始写组件时总是忘记给事件组件加[Event],导致后期不得不手动补生成代码。如果你已经规划好某个组件需要给外部做监听,建议一开始就标上[Event],不要等逻辑写完再加。
3.4 在Unity编辑器中启动代码生成
组件定义好之后,运行菜单Tools/Entitas/Update Code Generated,或者使用快捷键触发生成。生成完成后,你会看到Assets/Generated/目录下多出一堆类,包含GameContext、GameEntity、GameMatcher等。
这一步是Entitas体验的灵魂所在。生成代码并不需要你手动去改,它的核心价值是帮你保持一致性和类型安全。比如你在GameEntity里发现自然生成了AddPosition、ReplacePosition、RemovePosition这些方法,根本不用自己写。以后修改组件字段,再次刷新生成代码即可。
如果生成不成功,最常见的问题是组件类没有实现IComponent接口,或者字段没有用public修饰。另一个坑是如果项目里同时有两个同名的组件,但一个标了[Game],一个没有标,生成器会直接报错并拒绝工作。
4. 核心玩法实现:一个完整的移动与销毁逻辑
理论聊再多都不如实际跑一个Demo。我以最常见的“小球移动且掉出边界即销毁”为例,完整呈现数据流的运转方式。这个Demo麻雀虽小,但Entitas的典型结构基本都在里面了。
4.1 定义组件:Position、Velocity、OutOfBounds
为了让代码有层次,我把组件拆成两类:描述状态的组件和描述标记的组件。
[Game] public sealed class PositionComponent : IComponent { public Vector3 Value; } [Game] public sealed class VelocityComponent : IComponent { public Vector3 Value; } [Game] public sealed class OutOfBoundsComponent : IComponent { public Vector3 LastPosition; }OutOfBoundsComponent是一个标记组件,它一旦被添加到某个实体上,就说明这个实体已经越界,需要走销毁逻辑。这个相扑拆分的思维很重要:不要用一个布尔值在PositionComponent里标记“越界”,因为“越界”是一个事件状态,而不是位置的固有属性。
4.2 实现MovementSystem与RemoveOutOfBoundsSystem
接下来是核心系统之一MovementSystem:
public sealed class MovementSystem : IExecuteSystem { readonly GameContext _contexts; readonly IGroup<GameEntity> _movables; public MovementSystem(Contexts contexts) { _contexts = contexts.game; _movables = _contexts.GetGroup(GameMatcher.AllOf(GameMatcher.Position, GameMatcher.Velocity)); } public void Execute() { foreach (var entity in _movables.GetEntities()) { var pos = entity.position.Value; var vel = entity.velocity.Value; entity.ReplacePosition(pos + vel * Time.deltaTime); } } }这里有两个关键点:
- 使用IGroup来缓存实体集合,而不是每一帧用
_contexts.GetEntities(...)重新查找。 - 对位置数据的修改使用
ReplacePosition而不是entity.position.Value = newPos。因为Component通过引用方式获取后直接修改字段,Entitas就无法收到“组件被替换”的事件。
MovementSystem持续更新实体位置后,越界检测就可以在另一个System里做:
public sealed class OutOfBoundsCheckSystem : IExecuteSystem { readonly IGroup<GameEntity> _movables; public OutOfBoundsCheckSystem(Contexts contexts) { _movables = contexts.game.GetGroup(GameMatcher.AllOf(GameMatcher.Position, GameMatcher.Velocity)); } public void Execute() { foreach (var entity in _movables.GetEntities()) { var pos = entity.position.Value; if (Mathf.Abs(pos.x) > 20f || Mathf.Abs(pos.y) > 20f) { entity.IsOutOfBounds = true; } } } }4.3 ReactiveSystem:基于数据变化的响应式编程
实体OutOfBounds被设为true之后,销毁逻辑就不需要每帧扫描所有实体了,而是通过ReactiveSystem做事件驱动:
public sealed class RemoveOutOfBoundsSystem : ReactiveSystem<GameEntity> { public RemoveOutOfBoundsSystem(Contexts contexts) : base(contexts.game) { } protected override ICollector<GameEntity> GetTrigger(IContext<GameEntity> context) { return context.CreateCollector(GameMatcher.OutOfBounds.Added()); } protected override bool Filter(GameEntity entity) { return entity.hasOutOfBounds; } protected override void Execute(List<GameEntity> entities) { foreach (var entity in entities) { entity.Destroy(); } } }ReactiveSystem的魅力体现在:它只会在OutOfBounds组件被添加的这个事件发生时触发,批量获取那些真正发生了变化的实体,而不是每一帧去全量扫描。这个设计非常符合游戏逻辑的表达方式——很多事情本来就是事件驱动的,不是轮询驱动的。
4.4 Entity和GameObject玩一份“双轨制”
很关键的一个问题:Entitas本身不包含任何渲染功能,它管的是数据和逻辑,和Unity的GameObject是两套系统。通常我采用的方法是做一个桥接组件:
[Game] public sealed class ViewComponent : IComponent { public GameObject Value; }然后在初始化系统里根据实体创建对应的GameObject,并同步Transform数据。运行时,渲染层读取实体位置,逻辑层通过实体操作数据,互相不侵入。这套“双轨制”设计之后,换渲染方案的时候只需要改View层,逻辑部分完全不用动。我在几个原型的迭代中都体验到了这个好处。
5. 踩坑实录与排查技巧
使用Entitas的过程中,我踩过不少坑,有些是概念没理解透导致的,有些是工具特性带来的。这节我把最常遇到的问题和排查经验整理成速查表,并给出对应解法。
5.1 为什么我改了组件数据,事件没有触发
这是一个高频问题。新手写代码时经常这样做:
entity.position.Value = new Vector3(1f, 2f, 0f);这句代码表面上看没问题,但Entitas并不会因此触发任何事件通知,因为你是通过引用直接修改了组件内部字段。Entitas的事件机制建立在“组件替换/添加/移除”基础上,你必须调用生成器提供的Replace方法:
entity.ReplacePosition(new Vector3(1f, 2f, 0f));Replace方法的背后逻辑是先检查旧组件是否存在,如果存在再从组件池里取出一个新的组件实例,把新数据填进去,然后替换旧实例并回收旧实例。这样所有监听该组件事件的System才会知道“值变了”。
5.2 Group缓存为什么快,却也要背锅
Group是Entitas性能优化的核心。它的作用是维护一份“实时更新的实体索引”,当匹配组件条件发生变化时,自动把实体加入或移出组内列表。
很多人第一版代码写得好好的,后来为了图方便直接写_contexts.GetEntities(GameMatcher.Position),每一帧查询一次。这个调用每一次都会做完整集合匹配和筛选,实体一多性能就开始下滑。正确做法是在构造函数里获取Group,并缓存起来:
_movableGroup = contexts.game.GetGroup(GameMatcher.AllOf(GameMatcher.Position, GameMatcher.Velocity)); foreach (var entity in _movableGroup.GetEntities()) { // ... }Group还有一个优点:GetEntities返回的是内部缓存数组,遍历不会产生额外GC。相比每帧重新筛选,性能差异非常明显。如果System执行频率高、遍历集合又大,尽量优先使用Group。
5.3 组件池与内存回收:拒绝频繁new组件
Entitas默认启用了组件池。所谓组件池,就是组件实例会被缓存重用,而不是用完直接扔给GC。对频繁创建销毁的组件来说,这个机制能把运行时的GC压力压到很低。
但有一种情况容易破坏这个机制:你手动在System里new Component()并直接赋给实体。比如:
entity.AddPosition(new PositionComponent { Value = new Vector3(...) });AddPosition内部同样会走组件池,但你手动new出来的实例还是会产生一次垃圾分配。正确用法是让框架去取池里的实例:
entity.AddPosition(); entity.ReplacePosition(new Vector3(...));用默认构造方式添加组件,不仅更简洁,也能让对象池真正起作用。如果你写过类似“每帧实例化一个伤害数据组件”的代码,把这个习惯改掉后,Profiler里的GC Alloc曲线会有非常直观的改善。
5.4 常见问题速查表
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 修改组件数据后,ReactiveSystem没有触发 | 直接通过引用字段赋值,没有替换组件 | 使用ReplaceXxx()系列方法 |
System中GetEntities()耗时高 | 每帧调用_contexts.GetEntities()全量查询 | 在构造函数中缓存IGroup<TEntity> |
| 运行时GC分配明显 | 手动new组件实例 | 使用AddXxx()/ReplaceXxx(),让组件池接管生命周期 |
| 代码生成失败 | 组件没有实现IComponent,或字段不是public | 检查接口实现和字段访问修饰符 |
| 多个同名Group却表现不一致 | AllOf、AnyOf、NoneOf条件组合写错 | 逐个确认匹配条件,必要时用可视化调试器确认集合内容 |
| Generated下的代码与手写代码同命名冲突 | 手动创建了同名类 | 将手动类移到不同命名空间或重命名 |
6. 从技术选型角度看Entitas的适用边界
任何框架都有它的合适场景和反模式,Entitas也一样。我也不建议大家什么项目都无脑引入,技术选型要先想清楚边界。
6.1 适合Entitas的三大类项目
第一类是同类型实体特别多的项目,典型如策略游戏、格斗游戏、弹幕游戏、大量NPC世界。因为大量同质实体的行为高度相似,用统一System处理可以极大提高维护效率。
第二类是逻辑层与表现层分离需求强的项目,比如需要做服务器验证逻辑的项目。ECS天然保留了纯逻辑层数据流,我只需要把模拟层与Unity渲染层解耦,就能很方便地对核心战斗步骤做自动化测试。
第三类是团队需要明确分工的项目。Entitas的世界里,数据组件、逻辑系统、表现层边界清晰,美术同学只需要关注View表现。
6.2 不适合Entitas的场景
如果你的项目是一个很小的原型Demo,只用10来个小时就能跑通,那确实没必要上。ECS最终是一种架构投资,项目规模越大,回报越明显。
如果游戏玩法极度依赖复杂的单对象内部状态机,比如某些RPG对话系统里单角色有几十种交互状态,用传统OOP的状态模式可能会更自然。ECS在单个对象的内部精细化状态管理上,表达力其实偏弱。
另外如果你和团队没有一个人彻底搞懂ECS的思维方式,项目中期很容易退回“把System当Manager,把Component当Struct”的老路,最后变成比MonoBehaviour更复杂的结构。一定要有人能承担架构答疑和代码审查,这个角色非常关键。
6.3 团队上手与代码规范
经验证明,团队里每个人对ECS的理解速度差异很大,需要立几条硬性规范,否则代码风格很容易漂移:
- 组件只放字段,禁止写逻辑方法。
- 系统之间不直接引用,数据变更统一走组件替换。
- 同一种行为只在一个System里实现,避免两个System都去修改同一个组件。
- 新组件定义后必须重新生成代码,提交代码时同步提交Generated目录。
我在团队内还维护过一个简单的代码评审清单,专门检查组件是否过于庞大、System是否过度耦合。这套东西看起来朴素,但真的让Entitas项目长期保持可维护。
从我个人的实际操作体会来说,Entitas给我带来的最大价值并不是“性能快”这个标签,而是它逼着我把游戏逻辑拆成小而严谨的碎片。以前我遇到复杂玩法,第一反应是规划类和继承体系,现在我更自然地去想“需要哪些数据、数据如何流转、哪些系统在什么时候消费它们”——这个思维转变让很多本来充满歧义的架构讨论变得非常具体。最后再分享一个小技巧:在Entitas里做任何新功能时,先给自己十分钟把所有要动的组件写出来,不要急着写System。组件设计对了,System写起来会顺畅到让你意外。希望这篇内容能帮你少走一些弯路。