最近开发者群里聊“Dots节点”的频率明显又上来了。这里的 DOTS,说的就是 Unity 官方那套 Data-Oriented Technology Stack,面向数据的技术栈,核心包含 ECS 实体组件系统、Job System 多线程调度、Burst 高性能编译器这三件套。网上那些几万个小球同时弹跳、帧率纹丝不动的演示视频,基本都出自这套东西。这篇文章我想换个角度,把 DOTS 拆成一张“节点图”来理解,再带大家从零搭一个万人同屏的 Demo,把整个流程跑通,最后聊聊实际开发中容易踩的坑。无论你是在研究引擎底层,还是为现有项目做技术选型,这篇应该都能给你一个比较清晰的判断依据。
1. 当 5 万个单位同时动起来:传统 GameObject 方案的崩溃现场
先说清楚我们到底在解决什么问题。
很多人在没接触 DOTS 之前,觉得“无非就是优化一下代码嘛”。但等你真正面对一个需要同时驱动几万个单位的项目时,会发现问题根本不在某一个函数写得快慢,而是整个 GameObject 体系的底层设计完全撑不住这种规模。
1.1 GameObject 随机访问的内存量级
一个常规的 GameObject,本质上是一个托管对象,它持有的每个 Component 都是 C# 托管堆上分散的小对象。你在场景里放了 5 万个士兵,每个士兵身上挂两三个 Component,遍历一遍这些 Component 就等于在内存里做几万次随机访问。
CPU 取数可不是按字节来的,而是按 cache line 来,一条 cache line 通常是 64 字节。哪怕你只需要读一个 float(4 字节),CPU 也会把周围的 64 字节整个拉进缓存。如果这 64 字节里恰好躺着同一个士兵的另外几个字段,那这趟访问就值回票价。可惜 MonoBehaviour 的现状是:position 在托管堆这一块,hp 在那一块,动画状态又在另一块。结果就是每次访问都在浪费缓存空间,真实的 CPU 计算时间占比低得可怜。
这就是典型的“内存布局不友好”,也是 DOTS 想解决的第一个核心问题:把数据按访问顺序整整齐齐地摆在一起。
1.2 Update 调用的调度开销
第二个问题是调度开销。传统的 MonoBehaviour.Update 是引擎每帧逐个调用的,每个组件都有虚函数调用、生命周期判断、引擎内部状态机切换的成本。当活跃脚本数量从几百涨到几万,这部分固定开销会暴涨。再加上 Transform 有层级 dirty 标记,改一个父节点可能连锁触发一串子节点的矩阵更新。5 万个节点同时在场景里跑,光是 Transform 的级联刷新就够 CPU 喝一壶了。
我测过一个比较典型的场景:在 i7-10700K、16GB 内存的机器上,用纯 GameObject 方案在场景里生成 5 万个带简单旋转逻辑的 Cube,帧时间直接飙到 40ms 以上,也就是 20 多帧。这个数字不是某一个函数写得烂,而是整个架构的天花板。
DOTS 的思路,一句话概括就是:让数据长得像数据,让逻辑变成对连续内存的批量处理。接下来我们把这套结构拆开看。
2. Entity、Component、System 的节点化拆解:DOTS 的骨架
很多人第一次接触 ECS 会被一堆新名词劝退。其实把 DOTS 看成一张节点图就好懂了:Entity 是图里的节点,Component 是挂在节点上的数据包,System 是遍历节点做计算的处理器,World 是整张图的容器。
2.1 Entity:一个可复用的数字 ID
Entity 不是一个对象,它更像一张表里的行号。在 DOTS 里,Entity 本质上是个 int 组成的 ID,包含 index 和 version 两部分。index 定位到数据,version 用来检测这个 ID 是否已经失效。
这个设计带来的好处是:Entity 可以放进数组、Job、甚至跨线程传递,因为它就是纯值类型,没有引用计数的困扰,拷贝代价极低。你不需要“new”一个 Entity,你只是声明“我要往这个 ID 上挂数据”。
2.2 Component:纯数据,不掺逻辑
Component 是 DOTS 里最纯粹的环节。它就是个 unmanaged struct,比如:
public struct MoveOptions : IComponentData { public float Speed; public float Amplitude; public float Phase; }只能存值类型,不能存 class 引用,不能有方法逻辑。你甚至可以写一个没有任何字段的 Component,纯粹当“标签”用。这就是 Tag Component,用来标记“这个实体是敌军”“这个实体需要下雨特效”“这个实体已经被冻结”,系统可以按标签筛选参与计算的实体。
因为 Component 是纯数据,引擎就能把大量同类型 Component 整整齐齐地连续排在内存里,遍历的时候按顺序一条条读过去,充分发挥 CPU 缓存。这是 ECS 性能碾压传统方案的第一层底气。
2.3 Archetype 和 Chunk:内存到底怎么排
这里有个关键概念叫 Archetype。简单说,所有组件类型组合完全一致的实体会被归到同一个 archetype 里。一个实体身上挂的是Position + Velocity + Mass,另一个挂的是Position + Velocity + Color,这两个实体就属于不同 archetype。
每个 archetype 的数据存在一块块 16KB 的连续内存块里,这块内存叫 Chunk。同一个 chunk 内的实体数量取决于组件总大小。比如一个实体组件总大小是 128 字节,那一个 chunk 就能放下 16KB / 128B = 128 个实体。
真正精妙的地方在于:DOTS 在 chunk 内部不是把“实体一条、实体一条”串起来,而是按组件列存储。也就是说,chunk 里先连续存一列所有的 Position,再连续存一列所有的 Velocity。这样系统在批量处理某个组件时,读到的永远是一整块连续内存,而且可以做数据并行,让不同线程处理不同 chunk。
2.4 用一张表对比传统方案
| 维度 | GameObject + MonoBehaviour | DOTS(ECS + Job + Burst) |
|---|---|---|
| 实体表示 | 托管对象,有引用计数 | int ID,纯值类型 |
| 组件内存 | 分散在托管堆 | 按 archetype 连续排列在 chunk |
| 数据访问 | 随机访问,缓存命中差 | 顺序访问,缓存友好 |
| 逻辑调度 | 单线程逐组件调用 | 多线程 Job 并行处理 chunk |
| 编译优化 | JIT 解释执行 | Burst 编译为原生码 |
这套设计下来,遍历 5 万个实体,本质上是几段连续内存的循环,配合多线程和 Burst,CPU 每帧处理时间能压到毫秒级。
3. 封装实测 Demo:从包安装到十万实体生成的完整步骤
理论再好也得跑起来。下面这个 Demo 是典型的 DOTS 入门场景:生成 5 万个 Cube,让它们按正弦波上下浮动,看起来像一片起伏的海洋或者麦浪。整个流程我拆成四步,每一步都给出实际可用的代码。
3.1 环境准备与包安装
我用的是 Unity 2022.3 LTS 加 Entities 1.0.16 这套组合,这也是目前比较稳定的版本线。如果你是新开的 Unity 6 项目,后面的代码同样适用,注意个别 API 名称差异即可。
通过 Package Manager 安装以下几个包:
- com.unity.entities
- com.unity.entities.graphics
- com.unity.burst
- com.unity.collections
- com.unity.mathematics
其中 entities.graphics 负责把实体渲染出来,没有它你只能拿到数据看不到画面。安装完成后,把项目切换成 URP 管线,或者保持内置管线都行,DOTS 渲染不挑渲染管线。
注意:如果你在旧项目里看到的是 Entities 0.51.x,那说明还在用 2021 时代的版本。那个版本的 API 跟 1.0 差别很大,下面代码不适用,建议直接升级项目再继续。
3.2 定义数据组件与 Authoring 组件
DOTS 的烘焙流程是这样的:你还是在场景里摆 GameObject,但通过 Authoring 组件告诉烘焙器“这个 GameObject 烘焙成什么样的实体和组件”。这种设计的好处是美术和策划不用碰代码,在 Inspector 里配参数就行。
先定义运行时组件:
// MoveOptions.cs using Unity.Entities; public struct MoveOptions : IComponentData { public float Speed; public float Amplitude; public float Phase; }再定义 Authoring 类和它的 Baker:
// MoveOptionsAuthoring.cs using Unity.Entities; using UnityEngine; public class MoveOptionsAuthoring : MonoBehaviour { public float Speed = 1f; public float Amplitude = 2f; public float Phase = 0f; private class Baker : Baker<MoveOptionsAuthoring> { public override void Bake(MoveOptionsAuthoring authoring) { Entity entity = GetEntity(TransformUsageFlags.Dynamic); AddComponent(entity, new MoveOptions { Speed = authoring.Speed, Amplitude = authoring.Amplitude, Phase = authoring.Phase }); } } }TransformUsageFlags.Dynamic是烘焙时的一个关键参数,它告诉烘焙器这个实体需要动态变换(位置会实时变化),所以系统会为它保留 Transform 相关组件。如果你只是静态装饰物,可以用WorldSpace或Renderable,选错了会导致实体没有 Transform,烘焙出来位置对不上或者变换不生效。
接着定义生成器组件:
// Spawner.cs using Unity.Entities; public struct Spawner : IComponentData { public Entity Prefab; public int Count; public float Radius; }// SpawnerAuthoring.cs using Unity.Entities; using UnityEngine; public class SpawnerAuthoring : MonoBehaviour { public GameObject Prefab; public int Count = 50000; public float Radius = 60f; private class Baker : Baker<SpawnerAuthoring> { public override void Bake(SpawnerAuthoring authoring) { Entity entity = GetEntity(TransformUsageFlags.WorldSpace); AddComponent(entity, new Spawner { Prefab = GetEntity(authoring.Prefab, TransformUsageFlags.Dynamic), Count = authoring.Count, Radius = authoring.Radius }); } } }这里GetEntity会返回 Prefab 对应的实体引用。注意 Prefab 不能为空,否则烘焙器会报错。
3.3 编写生成系统和运动系统
生成器系统只执行一次:从 Prefab 实例化 Count 个实体,给每个实体随机位置和随机运动参数,然后把自己销毁。
// SpawnerSystem.cs using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; [BurstCompile] public partial struct SpawnerSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { if (!SystemAPI.TryGetSingletonEntity<Spawner>(out Entity spawnerEntity)) return; Spawner spawner = SystemAPI.GetComponent<Spawner>(spawnerEntity); EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.Temp); var random = new Random(42); for (int i = 0; i < spawner.Count; i++) { Entity instance = ecb.Instantiate(spawner.Prefab); float angle = random.NextFloat(0f, math.PI2); float radius = random.NextFloat(0f, spawner.Radius); float3 pos = new float3( math.cos(angle) * radius, 0f, math.sin(angle) * radius ); ecb.SetComponent(instance, LocalTransform.FromPosition(pos)); ecb.SetComponent(instance, new MoveOptions { Speed = random.NextFloat(0.5f, 2f), Amplitude = random.NextFloat(0.5f, 3f), Phase = random.NextFloat(0f, math.PI2) }); } ecb.DestroyEntity(spawnerEntity); ecb.Playback(state.EntityManager); ecb.Dispose(); } }这里用了临时 ECB 来记录结构性操作(实例化、删除实体),最后一次性 Playback。原因是 ECS 规定:你正在遍历查询结果的时候不能直接改实体结构,否则数据会被打乱。ECB 相当于把“增删实体”的请求先记账,再在安全时机统一执行。这个 Demo 里系统只在第一帧跑一次,用临时 ECB 是最直观的写法;后面如果每帧都要生成,应该用官方的EntityCommandBufferSystem让 ECB 延迟执行,避免每帧造成同步点。
运动系统就更直接了:每一帧找出所有带LocalTransform和MoveOptions的实体,把 y 坐标刷成正弦波的值。
// MovementSystem.cs using Unity.Burst; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; [BurstCompile] public partial struct MovementSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { double elapsed = SystemAPI.Time.ElapsedTime; foreach (var (transform, move) in SystemAPI.Query<RefRW<LocalTransform>, RefRO<MoveOptions>>()) { float t = (float)elapsed * move.ValueRO.Speed + move.ValueRO.Phase; float y = math.sin(t) * move.ValueRO.Amplitude; float3 pos = transform.ValueRW.Position; pos.y = y; transform.ValueRW.Position = pos; } } }这段代码看起来就是一个普通的 foreach,但背后 DOTS 会把它编译成对 chunk 数据的多线程批量处理。你不需要手动开线程,Job System 会基于可用的 CPU 核心自动分块并行。
3.4 搭场景:SubScene 与预制体
在场景里建一个空物体PrefabRoot,下面挂一个 Cube 子物体,把MoveOptionsAuthoring挂到 Cube 上,配好参数,拖到 Project 窗口做成 Prefab(注意要被 SubScene 引用的预制体不要放在场景里,而是放在 Assets 下)。
再建一个空物体SpawnerObject,挂SpawnerAuthoring,把 Prefab 拖到字段里,Count 填 50000,Radius 填 60。
最后把SpawnerObject所在的场景整体转成 SubScene。方法是右键 Hierarchy 里的场景根节点,选 New Sub Scene,然后把物体拖进去。SubScene 的作用是让烘焙器在进入 Play Mode 时自动把场景里的 GameObject 烘焙成实体,你不需要手动写转换代码。
进入 Play Mode 后,打开 Window > Entities > Hierarchy 面板,你会看到五万个 Entity 齐刷刷列出来,每帧帧时间应该在 2~5ms 左右(视 CPU 而定),对比 GameObject 方案的 40ms,差距非常直观。
注意:如果画面里什么都看不到,先检查 Entities Graphics 是否安装,以及预制体上的 MeshRenderer 是否正常。DOTS 渲染需要单独的渲染路径,缺一个包就是全黑。
4. System 之间的依赖链与调度:数据流是怎么“流动”的
Demo 跑通了,但离“能上生产项目”还差得远。真正复杂的是多个系统之间如何协作、如何避免数据竞争、如何高效调度。
4.1 World 与 SystemGroup 的层级结构
DOTS 把系统分组成几个阶段,默认有三个:InitializationSystemGroup、SimulationSystemGroup、PresentationSystemGroup。你写的系统会默认挂到 Simulation 组里。每个组内部还有 UpdateOrder 排序,系统间可以通过[UpdateBefore(typeof(SomeSystem))]或[UpdateAfter]显式声明先后。
为什么顺序这么重要?因为 DOTS 的多线程调度有一个硬性规则:同一帧内,两个系统不能同时写同一份组件数据,一个系统在读某个组件时,另一个系统不能去改它。这个规则叫安全系统(SafetySystem)约束。如果两个系统都声明要读写相同组件,DOTS 会让它们串行执行;如果完全没有交集,就可以并行跑。
所以从“节点图”的角度看,World 里的每个系统就是图中的一个节点,系统之间的依赖关系是边,数据沿着这些边流动。你排的 UpdateOrder 决定了数据的流经顺序,排错了就可能出现“上一帧的输入被下一帧的逻辑覆盖”这种诡异问题。
4.2 Sync Point 的代价
多线程调度最怕碰到 Structural Change,也就是增删实体、增删组件这类改“地图结构”的操作。这类操作会动 archetype,而 archetype 一变,chunk 的数据布局全要重排,正在其他线程上跑着的任务必须停下来等。
每次出现这种“等待”,就叫一个 Sync Point。用了 ECB 并不意味着没有代价了,ECB 的 Playback 发生在所有依赖它的系统之后,这个时刻仍会强制所有线程汇聚。
实践上的做法是:
- 尽量把结构性操作集中到帧头或帧尾,避免一帧里多次触发
- 不同系统尽量复用同一个 ECB 系统,减少等待次数
- 如果可以,用固定大小的原生容器(NativeArray)预先生成好实体,再一次性批量创建
我在自己项目里就吃过亏:生成 5 万实体没有问题,但在一个每隔 300 帧就会销毁一批敌人再生成一批敌人的玩法里,帧时间会从 3ms 瞬间跳到 12ms。后来把销毁和生成操作合并到同一个 ECB,并且安排在帧的末尾,帧时间降回 5ms 左右。这就是 Sync Point 的实感。
4.3 从数据依赖看 Job 的并行度
使用 IJobEntity 时,DOTS 会根据每个 Job 声明的读写权限自动计算依赖。比如一个系统读了 Position,另一个系统只读 Velocity,两者互不干扰,可以并行;但有个系统要写 Position,就必须等前一个读完。
这里有一个容易被忽略的点:如果你在 Job 里用到了[NativeDisableParallelForRestriction]这类特性,等于手动告诉引擎“我不在乎竞争”,引擎就会放开并行限制。但责任也在你身上,一旦真的写了,数据竞争的结果是不可预知的。我的建议是,只有在确认逻辑上必要的单例访问时才用,比如所有实体都读同一个GlobalParam组件,可以把它标记为只读,而不是禁用限制。
4.4 系统数量多起来之后的排查方法
系统多了以后,单靠脑子记调度顺序不现实。我用得最多的是 Window > Entities > Systems 面板,它会把所有系统、系统组的执行顺序和每帧耗时列出来,每个系统后面还带一行 Schedule 信息,能看出它是在主线程跑还是子线程跑。
排查“某个实体数值不对”这种问题时,直接在 Entities Hierarchy 里点击实体,右边 Inspector 能看到它当前所有组件的数据。如果数值在被某个系统改错了,就在 Systems 面板里逐个系统禁用来做二分定位,比 Debug.Log 高效得多。因为 DOTS 是数据流驱动,Log 只会告诉你“现在不对了”,而不会告诉你“是哪个系统在什么时候改的”。
5. 性能数据与优化方向:哪些指标才是真瓶颈
跑通 Demo 之后,大家最关心的问题通常是:DOTS 到底能快多少?我的建议是别只记一个“快 10 倍”的结论,要看你自己的瓶颈在哪。下面是我在 i7-10700K + RTX 3070 的机器上,针对“5 万个带正弦运动的 Cube”场景实测出来的数据,供参考:
| 方案 | 初始化耗时 | 每帧 CPU 耗时 | 备注 |
|---|---|---|---|
| 纯 GameObject + Update | 约 1.2s | 40~55ms | Transform 级联更新严重 |
| DOTS + 单线程 Foreach | 约 230ms | 9~12ms | Burst 已生效 |
| DOTS + Job 并行 + Burst | 约 120ms | 2.8~4ms | 多线程达到预期 |
| DOTS + 按 chunk 手工批处理 | 约 120ms | 2.1~3ms | 极致优化收益有限 |
可以看到最大的提升来自“内存布局 + Burst”,而不是多线程本身。单线程 DOTS 已经比 GameObject 快好几倍了,多线程再翻一倍多。这个结论很重要:如果你的项目瓶颈真的是 CPU 逻辑,DOTS 帮得上;如果瓶颈是 Draw Call、GPU 填充率,那 DOTS 只能帮你把 CPU 侧余量腾出来,最终还是要靠合批、LOD 和遮挡剔除。
5.1 优化方向一:把查询范围收缩到最小
SystemAPI.Query 的过滤条件越精确越好。如果你只关心 y 轴在某个范围内的实体,别在系统里写if (pos.y < threshold) continue,而应该考虑用IJobChunk配合 chunk 级别的过滤,甚至在 baking 阶段就把不需要的实体排除掉。DOTS 的查询是 archetype 匹配的,条件少,匹配的实体才多,每多一个过滤条件,遍历成本就上一个台阶。
5.2 优化方向二:慎用 SharedComponent 和 EnableableComponent
SharedComponent 会把实体按共享值分组,这相当于在 archetype 里又加了一个分组维度,会破坏部分线性内存特性。比如你用 SharedComponent 存“阵营颜色”,几十个颜色就分出几十个 chunk,每块 chunk 大小不足,缓存命中率下降。
EnableableComponent 是 IComponentData 上加了[Unity.Entities.EnableableComponent]特性,可以不用结构性变更就开关组件。这个很好用,但查询时如果同时过滤 enabled 状态,DOTS 需要额外做一次位标记检查。属于“用得起但别滥用”的优化手段。
5.3 优化方向三:用 Burst 的 AOT 编译
开发模式下 Burst 默认用 JIT,性能已经不错,但发布时记得开启 AOT 编译,并且为目标平台勾选对应的指令集(比如 SSE4.2、AVX、AVX2)。Burst 编译出的代码会针对 SIMD 指令做向量化,四个 float 一次算完,这种优化手写是做不到的。
一个常用的检查方法:在 Profiler 的 CPU 模块里看某个 Job 的耗时,如果 Burst 生效,函数名会带上代码生成的行号,且旁边的“编译器类型”显示 Burst。当你看到方法体里是绿色汇编指令而不是 C# 解释执行时,说明优化链路已经通了。
6. 上手 DOTS 必踩的坑清单与定位思路
DOTS 的坑不是“不会用”,而是“以为会用但行为跟直觉不符”。我把自己踩过的和从同行那里听到的高频问题整理成一份清单,每一条都备注了定位思路。
坑一:API 版本差异导致的编译废案
Entities 0.51 时代大家写entities.ForEach,SystemBase 配OnUpdate;到了 1.0,推荐 ISystem + SystemAPI.Query + IJobEntity。网上教程一大半还是老 API,直接抄下来编译报错一大片。定位思路很简单:先看包版本,再看代码用到的命名空间。尽可能用官方迁移工具 Entities API Updater,它能把大多数旧 API 自动升级。
坑二:ECB 每帧 new、每帧销毁造成的性能雪崩
我之前见过有人每个系统每帧都new EntityCommandBuffer(Allocator.TempJob),然后在 OnUpdate 结尾 Playback。这么写功能没错,但每帧都会制造两个以上 Sync Point,五万实体的场景帧时间能翻三倍。正解是让系统单独维护一个 ECB,或者使用BeginSimulationEntityCommandBufferSystem这类官方 ECB 系统,让它在所有依赖系统之后统一回放。
坑三:Baking 时 TransformUsageFlags 选错导致位置错乱
这是最常见的问题。如果预制体是动态物体(会移动),但你在 Baker 里写了TransformUsageFlags.WorldSpace,烘焙出来的实体没有 LocalTransform 的动态权限,运动系统查不到RefRW<LocalTransform>,实体就永远定在原地。反过来,一个静态装饰物如果标记成 Dynamic,会浪费不必要的 Transform 更新开销。定位思路:烘焙后在 Entities Inspector 里看实体实际挂的组件,对照你预期的组件列表,缺哪个组件就往 Baker 里找原因。
坑四:把 Entity 存进普通 List 导致 GC 抖动
Entity 虽然是值类型,但存进List<Entity>后每帧增删都会产生 GC。DOTS 里请统一用NativeList<Entity>或DynamicBuffer管理实体引用。另外,UI 或 MonoBehaviour 侧如果每帧引用 Entity 查询实体数据,也会产生托管调用,记得缓存查询结果。
坑五:查询的“结构性变更”把自己正在用的 chunk 改了
这是新手最容易困惑的:明明在 Job 里只是枚举实体,怎么跑着跑着就抛异常了?原因多半是另一个系统在你枚举期间做了结构性变更,导致当前 chunk 被回收。定位思路是看控制台抛出的异常类型,如果指向StructuralChange相关安全性错误,就把改结构的操作挪到 ECB 统一处理,并且关注系统执行顺序是否合理。
坑六:SubScene 的预烘焙目录没有正确配置
SubScene 的实体是在 Editor 里预先烘焙成二进制,打包时如果没有把 SubScene 所在的场景和预烘焙目录一起打进去,真机上会一片空白。定位思路:真机包启动后用 Debug 查看World里的实体数量是否为 0。如果为 0,检查 Project Settings 里的 Entities 相关配置,以及烘培产物是否在 Build 路径里。
经验总结:DOTS 的排查思路跟普通 C# 项目差别很大,它没有“断点调试每一步”的顺手感。正确路径是先在 Entities Hierarchy 里看数据结构对不对,再到 Systems 面板看调度顺序,最后用 Profiler 定位 CPU 耗时。三步走下来,绝大多数问题都能找到根因。
上面这些坑,前三个我基本每个项目都踩过一遍,尤其是 TransformUsageFlags 那个,排查了整整一个下午。不过也正是那几次踩坑,让我把 DOTS 的烘焙机制和调度模型彻底吃透了。最后再分享一个小技巧:如果你要在 DOTS 项目里快速验证某个想法,不用搭复杂的场景,直接在初始化系统里用EntityManager.CreateEntity批量创建实体,配合一个简单的 Debug 渲染把位置画出来,比反复改场景里的 SubScene 快得多。等逻辑验证通过,再补齐烘焙和渲染链路。这个工作流能帮你把“写逻辑”和“做内容”两件事彻底解耦,省下的时间足够多刷两集剧了。