ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

仿微信录音功能开发实战:声波动画、手势取消与文件存储全解析

仿微信录音功能开发实战:声波动画、手势取消与文件存储全解析 录音功能大家天天用微信里按住说话、松手发送看起来就是个简单的交互但等你自己动手去仿的时候会发现里面藏着一堆细节声波怎么画才自然、手指滑到哪里算取消、倒计时怎么提醒不打断录音、录到60秒怎么自动截取、录完的文件为什么会莫名从列表里消失。这篇文章把我这次做“仿微信录音”功能的全过程拆开讲一遍从方案选型到具体实现再到我自己踩过的坑尽量说人话代码能贴就贴希望能给正在做类似需求的朋友省点时间。这个功能最终的呈现效果是按住底部录音按钮开始录音界面出现实时声波动画手指上滑进入“松手取消”区域提示文案和颜色变化录音达到60秒自动截取并发送最后10秒有明显倒计时提醒录音文件落盘后能出现在系统录音列表里。适合正在做IM、社交产品、工具类App中语音交互模块的开发者参考也适合刚接触Android录音和自定义View的同学拿来练手。1. 项目整体设计与录音方案选型1.1 仿微信录音到底在仿什么微信的录音交互看起来是“按住说话”但仔细拆解它其实是一个多状态联动的过程按下按钮触发录音并启动声波动画手指按住不松手时持续录音手指上滑超过一定阈值进入“取消区域”此时声波颜色或界面提示变化松手后如果处于取消区域则不发送并删除录音文件否则发送。整个过程还伴随时长统计、上限截断、倒计时提示。所以仿微信录音本质上是在做三件事一是录音数据流的采集与文件落地二是声波可视化这一块的自定义UI绘制三是手势滑动、状态切换、定时提醒这一套交互状态机。这三件事互相纠缠必须一开始就在代码层面分层设计否则后面加功能会越改越乱。我的建议是录音采集用一个独立的AudioRecorderManager类管理UI相关的声波及手势全部放在Activity/Fragment或自定义View里两者之间用接口回调通信不要直接在录音线程里写界面逻辑。这样录音模块可以单独测试就算后面换UI方案录音核心代码也能复用。1.2 MediaRecorder还是AudioRecord两种方案的取舍Android里录音有两种常用方案MediaRecorder和AudioRecord。MediaRecorder封装程度高几行代码就能开始录音并直接输出AAC/MP3等编码文件缺点是拿不到实时音量数据也就很难做声波动画。AudioRecord是更底层的API能拿到原始PCM数据可以方便地计算音量并绘制声波但需要自己处理编码、文件写入、停止时可能产生的杂音等问题。做仿微信录音这种需要声波动画的功能我建议直接用AudioRecord拿PCM数据然后边录边写WAV文件。WAV文件虽然体积大但实现最简单不需要引入编码库。如果业务上对体积有要求后续可以通过MediaCodec或第三方库转成M4A/AAC这个可以在录音结束后异步做不影响交互体验。AudioRecord的关键参数是采样率、声道数、音频格式和缓冲区大小。采样率通常用44100Hz单声道PCM 16位。缓冲区大小通过AudioRecord.getMinBufferSize()获取读取数据时按这个缓冲区大小分块读每块数据计算一次音量。这里有个很容易踩的坑缓冲区太小会导致录音线程频繁读取CPU占用高缓冲区太大会导致音量计算延迟声波动画跟不上实际声音。我用的是minBufferSize的2倍实测下来平衡性还不错。1.3 Android录音权限与并发录音的坑录音功能必须申请RECORD_AUDIO权限Android 6.0以上属于危险权限需要运行时动态申请。还有一个容易忽略的细节是Android 14API 34开始录音权限的说明里明确区分了“麦克风录音”和“通话录音”等场景普通App只能访问麦克风不能访问通话音频流这个我们正常做语音消息录制不受影响。另外要留意多应用同时录音的问题。Android 9以上系统默认支持多个应用同时录音但有些定制ROM比如部分国产系统仍然只允许一个应用独占麦克风导致App切到后台或者另一个应用正在录音时我们的录音可能拿不到音频数据。测试时可以在设置里看看是否有“允许其他应用录音”之类的开关。如果遇到录音数据全为零的情况优先检查是不是被系统或者其他应用抢占了音频焦点。2. 声波动画让声音“看得见”的核心实现2.1 音量分贝计算的原理与公式声波动画的前提是实时拿到声音大小。AudioRecord读出来的PCM数据是短整型数组每个采样点取值范围是-32768到32767。声波的振幅大小可以用均方根RMS来衡量计算公式是RMS sqrt(sum(x[i]^2) / n)其中x[i]是第i个采样点的值n是本次读取的采样点总数。得到RMS之后分贝值dB可以用dB 20 * log10(RMS / reference)reference一般取32767也就是当前设备的满量程。这样算出来的分贝是一个负数通常在-60dB到0dB之间波动。静音的时候接近-60dB正常说话大概在-20dB到-5dB之间。这里注意一个细节分贝值变化范围很大直接拿来做动画会非常“跳”。我做的处理是先对分贝值做一次线性映射变成0到1之间的归一化数值然后对这个数值做滑动平均滤波也就是当前值乘以0.7、上一帧值乘以0.3这样的加权方式让声波高度平滑过渡。如果觉得响应慢了可以把系数调整为0.8/0.2这个可以根据实际观感微调。2.2 自定义View绘制声波的基础设计声波动画有两种常见实现方式一种是一根一根的竖条像现在的微信语音播放界面那样另一种是连续波形线。微信录音过程中的声波更接近前者所以我也选了竖条方案。自定义View的核心流程是用Handler或者Choreographer定时刷新每帧根据当前音量值控制竖条高度。竖条的个数可以固定也可以根据View宽度动态计算我这里是固定60根铺满整个View宽度。每根竖条的高度由音量值决定但为了让动画更自然我加了一个“尾部渐隐”效果音量峰值出现后后面的竖条不是立刻降下来而是逐帧递减这样会产生类似声波拖尾的视觉效果。绘制的时候用Canvas.drawRoundRect来画圆角矩形颜色可以用同一颜色但不同透明度来区分强弱。特别注意要用postInvalidate()而不是invalidate()来刷新因为onDraw执行在UI线程而音量计算在录音线程postInvalidate是线程安全的。2.3 实测踩坑分贝跳动不平滑怎么办第一次跑起来的时候声波跳动非常生硬人说话音量稍大一点竖条就直接飙满稍微安静一点又贴到地面视觉上完全是“跳跳糖”。排查后发现两个问题一是Rms计算时采样点太少导致波动太敏感二是没有对音量值的生命周期做处理没有“上升快、下降慢”的视觉惯性。解决办法是第一把每次读取的PCM数据分成两段分别计算RMS再取平均值相当于做了简单的移动平均降噪。第二定义一个音量Scale逻辑上升时直接用新值下降时按每帧衰减10%的方式平滑回落这样既保留了声音的动态感又不会显得突兀。第三音量映射时把底噪阈值抬高也就是小于一定分贝值直接按0处理否则安静环境下的底噪也会让声波有微弱的起伏看起来像“一直在漏音”。声波动画这块想做得好看一定要反复调试参数。我的参数是最小音量阈值-45dB最大音量阈值-10dB线性映射到0~1滑动平均系数0.7/0.3绘制区间高度占View高度的70%停顿时竖条约2dp。不同设备麦克风灵敏度有差异有条件的话用两台手机对比看一下效果。3. 上滑取消手势与状态机管理3.1 手势识别如何准确区分点击、上滑与滑动返回手势是仿微信录音里最容易出bug的地方。微信的交互是手指按下去开始录音按住不要松手指上滑超过一定距离进入取消区如果此时松手就取消发送如果滑动回落到录音区松手就正常发送。整个过程手指不需要离开屏幕。实现思路有两种一种是在录音按钮上直接处理onTouchEvent记录ACTION_DOWN、ACTION_MOVE、ACTION_UP的位置变化另一种是使用GestureDetector自定义手势。我推荐前者因为ON_TOUCH的原始坐标更直观方便做阈值判断。核心判断逻辑是按下时记录起始点Y坐标在MOVE事件里计算当前Y与起始Y的差值deltaY。如果deltaY小于某个负值比如-80dp判定进入“取消状态”如果之后deltaY又回到大于-40dp的位置判定回到“录音状态”。ACTION_UP时根据当前状态决定是发送还是取消。判断阈值转成像素时要乘以屏幕密度dip2px(context, 80f)。还有一个细节是按钮本身有点击事件录音逻辑要放在ACTION_DOWN里触发不能放在onClick里否则就做不到“按住说话”。3.2 录音状态机空闲、录音中、悬停取消、已取消一个清晰的录音状态机能让交互逻辑少很多bug。我定义的状态有四个IDLE空闲状态没有录音。RECORDING录音中手指在录音按钮上或滑动距离未超过取消阈值。CANCEL_READY手指已经上滑超过取消阈值界面显示“松手取消”声波动画可能变红或变灰。CANCELED已取消录音此时删除录音文件并释放资源。每个状态对应一组UI表现和允许的操作。比如IDLE状态下按钮显示“按住说话”RECORDING状态下显示声波动画和计时CANCEL_READY状态下显示“松手取消”且录音继续但松手后不发送各个状态之间不允许跳级必须按秩序流转。这样做的好处是不管用户怎么快速滑动代码永远可以根据当前状态决定下一步不会出现“明明取消了却发了消息”这种尴尬问题。状态管理可以用一个简单的枚举加上when表达式也可以用StateFlow之类的响应式方案。我用的是自定义回调接口加UI更新方法没有引入额外的状态管理库因为这个交互范围很小状态机写清楚就够了。3.3 与列表滚动冲突的处理细节如果你的录音按钮是嵌在一个ScrollView或RecyclerView里的那么“按住录音”和“上下滑动列表”天然冲突。微信的语音条一般不是在列表里直接长按录的但很多周边功能比如聊天输入栏上方的录音按钮也会遇到这个情况。解决思路有两个一是录音按钮不在可滚动容器内比如放在页面底部固定的工具栏上二是如果必须放在可滚动容器内在ACTION_DOWN时请求父容器不要拦截触摸事件也就是调用getParent().requestDisallowInterceptTouchEvent(true)在ACTION_UP或ACTION_CANCEL时恢复。第二种方式需要非常谨慎处理不好会导致列表滑动失效。我这次用的是第二种方式。实际测试中发现如果手指按住按钮后快速往上滑父容器的onInterceptTouchEvent会先收到事件所以要在父容器里判断如果当前正在录音状态直接不拦截否则正常拦截交给列表滚动。判断是否正在录音需要一个全局标志位这也是为什么状态机里的状态要能从外部读取而不能只管UI内部逻辑这个坑很典型。4. 倒计时提醒与超时截取逻辑4.1 时长上限与自动发送的策略微信的语音消息最长60秒录到60秒时自动截取并发送。这个逻辑在产品层面是硬性的因为服务端和播放器对语音时长都有限制。实现上倒计时和超时截取有两个核心点一是计时准确二是到了临界点要主动停止录音并走“发送”流程而不是等用户松手。计时我推荐用System.currentTimeMillis()做基准而不是简单地用计数器累加。因为录到一半如果系统卡顿计数器和真实时间会偏差越来越大。具体做法是开始录音时记录startTime每次更新UI时计算elapsed System.currentTimeMillis() - startTime然后用elapsed去刷新进度条和倒计时数字。自动截取触发时要主动调用stopRecording()并把当前状态置为RECORDING_FINISHED然后走发送分支。这里有个容易忽略的问题如果你在录音线程里调用stop()可能会阻塞UI线程需要把停止录音的操作封装成方法切回主线程后调用同时要避免重复回调onFinished。4.2 倒计时提醒的用户体验设计倒计时提醒做得好不好直接影响用户对“即将自动发送”的心理预期。微信的做法比较简单但实际产品里可以做得更丰富。我设定的逻辑是录音到50秒时开始显示倒计时数字10、9、8……同时声波动画区域轻微闪烁提示用户即将自动发送。倒计时数字我放在声波上方居中显示字体放大加粗每秒钟变化一次。为了减少闪烁感倒计时文字只更新数字不重新绘制整块背景颜色从白色渐变到红色最后3秒可以加上震动反馈。震动用Vibrator类的vibrate()方法注意在最后3秒每秒震动一次别整成连续震动否则体验很糟糕。这里的UI更新频率要控制在每秒一次和声波每帧刷新是两条独立的调度路径。我的做法是倒计时用Handler.postDelayed每秒任务声波动画用Choreographer帧刷新两者互不干扰。Handler任务在stopRecoding时一定要removeCallbacksAndMessages(null)否则会出现录音结束后还在倒计时的灵异事件。4.3 超时截取的实现细节“截取”这个词其实有点误导因为我们并不是把录音文件切一刀而是到了60秒就主动停止录音所以文件本身就是0到60秒之间的完整数据不存在真正的截取操作。真正的截取场景是用户提前松手录音文件长度可能只有3秒或5秒这时候如果产品要求最短时长比如微信必须超过1秒才发送就需要判断录音时长小于阈值时按无效处理。我实现的最小时长是1秒也就是录音时间小于1秒时松手会提示“说话时间太短”并且删除文件、不发送。这个时长阈值写在配置里产品要调整时不用改代码。实际操作中还要考虑录音文件的尾部静音问题因为AudioRecord停止时可能会多录进一小段空数据需要在stop之后读取WAV头部的data size如果发现尾部全是静音可以截掉一部分但对语音消息场景来说这段静音毫秒级基本无感一般不用处理。超时截取还有一个隐藏点即使到了60秒自动发送也要把录音文件路径、时长信息通过回调传给业务层。时长信息最好在保存WAV文件时就算好用总采样点数除以采样率得到而不是依赖计时器的值因为计时器可能受卡顿影响采样点数是准确的。5. 录音文件存储与常见问题排查5.1 录音文件为什么会被系统列表“吃掉”标题里有个热搜词“手机录音列表为空怎么回事”这个问题我实际也遇到过而且一般不严重但很磨人。原因是录音完成后文件虽然写进了App私有目录或公共目录但系统的MediaStore数据库没有及时更新于是系统录音App或文件管理器里看不到这个新文件。解决办法是录音保存后主动向MediaStore发起扫描。如果文件放在App外部存储比如getExternalFilesDir可以用MediaScannerConnection.scanFile()触发系统扫描如果目标用户能从系统录音App看到文件最好的做法是保存到公共目录并插入MediaStore使用MediaStore.Audio.Media.EXTERNAL_CONTENT_URI插入一条记录。从Android 10开始直接写公共目录需要用MediaStore API不能直接用FileOutputStream这个要注意适配。如果你只是App内部使用录音文件不要求系统列表可见那直接用内部存储/应用专属目录最省事不涉及权限和扫描问题。这个取舍要看产品需求录音是用户的“资产”还是仅仅是消息的临时载体。5.2 MediaStore索引与文件可见性录音列表为空的另一个常见原因是文件写入了但没有插入MediaStore或者插入了但DATA列值不对。Android 10以上的分区存储机制下MediaStore不再信任路径字符串而是通过Uri来标识文件。插入记录后要立即用插入返回的Uri来写入数据不能用自己拼的路径。代码流程大致是先生成文件名和目录通过ContentResolver.insert()得到Uri再通过openOutputStream写入PCM转码后的音频数据写入完成后更新MediaStore里的大小、时长、MIME类型等字段。注意如果录音过程中断插入的MediaStore记录要删除否则会留下一个0字节的僵尸记录系统录音App里会看到一个打不开的文件。我在处理这一关时踩了个坑插入MediaStore时没有指定RELATIVE_PATH导致文件被存到了默认目录用户找不到。后来显式设置了RELATIVE_PATH为Environment.DIRECTORY_MUSIC /MyRecordings问题就解决了。这个字段在API 29及以上才支持低版本直接用DATA设置绝对路径。5.3 长录音文件如19分钟的分片管理和压缩建议标题相关热词里有“真实录音19分mp3”实际项目里确实会遇到录很久的音频。我的建议是录音超过几分钟就应该在结束时立即异步转码把WAV转成AAC或MP3否则一个19分钟的WAV文件轻松上百MB用户手机空间顶不住上传服务端也会很痛苦。转码方案有几种MediaCodec是系统自带方案但API偏底层封装起来麻烦也可以用FFmpegKit这类开源库功能全但包体增大不少如果录音本来就用MediaRecoder的AAC编码就不存在这个问题但代价是拿不到实时音量这正好说明了为什么多数仿微信录音实现都选择“AudioRecord 后置转码”的组合。转码完成后删掉中间WAV文件只保留转码后的文件同时更新MediaStore记录。我在实测中处理19分钟音频转AAC128kbps大约需要10秒左右可以放在录音结束后的线程池里异步执行但要注意耗时期间用户可能立刻去查看录音列表所以列表数据源要同时处理“转码中”和“转码完成”两种状态。6. 写在最后几个实测后的真心话做这个仿微信录音前后折腾了大约两个星期大部分时间不是花在录音本身而是花在了“看似简单但细节琐碎”的地方。比如声波动画的平滑度、手势与列表滚动的冲突、60秒自动发送的状态切换、录音完成后系统列表看不到文件……这些问题如果不做一遍很难从文档里提前学会。我个人的一个体会是这类功能一定要把状态机放在代码设计的首位录音采集、音量计算、UI绘制、手势判断都只是围绕状态流转的零件。如果一开始没理清状态后面很容易出现“录音已经停了但声波还在跳”“文件已经发了但界面还显示取消”之类的低级bug。最后分享一个小技巧录音模块的调试阶段一定要写一个专门的调试页面把当前状态、音量分贝值、文件路径、文件大小、时长全部打印在屏幕上。切到真机上跑一圈很多问题一眼就能定位比断点调试高效得多。如果你也在做类似功能欢迎按这篇文章里的思路自己去实现一遍踩过的坑、调过的参才是你自己最值钱的收获。
返回列表