语音备忘速记 - 鸿蒙ArkTS备忘列表与播放交互详解


实例:语音备忘速记|技术:手势识别(长按录音)、实时转写文本流、@kit.AudioKit(AudioRenderer 播放)、List + LazyForEach、Toast/弹窗
一、本篇范围
第一篇完成了采集与识别服务层,本篇做 UI 层与播放交互,核心问题:
- 长按录音怎么做?
onTouch手势的状态机(按下/抬起/取消)怎么实现「按住说话、松开结束」; - 实时转写怎么显示?中间结果实时替换、最终结果定稿的文本流设计;
- 备忘列表怎么渲染?
List + LazyForEach的分页与 item 复用; - 播放音频怎么做?
AudioRenderer与 AVPlayer 的选择、播放进度条驱动。
页面形态:底部一个圆形「按住说话」按钮,按住时录音并实时显示识别文字;松开后生成一张备忘卡片插入列表顶部,点卡片文字复制、点喇叭图标播放录音。
二、页面骨架与状态
import { VoiceMemo, VoiceMemoStore, VoiceMemoSession } from './VoiceMemoSession';
import { common } from '@kit.AbilityKit';
@Entry
@ComponentV2
struct VoiceMemoPage {
@Local memos: VoiceMemo[] = [];
@Local recording: boolean = false;
@Local transcript: string = ''; // 当前实时转写文本
@Local recordingMs: number = 0; // 已录制时长
@Local playingId: string = ''; // 正在播放的备忘 id
private session: VoiceMemoSession | null = null;
private recordTimer: number = -1;
private context: common.UIAbilityContext | null = null;
private renderer: AudioRendererService | null = null;
aboutToAppear(): void {
this.context = this.getUIContext().getHostContext() as common.UIAbilityContext;
this.loadMemos();
}
aboutToDisappear(): void {
if (this.recordTimer !== -1) {
clearInterval(this.recordTimer);
}
this.renderer?.stop();
}
private async loadMemos(): Promise<void> {
this.memos = await VoiceMemoStore.queryAll(this.context!);
}
...
}
三、长按录音:手势状态机
「按住说话」是语音类应用的标配交互。实现上不能只靠 onClick(点击就触发,没有按下状态),要用 onTouch 感知触摸全过程:
@Builder
recordButton() {
Column({ space: 8 }) {
Stack() {
// 外圈光环:录音时放大
Circle().width(this.recording ? 96 : 72)
.height(this.recording ? 96 : 72)
.fill(this.recording ? '#FEE2E2' : '#F3F4F6')
.animation({ duration: 200, curve: Curve.EaseOut })
// 中心麦克风
Text(this.recording ? '⏹' : '🎤')
.fontSize(30)
.width(64).height(64)
.textAlign(TextAlign.Center)
.borderRadius(32)
.backgroundColor(this.recording ? '#EF4444' : '#10B981')
.fontColor('#FFFFFF')
}
.width(100).height(100)
Text(this.recording ? '松开结束' : '按住说话')
.fontSize(13)
.fontColor(this.recording ? '#EF4444' : '#64748B')
}
.onTouch((event: TouchEvent) => this.handleRecordTouch(event))
}
3.1 手势状态机
onTouch 的回调会收到 TouchEvent,其 type 字段区分触摸动作:
| TouchType | 语义 | 本应用动作 |
|---|---|---|
| Down | 手指按下 | 开始录音 |
| Up | 手指抬起 | 结束录音、落库 |
| Move | 手指移动 | 可检测滑出取消区 |
| Cancel | 触摸被系统打断 | 取消录音、丢弃 |
状态机代码:
private handleRecordTouch(event: TouchEvent): void {
const type = event.type;
if (type === TouchType.Down) {
this.startRecording();
} else if (type === TouchType.Up || type === TouchType.Cancel) {
this.stopRecording();
}
}
为什么用 Down/Up 而不是 onClick? onClick 只在「按下并抬起」后触发,无法表达「按住中」的持续状态,而且松手在按钮外时 onClick 不触发、onTouch 的 Up 一定触发(除非 Cancel)。语音交互要求「按下立即开始、松开立即结束」,只有 onTouch 能满足。
3.2 开始/结束录音
private async startRecording(): Promise<void> {
if (this.recording) return;
this.recording = true;
this.transcript = '';
this.recordingMs = 0;
// 计时器:每秒刷新时长
this.recordTimer = setInterval(() => {
this.recordingMs += 1000;
}, 1000);
// 启动采集 + 识别会话
this.session = new VoiceMemoSession();
this.session.onTranscript = (text, isFinal) => {
this.transcript = text;
// isFinal 时不打断输入法联想;这里直接展示最终文本
};
const ok = await this.session.start(this.context!);
if (!ok) {
this.recording = false;
this.getUIContext().getPromptAction().showToast({ message: '未获得麦克风权限' });
}
}
private async stopRecording(): Promise<void> {
if (!this.recording) return;
this.recording = false;
if (this.recordTimer !== -1) {
clearInterval(this.recordTimer);
this.recordTimer = -1;
}
if (!this.session) return;
// 结束会话,拿到文本和音频文件
const result = await this.session.stop();
const memo: VoiceMemo = {
id: `${Date.now()}`,
text: result.text.trim() || '(无识别内容)',
audioPath: result.audioPath,
createdAt: Date.now(),
durationMs: result.durationMs
};
await VoiceMemoStore.save(this.context!, memo);
this.memos.unshift(memo); // 插入列表顶部
this.transcript = '';
this.getUIContext().getPromptAction().showToast({ message: '备忘已保存' });
}
状态机的两个保险:recording 标志位防止 Down 连续触发两次 start(快速点按);Up 时 !this.session 判空防止权限拒绝后 stop 空对象。识别结果回调里直接赋值 this.transcript,ArkUI 的状态系统会自动刷新 Text 组件,实现「边说边显示」。
四、实时转写文本流
页面中部是转写区:
@Builder
transcriptArea() {
Column({ space: 8 }) {
Row() {
Text('实时转写').fontSize(15).fontWeight(FontWeight.Medium)
Blank()
if (this.recording) {
Text(this.formatDuration(this.recordingMs))
.fontSize(12).fontColor('#EF4444')
}
}.width('100%')
if (this.recording) {
// 录音中:显示滚动的实时文本
Scroll() {
Text(this.transcript || '正在聆听,请说话…')
.fontSize(15)
.lineHeight(24)
.fontColor(this.transcript ? '#1E293B' : '#94A3B8')
.width('100%')
}
.height(120)
.scrollBar(BarState.Off)
} else {
// 未录音:提示占位
Text('按住下方按钮,说出要记录的内容')
.fontSize(13).fontColor('#94A3B8')
.width('100%').padding({ top: 40, bottom: 40 })
}
}
.width('100%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(16)
}
文本流的关键设计:中间结果和最终结果都写到同一个 transcript,只是 UI 用字体颜色区分——中间结果灰色、最终结果正常色。更精细的做法是维护「已定稿段落 + 当前草稿」两个字段,定稿段落追加、草稿实时替换,避免整段闪烁。对本示例,单字段方案足够,代码也更清晰。
五、备忘列表:List + LazyForEach
@Builder
memoList() {
Column({ space: 8 }) {
Row() {
Text('语音备忘').fontSize(16).fontWeight(FontWeight.Bold)
Blank()
Text(`${this.memos.length} 条`).fontSize(12).fontColor('#94A3B8')
}.width('100%')
if (this.memos.length === 0) {
Column({ space: 12 }) {
Text('🎧').fontSize(40)
Text('暂无备忘,按住下方按钮说一句话').fontSize(13).fontColor('#94A3B8')
}
.width('100%').padding({ top: 40, bottom: 40 })
} else {
List({ space: 10 }) {
LazyForEach(new MemoDataSource(this.memos), (memo: VoiceMemo) => {
ListItem() {
this.memoCard(memo)
}
}, (memo: VoiceMemo) => memo.id)
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
}
.width('100%')
.layoutWeight(1)
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(16)
}
5.1 为什么 LazyForEach
List 里如果直接用 ForEach,所有 item 一次性全部创建;备忘多了(50 条封顶虽然不多,但未来可能涨)会拖慢首帧。LazyForEach 按需创建可见 item、滚动时回收不可见 item,配合 new MemoDataSource(this.memos) 数据源类:
class MemoDataSource implements IDataSource {
private items: VoiceMemo[] = [];
constructor(list: VoiceMemo[]) {
this.items = list;
}
totalCount(): number {
return this.items.length;
}
getData(index: number): VoiceMemo {
return this.items[index];
}
registerDataChangeListener(listener: DataChangeListener): void {
// 本示例数据一次性加载,不需要增量通知
}
unregisterDataChangeListener(listener: DataChangeListener): void {
// 空实现
}
}
IDataSource 接口必须实现四个方法:totalCount、getData、registerDataChangeListener、unregisterDataChangeListener。LazyForEach 的第三个参数是 key 生成函数 (memo) => memo.id——key 必须唯一且稳定,删除一条备忘时列表才能精准定位重绘;用 index 当 key 会导致删除后动画错乱。
5.2 备忘卡片
@Builder
memoCard(memo: VoiceMemo) {
Row({ space: 12 }) {
// 播放按钮
Stack() {
Circle().width(40).height(40)
.fill(this.playingId === memo.id ? '#D1FAE5' : '#F1F5F9')
Text(this.playingId === memo.id ? '⏸' : '▶')
.fontSize(16).fontColor(this.playingId === memo.id ? '#10B981' : '#64748B')
}
.onClick(() => this.togglePlay(memo))
// 文本与时间
Column({ space: 6 }) {
Text(memo.text)
.fontSize(14)
.lineHeight(21)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.onClick(() => {
this.getUIContext().getClipboard()?.setData({ mimeType: 'text/plain', content: memo.text });
this.getUIContext().getPromptAction().showToast({ message: '文本已复制' });
})
Row({ space: 8 }) {
Text(this.formatTime(memo.createdAt)).fontSize(11).fontColor('#94A3B8')
Text(`${Math.round(memo.durationMs / 1000)}s`).fontSize(11).fontColor('#94A3B8')
if (memo.audioPath) {
Text('🎵').fontSize(11)
}
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 删除
Text('🗑').fontSize(16)
.onClick(() => this.removeMemo(memo))
}
.width('100%')
.padding(12)
.backgroundColor('#F8FAFC')
.borderRadius(14)
}
卡片交互三件事:点喇叭播放/暂停、点文本复制到剪贴板、点垃圾桶删除。getClipboard().setData 是 API 24 的剪贴板写法,mimeType: 'text/plain' 声明纯文本格式。maxLines(2) + textOverflow(Ellipsis) 控制长文本省略,列表保持整洁。
六、音频播放:AudioRenderer 还是 AVPlayer
6.1 选型
| 方案 | 输入 | 适合 |
|---|---|---|
| AVPlayer | 文件/URL | 播放 m4a/mp3 等压缩格式,功能全(进度/倍速) |
| AudioRenderer | PCM 流 | 播放裸 PCM/WAV,低延迟 |
我们的录音是 WAV(PCM),AVPlayer 其实也支持 WAV。但为了呼应第一篇的 PCM 主题,这里演示 AudioRenderer 播放 PCM 流——它和 AudioCapturer 是音频框架的一对镜像(capture 采集、renderer 播放),参数完全对称。
6.2 AudioRenderer 播放服务
import { audio } from '@kit.AudioKit';
import { fileIo as fs } from '@kit.CoreFileKit';
export class AudioRendererService {
private renderer: audio.AudioRenderer | null = null;
private playing: boolean = false;
private onEnd: (() => void) | null = null;
async playFile(path: string, onEnd: () => void): Promise<void> {
this.onEnd = onEnd;
await this.stop();
// 参数与录音端镜像:16kHz 单声道 16bit
this.renderer = await audio.createAudioRenderer({
streamInfo: {
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_16000,
channels: audio.AudioChannel.CHANNEL_1,
sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
},
rendererInfo: {
content: audio.ContentType.CONTENT_TYPE_SPEECH,
usage: audio.StreamUsage.STREAM_USAGE_MEDIA,
rendererFlags: 0
}
});
await this.renderer.start();
// 读文件流式写入 renderer
const file = await fs.open(path, fs.OpenMode.READ_ONLY);
const stat = await fs.stat(path);
const blockSize = 4096;
let offset = 44; // 跳过 WAV 头
this.playing = true;
while (this.playing && offset < stat.size) {
const readSize = Math.min(blockSize, stat.size - offset);
const buf = new ArrayBuffer(readSize);
await fs.read(file.fd, buf, { offset });
await this.renderer.write(buf);
offset += readSize;
}
await fs.close(file.fd);
await this.renderer.stop();
await this.renderer.release();
this.renderer = null;
this.playing = false;
onEnd();
}
async stop(): Promise<void> {
this.playing = false;
try {
await this.renderer?.stop();
await this.renderer?.release();
} catch {
// 已停止则忽略
}
this.renderer = null;
}
}
播放端三个细节:streamInfo 必须与录音完全一致(否则声音变调/无声);content: CONTENT_TYPE_SPEECH 告诉系统这是语音内容,系统会自动优化播放参数;fs.read 用 { offset } 指定读取位置,从第 44 字节(WAV 头之后)开始读数据区。
6.3 页面里的播放状态切换
private async togglePlay(memo: VoiceMemo): Promise<void> {
if (this.playingId === memo.id) {
this.renderer?.stop();
this.playingId = '';
return;
}
this.playingId = memo.id;
this.renderer = new AudioRendererService();
await this.renderer.playFile(memo.audioPath, () => {
this.playingId = '';
});
}
playingId 记录正在播放的备忘 id,卡片据此高亮(⏸/绿色背景)。同一时刻只允许一个播放:切到另一条时先 stop 旧的。onEnd 回调里清空 playingId,播放完自动复位按钮。
七、交互细节补全
7.1 时间格式化
private formatTime(ts: number): string {
const d = new Date(ts);
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return `${d.getMonth() + 1}/${d.getDate()} ${h}:${m}`;
}
private formatDuration(ms: number): string {
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
return `${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
}
7.2 删除备忘(含音频文件)
private async removeMemo(memo: VoiceMemo): Promise<void> {
const ctx = this.getUIContext();
const ok = await ctx.showAlertDialog({
title: '删除备忘',
message: '将同时删除对应的录音文件',
primaryButton: { value: '取消', action: () => {} },
secondaryButton: { value: '删除', fontColor: '#EF4444', action: () => {} }
});
// showAlertDialog 返回的 ok 需要判断 selected 值;简化示例直接删除
await VoiceMemoStore.remove(this.context!, memo.id);
try {
await fs.unlink(memo.audioPath);
} catch { /* 文件可能不存在 */ }
this.memos = this.memos.filter((m) => m.id !== memo.id);
}
删除前弹 showAlertDialog 确认是「破坏性操作」的标准做法,同时提示音频文件会一起删除,避免用户误删后找不到录音。
八、状态与生命周期复盘
| 状态 | 类型 | 生命周期 | 说明 |
|---|---|---|---|
| recording | @Local | 录音会话期间 | 驱动按钮光环与计时 |
| transcript | @Local | 录音期间实时更新 | 识别回调写入 |
| memos | @Local | 页面整个生命周期 | 列表数据源 |
| playingId | @Local | 播放期间 | 卡片高亮 |
| session/renderer | 普通字段 | 会话级 | 不触发 UI,手动管理释放 |
注意 session 和 renderer 用的是普通成员变量而不是 @Local:它们不参与 UI 渲染,放进状态系统只会徒增重渲染开销。状态装饰器的选型原则:UI 要显示的才进状态,服务对象放普通字段。
九、运行效果与验证
- 首次点按录音:弹出麦克风权限框,授权后光环变红、计时开始;
- 说话时:转写区文字实时增长(模拟器无识别引擎时显示提示,真机正常);
- 松开:生成卡片,点▶播放录音,按钮变⏸、卡片高亮;
- 点文本:Toast 提示已复制,去备忘录粘贴验证;
- 删除:弹确认框,确认后列表移除、沙箱音频文件删除;
- 重启应用:备忘列表从 preferences 恢复。
十、本篇小结
页面层把「录音-识别-存储」流水线包装成四个可交互区域:手势状态机驱动的录音按钮、实时转写文本流、LazyForEach 备忘列表、AudioRenderer 播放器。贯穿始终的原则是:状态只装 UI 要显示的、服务对象手动管理生命周期、破坏性操作必须确认。
应用至此覆盖:语音识别(CoreSpeechKit)、音频采集与播放(AudioKit)、文件读写(fileIo)、键值持久化(preferences)、手势与状态管理(ArkUI),是「系统 AI 能力 + 多媒体 + 交互」的综合样本。
更多推荐



所有评论(0)