钉钉小程序语音备注实战:录音、播放与文件上传全解析

发布时间:2026/10/16 2:35:48
钉钉小程序语音备注实战:录音、播放与文件上传全解析
做企业应用开发的朋友应该能体会钉钉小程序里最容易被低估的功能不是表单不是审批流而是语音。我接到的需求很朴素审批页里加一个“语音备注”审批人打开就能听。刚开始以为录音、播放不就是三个按钮的事吗等真正把录音和播放功能在钉钉小程序里跑通、跑稳才发现API特性、权限边界、真机兼容问题比想象中多得多。这篇文章就基于这个“语音备注”实例把录音和播放从零到上线的完整过程拆给你看尤其适合正在做钉钉小程序、准备往移动端塞语音功能的人。1. 需求拆解语音备注这种需求到底卡在哪1.1 看起来很小做起来却很重的一个入口先说清楚业务背景。企业内部的审批单往往有一段结论性文字但审批人想知道的是“为什么”。真正在现场的人没有时间打字一句“设备已经换完供应商下午来签字”比任何表单都更有信息量。语音备注的本质是把“过程性信息”从打字负担里解放出来。这个小功能在产品上只有三个状态没录、正在录、正在播。可它背后横跨了三块能力录音设备控制、音频文件生命周期管理、播放状态管理。这三块在钉钉小程序里分别对应dd.getRecorderManager()、临时文件和持久化存储、dd.createInnerAudioContext()。每块单独看都简单合在一起就会出现授权失效、文件找不到、播放静音、真机偶发崩溃等连锁问题。1.2 为什么是“录音播放”而不是视频或实时通话我一开始也考虑过用视频备注毕竟手机都有摄像头。但视频有个致命短板审批人可能正在开会没法拿耳机听一段视频而且视频体积动辄几十兆在移动网络下加载体验很差。实时通话又引入了双方同时在线的约束复杂度直接上一个量级。录音是体积和表达力的最佳平衡点一分钟的人声用96kbps码率压下来大约700KB扫码、弱网都能接受而且天然适合“听一下再决定”的场景。所以这个功能只要把“录得稳、存得下、放得出”三件事做好业务价值就能立住。1.3 先明确能力边界别把其他平台的写法照搬过来钉钉小程序的基础库和主流小程序平台的API定义有不少相似之处但也有自己的差异。比如录音管理器的返回事件回调时间点、innerAudioContext在部分安卓机型上的onCanplay触发时机都需要在真机上重新验证。我的原则是先按平台文档跑通最小Demo再对照其他平台的成熟写法迁移而不是默认它们等价。2. 开工前的准备权限、调试环境、一个可复用的授权工具函数2.1 页面结构和权限声明的最小集先建一个干净的页面目录结构如下pages/voice/ voice.js voice.axml voice.acss voice.json页面模板里先只放三个按钮开始录音、停止录音、播放最近一条。这一步不为炫技是为了最快验证“录音能产出文件文件能播出来”。关于权限钉钉小程序调用recorderManager.start()时通常会自动拉起系统授权弹窗但前提是应用在开放平台配置的能力列表里已经申请了“录音”权限。如果真机调试时一直报类似“无权限调用”的错误先去开发者后合的应用能力配置页确认而不是在代码里反复加授权逻辑。这一步是很多人浪费半天时间的地方。2.2 授权链路首次、拒绝、永久拒绝三种状态系统授权弹窗只在第一次出现用户如果点了拒绝之后每次调用录音接口都会直接失败但不会弹窗。这个“静默失败”特别坑我在最开始就遇到了真机测试时第一次拒绝后第二天的所有测试看起来都“没反应”控制台只打一条错误页面毫无提示。所以我在voice.js里加了一个授权工具函数每次进入录音流程前先检查状态// utils/auth.js const RECORD_SCOPE scope.record; export function checkRecordAuth() { return new Promise((resolve) { dd.getSetting({ success(res) { const granted res.authSetting[RECORD_SCOPE]; resolve(!!granted); }, fail() { resolve(null); }, }); }); } export function openRecordSetting() { dd.openSetting({ success(res) { const granted res.authSetting[RECORD_SCOPE]; return granted; }, }); }如果checkRecordAuth()返回false我不直接调用录音而是弹一个遮罩解释“语音备注需要麦克风权限”再引导用户点击按钮去dd.openSetting()设置页。这个小小的交互改造直接消灭了“用户拒绝后功能假死”的投诉。2.3 真机调试环境里容易被忽略的几点钉钉小程序的模拟器通常不会真正调用麦克风很多API行为要靠真机才能暴露。我建议从第一天就直接连真机调试不要贪图模拟器的便利。真机调试时特别注意三点第一调试工具连接成功后先杀掉应用再冷启动一次避免增量更新导致授权状态脏掉第二音频文件的临时路径在调试会话里可用但一旦断电或重进页面临时文件极有可能失效后面会专门讲持久化第三如果用到了上传接口必须把上传域名配置到合法域名白名单里否则请求会被直接拦截错误信息还不明显。3. 录音端实现dd.getRecorderManager的完整使用和参数调优3.1 初始化与事件绑定的标准写法录音管理器的正确使用方式是先初始化监听器再去调用start()。事件回调必须在start()之前绑定否则会出现“录音已经停了但 onStop 没触发”的灵异现象其实只是监听晚了一步。我是这样封装的// pages/voice/voice.js const STATUS { IDLE: idle, RECORDING: recording, PAUSED: paused, }; Page({ data: { status: STATUS.IDLE, recordingSeconds: 0, currentTempFile: , }, onLoad() { this.recorderManager dd.getRecorderManager(); this.bindRecorderEvents(); }, bindRecorderEvents() { const manager this.recorderManager; manager.onStart(() { this.startTimer(); this.setData({ status: STATUS.RECORDING }); }); manager.onPause(() { this.stopTimer(); this.setData({ status: STATUS.PAUSED }); }); manager.onResume(() { this.startTimer(); this.setData({ status: STATUS.RECORDING }); }); manager.onStop((res) { this.stopTimer(); this.setData({ status: STATUS.IDLE, currentTempFile: res.tempFilePath, }); this.prepareFileForKeep(res.tempFilePath, res.duration); }); manager.onError((err) { this.stopTimer(); this.setData({ status: STATUS.IDLE }); dd.showToast({ title: 录音失败请重试, icon: none }); console.error(recorder error, err); }); }, });有一点必须强调iOS 和安卓在onStop回调里返回的字段基本一致但部分安卓机型在res.duration上会有几十毫秒的误差不要拿它做严格计时界面上的秒数只做展示业务上以实际音频时长为准。3.2 参数选择的逻辑不是抄一份配置就行manager.start()的参数里有几个关键值duration、sampleRate、numberOfChannels、encodeBitRate、format。我第一版直接抄了其他项目的配置结果文件体积大得离谱一分钟录音约1.8MB弱网播放卡顿明显。后来按不同用途整理了一套参数参数语音备注推荐值原因formataac或m4a两端兼容性好体积比mp3小sampleRate44100人声清晰度足够再高意义不大encodeBitRate96000语音场景体积和质量的折中numberOfChannels1单声道录音本就是单点声源duration不设长期录按需停止部分机型长录音会自动中断这里要特别说明duration。不要天真的设置一个超大值让它随意录我实测过连续录超过840秒后部分安卓机型会直接抛onError而 iOS 表现相对好一些。如果你的业务确实需要长录音更好的做法是前端分段录制每段不超过10分钟再在后端做拼接或直接展示分片。3.3 录音状态不能只用一个布尔值录音过程中用户会点击暂停、恢复、停止按钮文案和样式都要跟着状态变。如果只用true/false暂停和停止在页面表现上很难区分。我引入上面的STATUS枚举后recording和paused在界面上是完全不同的两套按钮逻辑判断也清晰很多。状态切换还有一个容易漏掉的细节暂停期间如果 app 被切到后台部分机型会自动恢复录音导致混入杂音。我在onHide页面生命周期里主动调用了recorderManager.pause()保证用户离开页面时不会偷偷录下一段空白。3.4 从临时文件到本地持久化别把希望寄托在 tempFilePathonStop返回的tempFilePath只是一个临时目录下的文件路径。在小程序运行期间这个路径还能访问但用户杀掉小程序重进、或者基础库清理缓存以后文件就找不到了。这是录音功能上线后最容易出现的“录音没了”问题。解决方案是在拿到tempFilePath后立刻通过文件系统接口保存到本地用户目录prepareFileForKeep(tempFilePath, duration) { const fs dd.getFileSystemManager(); const timestamp Date.now(); const savedDir ${dd.env.USER_DATA_PATH}/voice/${timestamp}.m4a; fs.saveFile({ tempFilePath, filePath: savedDir, success: (res) { this.setData({ savedPath: res.savedFilePath }); }, fail: (err) { console.error(save voice file failed, err); }, }); }我踩过这个坑的代价是开发环境录的十几条语音第二天全部打不开当时还以为是播放器问题排查到最后才确认是临时文件被清掉了。建议所有录音功能必须做这一步否则后面播放做得再好也是空壳。4. 播放端实现innerAudioContext的初始化与资源回收4.1 播放器的生命周期管理播放音频用的是dd.createInnerAudioContext()。它返回一个音频实例支持src、play()、pause()、stop()、seek()。我先按最朴素的方式写完播放逻辑实际测试才发现播放器实例不能复用一个无限循环每次切换音频要么重新设src要么直接销毁重建。我的封装方式简单粗暴每个音频一个实例播完就destroy()。这样能最大程度避免音频流互相串场playAudio(src) { if (this.innerAudio) { this.innerAudio.destroy(); } const innerAudio dd.createInnerAudioContext(); innerAudio.src src; innerAudio.obeyMuteSwitch false; // iOS静音键不影响App内播放 innerAudio.onPlay(() { this.setData({ playing: true }); }); innerAudio.onEnded(() { this.cleanupAudio(); }); innerAudio.onError((err) { console.error(audio play error, err); this.cleanupAudio(); }); innerAudio.play(); this.innerAudio innerAudio; }, cleanupAudio() { if (this.innerAudio) { this.innerAudio.destroy(); this.innerAudio null; } this.setData({ playing: false }); },4.2 卡在 loading 状态的排查思路播放最让人抓狂的现象是点击播放后始终触发不了onPlay进度一直不动。我遇到过两种根因。第一种是网络源问题。上传后的音频 URL 如果是 HTTP 或不在合法域名白名单里播放器会一直处于 loading。排查方法很简单把 URL 放到浏览器里直接打开看能不能加载浏览器都播不了就不是小程序的问题而是服务端资源没有正确返回。第二种是 iOS 上的静音开关。钉钉小程序在 iOS 上默认遵循静音键用户如果拨了静音音频会没有声音但onPlay照常触发给人一种“播了但没有声音”的错觉。我加了innerAudio.obeyMuteSwitch false之后只要用户允许就强制走扬声器这个问题就消失了。4.3 进度条、时长和异常中断的处理严格来说内部音频的duration属性在onCanplay之后才可信。我一开始直接取innerAudio.duration用于进度条经常拿到NaN后来统一在onCanplay回调里再取才稳定下来。播放进度条建议定时器每250ms读一次currentTime并更新界面太频繁会造成不必要的setData太稀疏则进度感很差。另外如果播放中途网络断开onError触发后一定要调用cleanupAudio()否则残留实例会占用音频焦点导致下一段播放没有声音。4.4 在页面卸载时必须清理资源onUnload和onHide两个生命周期我都做了清理。onUnload销毁播放器实例onHide时暂停录音但不要销毁播放器因为用户可能只是切出去看个消息回来还要继续听。代码很简单onUnload() { this.cleanupAudio(); if (this.recorderManager this.timer) { clearInterval(this.timer); } },千万不要只在onUnload清理录音机。如果不暂停就退出页面录音管理器仍然在后台工作再次进入页面时状态会错乱。这一条值得直接写进团队代码规范。5. 从临时文件到业务数据上传与列表加载的落地设计5.1 上传服务的文件名规范和报错处理本地保存完成后真正的业务闭环需要把文件上传到对象存储或自己的服务器然后拿着返回的永久 URL 去播放。我推荐的文件路径规范是这样的voice/{业务类型}/{yyyyMMdd}/{timestamp}_{random6}.m4a业务类型可以从审批单类型映射出来日期按年/月/日组织随机数避免同一秒多次提交的覆盖。这样在存储端检索某一笔业务的历史录音时直接按前缀扫文件列表就行。上传代码大致如下uploadVoice(tempFilePath, bizType) { const onProgress (res) { if (res.progress 100) { // 上传完成等待返回 } }; return new Promise((resolve, reject) { dd.uploadFile({ url: https://你的合法域名/upload/voice, filePath: tempFilePath, name: file, formData: { bizType, timestamp: String(Date.now()), }, success: (res) { try { const data JSON.parse(res.data); resolve(data.url); } catch (e) { reject(e); } }, fail: reject, }); }); }一个小提醒success回调里res.data往往是字符串没有自动帮你 parse记得手动转成 JSON。我在第一版就漏了这一步拿到的一直是字符串按钮也一直报错排查起来还挺绕。5.2 历史音频列表的加载策略列表页不需要一次性把全部音频文件拉到本地。我的做法是只让后端返回每条记录的元数据voiceUrl、duration、createdAt、bizId。用户点哪条哪条才真正初始化播放器播放完立即销毁。这样做的直接好处是列表滚动不卡弱网环境也不至于一次性下载所有音频消耗流量。如果你要在列表里显示时长不要依赖前端播放器去读取后端在接收上传时直接用音频解析库取真实时长存到数据库前端只管展示。5.3 跨端播放兼容性别等到换手机才发现钉钉用户既有安卓也有 iOS你必须保证录音格式在两端都能播放。我统一采用aacm4a容器编码后兼容性基本没有出过问题。小心的是如果某个版本用了mp3编码且encodeBitRate过低部分 iOS 机型会出现解码异常播放器直接走onError。另外对象存储的响应头里Content-Type要设置正确比如audio/mp4或audio/x-m4a。如果响应头是application/octet-stream部分播放器会拒绝解码。6. 真机实测的深坑三个必须写进文档的教训6.1 长录音的静默中断一次用户反馈“录了两分钟就自己停了”查了日志发现onStop根本没触发而是onError里出现了一个冷门错误码。后来定位是用户使用了安卓机且录音时长超过了系统限制。我们的规避方案很实在录制中每隔五秒检测一次duration如果超过团队约定的安全阈值比如9分钟自动弹窗提示用户“本段录音即将达到单段上限请点击停止”并通过manager.stop()强制收尾不让系统被动中断。这个方案上线后长录音丢数据的问题再没出现过。核心思路是主动管理边界而不是把风险交给系统。6.2 播放与录音互相抢占音频焦点有一次测试流程是先听一段历史录音接着直接录新的备注。结果新录音完全没有声音任务栏里也没有正在录制的图标。问题出在播放器实例没有销毁它一直占着音频焦点录音管理器彻底罢工。我在开始录音前加了一个互斥守卫startRecord() { if (this.innerAudio) { this.cleanupAudio(); } // 检查授权、再 start() }同理播放前如果正在录音先调用recorderManager.stop()。音频功能最容易出的“玄学问题”十有八九和音频焦点抢占有关。6.3 拒绝授权后列表接口的连锁失败授权对话框第一次出现时用户如果选了拒绝后续所有调用录音相关API都会失败。如果你只在录音入口处理了拒绝而在页面初始化时预先请求了权限就会有一系列列表、上传请求连带失败。我的统一做法是录音的授权状态独立管理不和其他流程耦合。页面加载后不主动检查麦克风权限只在用户第一次点击录音按钮时才走授权流程。这样就把“授权失败”的影响范围控制在录音按钮内审批单填写、列表加载这些流程完全不受影响。最后聊点实际体会语音备注功能上线一段时间后我最大的感受是移动端录音和播放的坑往往不是单个 API 不会用而是多个 API 的生命周期没有统一管理。录音、播放、授权、上传、清理每一个环节单独拿出来都能看懂串在一起就需要一张清晰的状态流转图以及一条“绝不把临时文件当永久文件”的底线。如果你准备在钉钉小程序里实现类似功能我的建议是先用最简页面跑通“录音→保存→上传→播放”的完整闭环再逐步补上权限引导、长录音分段、音频焦点互斥这些细节。另外语音转文字也是一个很自然的扩展方向后端拿到音频流后接一个转写服务就能把“只听”升级成“边看边听”体验还会再上一个台阶。