uniapp跨端录音播放实战:权限、兼容性与踩坑全解析 简介基于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 录出来是 m4aAndroid 低端机可能读不到时长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.1kHziOS 48kHz 在部分 Android 机型上有杂音 numberOfChannels: 1, // 单声道双声道在小程序端会被忽略 encodeBitRate: 128000, // 码率 128kbps体积和清晰度平衡点 format: aac, // 录制格式选 aaciOS/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和mp3App 端 iOS 支持aac、mp3、wavAndroid 原生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。但注意如果做的是音乐类 AppApple 审核时可能要求尊重静音键所以音乐播放器不要设置这个参数。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 AppAndroid App微信小程序H5录音格式aac / m4aaacaac / mp3webm / ogg依赖浏览器采样率设置支持自定义支持自定义仅 8000~48000 有效不支持自定义双声道录制支持部分支持不支持强制单声道不支持后台继续录音需配置 UIBackgroundModes需前台服务不支持不支持播放格式mp3/aac/wavmp3/aac/wavmp3/aac/m4amp3/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 直接没有 MediaRecorderH5 录音不可用。一个务实的策略是H5 端只做播放录音功能在小程序和 App 端提供H5 页面用提示引导用户打开小程序。如果业务必须 H5 录音播放时还要注意webm格式的文件在 Android WebView 和老版本 Chrome 上可能因为缺少audio/webm解码器而静默失败。统一方案是录音上传后服务端转成 mp3 或 aac 再下发播放不在前端处理格式兼容。还有一个容易被忽略的点MediaRecorder在浏览器中是分块输出数据的需要自行拼接 Blobuniapp 的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 调用这能避免很多玄学问题——希望帮到你。本文还有配套的精品资源点击获取