1. 项目概述为什么Unity ECS需要两种系统接口如果你正在或打算使用Unity的DOTSData-Oriented Technology Stack技术栈来开发游戏那么“系统”System这个概念你一定不陌生。它是ECS架构中负责逻辑执行的“大脑”。但当你真正开始动手写代码时一个令人困惑的选择会立刻摆在面前我是该继承SystemBase这个类还是实现ISystem这个接口官方文档和社区教程里两种写法都有它们看起来都能干类似的事情但背后的差异和选择逻辑却直接关系到你项目的性能、代码架构和未来的维护成本。简单来说SystemBase和ISystem是Unity ECS提供的两种定义系统的方式它们分别对应着“托管系统”和“非托管系统”两种不同的底层模型。这不仅仅是语法糖的区别而是Unity在ECS演进过程中为了平衡开发效率与运行时性能而做出的关键设计。SystemBase诞生得更早它封装了许多便利的API比如经典的Entities.ForEach让你能用更接近传统Unity托管代码的思维来编写逻辑上手快适合原型开发和快速迭代。而ISystem则是更“纯粹”的ECS公民它是一个结构体struct其生命周期方法OnCreate,OnUpdate,OnDestroy可以被Burst编译器优化甚至整个系统都能以非托管的形式存在旨在榨干硬件性能为大型、复杂的游戏提供极致的执行效率。理解这两者的区别不仅仅是学会两种写法更是理解Unity ECS从“可用”到“高效”的设计哲学转变。选择不当你可能会在项目后期遇到难以排查的性能瓶颈或者被陈旧的API所束缚。接下来我将结合自己的项目实战经验为你彻底拆解ISystem与SystemBase的核心差异、适用场景、迁移路径以及那些官方文档里不会写的“坑”。2. 核心差异深度解析托管与非托管世界的分界线要真正理解SystemBase和ISystem我们必须深入到“托管”与“非托管”这两个核心概念。在.NET和Unity的语境下这指的是内存管理和代码执行环境的根本不同。2.1 内存与执行模型托管堆 vs 非托管内存SystemBase是一个类它继承自System.Object。这意味着它的实例存在于托管堆上由.NET的垃圾回收器管理生命周期。这种模式我们非常熟悉它灵活、安全但GC垃圾回收可能带来的卡顿是性能敏感游戏的大敌。此外托管代码的调用需要通过.NET运行时会引入一定的开销。ISystem是一个接口但关键是你实现它的载体是一个结构体。这个结构体默认是“非托管的”。它通常被分配在线程栈或ECS内部管理的非托管内存块中。这意味着无GC压力系统实例本身不会触发垃圾回收。内存布局紧凑结构体是值类型其数据在内存中连续存储对CPU缓存友好。可与Burst深度集成因为是非托管类型其方法可以毫无障碍地被Burst编译器编译为高度优化的本地机器码。注意这里说的“非托管”主要指系统实例本身。系统内部仍然可以引用托管对象如EntityManager它本身是一个托管类但一旦你引用了对应的方法就无法被Burst编译了。2.2 API风格与演进Entities.ForEach的黄昏与SystemAPI的黎明这是最直观的编码体验差异。SystemBase的标志性API是Entities.ForEach。它用起来非常顺手像一个加强版的LINQ能直接在系统更新方法里写查询和逻辑。public partial class MyOldSystem : SystemBase { protected override void OnUpdate() { Entities .WithAllPlayerTag() .ForEach((ref Health health, in Damage damage) { health.Value - damage.Amount; }).ScheduleParallel(); // 可以方便地调度为并行Job } }然而根据Unity官方在2022年后的明确表态Entities.ForEach在SystemBase中虽得以保留但已被标记为“不鼓励使用”并计划在1.0稳定版之后的某个时间点弃用。它不会出现在新的ISystem中。ISystem推崇的是“符合语言习惯的foreach循环”配合SystemAPI.Query以及显式的Job结构如IJobEntity。public partial struct MyNewSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { // 方式1符合语言习惯的foreach主线程运行 foreach (var (health, damage) in SystemAPI.QueryRefRWHealth, RefRODamage() .WithAllPlayerTag()) { health.ValueRW.Value - damage.ValueRO.Amount; } // 方式2通过IJobEntity调度Job多线程运行 var job new ApplyDamageJob { DeltaTime SystemAPI.Time.DeltaTime }; job.ScheduleParallel(); } } [BurstCompile] public partial struct ApplyDamageJob : IJobEntity { public float DeltaTime; void Execute(ref Health health, in Damage damage) { health.Value - damage.Amount * DeltaTime; } }为什么会有这个转变根据我与DOTS团队交流的理解Entities.ForEach虽然方便但它是一个高度封装的“魔法”方法内部隐藏了复杂的代码生成和依赖管理逻辑。这导致了两个问题一是调试和性能分析变得困难你很难看清底层Job的边界二是在一些复杂查询或需要精细控制依赖关系的场景下不够灵活。而SystemAPI.Query加foreach或IJobEntity的模型更加透明、直观给予了开发者更精细的控制权也更容易被Burst编译器理解和优化。2.3 性能特征Burst编译能力是关键分水岭这是选择ISystem最硬核的理由。ISystem的OnUpdate()方法本身就可以标记[BurstCompile]。这意味着如果你的更新逻辑只涉及非托管数据组件数据、原生容器等那么整个更新循环的驱动代码——包括查询的构建和迭代——都可以被Burst优化。想象一下你有成百上千个小型系统每个系统只做一点点事情比如VelocitySystem只负责速度 加速度 * 时间。在SystemBase中即使你使用Entities.ForEach并调度了Burst编译的Job系统本身的调度开销调用ScheduleParallel管理依赖仍然是托管代码。而在ISystem中如果OnUpdate被Burst编译连这部分开销都几乎被消除了。对于大量小型系统或需要在主线程进行密集、同步操作的场景这带来的性能提升是显著的。相反SystemBase的OnUpdate是一个虚方法无法被Burst编译。它的性能优势在于其内部可以方便地调度并行的、Burst编译的Job。但对于系统本身的框架开销它无能为力。2.4 生命周期与状态管理SystemState的引入ISystem的方法OnCreate,OnUpdate,OnDestroy都接收一个ref SystemState参数。SystemState是一个轻量级的、非托管的句柄它提供了对系统所需资源的访问如EntityManager、ComponentLookup、TimeData等同时也管理系统间的依赖关系。SystemBase则通过基类的属性如this.EntityManager,this.Time来访问这些资源。SystemState的设计更符合ECS“数据驱动”的理念它将系统视为一个操作数据的函数所需的所有“工具”都通过参数显式传入而不是隐式地通过“this”上下文获取。这使得系统的依赖关系更加清晰也便于进行单元测试你可以模拟一个SystemState传入。3. 实操要点如何选择与编写你的系统了解了理论差异我们进入实战环节。面对一个具体的功能模块你该如何抉择又该如何正确地编写代码3.1 选择指南何时用ISystem何时用SystemBase我总结了一个简单的决策流程图但在解释之前请先记住Unity官方的长期建议将ISystem作为默认选择。特性/场景推荐使用ISystem推荐使用SystemBase性能优先级极高。适用于核心游戏循环、每帧运行的大量小型系统。中等。适用于非性能关键系统如UI更新、调试渲染、初始化。代码范式使用SystemAPI.Queryforeach或IJobEntity。仍可使用Entities.ForEach但需知未来风险或迁移到新范式。需要访问托管对象困难。如果必须访问Texture2D,GameObject,MonoBehaviour等纯托管对象需将相关代码放在非Burst编译的方法中或使用SystemBase。容易。天然在托管环境中访问任何Unity托管对象无障碍。项目阶段中后期性能优化、新建大型项目。快速原型、Game Jam、遗留ECS0.51之前项目升级的过渡阶段。系统复杂度适合逻辑纯粹、数据转换清晰的系统。适合需要复杂对象生命周期管理、与Unity编辑器深度交互的系统。团队熟悉度要求对ECS和Burst有较深理解。对从传统Unity转向ECS的开发者更友好。我的个人经验是在新项目中我从一开始就强制使用ISystem来编写所有游戏逻辑系统。只有当遇到必须与Unity UI系统、资源加载Addressables的托管回调或某些第三方托管插件交互时才会创建一个SystemBase作为“桥梁”或“管理器”系统。这个SystemBase系统负责与托管世界通信然后将必要的数据以组件或共享组件的形式注入到ECS世界中供其他ISystem消费。3.2 编写一个标准的ISystem从模板开始让我们手把手创建一个完整的ISystem。假设我们要实现一个移动系统。// 1. 定义系统结构体并实现ISystem接口 // 使用 partial 关键字是为了与生成的代码如Burst编译的代码共存。 [BurstCompile] // 为整个结构体标记Burst编译其下的BurstCompile方法才会生效。 public partial struct MoveSystem : ISystem { // 2. 系统创建时调用用于初始化状态。 // 注意此方法也可以标记[BurstCompile]但如果你在这里创建了托管对象如EntityQuery则不能标记。 [BurstCompile] public void OnCreate(ref SystemState state) { // 通常在这里创建EntityQuery但使用SystemAPI.Query后查询常是临时的这里也可以不创建。 // 如果需要每帧复用复杂查询可以在这里创建并存储。 // state.EntityManager.CreateEntityQuery(...); // 示例获取一个单例组件用于全局配置 var config SystemAPI.GetSingletonMoveConfig(); // ... 使用config初始化系统内部状态 } // 3. 系统销毁时调用用于清理资源。 // 如果系统持有NativeContainer等非托管资源必须在此释放否则会造成内存泄漏。 public void OnDestroy(ref SystemState state) { // 例如if (myNativeArray.IsCreated) myNativeArray.Dispose(); } // 4. 核心更新方法每帧调用。 [BurstCompile] // 关键标记此方法可被Burst编译。 public void OnUpdate(ref SystemState state) { // 获取时间增量。SystemAPI提供了对World状态的快速访问。 float deltaTime SystemAPI.Time.DeltaTime; // 方式A使用SystemAPI.Query foreach (主线程) // 适合逻辑简单、迭代实体不多或需要立即进行结构性更改增删实体/组件的情况。 foreach (var (transform, velocity, moveSpeed) in SystemAPI.QueryRefRWLocalTransform, RefROVelocity, RefROMoveSpeed()) { // 计算位移 float3 movement velocity.ValueRO.Value * moveSpeed.ValueRO.Value * deltaTime; // 应用位移。RefRWT 提供了 ValueRW 用于读写。 transform.ValueRW.Position movement; } // 方式B使用IJobEntity ScheduleParallel (多线程Job) // 适合处理大量实体且逻辑可以并行化、无结构性更改的情况。 // 注意Job的Schedule需要依赖关系管理。对于简单的独立系统可以直接Schedule。 // 但对于复杂依赖应使用SystemAPI提供的依赖状态state.Dependency。 var moveJob new MoveJob { DeltaTime deltaTime }; // 将job调度为并行执行并更新系统的依赖链。 // state.Dependency 管理了本系统之前所有已调度Job的依赖。 // moveJob.ScheduleParallel(state.Dependency) 会返回一个新的JobHandle它代表了“本系统moveJob”完成后的状态。 // 我们将这个新Handle赋值回state.Dependency供后续系统或本帧后续操作使用。 state.Dependency moveJob.ScheduleParallel(state.Dependency); } } // 定义对应的IJobEntity [BurstCompile] public partial struct MoveJob : IJobEntity { public float DeltaTime; // Execute方法签名通过特性定义要查询的组件 void Execute(ref LocalTransform transform, in Velocity velocity, in MoveSpeed speed) { transform.Position velocity.Value * speed.Value * DeltaTime; } } // 定义所需的组件示例 public struct Velocity : IComponentData { public float3 Value; } public struct MoveSpeed : IComponentData { public float Value; } public struct MoveConfig : IComponentData { public float GlobalSpeedMultiplier; }关键实操心得state.Dependency是核心在ISystem中你必须显式管理Job依赖链。state.Dependency代表了“在此系统之前所有已调度工作”的完成点。当你调度一个Job时需要将state.Dependency传入并将返回的新JobHandle赋回给state.Dependency。这确保了Job按正确顺序执行。RefRWT和RefROT这是ISystem查询中用于读写和只读组件的包装器。比起SystemBase里Entities.ForEach直接用的ref和in它们更显式避免了歧义。SystemAPI是你的瑞士军刀除了QuerySystemAPI还提供了GetSingleton,HasSingleton,TryGetSingleton, 获取EntityManager,ComponentLookup等快捷方式极大简化了代码。3.3 在SystemBase中使用新范式如果你有一个现有的SystemBase项目或者因某些原因必须使用SystemBase也强烈建议你开始使用新的SystemAPI.Query范式为未来迁移到ISystem做准备。public partial class MyHybridSystem : SystemBase { protected override void OnUpdate() { // 放弃Entities.ForEach改用SystemAPI.Query float deltaTime SystemAPI.Time.DeltaTime; // foreach循环在SystemBase中同样工作但运行在主线程。 foreach (var (transform, velocity) in SystemAPI.QueryRefRWLocalTransform, RefROVelocity()) { transform.ValueRW.Position velocity.ValueRO.Value * deltaTime; } // 或者调度一个IJobEntity与ISystem中完全一样 var job new MoveJob { DeltaTime deltaTime }; // 注意在SystemBase中你需要使用this.Dependency或this.JobHandle this.Dependency job.ScheduleParallel(this.Dependency); } }这样做的好处是当你决定将MyHybridSystem从class改为struct并实现ISystem时OnUpdate方法内部的逻辑几乎可以原封不动地复制过去只需修改方法签名和state.Dependency的引用即可。4. 高级主题与迁移策略4.1 处理托管对象与跨世界通信这是ISystem面临的主要挑战。假设你的系统需要加载一个音频剪辑AudioClip托管对象并在某个事件触发时播放。错误做法直接在ISystem的[BurstCompile]方法中尝试加载AudioClip。正确做法采用“桥梁”模式。创建一个SystemBase作为管理器这个系统负责所有与托管资源的交互。使用命令缓冲区或事件组件当ISystem中的游戏逻辑决定要播放声音时它不直接调用音频API而是向一个EntityCommandBuffer添加一个“播放声音请求”组件PlaySoundRequest : IComponentData或者直接在一个单例事件组件中设置参数。SystemBase管理器消费请求在SystemBase的OnUpdate中它查询所有的PlaySoundRequest根据请求中的资源ID如GUID或Addressables地址加载或获取托管AudioClip然后调用传统的AudioSource.PlayOneShot()等方法。清理播放完成后由SystemBase系统销毁请求实体或重置事件组件。// ISystem 世界 public partial struct CombatSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { var ecb SystemAPI.GetSingletonBeginSimulationEntityCommandBufferSystem.Singleton().CreateCommandBuffer(state.WorldUnmanaged); foreach (var (health, entity) in SystemAPI.QueryRefRWHealth().WithEntityAccess()) { if (health.ValueRO.Value 0) { // 1. 处理ECS逻辑标记实体死亡移除某些组件等。 ecb.AddComponentDeadTag(entity); // 2. 发出播放声音的请求非托管数据 ecb.AddComponent(entity, new PlaySoundRequest { SoundID SoundID.Explosion }); } } } } // SystemBase 世界 public partial class AudioPlaybackSystem : SystemBase { private AudioSource _audioSource; // 托管对象 private DictionarySoundID, AudioClip _loadedClips; // 托管字典 protected override void OnUpdate() { // 查询所有未处理的播放请求 foreach (var (request, entity) in SystemAPI.QueryPlaySoundRequest().WithEntityAccess()) { if (_loadedClips.TryGetValue(request.SoundID, out var clip)) { _audioSource.PlayOneShot(clip); // 调用托管API } // 清理请求组件 EntityManager.RemoveComponentPlaySoundRequest(entity); } } }4.2 从SystemBase迁移到ISystem的步骤对于已有项目全盘重写是不现实的。我建议采用渐进式迁移冻结新功能所有新系统一律使用ISystem开发。识别热点系统使用Profiler找出性能瓶颈最严重的SystemBase优先迁移它们。通常是那些每帧执行、处理实体数量多的系统如移动、物理、动画状态更新。迁移步骤 a. 将class YourSystem : SystemBase改为partial struct YourSystem : ISystem。 b. 将protected override void OnUpdate()改为public void OnUpdate(ref SystemState state)并加上[BurstCompile]。 c. 将方法体内所有this.EntityManager改为state.EntityManager所有this.Time改为SystemAPI.Time。 d. 将Entities.ForEach重构为SystemAPI.Queryforeach或IJobEntity。 e. 将this.Dependency或JobHandle的管理改为使用state.Dependency。 f. 添加OnCreate和OnDestroy方法处理原有的初始化OnCreate和资源清理OnDestroy。 g.重要在World的System更新列表中将原有的YourSystem替换为新的YourSystem。这通常在GameObject上的SimulationSystemGroup配置中或在你自定义的Bootstrap代码中。测试与验证每迁移一个系统都要进行严格的单元测试和性能对比测试确保逻辑正确且性能有提升或至少不下降。4.3 依赖管理与SystemGroup无论是ISystem还是SystemBase都需要被添加到SystemGroup中才能按顺序执行。ISystem的依赖管理更显式但原理相通。// 在Bootstrap代码中例如一个继承自SystemBase的初始化系统或MonoBehaviour public class MyBootstrap : MonoBehaviour { private World _simulationWorld; private void Start() { _simulationWorld new World(MySimulationWorld); var defaultSimGroup _simulationWorld.GetOrCreateSystemManagedSimulationSystemGroup(); // 添加ISystem defaultSimGroup.AddSystemMyISystem(); // 添加SystemBase (需要CreateInstance) defaultSimGroup.AddSystemManaged(new MySystemBase()); // 设置更新顺序 var updateGroup _simulationWorld.GetOrCreateSystemManagedMyCustomUpdateGroup(); updateGroup.AddSystemEarlyPhaseISystem(); updateGroup.AddSystemMainPhaseISystem(); updateGroup.AddSystemLatePhaseISystem(); defaultSimGroup.AddSystemManaged(updateGroup); } }在ISystem内部通过[UpdateInGroup(typeof(MyCustomUpdateGroup))]和[UpdateBefore(typeof(AnotherSystem))]/[UpdateAfter(...)]特性可以更声明式地定义顺序。5. 常见问题、性能陷阱与调试技巧在实际项目中踩过不少坑这里分享一些高频问题和解决思路。5.1 常见问题速查表问题现象可能原因解决方案编译错误BurstCompile方法中使用了托管引用在标记了[BurstCompile]的方法中访问了类对象、字符串非FixedString、数组等托管类型。1. 移除[BurstCompile]特性牺牲性能。2. 将托管数据以组件形式存储如BlobAssetReference或通过NativeArray/NativeList传递。3. 将该逻辑移出Burst方法放到另一个非Burst方法中处理。运行时错误访问已释放的NativeArrayISystem是结构体可能被复制。如果它在某个副本中持有一个NativeArray原系统释放后副本访问会导致错误。1. 避免在ISystem结构体中直接存储NativeCollection。如需存储使用ref字段或通过SystemState管理。2. 在OnDestroy中确保释放所有已创建的资源。SystemAPI.Query在foreach中报错“不能在ref方法中使用”尝试在IJobEntity的Execute方法已是ref上下文或某些Lambda表达式中使用SystemAPI.Query。SystemAPI.Query设计用于主线程的foreach循环。在Job内部或需要并行迭代时应使用IJobEntity、IJobChunk或手动创建EntityQuery并调用ToEntityArray/ToComponentDataArray不推荐有性能开销。state.Dependency管理混乱导致Job不执行或竞争条件调度Job时没有正确链接依赖或者多个并行Job读写同一数据未加锁。1.始终遵循模式state.Dependency myJob.ScheduleParallel(state.Dependency);。2. 对于需要读写同一数据的并行Job使用IJobChunk并确保每个Chunk内的操作是独立的或使用NativeHashMap等并发集合需小心性能。3. 使用SystemAPI.Query的.WithDisabledEntityFilter、.WithChangeFilter等来减少不必要的Job调度和依赖。从SystemBase迁移后系统不执行了忘记将新的ISystem添加到World的System更新列表中。ISystem和SystemBase的注册方式不同。ISystem通过泛型AddSystemT()添加而SystemBase需要实例化AddSystemManaged(new MySystem())。检查你的Bootstrap代码。性能提升不明显1.OnUpdate方法没有被Burst编译检查是否标记了[BurstCompile]且无托管引用。2. 系统本身逻辑很简单但实体数量少框架开销占比不大。3. 使用了SystemAPI.Queryforeach处理海量实体主线程瓶颈。1. 使用 Burst Inspector 查看方法是否成功编译。2. 使用Profiler的Deep Profile模式确认时间消耗在哪里。3. 对于海量实体务必使用IJobEntity或IJobChunk进行并行化处理。5.2 性能分析与调试心得善用Unity Profiler的“Entities”模块这是分析ECS性能的神器。它可以清晰显示每个ISystem和SystemBase的执行时间、Job的调度情况、活动实体/组件数量等。重点关注Main Thread和Job Threads的时间分布。如果ISystem的OnUpdate在主线程耗时仍长可能是内部的foreach循环处理实体太多应考虑改为Job。使用Unity.Profiling命名空间进行自定义标记在代码中插入ProfilerMarker可以更细粒度地测量性能。private static readonly ProfilerMarker s_MarkerUpdate new ProfilerMarker(MySystem.Update); public void OnUpdate(ref SystemState state) { using (s_MarkerUpdate.Auto()) { // ... 你的逻辑 } }Burst Inspector在Jobs - Burst - Open Inspector中可以查看哪些方法成功被Burst编译以及生成的汇编代码。如果某个你期望Burst编译的方法没出现说明它包含了Burst不支持的操作。Entity Debugger窗口查看实体的组件构成、Chunk的内存布局对于理解数据局部性和优化查询条件至关重要。5.3 关于代码模板与设计模式有开发者提到SystemBase因为是一个类可以更方便地使用模板方法等设计模式来减少重复代码。这在ISystem中确实更棘手因为结构体不支持继承。但我们可以通过组合和泛型来达到类似效果// 定义一个包含通用逻辑的结构体 public struct CommonSystemLogicT1, T2 where T1 : unmanaged, IComponentData where T2 : unmanaged, IComponentData { public void UpdateLogic(ref SystemState state, float multiplier) { foreach (var (a, b) in SystemAPI.QueryRefRWT1, RefROT2()) { // 通用处理逻辑 a.ValueRW.Value b.ValueRO.Value * multiplier; } } } // 在具体的ISystem中使用它 public partial struct SystemA : ISystem { private CommonSystemLogicHealth, Damage _logic; [BurstCompile] public void OnUpdate(ref SystemState state) { _logic.UpdateLogic(ref state, SystemAPI.Time.DeltaTime); } }虽然不如类继承优雅但在大多数情况下这种模式足以应对代码复用的需求。经过多个项目的实践我的结论非常明确对于追求性能的严肃商业项目ISystem是毋庸置疑的未来和首选。它代表着Unity ECS向更纯粹、更高性能的数据导向设计迈出的坚实一步。初期的学习曲线和迁移成本是存在的但带来的性能收益和代码的长期可维护性是值得的。从今天开始在新代码中尝试使用ISystem和SystemAPI你会逐渐体会到那种对性能和数据流的精准掌控感。至于SystemBase就让它安心地负责那些与托管世界打交道的“外交工作”吧。