news 2026/10/16 1:07:21

钉钉小程序录音播放完整实践:从录音到上传避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
钉钉小程序录音播放完整实践:从录音到上传避坑指南

做钉钉小程序开发这两年,处理最多的需求之一,就是在工作流里加一段语音。巡检上报、维修备注、审批补充、会议纪要口述,几乎每个业务方都想要“随手录一句、对方点开就能听”的能力。但真把录音和播放做进钉钉小程序里,坑比想象中多:授权弹窗不出现、录音文件播放不了、安卓正常iOS没声音、列表页反复播放导致音频串台……这些我都踩过。这篇就把一套能直接跑通“录音、预览、上传、播放”的完整实践过程拆开讲,代码可以直接抄,注释都给你标好。

这个方案基于钉钉小程序内置的录音和音频播放API,不引第三方SDK,不依赖原生插件,适合大部分办公类小程序的语音能力建设。不管你是刚接手钉钉小程序开发的新手,还是在已有项目里补功能的老手,下面的实现思路和排查清单都可以直接参考。

1. 方案选型与整体设计思路

1.1 为什么直接用钉钉小程序API,而不是H5方案

钉钉小程序基于特定的开放体系运行,和普通网页H5的音频能力有本质区别。H5里想录音通常要用Web Audio API加MediaRecorder,浏览器兼容性参差不齐,而且在钉钉容器里,一些浏览器接口会被拦截或提示“当前环境不支持”。更关键的是H5录音拿到的数据是小段Blob,要自己做编码合并、格式转换,上传时还要处理分片,逻辑链太长,出错点太多。

钉钉小程序内置的my.getRecorderManager()就是官方录音管理器,调起的是宿主App的原生录音能力,底层是系统级音频采集,稳定性比H5方案高一个档次。播放端配合my.createInnerAudioContext()创建音频实例,也不用手动写Audio元素或者管理播放状态机。两个API配合起来,录音结束直接给你一个tempFilePath临时文件路径,后续要预览播放、上传服务器、再次拉取播放,路径逻辑完全统一。

所以第一原则:能用原生API解决,绝不自造轮子。

1.2 整体功能拆分:录音、预览、上传、播放四个阶段

把需求拆开看,一个完整的语音备注功能至少包含四段逻辑:

  • 录音:获取麦克风权限、录音、结束录音、拿到临时文件。
  • 预览:录音结束后在本机试听,确认没问题再提交。
  • 上传:把临时文件传到自己的服务器或对象存储,拿到可访问的URL。
  • 播放:在列表页、详情页拉取URL,创建音频实例播放。

这四段看起来简单,但每一段都有独立的生命周期和边界问题。录音涉及权限与回调监听;预览涉及临时文件的存活时间;上传涉及网络和文件格式;播放涉及实例复用与销毁。我的做法是在代码结构上把四个阶段拆开封装,不要让它们层层嵌套在一个页面逻辑里,否则后面加需求、查问题都会很痛苦。

结构上,建议单独建一个audioManager.js工具类,集中封装录音参数与事件回调;页面里只负责用户交互和UI状态。这样录音和播放逻辑可以被多个页面复用,不需要每写一个页面就复制一遍录音代码。

1.3 数据流设计:临时文件、上传URL与时长不一致问题

这里有一个容易被忽略的设计点:录音产生的是本地临时文件,离开小程序环境就失效;上传到服务器后才会有稳定的线上URL。而播放页拿到的可能是临时路径,也可能是HTTP链接,这两类路径在InnerAudioContext里都能直接赋值给src,但业务上必须区分,否则测试环境正常、上线后偶尔播放失败。

另外,录音时长与播放端实际时长可能不一致。录音参数里设置的duration是最大录制时长,不是最终时长;最终时长要从onStop回调的duration字段读取。播放器里的duration属性则来自音频解码信息。我在做列表页显示“xx秒”时,统一用服务端存下来的时长,不在前端临时算,避免不同来源的数据互相覆盖。

2. 录音功能的核心实现与细节处理

2.1 录音管理器的初始化与参数配置

先看最基础的录音实现。在页面里引入录音管理器,建议放在onLoad里初始化,不要在data里存一个庞大的实例对象,避免被序列化出问题。

const recorderManager = my.getRecorderManager(); recorderManager.onStart(() => { console.log('录音开始'); this.setData({ recording: true }); }); recorderManager.onStop((res) => { console.log('录音结束,临时文件为:', res.tempFilePath); console.log('录音时长(毫秒):', res.duration); this.setData({ recording: false, tempFilePath: res.tempFilePath, recordDuration: res.duration }); }); recorderManager.onError((err) => { console.error('录音出错:', err); my.showToast({ title: '录音失败', icon: 'none' }); this.setData({ recording: false }); });

开始录音时,start方法传参数,我常用的配置如下:

recorderManager.start({ duration: 120000, // 最大录音时长,单位毫秒 sampleRate: 44100, // 采样率 numberOfChannels: 1, // 单声道 encodeBitRate: 64000, // 编码码率 format: 'aac' // 录音格式 });

这里有几个参数值得多说一句。sampleRate和encodeBitRate直接影响录音文件大小和清晰度。语音备注场景不需要HiFi音质,44100Hz + 64kbps的AAC编码已经很清楚,文件体积也小;有人喜欢把采样率拉到48000、码率拉到128k,结果一个60秒录音多出几百KB流量,真没必要。format我固定用aac,兼容性和压缩比都比mp3稳,实测iOS和Android在钉钉容器里都能正常播放和上传。

2.2 点击授权:录音权限的获取与状态判断

录音属于敏感权限,钉钉小程序里必须先取得用户授权。很多新人第一次写录音功能,直接调start(),结果在低版本钉钉上没有任何反应,连报错都没有,那是因为没有处理授权。

推荐做法:在录音按钮的点击事件里先判断权限状态,再决定是直接开始录音还是先跳授权。

my.getSetting({ success: (res) => { // res.authSetting 形如 { 'scope.record': true } if (res.authSetting['scope.record']) { // 已授权,直接开始录音 this.startRecord(); } else { // 未授权,弹窗申请 my.authorize({ scope: 'scope.record', success: () => { this.startRecord(); }, fail: () => { my.showToast({ title: '需要麦克风权限才能录音', icon: 'none' }); } }); } } });

这里有个细节:授权状态为“拒绝过”以后,再次调用my.authorize可能不会弹窗,直接进fail回调。这种情况正确的引导是让用户去设置页手动打开权限。小程序里可以提示“请在钉钉设置中允许使用麦克风”,但小程序本身无法直接调起设置页,只能靠文案引导用户去手机系统设置里操作。我在实际开发里发现,钉钉授权被拒后,用户在系统设置里能看到对应应用的麦克风开关,重新打开后回到小程序需要杀掉重进才会刷新授权状态,这一点文案里可以写清楚。

另一个容易踩的坑是:真机预览时如果开发版和正式版用的不是同一个体验账号,授权状态不共享。同一个钉钉账号下,开发版里授权过,正式版首次使用还是需要再授权一遍,测试时别以为这是Bug。

2.3 录音生命周期与超时处理

recorderManager.start()传入的duration只是最大时长,不是定时器。录音达到设置的最大时长后,系统会自动调用stop()并触发onStop。这个机制看起来省事,但有一个实际问题:如果录音超过最大时长,用户本来还在说话,突然被系统切断,体验很突兀。

我给产品建议的是:最大时长设成120秒(实际需求中几乎没有超过两分钟的语音备注),同时在UI上做倒计时提示,最后10秒显示“即将自动结束”。倒计时用页面里的setInterval实现,从start开始每秒计算剩余时间,到了0就手动调用stop()。为什么手动调用?因为自动停止的时机虽然也是准确的,但如果页面切后台,setInterval可能被挂起,手动停止和自动停止之间会产生一两秒误差。手动调stop()更可控,回调里的res.duration仍然是真实录制时长,数据不会错。

startRecord() { recorderManager.start({ duration: 120000, format: 'aac' }); this.startCountdown(120); }, stopRecord() { recorderManager.stop(); this.clearCountdown(); }

注意:录音过程中如果用户切到后台,录音一般会继续,但页面的倒计时可能不准了。我在项目里不强行处理这个场景,只在onHide里做一次状态标记,回来时校正倒计时。

2.4 录音取消与缓存清理

业务上通常需要一个“取消录音”的操作。录音管理器的设计是:录音一旦开始,只能stop()结束,没有cancel()方法。所以取消录音的实现方式是:先stop()拿到临时文件,然后调用my.removeSavedFile删除这个临时文件,同时丢弃tempFilePath,不把它当成有效录音。

cancelRecord() { recorderManager.stop(); // 在 onStop 里判断业务状态,如果是取消操作就不保存路径 this.setData({ canceled: true }); }

这一段里另一个隐蔽问题是临时文件的生命周期。录音返回的tempFilePath属于临时文件,小程序内会有一段时间的有效期,超过后可能被系统清理。如果用户录音结束后一直不提交,临时文件过期,播放就会失败。稳妥做法是:录音结束后立刻把本地临时文件复制到业务目录的持久化路径。用文件系统的保存接口复制一份:

my.saveFile({ tempFilePath: res.tempFilePath, success: (saveRes) => { // savedFilePath 是持久化的本地文件路径 this.setData({ savedFilePath: saveRes.savedFilePath }); } });

如果只是短时间预览再用,不持久化也能跑;但业务上我一律建议保存,因为语音备注经常会“录完再想一想”,不能让它被系统清掉。

2.5 完整录音模块封装示例

把上面的逻辑收拢成一个可在多个页面复用的模块,核心结构如下:

// audioManager.js class AudioManager { constructor() { this.recorderManager = my.getRecorderManager(); this.isRecording = false; this.bindEvents(); } bindEvents() { this.recorderManager.onStart(() => { this.isRecording = true; this.onStateChange && this.onStateChange('start'); }); this.recorderManager.onStop((res) => { this.isRecording = false; this.onRecordEnd && this.onRecordEnd(res); }); this.recorderManager.onError((err) => { this.isRecording = false; this.onError && this.onError(err); }); } start() { if (this.isRecording) return; this.recorderManager.start({ duration: 120000, format: 'aac', sampleRate: 44100, numberOfChannels: 1 }); } stop() { if (!this.isRecording) return; this.recorderManager.stop(); } } module.exports = new AudioManager();

页面里使用时,直接在onLoad里注入回调:

const audioManager = require('../../utils/audioManager'); audioManager.onRecordEnd = (res) => { this.setData({ tempFilePath: res.tempFilePath }); };

封装的好处是,录音状态机只维护一份,不会因为页面跳转产生多个录音实例同时存在。

3. 播放功能的实现与交互细节

3.1 创建音频实例并处理常见播放事件

播放端使用my.createInnerAudioContext(),这套API和多数小程序平台的音频用法高度相似。

const audioContext = my.createInnerAudioContext(); audioContext.src = this.data.tempFilePath; // 本地临时文件或线上URL audioContext.autoplay = false; audioContext.onPlay(() => { this.setData({ playing: true }); }); audioContext.onPause(() => { this.setData({ playing: false }); }); audioContext.onEnded(() => { this.setData({ playing: false }); this.setData({ currentTime: 0 }); }); audioContext.onError((err) => { console.error('播放出错', err); my.showToast({ title: '音频加载失败', icon: 'none' }); this.setData({ playing: false }); }); audioContext.play();

这里最容易被坑的是:创建了实例却没有在页面卸载时销毁。每次进入页面创建一个InnerAudioContext,退出页面不destroy(),内存里就会攒下一堆音频实例。尤其是列表页反复播放多条语音,不销毁的话,旧实例可能还在播放,新实例的声音叠在一起,听起来就像“串台”。所以onUnload里必须销毁:

onUnload() { if (this.audioContext) { this.audioContext.destroy(); this.audioContext = null; } }

销毁后不能再调用这个实例的任何方法,否则会报错。页面里开始新的播放前,先判断旧实例是否存在,如果存在就stop()后destroy(),再创建新实例。更稳妥的做法是在同一个页面始终复用同一个实例,只替换src。

3.2 播放进度条与当前时间更新

如果要做播放进度条,可以用onTimeUpdate事件拿到当前播放位置,再配合duration计算百分比。

audioContext.onTimeUpdate(() => { const currentTime = audioContext.currentTime; const duration = audioContext.duration; if (duration > 0) { const progress = Math.round((currentTime / duration) * 100); this.setData({ playerProgress: progress, currentTimeText: this.formatTime(currentTime), durationText: this.formatTime(duration) }); } });

onTimeUpdate的触发频率在不同平台上不一样,有的约250ms一次,有的更密集。拿来做进度条足够,但不要在这个回调里做复杂计算或频繁setData大对象,真机测试时会有明显卡顿,尤其是长列表页面。

拖动进度条时,要设置一个seeking状态,拖动过程中暂停更新时间,否则进度条会一边被用户拖动一边被回调拽回去。拖完再赋值audioContext.seek(position),然后解除seeking状态:

onSliderChange(e) { const position = e.detail.value; this.setData({ seeking: true, sliderValue: position }); audioContext.seek(position); }, onSliderChanging(e) { this.setData({ sliderValue: e.detail.value }); }, onSliderAfterChange() { this.setData({ seeking: false }); }

真机上seek后再触发onTimeUpdate,数据可能和sliderValue有毫秒级偏差,这都是正常的,不要过度纠偏。

3.3 播放列表:单实例复用的正确姿势

语音列表页最容易出现的问题是:每条item都有自己的播放按钮,点击一条在播,再点另一条,前一条没停,两条声音叠在一起。我的处理方式是不为每条语音单独创建实例,而是全局只维护一个播放实例,点击事件里判断:

  • 如果点击的是当前正在播放的条目,执行pause()或stop(),并把该条playing状态置为false。
  • 如果点击的是新的条目,先把实例src切换为新条目的音频地址,再play()。
playVoice(e) { const index = e.currentTarget.dataset.index; const item = this.data.list[index]; if (this.currentIndex === index && this.audioContext && !this.audioContext.paused) { this.audioContext.pause(); this.setData({ currentPlayIndex: -1 }); return; } if (this.audioContext) { this.audioContext.destroy(); } this.audioContext = my.createInnerAudioContext(); this.audioContext.src = item.fileUrl; this.audioContext.play(); this.setData({ currentPlayIndex: index }); }

这个方案还会伴随另一个小问题:切歌后旧实例的onEnded回调已经绑定了旧数据,如果不销毁会触发旧回调把新状态改掉。所以我切歌时一定先destroy()再创建新实例,确保回调绑定和当前播放对象一一对应。

3.4 播放控件在页面里怎么放,状态怎么同步

UI上我习惯用一个独立的播放状态对象,避免在每条数据里插一堆播放字段。

this.setData({ playerState: { index: index, playing: true, progress: 0, durationText: '00:00', currentTimeText: '00:00' } });

模板里用playerState.index判断当前条目是否为播放态,播放图标切换为暂停图标。这样做的一个额外好处是,列表数据从接口刷新后不会因为多了一些与业务无关的playing字段造成分页数据不一致。实际开发里,很多语音列表是滚动懒加载的,频繁重渲染数据,用独立状态对象能减少setData的体积。

4. 权限、真机环境与常见问题排查

4.1 钉钉后台的隐私权限与API权限申请

钉钉小程序的录音能力不是装上代码就能用的。开发者在钉钉开放平台创建小程序后,需要在后台申请“录音”权限能力。如果后台没有申请,真机上调用录音API会直接失败,常见报错包括“无权限”“scope.record 未获得授权”。这个权限申请一般需要企业管理员或应用管理员操作,个人开发者自建的小程序往往卡在这里。

另外,发布上线时钉钉会要求配置隐私政策,其中需要明确声明“本应用会调用麦克风权限用于语音备注/语音上报”。如果隐私协议里没有写明,审核阶段会被打回来。我遇到过一次,以为只是简单加一句话,实际上需要在后台的“隐私保护指引”里单独勾选麦克风信息采集项,审核才会通过。

4.2 iOS与Android在录音播放上的差异

两份系统在录音API上的行为大体一致,细节差异集中在三处:

第一,iOS上录音格式用mp3可能偶发编码问题,但aac表现稳定。如果你是老项目里已经录了mp3文件,播放端用InnerAudioContext通常也能解,但新录音建议一律aac。

第二,Android上部分机型在首次调用recorderManager.start()到真正开始录制之间有几十到几百毫秒的延迟,如果用户立刻说话,开头会被切掉一点。这个问题无法完全消除,折中办法是在onStart回调后再过300ms才显示“正在录音”动画,避免用户以为已经开始,说完头两个字就没了。

第三,iOS对音频播放有“静音拨片”限制。当手机侧面的静音开关打开时,InnerAudioContext播放可能无声。这在开发时非常容易误判为代码问题,实际是系统层面的音频会话配置。钉钉容器内我没有找到直接强制绕过静音开关的接口,测试时只能提醒自己把静音开关拨回来。

4.3 录音文件无法播放的常见原因

录音结束拿到tempFilePath后立即用InnerAudioContext播放,这一步在大部分情况没问题。如果播放失败,优先检查三点:

  • 路径是否还是个临时文件,是否在onStop回调之前就去取路径。onStop的res.tempFilePath一定存在,但如果你把录音结果存进data后再去别的页面读取,临时文件可能已经不在,应尽快保存或上传。
  • 上传后的文件URL是否带扩展名。有些服务端存储不写Content-Type,音频文件URL没有.aac扩展名,播放器识别不出来。上传接口返回URL后,我在前端会做一个兜底:如果URL不以.aac或.m4a结尾,播放时仍然赋值给src,大多数播放器能按内容识别,但不排除某些版本钉钉容器解析失败。稳妥做法是上传时把文件名设为带正确扩展名的随机串。
  • 播放地址是不是HTTPS。钉钉小程序的正式环境不允许HTTP资源,http://的音频地址大概率无法播放。

4.4 录音被来电、通知打断的处理

实际办公场景中录音时接到电话的情况太多了。录音API会在系统打断时触发错误或自动停止,不同机型表现不一样。我目前的做法是注册onError回调时做一层容错,录音被系统打断后,不要直接提示“录音失败”,而是提示“录音已中断,请重新录制”,然后清理本次录音状态。

recorderManager.onError((err) => { if (err.errMsg && err.errMsg.includes('interrupt')) { my.showToast({ title: '录音被中断,请重试', icon: 'none' }); } else { my.showToast({ title: '录音失败,请重试', icon: 'none' }); } this.setData({ recording: false }); this.clearCountdown(); });

播放端被系统来电打断后,InnerAudioContext通常会自动暂停,但不会统一触发onPause,有时会触发onStop。我的经验是在onShow里检查一遍播放状态,如果发现实例存在但paused为true且之前的状态是playing,就恢复UI为暂停态,不强求自动续播,把选择权交给用户。

4.5 常见问题排查速查表

现象可能原因排查与处理
点击录音按钮没反应后台未申请录音权限去钉钉开放平台后台确认录音能力已开通
authorize不弹弹窗之前拒绝过授权提示用户去系统设置打开麦克风权限
onStart一直不触发录音权限未授权检查authSetting中scope.record状态
录音结束文件播放无声格式不被播放器支持统一使用aac格式
播放列表串音多个InnerAudioContext同时存在切歌先destroy旧实例
播放到一半没声音系统来电打断在onShow里重置播放状态
上传后播放链接失效临时文件路径被服务端当永久文件上传成功后换用线上URL

5. 从录音到播放的完整业务串联

5.1 一个可运行的完整流程Demo

最后把整个链路串成一个可以照抄的流程:点击录音按钮开始录音,录音结束自动出现“试听”按钮,点击试听播放本地文件,确认无误后点击“提交”,提交时先上传音频文件,再连同业务信息一起提交表单。

Page({ data: { recording: false, tempFilePath: '', recordDuration: 0, playing: false, uploading: false }, onLoad() { this.audioContext = null; }, onUnload() { if (this.audioContext) { this.audioContext.destroy(); } }, onRecordTap() { if (this.data.recording) { this.stopRecord(); } else { this.checkPermissionAndStart(); } }, checkPermissionAndStart() { my.getSetting({ success: (res) => { const auth = res.authSetting && res.authSetting['scope.record']; if (auth) { this.startRecord(); } else { my.authorize({ scope: 'scope.record', success: () => this.startRecord(), fail: () => { my.showToast({ title: '需要麦克风权限', icon: 'none' }); } }); } } }); }, startRecord() { this.recorderManager = this.recorderManager || my.getRecorderManager(); this.recorderManager.onStop(this.handleRecordStop.bind(this)); this.recorderManager.start({ duration: 120000, format: 'aac' }); this.setData({ recording: true, tempFilePath: '', recordDuration: 0 }); }, stopRecord() { if (this.recorderManager) { this.recorderManager.stop(); } }, handleRecordStop(res) { this.setData({ recording: false, tempFilePath: res.tempFilePath, recordDuration: res.duration }); }, onPreviewTap() { if (!this.data.tempFilePath) return; if (this.audioContext) { this.audioContext.destroy(); } this.audioContext = my.createInnerAudioContext(); this.audioContext.src = this.data.tempFilePath; const ctx = this.audioContext; ctx.onEnded(() => this.setData({ playing: false })); ctx.play(); this.setData({ playing: true }); }, onSubmitTap() { if (!this.data.tempFilePath) return; this.setData({ uploading: true }); my.uploadFile({ url: 'https://your-domain.com/api/upload', filePath: this.data.tempFilePath, name: 'file', formData: { scene: 'voice-remark' }, success: (res) => { // res.data 通常是JSON字符串,需要parse const data = JSON.parse(res.data); this.submitBizData(data.fileUrl); }, fail: () => { my.showToast({ title: '上传失败', icon: 'none' }); }, complete: () => { this.setData({ uploading: false }); } }); }, submitBizData(fileUrl) { // 提交业务表单,例如工单号、备注文本、音频URL、时长 my.request({ url: 'https://your-domain.com/api/biz', method: 'POST', data: { fileUrl: fileUrl, duration: this.data.recordDuration }, success: () => { my.showToast({ title: '提交成功' }); } }); } });

这段代码里的上传逻辑有个关键点:my.uploadFile的filePath是临时文件路径时,可以在请求期间正常读取,但如果上传失败需要重试,临时文件可能已失效。我在重试场景下会把临时文件先my.saveFile保存成savedFilePath再上传,减少重试失败概率。

5.2 临时文件与上传策略的取舍

为什么上传接口要单独跑一遍?因为大多数业务服务器不是直接用tempFilePath就能访问的。临时路径只在用户当前会话内有效,服务器无法直接从公网拿到这个路径,必须通过表单上传把文件内容传给服务器。

上传时的formData和name参数要和后端约定好,不然拿到的是空文件。文件类型字段我一般让后端通过扩展名判断,前端在filePath后拼接一个带格式的临时文件名可以辅助识别:

const uploadPath = this.data.tempFilePath; // 部分版本的临时路径没有扩展名,做一层兜底 const finalPath = uploadPath.indexOf('.aac') > -1 ? uploadPath : uploadPath + '.aac';

这个拼接技巧不一定在所有环境都可靠,但实测下来对后端做文件类型识别确实有帮助。

5.3 体验优化清单

语音功能上线前,我把体验优化项列成一张清单,逐项确认:

  • 录音按钮的按下状态要有明显反馈,开始录音后显示秒数,防止用户以为没点中。
  • 录音过程中页面退出(onHide)时,建议自动停止录音并保存已有片段,不要丢数据。
  • 播放中如果有进度条,拖动结束要重新绑定onTimeUpdate,避免进度条不回弹。
  • 列表页切后台再回来,恢复播放按钮为暂停态,不要显示一个永远转圈的图标。
  • 上传过程中禁用录音按钮,防止用户在上传的同时又录一段新音频,两个流程抢资源导致文件错乱。
  • 语音时长显示精确到秒,少于1秒的直接按1秒算,避免用户说“我明明说了话,怎么显示0秒”。

这些点本身都不复杂,但漏掉任何一个都会让语音功能的体验打折扣。尤其是第一条,录音按钮如果没有录音中的视觉反馈,用户会在短时间内连续点击多次,每次点击都触发一次授权和启动,造成“录了又马上停”的奇怪表现。

6. 真机联调与上线前的检查清单

开发环境里的表现和真机上的表现差异极大。录音这类强系统能力,必须在真机上反复验证。我最终上线前做检查时,固定过一套清单:

  1. 真机上完成一次完整录音,确认onStop回传时长和播放器读取时长偏差不超过1秒。
  2. 录音过程中压home键切后台,再回到小程序,确认录音状态没有被错误置为“已停止”。
  3. 用列表页连续切换5条以上语音,确认没有串音、没有旧回调触发。
  4. 飞行模式下点播放,确认真实报错不是无限制的空白加载。
  5. 上传网络断开再重试,确认重试逻辑不会拿失效的临时路径再传。

这套检查做完,语音功能基本可以放心上线。我还在检查清单里加了一条:上线后看日志,重点关注onError回调的频率。如果线上录音错误率超过预期,优先查权限配置和隐私协议,而不是先怀疑代码。语音功能线上出问题,七成以上是权限和环境问题,代码反而比较稳。

最后再分享一个小习惯。录音相关的代码我习惯把所有事件回调都放在页面onLoad里统一绑定,而不是在每次点击时才绑定。原因是录音管理器是全局单例,如果每次点击都绑定一次回调,旧的回调仍然生效,会造成onStop触发多次、页面数据被反复覆盖。换成只绑定一次之后,这类问题再没出现过。希望这篇实践内容能帮你少踩几个坑,顺利把录音和播放功能做上线。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 1:06:28

Python异常体系的核心:raise与assert的用法、机制与选型边界

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:06:14

单台云服务器搭建高性价比CI/CD自动化部署方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:06:07

DS18B20单总线测温与数码管动态显示:单片机实战全解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:06:05

Spring Boot线程池实战:从@Async踩坑到可监控ThreadPoolExecutor

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:05:19

HttpContext.Current.Server.MapPath 报空引用异常?原理与替代方案全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:04:49

Vue过滤器从入门到实践:核心用法、链式调用与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华