多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Android录音机开发实战:MediaRecorder、权限与存储适配全解析

Android录音机开发实战:MediaRecorder、权限与存储适配全解析 简介面向Android初学者的录音机项目源码包完整演示了基于Java语言的音频采集与录制流程。项目围绕MediaRecorder核心类展开覆盖运行时权限申请、音频源与编码格式选择、文件存储路径配置、状态监听等关键环节同时包含UI控制界面与异步线程处理思路可帮助读者快速掌握Android平台上录音功能从权限到落盘的全链路实现。压缩包共41个文件大小仅146KB以xml布局配置、Java源码、Gradle构建脚本为主附带PNG图标和若干配置文件结构精简便于逐模块研读。目前已有313人学习下载适合具备基础Java语法、希望结合实例理解Android多媒体开发的读者。通过分析该项目可进一步理解MediaRecorder的暂停恢复局限、音频参数对文件质量的影响以及如何通过MediaMuxer等工具扩展合并功能是一份手把手式的入门参考。 做Android录音机这件事听起来像是新手练手项目实际做起来才知道坑有多密集。前段时间我在一个工具类App里加了录音模块产品经理的原话是“就录个音存下来简单吧”。结果从权限适配到分区存储、从前台服务到实时调音量一路踩下来我觉得是时候把整个实现思路和避坑经验整理出来。这篇博文就围绕Android录音机的完整开发链路展开适合正在做类似功能的Android开发同学尤其是被系统兼容性、存储权限、录音中断问题折磨过的可以先收藏再看。先说清楚核心结论录音机的本质是音频采集 编码写文件 状态管理三步都不难但每一步都有版本差异和厂商差异。我会先拆解整体设计思路再给核心代码链路然后是Android 10分区存储的适配方案、实时调音量的实现细节最后附上高频问题排查表。所有代码基于Kotlin MediaRecorder同时补充AudioRecord的选型对比。1. 需求拆解与录音方案选型1.1 录音场景决定了技术路线拿到需求第一件事不是写代码而是想清楚录音机要面对什么场景。同样是“录个音”用途不同技术路线完全不同。如果只是录人声、存成文件、支持播放和分享那用系统封装的MediaRecorder就够了代码量小稳定可靠AAC编码器也是各大ROM优化最充分的路径。如果要做实时波形显示、变声处理、边录边转文字那必须用AudioRecord拿PCM裸数据自己接编码器。我这次的需求是基础录音机点击开始、显示录音时长、点击停止、保存到本地并出现在录音列表里附带一个实时的音量振幅显示。这个需求用MediaRecorder完全可以覆盖振幅通过getMaxAmplitude()轮询就能拿到没必要上AudioRecord。如果一开始图新鲜用AudioRecord等于把编码、缓冲、文件写入这些本来系统帮你处理的事情全揽到自己身上出问题的面就大多了。1.2 MediaRecorder和AudioRecord怎么选很多新手在这两个API之间纠结我直接给个对比看完基本就清楚该选谁了。对比维度MediaRecorderAudioRecord编码能力内置编码器直接产出MP4/AAC/3GP文件只输出PCM裸数据编码需另接MediaCodec或第三方库实现复杂度低约10行代码可完成录制高需自己管理缓冲区、编码器、文件写入实时语音数据拿不到只能通过振幅估算可以拿到便于做可视化、算法处理系统兼容性好各ROM适配成熟中采样率和缓冲区大小因设备而异适用场景语音笔记、通话录音、采访记录变声、实时分析、自定义编码格式我的建议是没有特殊处理需求就老实用MediaRecorder把精力留给更复杂的权限和存储适配。如果你的项目后期要加“语音转文字”或者“声纹识别”再考虑用AudioRecord采集并在采集端做音频预处理但基础录音功能成型后这部分替换也不难。我在这个项目里选了MediaRecorder起步后续如果要加波形图再单独增加一条AudioRecord采集链路也不冲突这不是二选一的问题是分层的问题。2. 录音核心链路搭建2.1 权限申请这一步就卡住不少人录音机第一个坑就是权限。RECORD_AUDIO在Android权限体系里属于危险权限必须在运行时动态申请。很多人把权限申请放在onCreate里然后录音按钮点击时发现没权限回调还没回来体验很差。我习惯把权限申请和录音操作绑定点击录音按钮时先检查权限没有就申请获取成功后再自动开始录音。用ActivityResultContracts.RequestPermission是现在的标准写法代码精简不少。需要注意在Android 14API 34上如果应用target到34麦克风权限还涉及前台服务类型microphone的声明这点我放在前台服务那一节再展开。权限拒绝的情况也要处理尤其是用户勾选了“不再询问”你需要引导去设置页否则录音功能就废了。// 这里用Activity Result API来申请录音权限 private val requestAudioPermission registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted - if (granted) { startRecording() // 拿到权限后真正开始 } else { Toast.makeText(this, 没有录音权限无法录音, Toast.LENGTH_SHORT).show() } } // 点击录音时的统一入口 fun onRecordClick() { when { ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) PackageManager.PERMISSION_GRANTED - startRecording() else - requestAudioPermission.launch(Manifest.permission.RECORD_AUDIO) } }注意申请权限时不要一句话解释都不给就弹系统框主流做法是先弹一个自定义说明弹窗告知“需要麦克风权限用于录音”用户点击同意后再走系统申请流程对授权率有明显帮助。这点在审核和用户口碑上都值得做。2.2 MediaRecorder参数这样配才不容易翻车初始化MediaRecorder有几个参数看似无关紧要实际上对兼容性和文件质量影响很大。我这版用的是标准配置麦克风音源、MPEG-4封装、AAC编码、128kbps码率、44.1kHz采样率这是录音类App最常见的组合兼容性和音质平衡得比较好。有一点要特别提醒采样率不要随手设成48000。虽然很多设备支持但个别ROM在通过蓝牙耳机录音时对48000处理有问题反而44.1kHz更稳。码率也不要设太高128kbps录人声已经足够清楚设到256kbps文件体积直接翻倍录音时长长了之后存储压力会变大。输出文件路径要在prepare()之前设置好否则直接抛IllegalStateException。private fun createMediaRecorder(outputFile: File): MediaRecorder { return MediaRecorder(context).apply { setAudioSource(MediaRecorder.AudioSource.MIC) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) setAudioEncoder(MediaRecorder.AudioEncoder.AAC) setAudioEncodingBitRate(128_000) setAudioSamplingRate(44_100) setOutputFile(outputFile.absolutePath) prepare() } }2.3 开始、停止、释放的细节比你想的更多录音状态切换是另一处容易翻车的地方。先说开始start()要在prepare()之后调用这个没问题。真正容易踩坑的是停止。stop()这个操作在MediaRecorder内部是要做编码器flush和文件封装的在主线程调用偶发ANR特别是录音时间比较长的时候。我踩过一次录音40多分钟后停止界面卡了好一会儿。后来把stop()和release()都放到子线程问题就消失了。注意stop()如果抛出RuntimeException说明录制时间太短或write阶段失败生成的临时文件是坏的直接删掉最省事不要尝试保留它。private fun stopRecording() { if (mediaRecorder null) return // stop和release都放到子线程避免ANR Thread { try { mediaRecorder?.stop() } catch (e: RuntimeException) { // 录制时间过短或者写入阶段出错临时文件是坏的直接删除 currentFile?.delete() } finally { mediaRecorder?.release() mediaRecorder null } }.start() }还有一个小细节start()后立刻stop()大概率会触发RuntimeException因为录进去的有效数据太少编码器还没拿到足够数据来写文件头。所以录音时长建议最短限制到1秒UI层直接屏蔽过快的点击切换避免这种尴尬。3. Android 10存储适配录音机绕不开的分区存储3.1 分区存储下文件该往哪写如果你做录音机时用的还是Environment.getExternalStorageDirectory()配合WRITE_EXTERNAL_STORAGE权限那在Android 10以上的设备上大概率要翻车。分区存储推行后应用不能随意访问公共存储区域的任意路径只能写自己专属目录或者通过MediaStore把文件交给系统媒体库管理。录音文件属于“用户想在其他App里也能找到”的音频资料所以最好放到系统媒体库的Music目录下这样文件管理器、音乐播放器都能扫到。做法是使用MediaStore的MediaStore.Audio.Media在Android 10以上不需要存储权限只需要录音权限就可以往公共目录写文件。这个设计很多人不知道还以为是系统bug。// 通过MediaStore创建录音文件适配Android 10 RequiresApi(Build.VERSION_CODES.Q) private fun createAudioFileInMediaStore(context: Context): Uri? { val values ContentValues().apply { put(MediaStore.Audio.Media.DISPLAY_NAME, 录音_${System.currentTimeMillis()}.m4a) put(MediaStore.Audio.Media.MIME_TYPE, audio/mp4) put(MediaStore.Audio.Media.RELATIVE_PATH, Music/我的录音) put(MediaStore.Audio.Media.IS_PENDING, 1) // 告诉系统这个文件还在写入中 } return context.contentResolver.insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, values) }3.2 用MediaStore写文件注意IS_PENDING标记上面的代码里我埋了一个点IS_PENDING标记为1这个非常关键。如果不设置这个标记系统会把文件立即暴露给其他应用但此时文件内容还没写完别的App读到的是残缺数据。正确做法是插入时IS_PENDING1录音结束后用openOutputStream写入内容写完后把IS_PENDING置为0然后再通知系统刷新媒体库。还有一个细节是MediaRecorder本身支持传FileDescriptor所以写MediaStore时可以先把uri转成FileDescriptor再传给MediaRecorder但我试下来这种方式在个别设备上有兼容问题反而不如先写到应用专属目录、再拷贝到MediaStore稳定。录音过程中数据写在context.getExternalFilesDir()下的临时文件结束后用文件流拷贝到MediaStore的uri里两条链路都经过不冲突。fun saveToMediaStore(context: Context, sourceFile: File, uri: Uri) { val resolver context.contentResolver // 把IS_PENDING置为0之前先把文件内容写进去 resolver.openOutputStream(uri)?.use { output - FileInputStream(sourceFile).use { input - input.copyTo(output) } } // 写入完成标记置为0系统才会把文件索引到媒体库 val updateValues ContentValues().apply { put(MediaStore.Audio.Media.IS_PENDING, 0) } resolver.update(uri, updateValues, null, null) }注意这种“先临时文件、后拷贝”的方式虽然多一步IO但对录音过程最稳妥。录音时直接写MediaStore的uri有个问题还没写完就被媒体扫描到可能触发系统缩略图加载之类的异常。录音文件尤其怕被半路读取安全性优先。3.3 保活录音前台服务必须安排录音是一个长时任务如果用户在录音过程中切到后台、锁屏Process可能被系统回收录音直接断掉。这个是录音机类App的另一个大坑。解决方案很明确录音期间启动一个前台服务给系统明确信号“我在干活不要杀我”。前台服务需要通知栏常驻通知Android 13开始还需要POST_NOTIFICATIONS运行时权限来展示通知内容。Android 14更严格要求声明foregroundServiceTypemicrophone而且必须在manifest里声明FOREGROUND_SERVICE_MICROPHONE权限。如果你target到34却漏了这步启动前台服务直接抛ForegroundServiceStartNotAllowedException或SecurityException。manifest !-- Android 14前台服务类型权限 -- uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_MICROPHONE / uses-permission android:nameandroid.permission.RECORD_AUDIO / uses-permission android:nameandroid.permission.POST_NOTIFICATIONS / application service android:name.RecordingService android:exportedfalse android:foregroundServiceTypemicrophone / /application /manifest服务里把MediaRecorder的生命周期和Activity解耦Activity只负责通知服务开始或停止录音状态和文件路径都保存在服务里。这样即使Activity因为旋转重建录音也不会断。我在这个项目里就把MediaRecorder实例放在Service中持有Activity通过绑定服务来获取录音时长和振幅数据架构清晰很多。4. 实时调音量与状态管理4.1 maxAmplitude的正确打开方式录音过程中显示音量振幅算是录音机的标配功能。MediaRecorder虽然没有提供直接的PCM数据回调但它有一个getMaxAmplitude()方法可以拿到从上次调用到现在的最大振幅取值0到32767。这里有个坑getMaxAmplitude()第一次调用大概率返回0因为统计区间太短什么都没采到。所以轮询逻辑里我会用一个简单的状态机会话第一次取到0直接忽略后面连续取几次后数据才稳定下来。另外部分设备上这个值会突然跳到32767通常是因为麦克风采集到了瞬时的爆破音不是真实音量我会做一次滑动平均把瞬时尖峰削平。// 通过Handler轮询振幅平滑显示 private val amplitudeValues ArrayDequeInt() private fun pollAmplitude() { val amplitude mediaRecorder?.maxAmplitude ?: return // 忽略初始0值 if (amplitude 0) return // 滑动平均削去瞬时尖峰 amplitudeValues.addLast(amplitude) if (amplitudeValues.size 5) amplitudeValues.removeFirst() val avgAmplitude amplitudeValues.average().toInt() val db 20 * Math.log10(avgAmplitude / 32767.0 1e-3) // 把db值映射到UI的0~1范围 val volumeLevel ((db 50) / 50f).coerceIn(0f, 1f) viewModel.updateVolumeLevel(volumeLevel) amplitudeHandler.postDelayed(::pollAmplitude, 200) }转换成dB值的时候记得给log10的参数加一个极小值防止负无穷这是我调试时发现日志里出现NaN的罪魁祸首。UI上我用一个垂直的ProgressBar模拟声纹跳动轮询间隔200毫秒比较合适太频繁UI刷新过度太慢看起来反应迟钝。4.2 从单一Activity到录音服务的状态同步如果你的录音机只是单页应用把录音状态放在Activity里还说得过去。但如果录音机还有列表页、播放页、设置页状态跨页面共享就得想好统一管理方案。我用了一个共享ViewModel持有录音状态空闲、录音中、暂停配合Service持有MediaRecorder实例。Activity在onStart里绑定服务拿到录音时长和振幅离开页面时解绑但服务不停止录音继续。这个设计的好处是用户录音过程中可以退出录音页去看录音列表切回来录音还在继续体验很顺滑。状态同步用LiveData或Flow都行我这里用了StateFlow配合repeatOnLifecycle收集避免生命周期回调漏掉。录音结束时逻辑上要做的事还挺多停止MediaRecorder、删除临时文件或迁移到MediaStore、通知媒体库刷新、更新数据库记录。这些操作里任何一个放在主线程都可能卡顿所以我在服务里维护了一个单线程Executor录音开始、停止这些操作统一投递到Executor执行避免线程竞争。5. 常见问题与避坑实录花了三个晚上整理5.1 高频问题速查表我把自己踩过的和群里朋友经常问的问题整理成了一张表直接对着排查。问题现象原因分析解决方案点击录音没有反应没申请RECORD_AUDIO权限或权限被拒绝检查动态权限申请流程引导设置页开启start()抛IllegalStateException没有调用prepare()或输出路径为空确保prepare()成功后再start()stop()后文件是0KB录音时长太短或stop()抛出RuntimeExceptioncatch异常并删除坏文件限制最短录音时长录完文件在文件管理器里找不到分区存储没有走MediaStore使用MediaStore.Audio写入公共Music目录Android 14启动前台服务崩溃缺少FOREGROUND_SERVICE_MICROPHONE权限或未声明服务类型manifest补充权限service加foregroundServiceType录音一段时间后自动停止系统省电策略杀掉了后台进程用前台服务通知栏常驻提升进程优先级个别设备录出来音质忽大忽小自动增益控制(AGC)在作怪可尝试改用VOICE_RECOGNITION音源或后续用AudioRecord关AGC播放录音时声音小MIC音源采集的是近场人声不是扬声器声属于正常现象可在UI提示靠近麦克风5.2 几个值得注意的适配细节关于音源选择我有一个具体建议如果录音机的定位是“现场录音、采访、语音备忘”麦克风音源MIC没问题。但如果是“录会议通话”安卓系统层面并没有直接的“通话录音”API这个需求需要特殊处理不仅涉及合规风险技术上也依赖具体设备这篇就不展开讲了。再说一个ROM差异问题部分国产ROM对通知栏常驻通知比较敏感用户手动划掉通知会把服务一起干掉。解决办法是在onTaskRemoved里判断是否需要继续录音如果是主动退出应用但录音还在进行就得重新提升进程优先级这个是另一套保活思路不能在普通Service里硬扛。我的做法是给通知栏加“录音中”的Action按钮用户可以主动停止同时通过startForeground()时声明高优先级系统在清理后台时会把这种前台服务往后排。构建配置上我用的AGP版本是8.x配合Android Studio的Hedgehog版本没问题。编译时如果遇到R8混淆导致MediaRecorder调用失败记得在proguard-rules.pro里加系统类keep规则个别ROM对MediaRecorder做了封装混淆后反射调用会失败。# 个别ROM对MediaRecorder有封装混淆时容易出问题直接keep -keep class android.media.MediaRecorder { *; }关于循环录音、自动覆盖旧文件这类需求其实是在这个基础录音机上做增量核心链路不用动只是加定时器和管理策略。如果你要在我的方案上继续扩展我建议先把基础录音做好再加上录音列表、播放页最后再做高级功能每一步都验证过再往上堆。我在实际开发中的感受是录音机这个功能麻雀虽小五脏俱全权限、前后台切换、文件存储、媒体库索引每一块都是Android系统演进的重点区域把这些链路跑通你对整个Android权限和存储体系的理解会上一个台阶。最后再分享一个小技巧调试录音功能时不要把真机插着USB连着电脑录因为部分设备在USB连接时会切换音频路由录出来的声音会怪。提前断开USB排除这个干扰项你会少走很多弯路。本文还有配套的精品资源点击获取
返回列表