uni-app App 端长按录音的工程级实现

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 在实际运行中具有以下特性:

  1. onStart 是异步回调,可能明显延迟
  2. 用户已经松手、甚至已经调用 stop,onStart 才触发
  3. 极短录音或 start 后立即 stop 时,onStop 可能不回调
  4. iOS / Android 在回调时序上存在差异

如果 UI 状态完全依赖 onStart / onStop 回调来切换,就必然会引发竞态问题。


三、解决方案设计原则

基于以上问题,方案设计遵循以下原则:

  1. UI 状态不依赖系统回调
  2. UI 只由用户行为(按下 / 松手)驱动
  3. Recorder 回调只用于确认「是否生成了音频文件」
  4. 所有回调都需要考虑「延迟 / 丢失 / 乱序」
  5. 所有 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 状态完全由用户行为控制,
系统回调只负责“结果确认”,
就能从根本上规避异步竞态问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值