【听见课堂 HarmonyOS NEXT 实战系列 21】实时字幕先别急着接模型:8 状态会话机怎么设计

做实时字幕时,最容易被低估的不是模型接入,而是会话状态。如果页面只有一个 isListening 布尔值,开始失败、权限拒绝、主动暂停、引擎中断和正常结束都会被压成“没有在听”。用户看到的按钮、提示和恢复动作随之混乱:同一个“开始”可能重复创建引擎,“继续”可能在旧会话尚未释放时再次采集,“结束”也可能只改了页面文案却没有真正关闭资源。

听见课堂把实时课堂抽成 8 个显式状态:idlestartinglisteningpausedpermission_deniedunsupportederrorended。本文不讨论识别准确率,而是先解释状态、字幕快照、计时器和资源生命周期如何形成一个可恢复的会话机。

实时字幕 8 状态会话机与恢复路径

一、为什么一个布尔值不够

true/false 只能表达“正在听/没有听”,却回答不了下面这些问题:

  • 正在等待权限还是正在创建引擎?
  • 用户主动暂停,还是识别发生异常?
  • 设备不支持能力,还是页面尚未开始?
  • 结束后还能查看字幕吗?
  • 当前按钮应该显示“开始”“暂停”“继续”还是“授权”?

这些差异直接决定下一步动作。状态不显式,页面就会用越来越多互相交叉的布尔变量补洞,最终出现非法组合,例如 isListening=falseisError=trueisEnded=true 同时成立。

二、8 个状态分别承担什么语义

听见课堂的状态定义如下:

export enum LiveSessionState {
  IDLE = 'idle',
  STARTING = 'starting',
  LISTENING = 'listening',
  PAUSED = 'paused',
  PERMISSION_DENIED = 'permission_denied',
  UNSUPPORTED = 'unsupported',
  ERROR = 'error',
  ENDED = 'ended'
}
状态用户语义主动作资源预期
idle尚未开始或已恢复到准备态开始无活跃采集
starting正在授权、建引擎或建采集器禁止重复点击资源可能处于创建中
listening正在采集并送入识别引擎暂停引擎与采集器活跃
paused本次会话保留,采集停止继续字幕保留,采集器释放
permission_denied麦克风未授权授权/前往设置不创建采集器
unsupported当前设备或能力启动失败稍后重试释放已创建资源
error会话中途异常继续保留字幕并释放采集器
ended用户确认结束开始新一轮引擎关闭,字幕可继续查看

状态值不是错误码的别名,而是面向产品恢复路径的稳定契约。

三、先画合法迁移,再写按钮条件

项目的主要迁移可以概括为:

idle/ended/paused/error/unsupported
  -> starting
  -> listening

starting -> permission_denied
starting -> unsupported
listening -> paused
listening -> error
listening/paused -> ended
permission_denied -> starting -> idle 或 permission_denied

这张图比在 ArkUI 里直接堆 if 更重要。每条箭头都应该对应一个明确事件和后置条件;没有箭头的迁移默认不允许。例如 starting -> starting 没有业务意义,所以重复点击必须直接返回当前快照。

四、starting 是防重入门禁,不是装饰状态

用户双击“开始”时,权限弹窗、createEngine()createAudioCapturer() 都可能仍在执行。项目在 start() 和页面入口分别拦截:

if (this.state === LiveSessionState.LISTENING ||
  this.state === LiveSessionState.STARTING) {
  return this.getSnapshot();
}

// ArkUI 页面也阻止 STARTING 再次触发
if (this.liveState === LiveSessionState.STARTING) {
  return;
}

双层门禁各有作用:页面层减少无意义操作,Service 层保证即使未来出现新的调用方,也不会重复创建同一会话资源。

五、状态、文案、计时和字幕必须来自同一快照

如果页面分别读取 stateelapsedSecondsstatusMessagecaptions,读取期间 Service 可能刚好发生回调,页面就会出现“状态已暂停但计时仍增长”之类的撕裂。

项目用 LiveSessionSnapshot 一次性传递会话视图:

export class LiveSessionSnapshot {
  state: LiveSessionState;
  elapsedSeconds: number;
  statusMessage: string;
  captions: Array<LiveCaptionItem>;
  runtimeCaptionCount: number;
}

ArkUI 的 applyLiveSnapshot() 再集中映射为页面状态。这样一次回调只有一个一致版本,按钮、文案、计时和字幕不会各自猜测 Service 当前处境。

六、计时器必须跟随状态,而不是跟随页面

计时增长的条件只有一个:state === listening。项目在开始成功后启动 ticker,在暂停、结束、识别完成和错误回调中停止 ticker:

this.ticker = setInterval(() => {
  if (this.state === LiveSessionState.LISTENING) {
    this.elapsedSeconds += 1;
    this.emitSnapshot();
  }
}, 1000);

页面退出并不天然等于会话结束。听见课堂在离开实时课堂时主动暂停,而不是悄悄把状态改成 idle。这保留了本次字幕和已计时长,也避免麦克风继续在后台采集。

七、paused、error、unsupported 为什么要分开

三个状态都会让采集停止,但用户预期不同:

  • paused 是用户主动操作,提示“课堂已暂停”,继续通常可以直接重建采集;
  • error 是会话中断,应保留字幕、给出可理解反馈并允许重试;
  • unsupported 表示当前设备或能力初始化失败,不应暗示“多点几次就一定成功”。

把它们统一为 idle 会丢掉原因,也会让埋点、QA 和无障碍播报无法区分正常暂停与能力降级。

八、permission_denied 不是死路

权限拒绝后,项目不会清空已 Seed 或已识别字幕,也不会只剩一个不可用按钮。permission_denied 对应明确的“前往系统设置”恢复动作;恢复结果再进入 idle 或保持拒绝态。

这条路径还能避免反复弹出系统权限请求。华为官方权限 FAQ 也提示:权限被拒后,后续调用首次授权接口可能不再弹窗,应适度使用 requestPermissionOnSetting() 或引导用户进入系统设置。

九、ended 是业务终点,不是对象销毁

结束课堂需要释放 AudioCapturer、结束识别会话、关闭引擎、停止计时并生成保存提示,但页面仍可能展示字幕、重点和“没听清”标记。因此 ended 不等于把所有字段重置为初始值。

项目把彻底清理数据放在 resetForDataDeletion(),把正常结束放在 stop()。两者语义不同:前者清空课堂域数据,后者只结束实时采集并保留可读结果。

十、字幕快照为什么必须复制

Service 内部保存的是可变数组。如果 getSnapshot() 直接返回原引用,页面或未来的 ViewModel 可能无意间改写 isCurrentisKeyPoint 等字段,破坏状态机内部不变量。

项目对字幕逐项调用 copy()

captions: this.captions.map((item: LiveCaptionItem) => item.copy())

复制不是为了“函数式好看”,而是明确所有权:Service 拥有会话事实,页面只拥有一次渲染快照。

状态、资源、快照与 ArkUI 展示之间的单向数据流

十一、ArkUI 只做状态映射,不接管会话规则

页面根据状态计算标签与颜色:listening 显示“暂停”,permission_denied 显示“授权”,paused/error/unsupported 显示“继续”。这些属于展示映射。

真正的合法迁移、资源释放、计时器和回调过滤仍在 LiveTranscriptionService。如果页面直接调用引擎并自己切换多个布尔值,大屏布局、手机布局和未来的新入口都会复制同一套易错逻辑。

十二、至少要覆盖这些状态机测试

场景关键断言
连续点击两次开始只创建一轮引擎/采集器,状态不重复进入 starting
首次拒绝权限进入 permission_denied,已有字幕不丢
系统设置授权后返回回到 idle,不会自动偷偷开始采集
正在听写时暂停ticker 停止,采集器释放,字幕保留
暂停后继续新 session 可建立,旧字幕仍在
引擎回调旧 sessionId被忽略,不污染当前会话
识别中断进入 error,释放采集器,允许继续
确认结束进入 ended,引擎关闭,结果仍可展示
结束后再次开始建立新会话,不复用旧 sessionId
清空课堂数据回到 idle,字幕、计时和运行计数清零

除了状态值,还应断言资源和快照。只检查按钮文案会漏掉麦克风仍占用、ticker 重复启动等问题。

十三、项目证据与未验证边界

听见课堂在 HarmonyOS 6.0.2(22) 工程中通过了实时听写静态合约,并在历史模拟器验收中观察到权限弹窗、监听态、暂停、继续、结束和无崩溃路径。华为当前公开的 Core Speech Kit 语音识别指南同时写明该能力不支持模拟器,因此这些历史结果只能证明项目自己的 UI、权限和资源生命周期路径,不能当作模拟器已完成真实语音识别的官方能力证明。

物理真机产生新字幕、最终句回调、45 分钟以上长课堂、系统音频中断和内存曲线仍需单独验证。文章中的状态机来自当前源码,运行结论则绑定历史证据,二者不能混为一谈。

十四、总结

实时字幕首先是一个有资源、有权限、有错误恢复的长生命周期会话,其次才是模型输出。8 状态会话机解决的不是“代码看起来更规范”,而是让每一次点击都有合法迁移,让每一个提示都有恢复方向,让计时、字幕和底层资源保持一致。

下一篇继续沿着状态机向下拆:麦克风权限应该何时申请,首次拒绝后如何恢复,以及为什么拒绝后仍要允许用户查看已有字幕。

参考:华为开发者联盟 Core Speech Kit语音识别开发指南权限拒绝后再次授权 FAQ

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐