【听见课堂 HarmonyOS NEXT 实战系列 25】识别回调如何变成可展示字幕:临时结果、最终结果与当前行
【听见课堂 HarmonyOS NEXT 实战系列 25】识别回调如何变成可展示字幕:临时结果、最终结果与当前行
语音识别回调给出的通常不是一组一次性完成的句子,而是同一句话不断修正的临时结果,最后再用 isFinal 标记稳定版本。如果每次回调都向列表追加一行,用户会看到“今天”“今天我们”“今天我们学习”三条重复字幕;如果只保留最终结果,课堂字幕又会明显迟到。
听见课堂用 currentCaptionId 把临时结果更新到同一行,用 isFinal 结束当前行,再通过 LiveCaptionItem 与 LiveSessionSnapshot 交给 ArkUI。本文拆解这条归一化链路,也指出当前 100 条可见窗口、全量持久化和高频渲染之间尚需补齐的工程边界。

一、识别回调不能直接等同于字幕记录
一段“请打开课本第三十页”的识别过程可能依次返回:
请打开
请打开课本
请打开课本第三十
请打开课本第三十页(final)
这些是同一语义单元的修订过程,不是四条课堂事实。领域层需要先判断“更新当前行还是创建新行”,再决定何时允许持久化。
二、LiveCaptionItem 承担哪些展示语义
当前模型包含:
export class LiveCaptionItem {
id: string;
timestamp: string;
speaker: string;
text: string;
isKeyPoint: boolean;
isUncertain: boolean;
isCurrent: boolean;
isFinal: boolean;
}
各字段分为三组:
- 身份与内容:
id、timestamp、speaker、text; - 用户确认信息:
isKeyPoint、isUncertain; - 实时会话信息:
isCurrent、isFinal。
isKeyPoint 与 isUncertain 不是模型置信度。当前 SDK 结果模型没有被项目使用的置信度字段,“没听清”由用户主动标记,不能伪造一个百分比分数。
三、先过滤旧 session 和空文本
回调入口先做两道门禁:
if (activeSessionId !== this.sessionId) {
return;
}
const recognizedText: string = result.result.trim();
if (recognizedText.length === 0) {
return;
}
旧会话回调被忽略,避免暂停/继续后污染新会话;纯空白结果不创建占位行,避免列表出现没有内容却参与计数的字幕。
真正的生产实现还可以限制单条最大长度、处理异常控制字符,但不能静默改写用户语义。
四、没有 currentCaptionId 时创建当前行
第一次有效结果到来时,项目生成稳定 ID:
this.currentCaptionId =
activeSessionId + '-caption-' + Date.now().toString();
this.captions.push(new LiveCaptionItem(
this.currentCaptionId,
this.formatElapsed(this.elapsedSeconds),
'课堂语音',
recognizedText,
false,
false,
true,
result.isFinal
));
时间戳取首次出现时的会话计时,后续临时修订不会不断改时间。这比用每次回调时间更接近“这句话从何时开始进入字幕”的展示语义。
五、临时结果更新同一行
只要 currentCaptionId 仍存在,后续结果就查找同一项并覆盖文本:
const index = this.captions.findIndex(
(item: LiveCaptionItem) => item.id === this.currentCaptionId
);
if (index >= 0) {
this.captions[index].text = recognizedText;
this.captions[index].isCurrent = true;
this.captions[index].isFinal = result.isFinal;
}
这样 ArkUI 中只有一行在增长,用户可以及时看到内容,又不会积累重复片段。列表 key 包含 id 和文本状态,目标行会随修订更新。
六、最终结果如何固化
当 result.isFinal 为 true 时,项目把当前 ID 清空:
if (result.isFinal) {
this.currentCaptionId = '';
}
下一次有效回调会创建新的字幕行。已完成项的 isFinal 保持为 true,文本不会再被本轮临时结果覆盖。
这里的“固化”是 Service 内部语义,不代表已经写入数据库。当前项目在用户结束课堂时才把可用字幕交给 Repository;“最终识别结果”和“已持久化记录”必须分成两个状态理解。
七、isCurrent 与 isFinal 可以同时为 true
当前实现中,一条最终结果到达时会同时设置 isCurrent=true 和 isFinal=true,随后清空 currentCaptionId。直到下一条结果调用 clearCurrentMarker(),这条最终字幕仍是页面上的“当前行”。
这不是逻辑矛盾:
isFinal表示文本不再被本轮识别修订;isCurrent表示它仍是用户操作“重点/没听清”默认作用的最近一行。
如果产品把 current 定义为“仍在流式生成”,就应在 final 时立即清除;如果定义为“当前关注行”,现有行为更符合用户标记操作。字段语义必须在产品和测试中写清楚。
八、重点和没听清为什么作用于当前行
项目通过 currentCaptionIndex() 找到 isCurrent 项;没有显式当前项时,回退到最后一条:
const markedIndex = this.captions.findIndex(
(item: LiveCaptionItem) => item.isCurrent
);
return markedIndex >= 0 ? markedIndex : this.captions.length - 1;
这样用户在老师刚讲完一句后点击“重点”或“没听清”,仍能作用于最近字幕。没有字幕时则返回明确提示,而不是创建空记录。
isUncertain 是人工证据,适合课后按时间点校正;isKeyPoint 则进入重点复盘。两者都应随字幕 ID 持久化,而不是只保存在按钮视觉状态里。
九、为什么每次结果前要清除旧 current 标记
clearCurrentMarker() 遍历字幕,把所有 isCurrent 设为 false,随后只把本轮目标项设回 true。它保证列表最多只有一个当前行。
这个不变量很重要:如果两个字幕都被标为 current,用户点击“重点”时 findIndex() 只处理第一个,页面高亮和业务动作会不一致。
长列表下每次遍历最多 100 项,当前成本可控;若未来全量列表进入内存,应直接保存 current 索引或用 Map 查找,避免每次回调线性扫描。
十、快照复制如何保护 Service 所有权
getSnapshot() 返回字幕副本:
this.captions.map((item: LiveCaptionItem) => item.copy())
ArkUI 拿到的是一次展示快照,不能通过修改数组或对象反向改变 Service 内部事实。页面上的重点按钮也不直接改 liveCaptions[index],而是调用 Service 动作,再接收新快照。
这形成清晰单向流:
RecognitionListener
-> Service 归一化
-> LiveSessionSnapshot 副本
-> ArkUI 渲染
-> 用户动作回到 Service

十一、状态文案也要区分临时与最终结果
项目在临时结果时提示“正在生成真实麦克风字幕”,最终结果时提示“已生成 N 段真实麦克风字幕”。capturedCaptionIds 只在创建新行时追加,因此临时修订不会反复增加段数。
这比直接显示回调次数更有意义。回调次数是技术指标,字幕段数才是用户理解的内容单位。两者可以同时监控,但不能混用。
十二、MAX_VISIBLE_CAPTIONS 的真实边界
超过 100 条后,Service 只保留最后 100 条:
if (this.captions.length > MAX_VISIBLE_CAPTIONS) {
this.captions = this.captions.slice(
this.captions.length - MAX_VISIBLE_CAPTIONS
);
}
这防止 ArkUI 列表、快照复制和 clearCurrentMarker() 无限增长。但当前结束保存也是从 liveCaptions 生成记录,因此被裁掉的早期字幕可能不会进入最终替换写入。
理想设计需要分成:
allFinalCaptions或分批持久化的全量事实;visibleCaptions的最近 100 条窗口;currentDraft的单条临时结果;persistedCount与visibleCount的独立指标。
当前项目尚未完成这层分离,不能把“最多显示 100 条”宣传成“长课堂仍完整保存全部字幕”。
十三、临时结果不应直接持久化
如果每次 partial 都更新数据库,会形成高频事务,并把不断修正的草稿当成稳定课堂事实。更合适的方案是:
partial -> 只更新 currentDraft 和 UI
final -> 进入待持久化批次
每 N 条/每 N 秒 -> 批量事务写入
结束 -> 刷新最后一批并校验计数
异常恢复时还需为最终项设计幂等键,避免重试产生重复字幕。sessionId + caption sequence 比纯时间戳更稳定,也便于按课程和会话查询。
十四、回调频率与 ArkUI 渲染需要节流
当前每次有效识别结果都会 emitSnapshot(),计时器每秒也会发一次快照。短演示没有问题,真实设备上的 partial 回调如果很密集,可能带来列表 key 变化、自动滚动和复制开销。
可以只对临时结果做短窗口合并,例如 50—100 ms;最终结果、错误和用户标记则立即发出。节流必须满足:
- 不重排最终字幕顺序;
- 不吞掉 final;
- 页面延迟仍符合无障碍实时性;
- 用户暂停自动滚动后不被强制拉回;
- 性能指标能区分识别延迟与 UI 延迟。
十五、测试矩阵要覆盖哪些回调序列
| 输入序列 | 预期字幕结果 |
|---|---|
| partial A → partial AB → final ABC | 同一 ID、一行文本最终为 ABC |
| final A → final B | 两个不同 ID、两条最终字幕 |
| 空文本 → final A | 不创建空行,只保留 A |
| 旧 session final → 当前 session final | 忽略旧回调,只展示当前会话 |
| partial A → error | A 保留,进入可恢复错误态 |
| final A → 点击重点 | A 的 isKeyPoint 切换 |
| final A → 点击没听清 | A 的 isUncertain 为 true |
| 超过 100 条 | UI 只有最近 100 条,另行验证全量存储策略 |
| 快速 partial 高频输入 | 页面不卡顿,final 不丢失 |
| 结束并强停重启 | 最终字幕按既定持久化策略回读 |
应使用可控的 Listener 夹具测试归一化,再用真机验证真实回调节奏。只靠对着麦克风说一句话,很难稳定覆盖乱序、旧 session 和高频 partial。
十六、项目证据与未验证项
当前源码已经实现旧 session 过滤、空文本过滤、临时行更新、final 固化、当前行标记、人工重点/没听清、快照复制和 100 条窗口。静态实时听写合约通过,历史设备验收也验证了人工标记和可恢复错误态。
但项目报告明确记录:模拟器没有产生新的真实识别文本,真实 final 回调、真机字幕新增、长课堂全量保存与重启读回均未执行。华为当前指南还注明语音识别能力不支持模拟器。因此本文对回调的分析来自当前源码与官方 API 契约,不把未发生的真机结果写成已通过。
十七、总结
识别回调到字幕之间必须有一层领域归一化:用 session 隔离旧事件,用 current ID 合并临时结果,用 final 切分句子,用人工标记补足课堂语义,再通过不可变快照交给 ArkUI。
听见课堂当前已经建立了这条主链,但 100 条显示窗口和全量持久化仍未真正分层,高频 partial 的节流与真机长课堂也需要补验。只有把“回调、可见、最终、已保存”四个概念分开,实时字幕才不会在演示成功后陷入数据丢失和性能误判。
下一篇将继续拆解暂停、继续和结束为什么是三种不同的生命周期,以及每种状态应该保留和释放哪些资源。
更多推荐



所有评论(0)