简介:基于uniapp框架实现的录音与播放功能示例代码,主要面向需要在小程序、App等多端快速落地“按住说话、松开结束”语音交互的开发者。资源包内仅1个Vue文件,压缩包约1KB,结构精简,便于直接阅读与移植。已有5794人学习下载,热度可观。代码演示了uni.startRecord、uni.stopRecord、uni.getRecorderManager等录音API及uni.playVoice、uni.onVoicePlayEnd等播放API的用法,并通过touchstart/touchend事件实现按住录音、松开发送的完整交互。对于不熟悉录音时长限制、状态监听或跨端兼容处理的开发者,这份示例也提供了可对照的实践参考,能帮大家快速理解核心逻辑并接入自身项目。
1. 一大半刚接触 uniapp 的开发者,都栽在录音和播放这条路上
uniapp 做录音播放,初看就是uni.getRecorderManager()加uni.createInnerAudioContext()两个 API 的事,真正上手才会发现:权限配置、格式兼容、生命周期恢复、静音开关,任何一环没处理,真机上就是无声、闪退或者播放没反应。尤其是同一个代码既要跑小程序又要跑 App 时,录音和播放的端差异比想象中大得多——iOS 录出来是 m4a,Android 低端机可能读不到时长,H5 端在非 HTTPS 环境下压根不能录音。这篇笔记把一个可交付的录音播放方案拆开讲,从 API 原理到参数调优到踩坑记录,覆盖新手的完整复现路径,也回答熟手最关心的兼容边界。做这个方向投入产出比很高:录音播放是即时通讯、健康打卡、语音笔记、客服工单这些场景的基础件,值得认真做一次、长期复用。
2. 录音最小实现:从 RecorderManager 到权限配置一条链路
2.1 为什么录音首选 getRecorderManager 而不是其他方案
uniapp 里录音有两条主流路线:uni.getRecorderManager()和plus.audio.Recorder。后者是 5+ App 的专用 API,只能在 App 端用,小程序和 H5 直接报错。前者是 uni-app 官方封装的跨端录音管理器,底层会根据当前运行环境自动映射到微信小程序的录音接口或 App 原生录音接口,开发者只维护一套代码。
我一般建议项目里优先用getRecorderManager,原因有三个:一是它的生命周期事件齐全,onStart、onStop、onPause、onResume、onError都有,方便做 UI 状态同步;二是返回值是单例,不需要手动释放,重复调用不会内存泄漏;三是它天然适配小程序的授权模型,配合uni.authorize可以在录之前把权限弹窗的时机控制好。
至于plus.audio.Recorder,只在需要录制amr格式且明确只做 App 端时考虑,它的参数体系更接近原生,但换来的是跨端代码要写两份,不划算。
2.2 录音参数逐个拆:format、sampleRate、encodeBitRate 怎么设
录音 API 的核心参数就那么几个,但每个参数在不同端上的表现差异很大。下面这段代码是一个能直接用的录音封装,我把关键参数和注释写在里面:
// /utils/recorder.js import { recorderConfig } from './config' class RecorderService { constructor() { // 跨端录音管理器,整个应用只需要一个实例 this.recorder = uni.getRecorderManager() this.isRecording = false this._registerEvents() } _registerEvents() { this.recorder.onStart(() => { this.isRecording = true uni.$emit('recorder:state', { state: 'start' }) }) this.recorder.onStop((res) => { this.isRecording = false uni.$emit('recorder:state', { state: 'stop', tempFilePath: res.tempFilePath, duration: res.duration, fileSize: res.fileSize }) }) this.recorder.onPause(() => { uni.$emit('recorder:state', { state: 'pause' }) }) this.recorder.onResume(() => { uni.$emit('recorder:state', { state: 'resume' }) }) this.recorder.onError((err) => { this.isRecording = false uni.$emit('recorder:state', { state: 'error', errMsg: err.errMsg }) }) } start() { // 参数按跨端最稳的组合设置,详见下方参数说明 this.recorder.start({ duration: 60000, // 最长录制 60 秒,超时自动停止 sampleRate: 44100, // 采样率统一用 44.1kHz,iOS 48kHz 在部分 Android 机型上有杂音 numberOfChannels: 1, // 单声道,双声道在小程序端会被忽略 encodeBitRate: 128000, // 码率 128kbps,体积和清晰度平衡点 format: 'aac', // 录制格式选 aac,iOS/Android 原生都认 frameSize: 20 // 帧大小,音频数据回调的间隔,默认 20ms 即可 }) } stop() { this.recorder.stop() } } export default new RecorderService()参数说明这几点要记住:duration单位为毫秒,设置后到达时长会自动触发onStop,不传则默认无限录,需要手动停止。sampleRate我统一用 44100,不用 48000,是因为实测部分 Android 机型对 48kHz 的 aac 编码支持不完整,录出来偶发破音。numberOfChannels在 App 端支持设 2,但小程序端只能设为 1,设 2 会直接报错,所以跨端项目直接写 1 最省心。encodeBitRate和sampleRate有关联,44100 采样率对应 128000 码率是性价比最高的组合。
format的选择是跨端最容易翻车的地方:小程序端支持aac和mp3,App 端 iOS 支持aac、mp3、wav,Android 原生plus.audio.Recorder支持amr、aac,但RecorderManager映射到 Android 上默认产出aac。所以统一选aac是兼容性最稳的答案,后续上传服务器转码也方便。
2.3 真机权限:manifest 配置和授权弹窗的触发时机
录音权限是所有录音功能的第一道坎。在 uniapp 里,权限分成两层:一层是应用安装时的系统权限声明,另一层是运行时调用uni.authorize触发的弹窗授权。
Android 端需要在manifest.json的app-plus节点下配置权限声明,核心是录音权限和存储权限:
{ "app-plus": { "distribute": { "android": { "permissions": [ "<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>", "<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>", "<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>", "<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\"/>" ] }, "ios": { "privacyDescription": { "NSMicrophoneUsageDescription": "需要麦克风权限以录制语音", "NSPhotoLibraryUsageDescription": "需要相册权限以保存音频文件" } } } } }iOS 端的权限描述必须写清楚用途,App Store 审核时会读取NSMicrophoneUsageDescription的文案,只写“录音”不够,最好写“用于录制语音消息并发送给好友”这类具体用途,否则有被拒的风险。
运行时授权我用uni.authorize做,配合一个状态标记,避免每次进页面都弹窗:
// 录音前检查并申请权限 async function ensureRecordAuth() { try { const auth = await uni.getSetting() if (auth.authSetting['scope.record'] === false) { // 用户之前拒绝了,需要引导去设置页 uni.showModal({ title: '需要麦克风权限', content: '请在设置中开启麦克风权限,否则无法录音', confirmText: '去设置', success: (res) => { if (res.confirm) uni.openSetting() } }) return false } if (auth.authSetting['scope.record'] === undefined) { // 首次申请 await uni.authorize({ scope: 'scope.record' }) } return true } catch (e) { console.error('录音权限申请失败', e) return false } }这段逻辑的要点是区分三种状态:undefined表示从未申请过,走uni.authorize正常弹窗;false表示用户拒绝过,直接弹窗引导去openSetting,否则再次authorize不会弹窗,只会静默失败;true表示已授权,直接返回。公众号 H5 端有个额外限制:录音只能在 HTTPS 环境下使用,http://的域名在部分浏览器里MediaRecorder不可用,本地开发可以用localhost绕过,线上必须上 HTTPS。
3. 播放功能:InnerAudioContext 的实例管理、事件监听与释放时机
3.1 创建播放实例时最容易忽略的 autoplay 与 obeyMuteSwitch
录音拿到的是临时文件路径tempFilePath,播放需要靠uni.createInnerAudioContext()创建的实例来完成。这个 API 底层在小程序端映射的是wx.createInnerAudioContext(),App 端映射的是原生播放器,H5 端走的是 HTML5 Audio 元素。
创建实例时有几个参数值得单独说,其中obeyMuteSwitch是 iOS 上最典型的坑:
// /utils/player.js class PlayerService { constructor() { this.playerMap = new Map() // 用 Map 管理多个播放实例,key 是音频 id } play({ id, src, autoPlay = true }) { // 停止同 id 的上一个实例,避免重复播放叠加 if (this.playerMap.has(id)) { this.playerMap.get(id).stop() this.playerMap.get(id).destroy() this.playerMap.delete(id) } const player = uni.createInnerAudioContext() player.src = src player.autoplay = autoPlay // iOS 静音模式下仍然发声的关键开关 player.obeyMuteSwitch = false player.onPlay(() => { uni.$emit(`player:${id}:state`, { state: 'playing' }) }) player.onEnded(() => { uni.$emit(`player:${id}:state`, { state: 'ended' }) this._destroyPlayer(id) }) player.onError((err) => { console.error(`播放失败 ${id}`, err) uni.$emit(`player:${id}:state`, { state: 'error', errMsg: err.errMsg }) this._destroyPlayer(id) }) this.playerMap.set(id, player) return player } _destroyPlayer(id) { const player = this.playerMap.get(id) if (player) { player.destroy() this.playerMap.delete(id) } } stop(id) { const player = this.playerMap.get(id) if (player) { player.stop() this._destroyPlayer(id) } } } export default new PlayerService()obeyMuteSwitch只有在 iOS 上生效,默认值为true,意思是当 iPhone 的静音拨片处于静音状态时,音频播放会被强制静音。设为false后,播放会无视静音拨片,通过扬声器发声。语音消息类应用通常都希望用户打开静音开关也能听到语音,所以这个参数要显式设为false。但注意:如果做的是音乐类 App,Apple 审核时可能要求尊重静音键,所以音乐播放器不要设置这个参数。
autoplay参数建议显式传,不要依赖默认值。不同端对默认行为的处理不完全一致,有的端创建实例后不设置src不会自动播放,有的端设置了src会直接播放。统一显式传autoplay: true/false可以消除这种不确定性。
3.2 src 赋值时机与 iOS 无声播放的原因分析
iOS 上播放无声是高频问题,我排查过几次,根因几乎都出在src赋值和play()调用的时序上。InnerAudioContext的src设置后,需要等待音频解码完成才能播,iOS 上如果src刚赋值就立刻调用play(),引擎会吞掉这次播放请求,表现为什么都不发生,也没有报错。
稳妥的做法是先赋值src,然后监听onCanplay,在回调里再调用play():
function playWithCanplay(url) { const player = uni.createInnerAudioContext() player.src = url player.autoplay = false player.onCanplay(() => { player.play() }) player.onError((err) => { console.error('解码失败', err) }) }onCanplay表示音频资源已经加载到可播放状态,这个事件在小程序端和 App 端都可靠。网络慢时,onCanplay可能要等几百毫秒甚至几秒,UI 层可以在这段时间显示 loading 状态。
还有一种 iOS 无声的情况是音频路由问题:iPhone 连接了蓝牙耳机时,播放会走蓝牙通道,如果蓝牙设备断连或者进入低功耗模式,播放会暂停。这种情况没有直接 API 可以查当前音频路由,只能通过监听onPause事件辅助感知。
3.3 播放进度、时长读取和播放完成的统一状态机
播放器状态的统一管理比功能本身更影响体验。我一般把播放状态收敛成一个状态机:idle -> loading -> playing -> paused -> ended -> error,每个状态对应一套 UI。
// 在组件中管理播放状态 data() { return { playState: 'idle', // idle | loading | playing | paused | ended | error currentTime: 0, duration: 0 } } // 时间更新与进度展示 listenPlayerEvents(player) { player.onTimeUpdate(() => { this.currentTime = player.currentTime // 小程序端 duration 在 onCanplay 后可能为 0,用这个兜底 if (this.duration === 0 && player.duration > 0) { this.duration = player.duration } }) player.onEnded(() => { this.playState = 'ended' this.currentTime = 0 this.duration = 0 }) }currentTime是当前播放的进度秒数,duration是总时长秒数。这里有个已知问题:部分 Android 设备上,duration在onCanplay之后读取可能仍为 0,需要等onTimeUpdate触发后从player.duration重新获取,所以我在上面代码里做了兜底赋值。
播放完成后的实例要及时destroy()。createInnerAudioContext每次调用都会生成新的原生播放器实例,如果只stop()不destroy(),在页面频繁切换后会产生大量僵尸播放器,表现为内存上涨和后续播放偶发无声。标准的收尾动作是:stop()后调用destroy(),并把实例从缓存 Map 中移除。
4. 跨端兼容性对照:同一份代码在 iOS、Android、小程序和 H5 上的表现差异
4.1 录音格式与码率参数在四个端上的真实支持表
多端兼容是 uniapp 的卖点,但音频组件恰恰是端差异最大的部分。下面是我按实际真机验证整理的参数支持情况,跨端开发时直接对照这个表设参数:
| 能力项 | iOS App | Android App | 微信小程序 | H5 |
|---|---|---|---|---|
| 录音格式 | aac / m4a | aac | aac / mp3 | webm / ogg(依赖浏览器) |
| 采样率设置 | 支持自定义 | 支持自定义 | 仅 8000~48000 有效 | 不支持自定义 |
| 双声道录制 | 支持 | 部分支持 | 不支持(强制单声道) | 不支持 |
| 后台继续录音 | 需配置 UIBackgroundModes | 需前台服务 | 不支持 | 不支持 |
| 播放格式 | mp3/aac/wav | mp3/aac/wav | mp3/aac/m4a | mp3/aac(其余看浏览器) |
| 播放时忽略静音键 | 支持 | 无此概念 | 支持 | 不支持 |
这张表是我整理的记忆版,具体到某台真机可能有差异,但方向不会错。重点记住三件事:录音格式统一用aac,两边都有原生支持;采样率设 44100 而非 48000;小程序端严格不设numberOfChannels: 2。
4.2 后台录音与播放的配置差异:为什么 Android 能录 iOS 会中断
后台录音和后台播放是两个经常被混淆的场景。录音指的是 App 切到后台后继续采集麦克风数据,播放指的是 App 切后台后音频继续出声。
先说后台播放:小程序端不支持 App 切后台后继续播放,除非是用wx.setInnerAudioOption开启mixWithOther加原生后台模式,但小程序从后台恢复时播放器会暂停,这是平台限制。App 端 iOS 需要在manifest.json中配置后台音频模式:
{ "app-plus": { "distribute": { "ios": { "UIBackgroundModes": ["audio"] } } } }Android 端 Play 服务不存在这个开关,默认切后台音频不会中断,但部分国产 ROM 会在应用被清理时杀掉播放进程,需要引导用户加入电池优化白名单。
后台录音则复杂得多。Android 上需要前台服务保活,uniapp 没有直接暴露这个能力,常见做法是使用原生插件,或者接受“后台录音不可用”的现状。iOS 上需要在UIBackgroundModes里加audio并配合 AVAudioSession 的AVAudioSessionCategoryPlayAndRecord模式,同样需要原生代码介入。所以纯 uniapp 项目里,我一般会把后台录音标记为“受限能力”,产品上改为锁屏前录完,或者提示用户保持 App 在前台。
4.3 H5 端的浏览器差异:为什么 HTTPS 和 UA 一起决定录音能不能用
H5 端录音底层走的是浏览器MediaRecorderAPI,播放走的是 Audio 元素。这意味着 H5 端的录音能力完全依赖浏览器实现,差异很大。
Chrome 系浏览器对MediaRecorder支持最好,输出格式通常为webm,但这种格式在 iOS 原生 App 里播不了,如果录音要跨端使用,H5 端采集后需要服务端转码。Safari 对MediaRecorder的支持是从 14.1 之后才稳定的,老版本 Safari 直接没有 MediaRecorder,H5 录音不可用。一个务实的策略是:H5 端只做播放,录音功能在小程序和 App 端提供,H5 页面用提示引导用户打开小程序。
如果业务必须 H5 录音,播放时还要注意webm格式的文件在 Android WebView 和老版本 Chrome 上可能因为缺少audio/webm解码器而静默失败。统一方案是录音上传后服务端转成 mp3 或 aac 再下发播放,不在前端处理格式兼容。
还有一个容易被忽略的点:MediaRecorder在浏览器中是分块输出数据的,需要自行拼接 Blob,uniapp 的RecorderManager在 H5 端返回的tempFilePath是浏览器内部生成的 Blob URL,这个 URL 只在当前页面会话有效,刷新页面就失效了,不能持久化存储,必须上传到服务器换取持久 URL。
5. 录音播放避坑地图:五个真机场景,从现象到根因再到解决
5.1 场景一:Android 录音没声音,但权限已经开了
现象:uni.authorize通过,录音正常开始,onStop也回调了,但播放录音文件时只有轻微的底噪,人声几乎听不见。
原因:Android 部分机型的麦克风默认开启了降噪/回声消除,录制人声时把低频和高频都削掉了一部分,尤其是安静环境下更明显。另有一种情况是 TBS 内核的 WebView 录音权限正常,但音频路由错误地指向了通话通道。
解决:录音前显式设置音频路由,在plus.audio层面没有直接 API,常见做法是播放时用createInnerAudioContext的obeyMuteSwitch参数并考核设备本身的麦克风降噪策略。实测可行的手段是录制时靠近麦克风并且采用 44.1kHz 采样率,能显著减轻降噪算法对语音的损伤。如果产品对音质要求高,考虑换用原生录音插件。
5.2 场景二:iOS 第一次录音正常,第二次录音闪退
现象:App 在 iOS 上首次进入录音页正常,录完一次返回再进,点击录音按钮应用直接闪退或黑屏。控制台没有任何报错,只有out of memory式的崩溃信息。
原因:录音管理器是单例,但onStop回调里如果持有录音文件的引用没有释放,加上播放器实例未 destroy,连续创建后内存暴涨。iOS 对内存的处理比 Android 激进,超限直接杀进程。
解决:每次onStop后把tempFilePath赋值给数据后用uni.removeSavedFile清理不再需要的本地临时文件。录制文件已上传的,服务器保留就够,本地副本一律移除。同时严格检查播放器实例,stop()之后强制destroy(),不要依赖 GC。
5.3 场景三:小程序端录音 60 秒自动停止,但 iOS 上一直录不停
现象:小程序端设置了duration: 60000,到点自动触发onStop;但同一套代码在 iOS App 端录制超过 60 秒仍不停止。
原因:App 端RecorderManager对duration的语义和小程序端有细微差异,部分 iOS 版本上duration参数未透传给原生录音模块,导致按需时长失效。
解决:前端自己做定时器兜底,进入start()时同时启动一个setTimeout对应时长,到点调用stop()。定时器要在onStop回调后清理,避免重复触发。这个方案替代对duration的依赖,四端统一生效。
start() { clearTimeout(this._timer) this.recorder.start({ duration: 60000, format: 'aac' /* 其他参数 */ }) this._timer = setTimeout(() => { if (this.isRecording) { this.recorder.stop() } }, 61000) // 留 1 秒冗余给启动延迟 }5.4 场景四:Android 上 aac 文件播放时,进度条走到一半自动跳到结尾
现象:录音时长 30 秒,播放到 20 秒左右直接触发onEnded,进度条瞬间到 100%,但音频实际没播完。
原因:Android 部分 ROM 的解码器对 aac 文件的时长计算不准确,duration值比真实时长短,播放器按错误的时长触发结束事件。
解决:播放时不用duration决定进度条总长,改用onTimeUpdate累计实际播放时长,或者从录音侧在onStop回调里拿到res.duration传给播放页作为预期时长。更进一步,服务端转码成 mp3 也能规避这个问题,mp3 的时长字段在 Android 解码器上更可靠。
5.5 场景五:录音权限弹窗不出现,点了按钮没反应
现象:用户第一次点录音按钮,预期弹出系统权限询问框,实际什么都没发生,按钮点击无反馈。
原因:uni.authorize在某些版本的 App 端对scope.record的处理有兼容问题,微信小程序端必须scope.record,但 App 端部分版本不识别这个 scope,权限校验静默失败。
解决:App 端直接调用原生录音 API 的前置检查,或者先用uni.getSetting检查是否已授权,再按 uniapp 官方推荐方式触发。实操中,App 端弹权限失败的兜底方案是直接提示用户手动去系统设置打开麦克风权限,引导路径为:设置 → 应用管理 → 当前应用 → 权限 → 麦克风。代码里判断授权状态为false或调用后无反应时,展示手动开启步骤即可。
6. 进阶实践:录音时长限制、音量可视化与文件清理的一体化方案
录音播放做到能跑只是及格线,体验上要过得去,时长控制、实时反馈、文件生命周期这三个点要一起处理。先说时长控制:不要依赖单一 API 的duration,用定时器加管理器状态双保险,onStop和定时器两个来源都确认后再更新 UI;再说反馈:录音时 UI 上要有实时音量指示条,用RecorderManager的onFrameRecorded事件可以拿到原始音频数据帧,计算 RMS 值驱动音量动画:
// 录音过程中监听音频帧,计算音量大小 this.recorder.onFrameRecorded((res) => { if (!res.frameBuffer) return // frameBuffer 是 ArrayBuffer,转为 Int16Array 计算 RMS const buffer = new Int16Array(res.frameBuffer) let sum = 0 for (let i = 0; i < buffer.length; i++) { sum += buffer[i] * buffer[i] } const rms = Math.sqrt(sum / buffer.length) // 映射到 0~100 的音量值,驱动 UI 柱状条;rms 最大约 32768 const volume = Math.min(100, Math.round((rms / 32768) * 100)) uni.$emit('recorder:volume', volume) })这段逻辑的关键是frameBuffer的解析方式:底层是小端序的 16 位 PCM 数据,所以用Int16Array视图读取;RMS 值能反映实际音量大小,映射到 0~100 的百分比就够用了。onFrameRecorded需要在start()参数里设置frameSize才会触发回调,frameSize是采样帧数而非毫秒数,20 表示每次回调携带 20 帧数据。
最后是文件清理的收尾习惯。我现在的做法是:录音结束后立即把文件传到服务器或对象存储,拿到持久 URL 后立刻uni.removeSavedFile删除本地临时文件;播放器实例一律在onEnded/onError后destroy()。这套清理逻辑和录音播放本身一样重要,时间久了内存和存储问题都会找上门。
音频方向做到这里,基本能覆盖大多数业务诉求。遇到更复杂的场景,比如实时变声、音频裁剪、多段拼接,就需要接原生插件或服务端处理,但录音、播放、权限、兼容这几个基建件用 uniapp 完整跑通是不成问题的。我在目前的项目里始终保持一个习惯:录音和播放相关代码全部收敛成单例服务,禁止在页面里散落 create 和 destroy 调用,这能避免很多玄学问题——希望帮到你。
本文还有配套的精品资源,点击获取