4. 倍速控制的状态机设计

逐笔回放中的倍速控制看似只是“点一下 2× 按钮”,实际上它同时管理着播放、暂停、变速、跳转四类操作,并且这些操作会互相打断、叠加。如果只用一个布尔变量 isPlaying 加一个 speed 数值来维护,很容易出现“暂停后变速却自动开始播放”“跳转过程中被旧的回放定时器覆盖”等问题。本节把倍速控制抽象为一个显式状态机,先定义状态与迁移条件,再讨论状态一致性约束,最后给出并发操作下的同步方案。

一、状态定义:把“播放中”拆成可区分的状态

回放控制器的核心状态可以归纳为四个:空闲(Idle)、播放中(Playing)、暂停(Paused)、跳转中(Seeking)。其中“跳转中”是一个容易被忽略但必须独立存在的状态:当用户拖动进度条或点击某笔成交定位时,系统需要先停止当前回放定时器、重新定位数据游标、再按当前倍速恢复,这一过程不是瞬时的,若与播放状态混在一起,就会出现游标与定时器不同步。

除状态外,还需要三个关键上下文变量:speed(当前倍速,取值如 0.5/1/2/4)、cursor(当前回放到的逐笔序号或时间戳)、pendingSeek(跳转目标位置)。状态决定“能不能推进游标”,上下文变量决定“以多快、从哪里推进”。把状态与上下文分离,是后续一致性约束能够成立的前提。

空闲 Idle cursor=0,无定时器 播放中 Playing 定时器运行,cursor 推进 暂停 Paused 定时器停止,cursor 保留 跳转中 Seeking 定位 pendingSeek play() pause() resume() seek(target) seek(target) 定位完成且原为播放 定位完成且原为暂停 setSpeed(s) 自迁移 新 seek 状态节点 普通迁移 跳转迁移 注:setSpeed 不改变状态,仅改变推进速率

二、迁移条件与守卫

状态机不能只画箭头,每条迁移都要有明确的触发事件和守卫条件。以 play() 为例,它只在 Idle 或 Paused 下有效;在 Playing 下重复调用应被忽略而不是重置游标;在 Seeking 下调用则应记录“定位完成后自动播放”的意图,而不是立即启动定时器。同理,setSpeed(s) 是一条自迁移:它不改变状态,只更新 speed,但必须校验 s 属于允许集合,并且要按新倍速重算定时器间隔——若当前是 1×、每 200ms 推进一笔,切到 4× 后应改为每 50ms 推进一笔,而不是继续沿用旧间隔。

跳转迁移需要携带来源信息。工程上常用做法是在进入 Seeking 前记录 resumeAfterSeek = (state === Playing),定位完成后据此决定回到 Playing 还是 Paused。这样即使用户在跳转过程中又点了暂停,也只需更新这个标志位,而不会产生“跳转完成后莫名其妙开始播放”的错觉。

三、状态一致性约束

状态机要真正可靠,必须把“状态与上下文必须同时满足的规则”写成可断言的不变式,并在每次迁移后校验。逐笔回放场景中至少有三条:

  • 定时器唯一性:任意时刻至多存在一个回放定时器,且仅当 state === Playing 时存在。进入 Paused、Seeking、Idle 都必须先清除定时器。
  • 游标单调性:正常播放时 cursor 单调递增且不超过数据总笔数;只有 Seeking 允许游标跳变。若在 Playing 下检测到游标回退,说明有旧定时器残留。
  • 倍速有效性:speed 始终属于允许集合,且定时器间隔与 speed 满足 interval = baseInterval / speed。这条约束把“倍速”从展示值变成了可验证的运行参数。
把这三条写成开发环境下的断言(如 console.assert 或单元测试),能在联调阶段快速暴露“暂停后定时器没清”“跳转后旧回调仍在跑”这类隐蔽缺陷。

四、并发操作下的状态同步问题

真实交互中,用户操作与异步回调是并发的。典型冲突有三类:其一,用户快速连点“暂停—播放—暂停”,若每次点击都异步派发,回调到达顺序可能与点击顺序不一致,导致最终状态与最后一次点击相反;其二,跳转尚未完成时用户又拖动进度条,两个定位任务竞争写 cursor;其三,旧倍速下的定时器回调在变速后仍然触发,用旧间隔推进游标,造成“变速后速度不对”。

解决思路不是加锁,而是用单调递增的操作序号做“最后写入胜出”。每次用户操作生成 opSeq++,异步任务携带自己的序号;任务完成时若发现自己的序号小于当前已应用序号,则丢弃结果。对定时器则采用“代际令牌”:每次启动定时器生成新令牌,回调执行前比对令牌,不一致直接返回。这样旧回调自然失效,无需精确追踪每个定时器的句柄。

// 状态机核心:迁移 + 守卫 + 代际令牌
const ALLOWED_SPEED = [0.5, 1, 2, 4];
let state = 'Idle', speed = 1, cursor = 0;
let opSeq = 0, appliedSeq = 0, timerToken = 0, timer = null;

function transition(event, payload = {}) {
  const seq = ++opSeq;
  switch (event) {
    case 'play':
      if (state === 'Seeking') { resumeAfterSeek = true; return; }
      if (state === 'Playing') return;          // 幂等,忽略
      state = 'Playing'; startTimer(seq);
      break;
    case 'pause':
      if (state !== 'Playing') return;
      state = 'Paused'; stopTimer();
      break;
    case 'setSpeed':
      if (!ALLOWED_SPEED.includes(payload.speed)) return;
      speed = payload.speed;
      if (state === 'Playing') { stopTimer(); startTimer(seq); }
      break;
    case 'seek':
      resumeAfterSeek = (state === 'Playing');
      state = 'Seeking'; stopTimer();
      doSeek(payload.target, seq);
      break;
  }
  appliedSeq = seq;
}

function startTimer(seq) {
  const token = ++timerToken;
  const interval = 200 / speed;                 // 1× 时 200ms/笔
  timer = setInterval(() => {
    if (token !== timerToken) return;           // 旧代际,丢弃
    if (seq < appliedSeq) return;               // 过期操作,丢弃
    cursor++;
  }, interval);
}
function stopTimer() { clearInterval(timer); timer = null; timerToken++; }

上面这段示例代码中,baseInterval = 200ms 与允许倍速集合均为教学假设值,实际项目应按数据密度和渲染能力调整。可以看到,setSpeed 在 Playing 下会先停旧定时器再按新间隔启动,配合代际令牌,旧回调即使已进入事件队列也会被丢弃,从而保证“变速立即生效且不重复推进”。

五、结果如何观察与判断

验证状态机是否正确,不必依赖肉眼盯盘。可以在每次迁移后输出一行结构化日志,包含 event、from、to、speed、cursor、timerToken,然后构造一组并发用例:连续快速点击暂停与播放 10 次、在 4× 播放中拖动进度条、跳转未完成时再次跳转。判断依据是:最终状态等于最后一次有效操作对应的状态;游标在非 Seeking 期间不出现回退;同一时刻日志中活跃定时器数量不超过 1。若某条用例失败,日志中的 from/to 序列通常能直接定位到缺失的守卫或未清理的定时器。

把状态、上下文、守卫和并发令牌四件事分开设计,倍速控制就从“一堆 if-else”变成了可断言、可回放、可测试的确定性组件,这也是后续实现断点跳转与回放录制的基础。