opoojkk

Vue录音模块状态机重构笔记

lxx
目次

最近被抓去写微信小程序,按照决策者的话说,逻辑都是一样的。

这句话对了一半。

作为一个成熟的 UI 框架,支持的交互能力一定是相似的:点击、长按、滑动、双击,不论是 Android、iOS 还是小程序,最终都会落到同一套用户行为模型上。但另一半恰恰是问题所在——语言和范式,会反过来塑造你组织逻辑和状态的方式。

以 Android 和小程序为例。Android 使用 Java、Kotlin 甚至 C++,这些都是强类型语言,有继承、多态、接口,重复逻辑既可以组合,也可以通过继承收敛。状态往往会被包进类里,由类型系统帮你兜底。

而在 Vue / 小程序中,语言是 JavaScript,本身动态类型语言,在不引入 TypeScript 的情况下,几乎没有编译期类型约束;虽然语法上存在 class,但在实际工程中,很少通过继承体系来承载状态语义。

这在简单页面里问题不大,但一旦交互复杂,很容易失控。

这篇文章记录的,就是我在一个录音页面里,从“多个状态变量维护”一路踩坑,最终重构成状态机的过程。

背景 #

决定重构并不是为了“写得更优雅”,而是代码已经修不动了。

页面里有两个主要流程:

限制条件很多:

真正卡住我的是一个 bug:

长按后立刻松手,重复几次后,会进入“再也无法录音”的状态。

日志看不出异常,复现不稳定,调了大半天没有头绪。越查越发现一个问题: 我已经不知道系统当前到底处在什么状态了。

状态失控 #

先看状态变量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const isRecordingCanceled = ref(false)
const isPressingRecordButton = ref(false)
const hasSlideToCancel = ref(false)
const recordingReady = ref(false)
const isWaitingForInput = ref(false)
const isPlayingAudio = ref(false)
const showRecordingUI = ref(false)
const recordingStartTime = ref(0)
const audioFilePath = ref('')
const recordingType = ref('')

const userActionCount = ref(0)
const currentSessionCount = ref(0)
const currentResponseCount = ref(0)
const canTriggerAction = ref(true)

// 名义上的“主状态”
const currentStatus = ref(STATUS_WELCOME)

变量本身都很合理,问题在于: 它们组合在一起,才代表一个“真实状态”,但这种组合完全靠人脑维护。

判断“现在能不能开始录音”,需要这样写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
if (
  isPressingRecordButton.value &&
  !isRecordingCanceled.value &&
  recordingReady.value &&
  !isWaitingForInput.value &&
  !isPlayingAudio.value &&
  showRecordingUI.value &&
  currentStatus.value === STATUS_WAITING_INPUT
) {
  // 可能可以录音
}

当这些变量组合在一起,可能性已经完全超过了可以用逻辑理清的范畴

异步时序 #

微信的录音 API 是异步的:

正常流程是:

1
按下 → start → onStart → 松手 → stop → onStop

但如果用户“秒按”:

1
按下 → start → 松手 → stop → onStart → onStop

旧代码里的 onStart 并不知道用户已经松手:

1
2
3
recorderManager.onStart(() => {
  updateRecordingStatus()
})

于是 UI 会被切到“录音中”,而用户此时已经放开手,状态就此错位。

这正是那个“松手了还在录音”的根源。

重构一:明确状态 #

第一步是把“隐含状态”变成“显式状态”。

1
2
3
4
5
6
7
const STATUS_SYSTEM_WAITING_INPUT = 12
const STATUS_SYSTEM_RECORDING = 13
const STATUS_SYSTEM_PROCESSING = 14

const STATUS_USER_WAITING_INPUT = 22
const STATUS_USER_RECORDING = 23
const STATUS_USER_PROCESSING = 24

现在判断“是否可以录音”只需要一行:

1
currentStatus.value === STATUS_SYSTEM_WAITING_INPUT

状态本身就是语义,不再需要拼布尔值。

重构二:用会话 ID 隔离异步回调 #

为了解决时序问题,我引入了录音会话 ID。

1
2
const currentRecordSession = ref(0)
const isHardwareRunning = ref(false)

开始录音时生成会话:

1
2
3
const session = Date.now()
currentRecordSession.value = session
recorderManager.start()

onStart 中校验:

1
2
3
4
if (currentRecordSession.value === 0) {
  recorderManager.stop()
  return
}

如果用户在硬件启动前松手,直接作废会话:

1
currentRecordSession.value = 0

所有异步回调都必须验证 session 是否有效,Race Condition 被彻底隔离。

重构三:状态驱动UI #

所有 UI 行为统一收敛到配置表:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const STATUS_CONFIG_MAP = {
  [STATUS_SYSTEM_WAITING_INPUT]: {
    buttonType: 'record',
    buttonEnabled: true,
    videoControlEnabled: false
  },
  [STATUS_SYSTEM_PROCESSING]: {
    buttonEnabled: false
  }
}

状态切换统一通过:

1
toStatus(newStatus)

UI 不关心“为什么”,只关心“现在是什么状态”。

重构四:状态日志面板 #

为了验证状态流是否正确,我在页面里加了一个状态日志面板,记录每一次状态切换和关键日志。

事实证明这是非常值得的投入: 很多“偶现问题”,在日志里其实一眼就能看出来,只是以前没有工具帮你看见。

重构结果 #

判断当前的状态由十几个变量改为一个变量,通过判断变量状态的值就能够判断当前显示的样式。好处很明显,降低了复杂度(想不到写代码还会用到这个词),只要流程是清晰的,状态的改变一定是按照预先考虑的顺序的,即使中间有状态的转换。

当控件由多个布尔值维护时,其实已经意识到这部分代码很可能已经失控了, 变量越用越多,状态就越来越复杂,总有复杂到理不清楚的状态。

反思 #

回过头再想,重构做的好吗,是还能做的更好的,这明显就是状态模式,可惜的是 JavaScript 在语言层面并不支持 sealed 这类类型系统能力,只能通过约定和运行时校验来模拟。

回过头来看这个录音模块,可以先抽象出两个高优先级维度,再衍伸出具体业务状态。

第一类:交互方式维度

第二类:是否允许触发

在这两个维度之下,才进一步派生出更具体的业务状态,比如:

在 JavaScript / Vue 里,这些关系通常会被压扁成一堆布尔值; 而在 Kotlin 里,这是密封类(sealed class)绝佳的使用场景。

密封类描述状态 #

如果这是 Android,我大概率会先定义一个状态模型,而不是一堆 flag。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
sealed class RecordState {

    // 点击类交互
    sealed class Tap : RecordState() {

        object Disabled : Tap()

        object Ready : Tap()

        object Processing : Tap()
    }

    // 长按类交互
    sealed class LongPress : RecordState() {

        object Disabled : LongPress()

        object Waiting : LongPress()

        data class Recording(
            val startTime: Long
        ) : LongPress()
    }
}

这里有几个关键点:

  1. 状态是封闭集合

    • 不存在“忘记处理的状态”
    • 编译器会强制你在 when 里覆盖完整
  2. 交互方式本身是一级语义

    • Tap 和 LongPress 是两棵完全独立的状态树
    • 不需要靠 isLongPress && canRecord && !isPlaying 这种组合判断
  3. 是否可触发被状态本身表达

    • Disabled 就是不允许
    • 不需要额外的 canTrigger 变量

状态驱动 UI #

使用会非常简单,比如在 UI 层:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
when (val state = recordState) {
    is RecordState.Tap.Disabled -> {
        disableButton()
    }
    is RecordState.Tap.Ready -> {
        showTapButton()
    }
    is RecordState.Tap.Processing -> {
        showLoading()
    }

    is RecordState.LongPress.Disabled -> {
        disableRecordButton()
    }
    is RecordState.LongPress.Waiting -> {
        showRecordHint()
    }
    is RecordState.LongPress.Recording -> {
        showRecordingUI(state.startTime)
    }
}

这里没有任何“隐式约定”,每一种 UI 行为,都由一个确定的状态类型驱动。

这次最难处理的,其实不是状态本身,而是录音 start / stop 的异步时序。

在 Kotlin 中,我很可能会把它直接建模进状态里:

1
2
3
4
data class Recording(
    val sessionId: Long,
    val startTime: Long
) : LongPress()

然后在回调里只做一件事:

1
2
3
4
5
if (currentState is Recording &&
    currentState.sessionId == callbackSessionId
) {
    // 合法回调
}

非法回调在类型层面就就被过滤掉了,用不到依赖其他变量或是逻辑。

对比 #

在 Vue / JavaScript 里,我只能通过:

才能达到类似的效果。

我认为这是语言的定位不同造成的,JS最最开始也是只操作dom树的语言,至于后续为什么演变成现在的状态,我没有经历过,不敢轻下妄言。

这次在小程序里用状态机,本质上是模拟 Kotlin sealed class :

只是这些,在 Kotlin 里,编译器可以帮你约束; 在 JavaScript 里,只能靠你自己。

标签:
Categories: