首页 / 命令模式专栏

命令模式专栏

场景加载那条主链、传送的每个阶段,都跑在它上面。 这套命令框架本身不吃业务,只解决一件事:把一堆异步步骤按顺序推进,并给出一个可 await 的最终结果。 它只有六个文件,看懂调用方怎么进来、六个件的依赖方向,就掌握了整条链的骨架。 本专栏先讲框架本身(六文件 + 关系总图),再落到两个真实案例: 场景加载的 loadwp 命令与传送的阶段命令。

命令模式 六个文件 TimeSlice 分片 五种结果 Bail 会回滚 案例:loadwp / 传送
这一栏在讲什么 「nami」是本工程的 depot 代号(//nami/branch_0.7.1),所以「nami 的命令模式」指的就是本工程的 Src/Core/Utils/Command/ 这套自研命令框架。
第 1–9 节讲框架本身,第 10 节给出两个案例的入口,案例页分别拆解「怎么用框架把一条真实业务链跑起来」。

1. 六个文件各是什么

文件角色一句话职责
CommandExecutor.ts执行器(逻辑中心)持有命令队列与状态机,把命令逐条推进,最后产出五种结果之一
CommandBase.ts命令基类一条命令的抽象:Execute() 被调用,用 Succeed/Fail/Bail/Fatal 回传
CommandSystem.ts调度器(单例)统一 Tick,用时间切片驱动当前所有运行中(Running)的 executor
CommandContextBase.ts上下文基类一次执行里共享的数据 / 结果载体,作为泛型 T 贯穿 executor 与命令
CommandPromise.ts结果承诺让调用方能 await 的 Promise;执行结束时 SetResult 兑现
CommandBudgetGroup.ts预算组枚举声明有哪些限流组,决定命令进哪个时间切片通道
只有 Executor 是「中心」,其余五个都围着它转 CommandSystem 管它的调度、CommandPromise 管它的结果、CommandBase 是它推进的对象、 CommandContextBase 是它和命令共享的泛型参数、CommandBudgetGroup 是它选通道用的名字表。 抓住这条主谓关系,六个文件就不是六个孤立概念。

2. 关系总图

先看全貌。三个横带分别是调用方 / 执行器与调度 / 命令与上下文; 中间那条带里,CommandExecutor<T> 是唯一的焦点。

① 调用方(业务层) ② 执行器与调度 ③ 命令与上下文 调用方 Flow / 业务层 new Executor<T>() → await Execute() CommandSystem 分片调度 · 运行队列 CommandExecutor<T> 状态机 · 命令队列 驱动 + 回滚 + 结算 CommandPromise Promise<结果> · SetResult CommandBudgetGroup 限流预算组 / 通道名 CommandBase<T> Succeed / Fail / Bail / Fatal Execute / PauseExecution CommandContextBase 泛型 T · 共享数据 / 结果 ① 造链 + 启动 ② 调度 ③ 结算 ④ 队列驱动 ⑤ 结果回传 ⑥ 通道 / 预算 泛型 T 泛型 T await 结果 实线 = 调用 / 驱动 / 结算 紫色虚线 = 泛型约束 / 结果传递 蓝色 = 命令把结果回传给执行器 命令用 Succeed / Fail / Bail / Fatal 收口 一句话:Flow 造 Executor → System 驱动 → Executor 驱动 Command → 命令回传结果 → Promise 兑现给 Flow

2.1 六条主干怎么读

  1. ① 造链 + 启动:调用方 new CommandExecutor<T>(),Init 后再 await Execute()。
  2. ② 调度:CommandSystem 是全局单例,每帧按时间切片把运行中的 executor 推进一把(双向:Executor 把运行队列挂到 System,System 回过来 Tick 它)。
  3. ③ 结算:执行结束时 Executor 调 CommandPromise.SetResult(result)(双向:Executor 持有 Promise,Promise 回过来兑现给 Flow)。
  4. ④ 队列驱动:Executor 从队列里 PopCommandAndExecute,把命令一条条驱动起来。
  5. ⑤ 结果回传:命令调 Succeed/Fail/Bail/Fatal,经 CommandSuccess/CommandFail/... 回到 Executor。(这条画成蓝色,因为它是「控制权回流」的关键。)
  6. ⑥ 通道 / 预算:命令的 ThrottleControlGroupName 与 CommandBudgetGroup 对齐后,决定它进 System 的哪个时间切片通道。

另外三条紫色虚线是「泛型 T」与「await 结果」:CommandContextBase 同时被 CommandExecutor<T> 和 CommandBase<T> 约束(同一条 T), 而 CommandPromise 兑现的结果最终回到调用方的 await 处。

3. 调用方怎么用

// 场景加载链的入口(简化)
const exec = new CommandExecutor<SceneLoadingCmdContext>();
exec.Init(context, expireTime, callbacks...);   // 绑定 context 与各类回调
exec.AddInitCommand(rootCmd);                // 构链期:InitCommand 在此展开子命令
const result = await exec.Execute();          // 交 CommandSystem 分片驱动
// result: ECommandExecutorResult(Success / Bail / Fail / Timeout / Fatal)
Execute() 返回的就是那个 Promise await exec.Execute() 等价于「等整条链跑完」。 Executor 在 FinishExecute 里调 CommandPromise.SetResult, 此时 await 才 resolve——所以调用方拿到的永远是终态结果,不会拿到中间态。

4. 一次执行的完整生命周期

1
new CommandExecutor<T>()
创建,内部状态 None;命令队列为空
2
Init(context, expireTime, ...)
绑定 context、过期时间与 execute / result 回调;状态转 Init
3
AddInitCommand(rootCmd)
构链期:InitCommand 在此 OnAddInitCommand 展开子命令,depth + 1
4
Execute()
CommandSystem.AppendRunningExecutor(this),返回 CommandPromise;状态转 Executing
5
CommandSystem.TickByTimeSlice()
每帧按预算选通道:先默认通道,再遍历 TimeSliceChannels
6
PopCommandAndExecute()
从队首取出下一个命令(分片下用 Front 取,不立即 Pop)
7
command.Execute()
命令跑自己的逻辑;异步命令在这里挂着,自己决定何时收口
8
Succeed / Fail / Bail / Fatal
命令把结果回传:CommandSuccess / CommandFail / CommandBail / CommandFatal
9
FinishExecute(result)
清回调 → CommandPromise.SetResult(result) → CommandSystem.RemoveRunningExecutor(this)
分片模式下第 8 步不会立即结算 开启时间切片时,CommandSuccess 只置一个 TimeSliceSyncStepDone = true 就返回, 真正的「取下一命令」由调度循环在预算内继续推进。所以同步命令在分片下也可能跨帧。

5. 五种结果 & 七个命令状态

两个枚举别混:executor 结果是「整条链的终态」,命令状态是「一条命令的当下态」。

ECommandExecutorResult含义
Success全链正常跑完
Bail中途退出:命令主动 Bail() 触发,会回滚已执行命令(见 §6)
Fail失败:命令 Fail(),触发回滚
Timeout超时:到期时间到(默认行为见下)
Fatal严重错误:无法继续,按异常处理
ECommandState(7 个)含义
Idle已创建、未执行
Executing正在执行
Succeeded成功
Failed失败
Bailed主动中止(不是「跳过」,见 §6)
Disposed已销毁
Fatal严重错误态
Timeout 默认「只打日志」不结算 DoExecutorTimeout() 在默认配置(TriggerCallbackOnTimeout = false)下 只记一条日志,不改状态、不结算,为的是兼容既有调用方。 所以「有没有超时兜底」实际取决于命令自己有没有设超时——这正是场景加载链「卡 Loading」的结构性原因。

6. Bail 到底干了什么:终止整条链,不是跳过本条

这是最容易读错的一处。CommandBase.Bail() 的源码注释写得很清楚: 「会当即退出所有的命令执行流程,不算做失败」。它的实际路径是:

// CommandBase.ts
Bail(bailReason) → State = ECommandState.Bailed → ExecutorInternal!.CommandBail()

// CommandExecutor.ts
CommandBail() → DoExecutorBail()
DoExecutorBail():
    if (StateInternal >= Success) return;   // 已结束就不再处理
    StateInternal = Bail;
    RollbackExecutedCommands();              // 反向回滚已执行的命令
    FinishExecute(ECommandExecutorResult.Bail);
Bail ≠ 「这条不做,继续下一条」 Bail() 会结束整个 executor 并触发回滚,语义是「不继续了」。 真正「跳过某一步、链条继续」的命令,调用的其实是 Succeed()—— 例如场景加载里的 SceneWaitWpStreamingCmd、SceneLoadingMoveCmd, 条件不满足时都直接 Succeed() 跳过。

所以排查「某步好像没执行」时:看到 Succeeded 才是「被条件跳过、链继续」, 看到 Bailed 是「整条链被中止了」。这一点在 场景加载命令链 · 2.1 状态里有同款澄清。

7. TimeSlice 分片驱动

如果每条命令都同步跑完,一个重帧就能把画面卡住。框架的解法是预算化的时间切片。

PauseExecution 只在分片下才「让帧」 PauseExecution() 在非时间切片环境下会退化成 Succeed()(直接成功、不等下一帧)。 靠它分批处理的逻辑,在退化路径下会只处理第一批就往下走且不报错。

8. 泛型 T 与 CommandContextBase

CommandContextBase 是一次执行的共享数据 / 结果载体。 它作为泛型 T 同时约束执行器与命令:

export class CommandExecutor<T extends CommandContextBase> { ... }
export abstract class CommandBase<T extends CommandContextBase> { ... }

好处是:命令通过 context 读写共享状态时有完整类型提示,且不同链路(场景加载、传送、重连) 各有自己的 Context 子类,互不污染。Context.Dispose() 会在全部命令执行完后被调用并触发 OnDispose() 钩子。

9. CommandBudgetGroup 与限流

不是所有命令都需要限流,但大批量、单条轻、总量重的命令(实体初始化、UI 复制项)必须。 框架用「预算组」来表达这件事。

export const enum ECommandBudgetGroup {
    EntityComponentInit,     // 实体组件初始化
    EntityComponentStart,    // 实体组件 Start
    SceneInteractionShow,    // 场景交互显示
    UiCopyItem,              // UI 复制项
}
新增预算组要改两处 按源码注释的硬性要求:① 在 ECommandBudgetGroup 加枚举值;② 在 CommandSystem.InitAllBudgetGroups 加对应的 SetBudgetLimiter。 漏掉第二步,分组名存在但不会被限流。

10. 两个案例:框架怎么落到业务链

框架只会「按序推进 + 给终态结果」,真正的复杂度在调用方怎么把业务步骤拼成一条链。 两个案例分别代表两种拼法:一个是构链期一次性展开(loadwp),一个是阶段化 + 轮次隔离(传送)。

案例载体看点入口
案例一 · loadwp SceneLoadingWpMapCmd 作为根命令,在 OnAddInitCommand 里构链期一次性展开整条场景加载主链;OnExecute 只 Succeed() 让位 案例一 →
案例二 · 传送 Teleport/Cmd/* 阶段命令基类约定四出口;用轮次令牌隔离迟到异步;简化分段用 CommandExecutor 现场编排 案例二 →

11. 面试怎么讲

这套命令框架的核心是什么概览
一句话:把异步步骤排成队、按预算分帧推进、最后给调用方一个可 await 的终态结果。
六个文件的中心是 CommandExecutor:它持有队列和状态机;CommandSystem 负责调度它; CommandBase 是被它驱动的命令;CommandPromise 兑现结果给调用方; CommandContextBase 作为泛型 T 让执行器与命令共享同一份数据;CommandBudgetGroup 决定命令进哪个限流通道。
为什么要有 CommandSystem,Executor 自己 Tick 不行吗设计
不行。同一时刻可能有多个 executor 在跑(场景加载 + 传送 + 各种初始化)。 若各自挂在自己的 Tick 上,预算无法统一汇总,一个重帧还是可能被多个 executor 叠加打爆。
CommandSystem 是唯一入口,集中做「本帧总共能花多少预算、按通道分配、先默认后专用」, executor 只管把自己的运行队列登记进去(AppendRunningExecutor)。这是把限流做成全局资源而不是各自为政。
Bail 和 Fail 有什么区别,跟「跳过一步」是一回事吗易错
Bail 与 Fail 都会终止整条链并回滚已执行命令,区别在于语义与结果码: Fail 是「出错了」,结果是 Fail(关键节点还会 FailCritical 升级为客户端异常); Bail 是「不继续了,但不算失败」,结果是 Bail。
两者都不是「跳过本条继续下一条」。要跳过一步、链继续,命令应调 Succeed()—— 场景加载链里那些「进来就判断条件」的命令就是这么写的;传送里的 Skip() 则是把「让位」语义映射到 Bail(见案例二)。
「卡 Loading」这类问题为什么常和这套框架有关细节
因为 executor 层默认没有超时兜底(DoExecutorTimeout 默认只打日志)。 一条等待型命令(如等 WP 流送)如果自己不设超时,就会永远停在 Executing, 整条链不结算、await 不 resolve、Loading 界面不消。
排查路径:看进度上报卡在第几步 → 找到那条命令 → 看它有没有自带超时。详见 场景加载命令链。