uni-app App 端长按录音的工程级实现
——彻底解决 iOS / Android 录音回调竞态问题
摘要
在 uni-app / App / 小程序中实现长按录音功能时,即使业务逻辑完全正确,仍然经常出现 iOS 松手后录音不停、Android 声纹动画卡死、onStart / onStop 回调乱序或不触发等问题。
这些问题并非代码错误,而是 RecorderManager 在不同平台上的异步竞态所导致。
本文从问题本质出发,给出一套 经 iOS / Android 双端验证的工程级解决方案,核心思想是:
UI 状态完全由用户行为驱动,Recorder 回调只负责“是否生成音频文件”。
一、背景与常见问题
在 uni-app / 小程序 / App 中实现「长按录音」时,开发者往往会遇到以下问题:
- iOS 下松手后录音仍在继续
- Android 偶发声纹动画不消失
- onStart / onStop 回调顺序错乱、延迟甚至不回调
- UI 卡死在 recording 状态,只能重进页面
很多时候,即便严格按照官方示例编写代码,问题仍然会随机出现。
二、问题本质分析
问题的根源并不在业务逻辑,而在于 RecorderManager 本身的异步与不可控性。
RecorderManager 在实际运行中具有以下特性:
- onStart 是异步回调,可能明显延迟
- 用户已经松手、甚至已经调用 stop,onStart 才触发
- 极短录音或 start 后立即 stop 时,onStop 可能不回调
- iOS / Android 在回调时序上存在差异
如果 UI 状态完全依赖 onStart / onStop 回调来切换,就必然会引发竞态问题。
三、解决方案设计原则
基于以上问题,方案设计遵循以下原则:
- UI 状态不依赖系统回调
- UI 只由用户行为(按下 / 松手)驱动
- Recorder 回调只用于确认「是否生成了音频文件」
- 所有回调都需要考虑「延迟 / 丢失 / 乱序」
- 所有 stop 操作必须具备兜底机制
一句话总结:
用户行为是确定的,系统回调是不可信的。
四、核心状态机设计
整个录音流程使用一个清晰的状态机控制:
idle 空闲
preparing 已按下,等待 start(防误触)
recording 正在录音
done 录音成功(可播放)
UI 的所有展示逻辑 只依赖这个状态机。
五、完整实现代码(可直接使用)
以下代码已在 iOS / Android 双端验证,可直接用于生产环境。
Template
<template>
<view class="box">
<!-- 顶部导航 -->
<custom-nav title="录制声纹" :background="'none'" :showBack="true" fixed :useDefaultBack="false" @back="goBack" />
<view class="placeholder"></view>
<!-- 文案 -->
<view class="text-area">
<view class="title">请对我说一句话</view>
<view class="eg">例:你好,雷格斯,今天天气怎么样</view>
</view>
<!-- 声纹动画 -->
<view class="wave-area" v-if="recordStatus === 'recording'">
<view class="wave" v-for="i in 5" :key="i" />
</view>
<!-- 长按录音 -->
<view class="record-btn"
@touchstart.prevent="onPressStart"
@touchend.prevent="onPressEnd"
@touchcancel.prevent="onPressEnd">
<canvas
canvas-id="progressCanvas"
class="progress-canvas"
:style="{ width: canvasSize + 'px', height: canvasSize + 'px' }" />
<view class="inner"></view>
</view>
<!-- 操作区 -->
<view class="action-area" v-if="recordStatus === 'done'">
<view class="action-btn play" @click="playAudio">播放</view>
<view class="action-btn retry" @click="resetRecord">重录</view>
</view>
</view>
</template>
Script(核心逻辑)
重点:UI 与 Recorder 回调彻底解耦
export default {
data() {
return {
recorder: null,
player: null,
recordStatus: 'idle',
recordStartTime: 0,
audioPath: '',
maxDuration: 10,
minDuration: 1500,
pressTimer: null,
hardStopTimer: null,
progressTimer: null,
isPressing: false,
shouldStopAfterStart: false,
ctx: null,
canvasSize: 62,
radius: 31,
stopping: false,
stopFallbackTimer: null
};
},
onLoad() {
this.initRecorder();
this.player = uni.createInnerAudioContext();
this.$nextTick(() => {
this.ctx = uni.createCanvasContext('progressCanvas', this);
this.drawProgress(0);
});
},
onUnload() {
this.forceStopRecord();
},
methods: {
initRecorder() {
this.recorder = uni.getRecorderManager();
this.recorder.onStart(() => {
if (this.stopping || !this.isPressing || this.shouldStopAfterStart) {
this.shouldStopAfterStart = false;
this.recorder.stop();
return;
}
this.recordStatus = 'recording';
this.recordStartTime = Date.now();
this.startProgressTimer();
});
this.recorder.onStop((res) => {
this.stopping = false;
if (this.stopFallbackTimer) {
clearTimeout(this.stopFallbackTimer);
this.stopFallbackTimer = null;
}
this.finishByStopResult(res);
});
},
finishByStopResult(res) {
this.clearAllTimers();
this.drawProgress(0);
const duration = Date.now() - this.recordStartTime;
if (res?.tempFilePath && duration >= this.minDuration) {
this.audioPath = res.tempFilePath;
this.recordStatus = 'done';
} else {
this.audioPath = '';
this.recordStatus = 'idle';
uni.showToast({
title: duration < this.minDuration ? '说话时间太短' : '录音失败',
icon: 'none'
});
}
},
onPressStart() {
if (this.recordStatus !== 'idle') return;
this.isPressing = true;
this.shouldStopAfterStart = false;
this.recordStatus = 'preparing';
this.pressTimer = setTimeout(() => {
if (!this.isPressing) return;
uni.vibrateLong();
this.startRecord();
}, 120);
},
onPressEnd() {
this.isPressing = false;
if (this.recordStatus === 'preparing') {
this.shouldStopAfterStart = true;
}
this.forceStopRecord();
},
startRecord() {
this.recorder.start({
sampleRate: 16000,
numberOfChannels: 1,
format: 'wav'
});
this.hardStopTimer = setTimeout(() => {
this.forceStopRecord();
}, this.maxDuration * 1000);
},
forceStopRecord() {
if (this.pressTimer) {
clearTimeout(this.pressTimer);
this.pressTimer = null;
}
this.clearProgressTimer();
this.drawProgress(0);
if (this.recordStatus !== 'done') {
this.recordStatus = 'idle';
}
this.stopping = true;
try {
this.recorder.stop();
} catch (e) {}
this.stopFallbackTimer = setTimeout(() => {
this.stopping = false;
this.recordStatus = 'idle';
}, 500);
},
startProgressTimer() {
let t = 0;
this.progressTimer = setInterval(() => {
t += 0.1;
this.drawProgress(Math.min(t / this.maxDuration, 1));
}, 100);
},
clearProgressTimer() {
if (this.progressTimer) {
clearInterval(this.progressTimer);
this.progressTimer = null;
}
},
clearAllTimers() {
this.clearProgressTimer();
clearTimeout(this.hardStopTimer);
clearTimeout(this.stopFallbackTimer);
},
drawProgress(p) {
if (!this.ctx) return;
const c = this.canvasSize / 2;
this.ctx.clearRect(0, 0, this.canvasSize, this.canvasSize);
if (p > 0) {
this.ctx.beginPath();
this.ctx.moveTo(c, c);
this.ctx.setFillStyle('#7b6cff');
this.ctx.arc(c, c, this.radius, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * p);
this.ctx.fill();
}
this.ctx.draw();
},
playAudio() {
if (!this.audioPath) return;
this.player.src = this.audioPath;
this.player.play();
},
resetRecord() {
this.forceStopRecord();
this.audioPath = '';
}
}
};
六、方案优势总结
- ✅ 彻底解决 iOS 松手不停录
- ✅ 彻底避免 Android 动画卡死
- ✅ 不依赖 onStart / onStop 的时序正确性
- ✅ 支持极短录音、权限延迟等边界情况
- ✅ 可直接用于生产环境
七、总结
在涉及 系统级能力(录音、相机、定位) 时:
永远不要用系统回调来驱动 UI 状态。
只要 UI 状态完全由用户行为控制,
系统回调只负责“结果确认”,
就能从根本上规避异步竞态问题。
1万+

被折叠的 条评论
为什么被折叠?



