1. 项目概述:为什么我们需要深入理解DOTS Component?
如果你正在用Unity做项目,尤其是那种需要处理成千上万个单位、粒子或者复杂逻辑的游戏,你大概率已经听过DOTS(Data-Oriented Technology Stack)的大名。DOTS的核心是ECS(Entity Component System),它彻底颠覆了传统面向对象的GameObject/Component模式。但当你真正上手时,第一个拦路虎往往就是“Component”这个概念。在DOTS里,Component不再是挂载在GameObject上的脚本,它变成了一种纯粹的数据结构。这种转变带来的性能提升是巨大的,但理解和使用它的门槛也着实不低。今天,我们就来彻底拆解一下Unity3D DOTS中的Component,从最基础的IComponentData,到复杂的动态缓冲区,再到让人又爱又恨的Hybrid Component,我会结合我踩过的坑和实战经验,帮你理清思路,真正把DOTS Component用起来。
2. DOTS Component的核心设计哲学与类型解析
2.1 从GameObject.Component到ECS.IComponentData:思维的彻底转变
在传统Unity开发中,一个MonoBehaviour脚本挂到GameObject上,它就既是数据容器(字段),又是行为逻辑(方法)。这种“数据与行为强耦合”的模式,在对象数量少时很直观,但当你要管理十万个士兵时,问题就来了。CPU缓存命中率低,大量虚函数调用,GC(垃圾回收)压力山大。
DOTS的Component,准确说是IComponentData,其设计哲学就八个字:数据与行为分离。它只是一个struct(结构体),里面只包含数据,没有任何方法。所有的逻辑处理,都交给System(系统)来批量执行。这种设计带来了几个根本性优势:
- 内存布局紧凑(SoA/AoS优化):相同类型的Component数据在内存中是连续存储的(Archetype块内存管理),这极大提高了CPU缓存利用率。系统遍历处理数据时,就像在高速公路上飙车,而不是在乡间小道上绕来绕去。
- 支持Burst编译与Job系统:因为
IComponentData是纯值类型的struct,它可以被安全地放入NativeArray,交给Burst编译器优化成接近C++性能的机器码,并利用多核并行Job系统处理。 - 明确的依赖与组合:实体(Entity)是什么?它就是一个ID,加上一组Component。实体的“身份”和“能力”完全由它拥有的Component组合定义。这比复杂的类继承层次要清晰和灵活得多。
注意:这里有个关键点,
IComponentData必须是不可变的struct。这意味着你不能在Component内部持有托管对象(如class)的引用,因为那会破坏Burst编译和Job的安全性。如果你需要字符串、数组等复杂数据,需要使用FixedString、BlobAssetReference或DynamicBuffer。
2.2 三大核心Component类型详解与选型指南
DOTS中的Component并非只有一种,针对不同场景,Unity提供了三种主要类型,用错了地方性能会大打折扣。
1. IComponentData:主力军,用于绝大多数场景这是最常用、性能最好的Component类型。它就是一个简单的数据结构。
public struct Health : IComponentData { public float Value; // 只包含数据字段 } public struct MovementSpeed : IComponentData { public float MetersPerSecond; }在System中,你可以通过EntityManager或ComponentSystemBase的API来查询和修改它们。它的数据直接存储在Entity所在的Archetype Chunk中,访问速度最快。
2. ISharedComponentData:用于数据分组与筛选ISharedComponentData的特殊之处在于,拥有相同ISharedComponentData值的实体会被分组到同一个Chunk中。这常用于渲染分组(如共享同一材质球RenderMesh)、逻辑分组等。
public struct TeamAffiliation : ISharedComponentData, IEquatable<TeamAffiliation> { public int TeamId; public bool Equals(TeamAffiliation other) => TeamId == other.TeamId; public override int GetHashCode() => TeamId.GetHashCode(); }实操心得:
ISharedComponentData虽然方便分组,但修改它的代价很高。因为修改一个实体的SharedComponent值,会导致该实体被移动到另一个匹配该值的Chunk中,这可能引发内存重排。所以,适合那些在实体生命周期内极少改变的数据,比如渲染网格、阵营。切勿用它来存储每帧都在变化的数据。
3. DynamicBufferComponentData:处理可变长度数组数据当你需要为实体关联一个列表数据时,比如一个单位的库存物品列表、路径点序列,就需要用到DynamicBuffer<T>。
public struct PathBuffer : IBufferElementData { public float3 Position; } // 使用时,通过 EntityManager.AddBuffer<PathBuffer>(entity) 添加它在内存中并不直接存储在实体Chunk内,而是有一个单独的缓冲区,但通过Entity可以高效访问。它解决了IComponentData无法动态扩容的问题。
选型速查表:
| 组件类型 | 数据特点 | 性能影响 | 典型应用场景 |
|---|---|---|---|
| IComponentData | 固定大小,值类型,数据独立 | 最优,内存连续,缓存友好 | 生命值、速度、位置(Translation)、状态标识 |
| ISharedComponentData | 固定大小,值类型,数据共享 | 修改成本高,查询效率高 | 渲染网格、材质、团队、关卡分区 |
| IBufferElementData | 可变长度数组 | 访问比IComponentData稍慢,但灵活 | 技能队列、路径点、库存物品ID列表 |
2.3 Hybrid Component:连接旧世界与新世界的桥梁
这是资料中重点提及,也是实践中最容易让人困惑的部分。正如官方文档所说,很多Unity现有的功能(如渲染器、灯光、音频源)还没有原生的DOTS版本。为了让DOTS项目能利用这些现有资源,Hybrid Component登场了。
它是什么?本质上,Hybrid Component是一种特殊的机制,允许你在ECS代码中“访问”传统的UnityEngine.Component。但它不是为了性能,而是为了兼容和过渡。
核心工作原理与“伴侣GameObject”:
- 创建时机:Hybrid Component只能在转换系统(
GameObjectConversionSystem)中,通过AddHybridComponent方法声明。你不能在运行时动态添加。 - 伴侣GameObject:系统会为每个拥有Hybrid Component的实体,在背后创建一个隐藏的(
HideFlags)GameObject,称为“Companion GameObject”。这个GameObject上挂载着你声明的那些UnityEngine组件。 - 单向链接:实体通过一个名为
CompanionLink的托管组件来持有对这个伴侣GameObject的引用。关键点来了:这个链接是单向的(Entity -> GameObject)。你不能从伴侣GameObject反向找到实体。 - 数据同步:通常,ECS侧的数据(如实体的
LocalToWorld矩阵)会单向同步到伴侣GameObject的Transform上,以驱动渲染位置。反之则不行,直接修改伴侣GameObject的Transform是错误操作,会被ECS系统覆盖。
// 转换系统示例 public class MyRendererConversionSystem : GameObjectConversionSystem { protected override void OnUpdate() { Entities.ForEach((MeshRenderer meshRenderer, MeshFilter meshFilter) => { var entity = GetPrimaryEntity(meshRenderer); // 将传统的MeshRenderer和MeshFilter声明为Hybrid Component AddHybridComponent(meshRenderer); AddHybridComponent(meshFilter); // 同时,我们可能还需要添加ECS原生的渲染组件(如果可用) // DstEntityManager.AddComponentData(entity, new RenderMesh {...}); }); } }主要限制与使用警告:
- 无性能收益:使用Hybrid Component不会获得Jobs、Burst或内存优化上的好处。它背后的GameObject和传统GameObject开销一样。
- 仅数据访问:伴侣GameObject上的组件,大部分事件函数(如
Update、Start)不会被调用。它主要作为一个数据容器被ECS读取。 - 明确使用(Opt-in):你需要显式声明哪些组件是Hybrid的,它不是自动的。
- 生命周期绑定:伴侣GameObject的生命周期完全由所属实体管理。实体销毁,它也随之销毁。
踩坑实录:我曾经试图在运行时通过
GetComponent<MeshRenderer>()从实体获取Hybrid Component,结果总是null。后来才明白,必须通过EntityManager.GetComponentObject<T>(entity)这个特定的API来获取。这提醒我们,Hybrid Component的访问路径和传统方式、ECS原生方式都不同,需要特别注意。
3. Component的实战:定义、查询与高效操作
3.1 定义最佳实践与内存布局考量
定义一个好的Component是高效使用DOTS的基础。除了选择正确的类型,还有几个细节决定成败。
1. 结构体大小与内存对齐Burst编译器与CPU对数据对齐很敏感。尽量让IComponentData的大小是2的幂次方字节(如16, 32, 64字节),并注意字段顺序,将相同类型的字段放在一起,可以减少内存填充(Padding),提高内存带宽利用率。
// 不佳示例:存在内存空洞 public struct BadLayout : IComponentData { public byte Flag; // 1字节 // 此处编译器可能会插入7字节填充以满足8字节对齐 public float Value; // 4字节 public int Id; // 4字节 } // 总大小可能为16字节 // 更佳示例:紧凑布局 public struct GoodLayout : IComponentData { public float Value; // 4字节 public int Id; // 4字节 public byte Flag; // 1字节 // 总大小可能为12字节,且更紧凑 }你可以使用Unity.Collections.LowLevel.Unsafe.UnsafeUtility.SizeOf<T>()来检查你的结构体实际大小。
2. 使用标记组件(Tag Component)标记组件是一种不包含任何数据的IComponentData,仅用于在查询中标识实体。
public struct EnemyTag : IComponentData { } public struct ProjectileTag : IComponentData { }这比用一个bool字段在另一个Component中更高效,因为Archetype查询可以直接通过组件存在性来筛选,无需比较数据。
3. 启用与禁用组件DOTS提供了Enableable Component的概念(通过[EnableableComponent]特性标记)。你可以在运行时动态启用或禁用某个组件,而无需从实体上真正添加或移除它,从而避免改变实体的Archetype(这是一个昂贵的操作)。
[GenerateAuthoringComponent] // 可选,用于生成GameObject挂载脚本 public struct Health : IComponentData, IEnableableComponent { public float Value; } // 在System中禁用/启用 EntityManager.SetComponentEnabled<Health>(entity, false);3.2 系统(System)中的组件查询模式
System的核心工作就是找到拥有特定组件组合的实体,然后处理它们。EntityQuery是完成这项工作的工具。
基础查询:
public class MovementSystem : SystemBase { protected override void OnUpdate() { // 查询所有拥有Translation, Rotation, MovementSpeed组件的实体 Entities .WithAll<Translation, Rotation, MovementSpeed>() .WithNone<FrozenTag>() // 排除拥有FrozenTag的实体 .ForEach((ref Translation pos, ref Rotation rot, in MovementSpeed speed) => { // 处理逻辑... }).ScheduleParallel(); // 并行调度 } }WithAll<T>: 实体必须拥有所有这些组件。WithAny<T>: 实体必须拥有其中至少一个组件(使用需谨慎,可能影响性能)。WithNone<T>: 实体不能拥有这些组件。WithChangeFilter<T>: 仅处理自上次更新后,T组件数据发生变化的实体。这对于优化性能非常有用,避免处理静止不动的实体。WithEntityQueryOptions(EntityQueryOptions.FilterWriteGroup): 使用Write Group进行更精细的组件过滤。
使用EntityQuery对象进行复杂查询:对于更复杂的查询逻辑,或者需要在多个地方复用查询,可以显式创建EntityQuery。
private EntityQuery _movingEnemiesQuery; protected override void OnCreate() { // 在OnCreate中创建查询,避免每帧分配 _movingEnemiesQuery = GetEntityQuery( new EntityQueryDesc { All = new ComponentType[] { typeof(Translation), typeof(EnemyTag), typeof(MovementSpeed) }, None = new ComponentType[] { typeof(FrozenTag) } } ); } protected override void OnUpdate() { var translations = _movingEnemiesQuery.ToComponentDataArray<Translation>(Allocator.TempJob); // ... 使用translations translations.Dispose(); // 切记手动释放临时分配的内存 }3.3 高效操作组件数据的模式与陷阱
在System中操作组件数据,尤其是使用Job时,必须遵循DOTS的规则。
1. 读写权限与in/ref关键字在Entities.ForEach的Lambda参数中:
ref ComponentType comp: 表示你需要读写这个组件。系统会为此组件添加写依赖。in ComponentType comp: 表示你只需要读取这个组件。这允许更大的并行性,多个只读该组件的Job可以同时运行。- 如果组件没有出现在参数中,但在查询中,默认是只读的。
错误地使用ref会导致不必要的Job依赖,限制并行度。原则:能in就绝不ref。
2. 通过EntityManager进行结构性更改添加、移除组件,或销毁实体,被称为“结构性更改”(Structural Change)。这些操作会改变Archetype,绝对不能在并行Job内部或Entities.ForEach的Lambda中直接执行。
// 错误!在Job中执行结构性更改 Entities.ForEach((Entity entity, in Health health) => { if (health.Value <= 0) { EntityManager.DestroyEntity(entity); // 运行时错误! } }).Run(); // 正确做法:使用命令缓冲区(EntityCommandBuffer) private EndSimulationEntityCommandBufferSystem _ecbSystem; protected override void OnUpdate() { var ecb = _ecbSystem.CreateCommandBuffer().AsParallelWriter(); Entities.ForEach((Entity entity, int entityInQueryIndex, in Health health) => { if (health.Value <= 0) { ecb.DestroyEntity(entityInQueryIndex, entity); // 将命令记录到缓冲区 } }).ScheduleParallel(); // 依赖关系会自动处理,命令将在主线程安全执行 _ecbSystem.AddJobHandleForProducer(this.Dependency); }EntityCommandBuffer是处理结构性更改的标准模式,它将命令记录起来,在Job完成后,在主线程一次性执行。
3. 访问其他实体的组件有时你需要在一个实体的处理逻辑中,读取或修改另一个实体的组件。这需要通过ComponentDataFromEntity<T>来实现。
protected override void OnUpdate() { // 获取一个允许从Entity索引访问Health组件的“字典” var healthFromEntity = GetComponentDataFromEntity<Health>(true); // true表示只读 var healthFromEntityWritable = GetComponentDataFromEntity<Health>(false); // false表示可写 Entities.ForEach((Entity entity, in Damage damage, in Target target) => { if (healthFromEntity.HasComponent(target.Entity)) { var targetHealth = healthFromEntityWritable[target.Entity]; targetHealth.Value -= damage.Amount; healthFromEntityWritable[target.Entity] = targetHealth; } }).ScheduleParallel(); }注意事项:
ComponentDataFromEntity在Job中使用时,其读写状态(isReadOnly)必须明确指定,并且要纳入Job的依赖管理。对可写版本的并发访问需要小心竞争条件,通常需要配合NativeHashMap或使用[NativeDisableParallelForRestriction]特性,并手动管理依赖。
4. 性能调优、调试与常见问题排查
4.1 性能瓶颈分析与优化策略
使用DOTS是为了性能,但用不好反而会引入新的瓶颈。以下是几个关键的性能检查点:
1. Archetype碎片化这是最常见的性能杀手。每次为实体添加或移除一个组件,它都可能需要移动到另一个Archetype的Chunk中。频繁的操作会导致内存碎片化和大量的数据移动。
- 优化策略:
- 使用Enableable组件:代替频繁的添加/移除操作。
- 批量操作:使用
EntityManager的AddComponent、RemoveComponent等批量方法,或者通过EntityCommandBuffer在帧末统一处理。 - 设计稳定的组件组合:在实体创建时就确定好其核心组件集,避免运行时频繁改变“身份”。
2. 低效的EntityQuery
- 避免
WithAny:WithAny查询会导致更复杂的查询计划和可能更低的性能,尽量用WithAll和WithNone组合替代。 - 善用
WithChangeFilter:对于非每帧都需要处理的逻辑(如AI决策、状态检测),使用变化过滤器可以大幅减少处理实体数量。 - 缓存查询结果:如果一组实体列表在多帧内变化不大,可以考虑将
EntityQuery的结果(ToEntityArray)缓存起来,并增量更新,而不是每帧重新查询。
3. Job依赖与竞争不合理的Job依赖会阻止并行执行。
- 检查读写声明:确保Lambda参数中只对真正需要写的组件使用
ref。 - 使用
ScheduleParallel而非Run:除非Job非常轻量级,或者有严格的顺序要求,否则优先使用ScheduleParallel。 - 使用
Dependency属性:正确管理SystemBase的Dependency句柄,系统会自动处理Job之间的依赖关系。但如果你手动创建了Job,需要显式管理其依赖。
4. 托管对象与GC压力即使在DOTS中,如果不当使用托管对象(如new数组、字符串操作),仍会触发GC。
- 使用
NativeCollection:在Job和System中使用NativeArray、NativeList、NativeHashMap等,它们分配在非托管堆,不受GC管理。 - 谨慎使用
IComponentData中的class:如前所述,这通常是不允许的。对于复杂数据,考虑BlobAsset(不可变数据资产)或DynamicBuffer。 - 及时释放:所有
NativeCollection都必须显式调用Dispose()释放,或者使用Allocator.TempJob并在Job完成后释放。
4.2 调试工具与技巧
DOTS的调试比传统模式更复杂,因为数据是分散的。掌握工具至关重要。
1. Entity Debugger (Window > Analysis > Entity Debugger)这是最重要的工具。它可以让你:
- 查看所有World和System。
- 查看每个Entity Query匹配的实体列表。
- 查看Archetype和Chunk:这是核心。你可以看到每个Archetype由哪些组件构成,有多少Chunk,每个Chunk的使用率如何。低使用率的Chunk意味着内存浪费。
- 实时查看和修改实体的组件数据。
2. Unity Profiler 与 Deep Profiling使用Profiler的Deep Profiling模式,可以深入到每个System和Job的内部,查看耗时。
- 关注
Entities.ForEach和Job的调度开销。 - 查看主线程等待Job完成的时间(同步点)。
- 使用“Burst”和“Jobs”分析器类别,查看Burst编译情况和Job执行情况。
3. 自定义调试可视化在开发阶段,为关键组件添加调试绘制功能非常有用。
// 在System中,使用UnityEngine.Debug API(必须在主线程) Entities.WithoutBurst().WithAll<Translation, EnemyTag>().ForEach((in Translation translation) => { Debug.DrawRay(translation.Value, new float3(0, 2, 0), Color.red); }).Run(); // 注意这里用.Run()在主线程执行注意,Debug.DrawRay等是托管代码,不能在Burst编译的Job中使用,所以需要.WithoutBurst()和.Run()。
4.3 常见问题排查速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 运行时报错:InvalidOperationException | 在Job中访问了托管对象或执行了不安全操作。 | 1. 检查Job中是否使用了ref、in之外的参数类型(如EntityManager)。2. 检查是否在Job中直接进行了结构性更改。 3. 确保所有 NativeCollection在Job声明时已正确传递依赖。 |
| 性能不升反降 | Archetype碎片化严重;Job依赖过重;查询效率低。 | 1. 用Entity Debugger查看Archetype数量和各Chunk使用率。 2. 在Profiler中查看Job调度和执行时间线,检查是否有长时间的主线程等待。 3. 审查EntityQuery,避免 WithAny,尝试添加WithChangeFilter。 |
| Hybrid Component不显示/位置不对 | Companion GameObject创建失败或数据同步问题。 | 1. 确认转换系统正确调用了AddHybridComponent。2. 检查实体是否有 LocalToWorld或Translation、Rotation组件来驱动位置。3. 使用 EntityManager.GetComponentObject<Transform>(entity)获取伴侣Transform,手动检查其位置。 |
| Burst编译警告或错误 | 代码中存在Burst不支持的C#特性。 | 1. 查看Console中的Burst警告信息。 2. 常见问题:使用了 try-catch、string格式化、虚函数调用、委托(非函数指针)等。3. 将不支持的逻辑移到 [BurstCompile]方法之外,或用[BurstDiscard]标记。 |
| 内存泄漏(Memory Leak) | NativeCollection未正确释放。 | 1. 确保每个Allocator.Persistent或Allocator.TempJob的分配都有对应的Dispose()。2. 对于 Allocator.Temp,确保在方法返回前释放。3. 使用Unity的Memory Profiler工具追踪非托管内存分配。 |
| 实体查询不到 | 组件未正确添加;查询条件有误;实体处于禁用状态。 | 1. 在Entity Debugger中直接搜索该Entity ID,查看其拥有的组件列表。 2. 检查查询的 WithAll/WithAny/WithNone条件是否与实体组件匹配。3. 检查相关组件是否被 SetComponentEnabled禁用了。 |
5. 进阶模式与架构思考
5.1 组件设计模式:超越基础数据存储
当项目规模变大,良好的组件设计模式能保持代码清晰。
1. 状态机与组件不要用MonoBehaviour里那种Update中switch-case的状态机。在ECS中,用不同的组件组合来表示状态。
// 状态:巡逻 public struct PatrolState : IComponentData { public float3 PatrolCenter; public float PatrolRadius; } // 状态:追击 public struct ChaseState : IComponentData { public Entity TargetEntity; } // 状态:攻击 public struct AttackState : IComponentData { public float AttackCooldown; }一个敌人实体在同一时间只会拥有PatrolState、ChaseState、AttackState中的一个。切换状态时,就是移除旧状态组件,添加新状态组件。然后由不同的System(PatrolSystem,ChaseSystem,AttackSystem)分别处理对应状态的实体。
2. 事件组件(One-frame Components)用于在系统间传递事件消息。添加后,在下一帧由负责处理的系统消费并移除。
public struct DamageEvent : IComponentData { public Entity Target; public Entity Instigator; public float Amount; } // 攻击系统产生事件 public class AttackSystem : SystemBase { protected override void OnUpdate() { var ecb = ...; Entities.ForEach((Entity attacker, in AttackCommand cmd) => { ecb.AddComponent(attacker, new DamageEvent { Target = cmd.Target, Amount = 10 }); }).ScheduleParallel(); } } // 伤害处理系统消费并移除事件 public class DamageSystem : SystemBase { protected override void OnUpdate() { Entities.ForEach((Entity entity, ref Health health, in DamageEvent dmgEvent) => { health.Value -= dmgEvent.Amount; }).ScheduleParallel(); // 本系统执行完后,需要移除所有DamageEvent组件 EntityManager.RemoveComponent<DamageEvent>(GetEntityQuery(typeof(DamageEvent))); } }3. 单例组件(Singleton Component)用于存储全局游戏状态,如游戏时间、分数、玩家实体引用等。通常通过一个特殊的单例实体来持有。
public struct GameTime : IComponentData { public float ElapsedTime; public float DeltaTime; } // 在Bootstrap系统中创建单例实体 var singletonEntity = EntityManager.CreateEntity(); EntityManager.AddComponent<GameTime>(singletonEntity); // 在其他系统中通过GetSingleton获取 var gameTime = GetSingleton<GameTime>();5.2 与Unity引擎其他模块的协作
DOTS不是孤岛,最终还是要和渲染、物理、UI等交互。
1. 与渲染管线(URP/HDRP)协作对于自定义渲染,ECS提供了EntitiesGraphics包。你需要定义MaterialOverride等组件。对于Hybrid渲染,确保转换系统正确添加了RenderMesh(Hybrid)或MeshInstanceRenderer等组件,并且实体的LocalToWorld矩阵数据正确。
2. 与物理(Unity Physics)协作使用Unity.Physics包。物理实体同样由Component定义,如PhysicsVelocity、PhysicsMass、PhysicsCollider。物理模拟在一个独立的PhysicsWorld中运行,你需要通过PhysicsStep组件来配置,并通过PhysicsWorld的Singleton来访问物理查询结果。
3. 与UI(UI Toolkit/UGUI)交互这是目前DOTS的薄弱环节。通常的做法是:
- 在ECS中维护UI需要的数据(如玩家血量、敌人数量)。
- 创建一个传统的
MonoBehaviour系统,每帧从ECS单例组件中读取这些数据。 - 该
MonoBehaviour系统负责调用UI API(如Document.rootVisualElement.Q<Label>("health").text)来更新界面。 - 反之,UI事件(如按钮点击)也通过这个
MonoBehaviour系统接收,然后转换成ECS事件组件(如ButtonClickEvent)添加到世界中。
5.3 面向未来的组件设计考量
DOTS仍在快速发展中。在设计组件时,考虑以下趋势:
- Netcode for Entities:如果你计划做多人游戏,需要考虑网络同步。为组件添加
[GenerateAuthoringComponent]特性可以方便生成GameObject界面,但网络同步通常需要定义ICommandData和IRpcData。在设计数据结构和状态时,提前思考哪些数据需要同步、如何压缩(如使用Quantized)。 - Burst-Compatible Mathematics:坚持使用
Unity.Mathematics中的float3,quaternion,bool4等类型,它们是为SIMD和Burst优化而生的。 - Code Generation:考虑使用Source Generators或自定义工具来生成重复的组件和系统代码,减少样板代码,例如自动为每个组件生成对应的“添加”、“移除”命令缓冲区扩展方法。
我个人在大型项目中的体会是,DOTS Component的成功应用,始于对“数据驱动”思维的真正接纳。它强迫你从“这个对象要做什么”转向“描述这个世界有哪些数据,以及这些数据如何被变换”。初期会感到束缚,但当你习惯了这种思维,并看到成千上万的实体流畅运行时的性能表现,你会觉得这一切都是值得的。最后一个小技巧:在项目早期,就建立严格的组件命名和分类规范,比如所有标签组件都以Tag结尾,所有事件组件都以Event结尾,所有状态组件都以State结尾,这能在项目复杂度提升时,极大减轻心智负担。