基于Entitas的Unity ECS架构实践:告别面条式代码

发布时间:2026/10/12 21:13:55
基于Entitas的Unity ECS架构实践:告别面条式代码 做游戏框架选型的时候我一直在找一个能让团队不再被“面条式代码”折磨的架构。后来把目光落在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接下来是核心系统之一MovementSystempublic sealed class MovementSystem : IExecuteSystem { readonly GameContext _contexts; readonly IGroupGameEntity _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 IGroupGameEntity _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 : ReactiveSystemGameEntity { public RemoveOutOfBoundsSystem(Contexts contexts) : base(contexts.game) { } protected override ICollectorGameEntity GetTrigger(IContextGameEntity context) { return context.CreateCollector(GameMatcher.OutOfBounds.Added()); } protected override bool Filter(GameEntity entity) { return entity.hasOutOfBounds; } protected override void Execute(ListGameEntity 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()全量查询在构造函数中缓存IGroupTEntity运行时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写起来会顺畅到让你意外。希望这篇内容能帮你少走一些弯路。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询