1. 项目概述为什么从“音频的录制和播放”切入音视频开发做Android音视频开发很多人一上来就想搞H.264硬编、MediaCodec封装、FFmpeg交叉编译结果三天没跑通一个YUV帧渲染直接劝退。我带过十几位刚转音视频方向的开发者八成卡在第一步——连麦克风录进来的PCM数据长什么样都不知道更别说怎么把一段wav文件正确喂给AudioTrack播放出来。所以这个系列第一篇我们不碰编码器、不聊时间戳同步、不扯SurfaceView渲染管线就死磕最底层、最实在、也最容易验证效果的两个动作用AudioRecord录下环境声音再用AudioTrack原样放出来。它看似简单但背后牵扯到采样率、位深、声道数、缓冲区对齐、线程调度、JNI调用时机、设备兼容性等一整套Android音频子系统的运行逻辑。你真正搞懂这一环后面学MediaRecorder、MediaPlayer、ExoPlayer甚至自定义音频处理链路都会像拆积木一样清晰。适合人群很明确有Java/Kotlin基础、写过Activity但没碰过Audio API的安卓新手想转音视频但被文档吓退的中级开发者或者正在调试录音无声、播放破音、延迟抖动等问题的实战派。核心关键词就是Android音频基础、AudioRecord、AudioTrack、PCM数据流、采样参数配置、实时音频IO——这些不是概念名词而是你明天就能在AS里新建一个空Activity粘贴几段代码用手机麦克风对着说话然后在扬声器里听到自己声音的完整闭环。2. 音频本质与Android音频架构先理解“声音”在系统里怎么被对待要让录音和播放不变成玄学得先放下代码回到物理和系统层面看问题。声音本质是空气振动产生的模拟信号手机麦克风把它转换成电压变化再经ADC模数转换器变成一串数字——这就是PCM脉冲编码调制数据。它不是MP3也不是AAC没有压缩没有元数据就是赤裸裸的“每秒采样多少次 × 每次采样用多少比特存 × 左右声道怎么排”。比如常见的44.1kHz/16bit/立体声配置意味着每秒产生44100个采样点每个采样点用16位二进制数表示振幅左右声道交替排列一秒钟原始数据量就是44100×2×2176400字节约172KB。这个数字必须刻进脑子里因为AudioRecord和AudioTrack的所有缓冲区大小、读写节奏、线程sleep时长全靠它推算。Android的音频路径比想象中更复杂。它不是App直接读写硬件而是走了一条标准化中间层App调用AudioRecord → AudioFlinger服务接管 → HAL硬件抽象层驱动真实ADC → 最终到SoC的音频子系统。播放同理App写入AudioTrack → AudioFlinger混音 → HAL驱动DAC → 扬声器发声。这条链路上每一环都有自己的缓冲区和调度策略。比如AudioFlinger默认使用FastMixer线程采样率固定为48kHz而你的AudioRecord如果设成44.1kHz系统就会在HAL层做重采样——这不仅引入延迟还可能因算法缺陷导致高频失真。所以第一原则录音和播放尽量用同一套采样参数且优先选系统原生支持的值。查设备支持列表不能只看文档得实测AudioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)和AudioManager.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER)这两个API返回的才是当前设备AudioFlinger实际使用的基准值48kHz/96kHz比44.1kHz更稳妥尤其在中低端机型上。另一个常被忽略的点是音频焦点Audio Focus。Android从2.2开始就强制要求App申请音频焦点才能独占音频通道。如果你没调用requestAudioFocus()系统可能静音你的AudioTrack或者把你的录音流路由到听筒而非麦克风。这不是Bug是设计。很多新手录不到音第一反应是权限没开其实权限只是第一步焦点才是关键闸门。还有线程模型AudioRecord.read()和AudioTrack.write()都是阻塞调用必须放在独立线程里否则UI线程卡死。但线程也不能随便new Thread得用HandlerThread或ExecutorService配合适当的优先级Process.setThreadPriority(Process.THREAD_PRIORITY_AUDIO)否则系统可能把你的音频线程调度到后台导致缓冲区欠载underrun——播放端表现为咔哒声录音端则是丢帧。提示别迷信“高采样率高音质”。48kHz对人耳已足够覆盖20Hz-20kHz范围强行上96kHz只会增大缓冲区压力、增加功耗、放大设备兼容性问题。实测某款联发科芯片手机44.1kHz录音正常切到96kHz后AudioRecord.startRecording()直接抛IllegalStateException。3. AudioRecord实战从零搭建稳定录音链路3.1 权限与初始化绕不开的三道坎Android 6.0必须动态申请RECORD_AUDIO权限但光有权限远远不够。初始化AudioRecord前得先确认三件事设备是否支持目标参数、麦克风是否被其他App占用、系统是否允许前台录音。我见过太多案例代码逻辑完美但测试机上永远返回AudioRecord.STATE_UNINITIALIZED最后发现是厂商定制ROM把录音权限锁在“隐私设置→特殊权限→音频输入”里比系统设置还深一层。初始化代码看似简单但参数组合极易踩坑val minBufferSize AudioRecord.getMinBufferSize( sampleRateInHz 48000, channelConfig AudioFormat.CHANNEL_IN_MONO, audioFormat AudioFormat.ENCODING_PCM_16BIT ) // 注意minBufferSize是系统建议的最小缓冲区不是你该用的值 val actualBufferSize minBufferSize * 4 // 实际使用至少2-4倍 val audioRecord AudioRecord( audioSource MediaRecorder.AudioSource.MIC, sampleRateInHz 48000, channelConfig AudioFormat.CHANNEL_IN_MONO, audioFormat AudioFormat.ENCODING_PCM_16BIT, bufferSizeInBytes actualBufferSize )这里的关键陷阱在getMinBufferSize()。它的返回值仅保证“能工作”但极不稳定。实测某华为P4048kHz/16bit/MONO下getMinBufferSize()返回1920字节对应缓冲区仅容纳0.04ms数据——AudioRecord.read()还没执行完缓冲区就空了。正确做法是取minBufferSize * 3到* 6之间的值具体倍数看场景实时语音通信选3倍低延迟语音笔记选6倍防丢帧。计算逻辑很简单目标缓冲时长如20ms× 每秒字节数。48kHz/16bit/MONO每秒96000字节20ms需1920字节那就设actualBufferSize 1920 * 3 5760。3.2 录音线程与数据采集如何避免丢帧和阻塞AudioRecord.startRecording()之后必须在循环里持续调用read()否则数据会堆积在HAL缓冲区直至溢出。但read()是阻塞的如果线程优先级不够或者主线程做了耗时操作就可能错过采样窗口。我推荐用HandlerThread封装因为它自带Looper能精准控制消息调度private val audioThread HandlerThread(AudioRecordThread).apply { start() } private val audioHandler Handler(audioThread.looper) private fun startRecording() { audioRecord?.startRecording() audioHandler.post(recordRunnable) } private val recordRunnable object : Runnable { override fun run() { val buffer ByteArray(actualBufferSize) val readSize audioRecord?.read(buffer, 0, buffer.size) ?: 0 if (readSize 0) { // 处理PCM数据可存文件、送网络、做FFT分析 processAudioData(buffer, readSize) } // 关键用postDelayed替代while(true)避免线程饿死 audioHandler.postDelayed(this, 10) // 根据缓冲区大小调整间隔 } }注意postDelayed(this, 10)里的10ms不是随意写的。它应该略大于单次read()理论耗时。以5760字节缓冲区为例48kHz下每秒读2次5760×211520≈96000即每次间隔约50ms。但实际要留余量设成40-60ms更稳。如果设太小如1msHandler疯狂投递消息线程忙于调度无暇处理数据设太大如100ms缓冲区可能提前填满导致新数据被丢弃。这个值必须结合actualBufferSize和sampleRateInHz反向推算而不是凭感觉。3.3 PCM数据落地WAV头封装与文件存储Raw PCM数据不能直接播放需要加WAV头声明格式信息。WAV是RIFF容器头结构固定44字节包含采样率、位深、声道数等元数据。手动写头容易出错我封装了一个轻量工具类fun writeWavHeader( outputStream: FileOutputStream, sampleRate: Int, channels: Int, bitDepth: Int, byteCount: Long ) { val header ByteArray(44) // RIFF file size - 8 header[0] R.toByte(); header[1] I.toByte(); header[2] F.toByte(); header[3] F.toByte() val fileSize byteCount 36 // 44 - 8 header[4] (fileSize and 0xFF).toByte() header[5] ((fileSize shr 8) and 0xFF).toByte() header[6] ((fileSize shr 16) and 0xFF).toByte() header[7] ((fileSize shr 24) and 0xFF).toByte() // WAVE header[8] W.toByte(); header[9] A.toByte(); header[10] V.toByte(); header[11] E.toByte() // fmt 16 header[12] f.toByte(); header[13] m.toByte(); header[14] t.toByte(); header[15] .toByte() header[16] 16.toByte(); header[17] 0.toByte(); header[18] 0.toByte(); header[19] 0.toByte() // format code: 1 for PCM header[20] 1.toByte(); header[21] 0.toByte() // channels header[22] channels.toByte(); header[23] 0.toByte() // sample rate header[24] (sampleRate and 0xFF).toByte() header[25] ((sampleRate shr 8) and 0xFF).toByte() header[26] ((sampleRate shr 16) and 0xFF).toByte() header[27] ((sampleRate shr 24) and 0xFF).toByte() // byte rate sampleRate * channels * bitDepth/8 val byteRate sampleRate * channels * bitDepth / 8 header[28] (byteRate and 0xFF).toByte() header[29] ((byteRate shr 8) and 0xFF).toByte() header[30] ((byteRate shr 16) and 0xFF).toByte() header[31] ((byteRate shr 24) and 0xFF).toByte() // block align channels * bitDepth/8 val blockAlign channels * bitDepth / 8 header[32] blockAlign.toByte(); header[33] 0.toByte() // bits per sample header[34] bitDepth.toByte(); header[35] 0.toByte() // data subchunk2Size header[36] d.toByte(); header[37] a.toByte(); header[38] t.toByte(); header[39] a.toByte() header[40] (byteCount and 0xFF).toByte() header[41] ((byteCount shr 8) and 0xFF).toByte() header[42] ((byteCount shr 16) and 0xFF).toByte() header[43] ((byteCount shr 24) and 0xFF).toByte() outputStream.write(header) }调用时注意WAV头必须在录音开始前写入文件且byteCount传0因为还不知道总长度等录音结束再用RandomAccessFile回填真实大小。否则播放器解析头时发现subchunk2Size0会直接报错。这是新手最常犯的错误之一——以为头写完就能播结果VLC打开提示“无法识别格式”。注意不要用Environment.getExternalStorageDirectory()存录音文件Android 10已废弃。改用context.getExternalFilesDir(null)路径如/sdcard/Android/data/com.yourapp/files/无需额外权限卸载App自动清理。4. AudioTrack播放从PCM到扬声器的精确控制4.1 初始化与模式选择Streaming还是StaticAudioTrack有两种模式MODE_STATIC和MODE_STREAMING。前者把全部PCM数据一次性加载到内存适合短提示音如按键音后者边写边播适合长录音回放。入门必选MODE_STREAMING但初始化参数比AudioRecord更敏感。关键区别在于bufferSizeInBytes它不是“最小缓冲区”而是AudioTrack内部环形缓冲区的总大小必须≥getMinBufferSize()且最好是其整数倍。计算公式和录音端一致但建议倍数更高4-8倍因为播放端缓冲区欠载会导致严重破音。另一个致命细节是streamType。早期用STREAM_MUSIC但Android 8.0后推荐STREAM_VOICE_CALL或STREAM_ALARM取决于场景。回放录音用STREAM_MUSIC没问题但若同时有VoIP通话必须用STREAM_VOICE_CALL并配合AudioFocus管理否则系统会降音或静音。初始化代码val minPlayBufferSize AudioTrack.getMinBufferSize( sampleRateInHz 48000, channelConfig AudioFormat.CHANNEL_OUT_MONO, audioFormat AudioFormat.ENCODING_PCM_16BIT ) val playBufferSize minPlayBufferSize * 6 val audioTrack AudioTrack( streamType AudioManager.STREAM_MUSIC, sampleRateInHz 48000, channelConfig AudioFormat.CHANNEL_OUT_MONO, audioFormat AudioFormat.ENCODING_PCM_16BIT, bufferSizeInBytes playBufferSize, mode AudioTrack.MODE_STREAMING )4.2 播放线程与数据推送如何保持流畅不破音AudioTrack.play()之后必须持续调用write()喂数据否则缓冲区清空就停播。但write()也是阻塞的且行为和read()相反它会等缓冲区有空间才返回。如果推送节奏太慢缓冲区空了就破音太快则数据堆积导致延迟。最佳实践是用专用线程循环read/write但必须加状态控制private var isPlaying false private val playThread Thread { isPlaying true audioTrack?.play() val buffer ByteArray(playBufferSize) while (isPlaying !audioFile.eof()) { val readSize audioFile.read(buffer, 0, buffer.size) if (readSize 0) { val writeResult audioTrack?.write(buffer, 0, readSize) ?: 0 if (writeResult ! readSize) { // 写入失败可能是AudioTrack停止或错误 break } } else { break // 文件读完 } } audioTrack?.stop() isPlaying false } playThread.start()这里isPlaying标志位至关重要。很多教程直接用while(true)结果用户点暂停时线程还在狂读文件造成资源泄漏。另外audioTrack.write()返回值必须校验——它可能只写入部分数据如返回1000但你给了2000这时要重试剩余部分否则音频会跳变。4.3 WAV文件解析与播放跳过头精准定位PCM播放WAV文件时不能直接把整个文件扔给AudioTrack。必须跳过44字节头从第44字节开始读PCM数据。更麻烦的是WAV头里subchunk2Size字段告诉你PCM数据长度但有些录音软件生成的WAV头不规范如subchunk2Size为0这时得靠文件总大小减去44来推算。安全做法是用RandomAccessFile读取前44字节解析subchunk2Size字节40-43如果为0则pcmLength file.length() - 44用FileInputStream.skip(44)跳过头循环读取直到读够pcmLength字节。我遇到过某款国产录音笔导出的WAV头里subchunk2Size是乱码强行按头解析会读到负数长度。最终方案是先尝试按头解析失败则fallback到file.length()-44并加日志告警。这种细节不写进文档但线上问题90%出在这里。实操心得播放时开启audioTrack.setPlaybackRate(48000)确保采样率匹配。虽然初始化时已指定但某些设备尤其平板在横竖屏切换后会重置播放速率导致音调变高或变低。加这一行成本几乎为零却能规避大量兼容性问题。5. 录制与播放联动构建端到端验证闭环单独跑通录音或播放只是半成品真正的价值在于“录完立刻播”。这要求两个模块无缝衔接录音线程把PCM写入内存缓冲区非文件播放线程从同一缓冲区读取。我推荐用ArrayBlockingQueueByteArray做管道容量设为2-3个缓冲区大小避免生产者消费者速度差异导致阻塞。private val pcmQueue ArrayBlockingQueueByteArray(3) private val recorderThread Thread { val buffer ByteArray(actualBufferSize) audioRecord?.startRecording() while (isRecording) { val size audioRecord?.read(buffer, 0, buffer.size) ?: 0 if (size 0) { // 深拷贝避免播放线程修改原数组 val copy buffer.copyOf(size) pcmQueue.offer(copy) // offer不阻塞满时丢弃最老帧 } } } private val playerThread Thread { audioTrack?.play() while (isPlaying) { val buffer pcmQueue.poll(100, TimeUnit.MILLISECONDS) // 等待100ms if (buffer ! null) { audioTrack?.write(buffer, 0, buffer.size) } else { // 缓冲区空可插入静音帧或等待 Thread.sleep(10) } } }这个设计解决了三个痛点一是避免频繁IO不用写磁盘二是降低延迟内存传输微秒级三是便于调试——你可以在pcmQueue.offer()前后打日志看每秒进多少帧是否丢帧。实测某红米Note 12用文件IO回放延迟达800ms改用内存队列后压到120ms以内基本达到实时对讲水平。但要注意线程安全。ArrayBlockingQueue本身是线程安全的但ByteArray是可变对象如果播放线程直接修改buffer内容比如做音量调节会影响后续复用。所以copyOf(size)深拷贝必不可少。另外offer()和poll()的超时时间要匹配offer()不设超时满时丢帧保实时poll()设100ms超时避免播放线程饿死。这个数值不是拍脑袋而是根据actualBufferSize和sampleRateInHz计算的理论缓冲时长的2-3倍。6. 常见问题与排查技巧实录那些文档里不会写的坑6.1 录音无声的12种可能及定位方法新手遇到录音无声第一反应是权限但实际原因远不止于此。我整理了一份速查表按发生概率排序问题类型具体表现快速定位方法解决方案权限与焦点录音时无异常但buffer全0调用audioRecord.getState() STATE_INITIALIZED且audioRecord.getRecordingState() RECORDSTATE_RECORDING但read()返回0检查AudioManager.requestAudioFocus()是否成功用adb shell dumpsys audio看焦点状态麦克风被占用其他App如微信语音在录音时本App录不到audioRecord.getState()为STATE_UNINITIALIZED调用AudioManager.isMicrophoneMuted()监听AudioManager.ACTION_MICROPHONE_MUTE_CHANGED广播采样率不匹配某些机型如三星S2244.1kHz完全无声audioRecord.getState()正常但read()返回ERROR_INVALID_OPERATION改用AudioManager.getProperty()获取设备推荐值优先48kHz/96kHz缓冲区太小录音几秒后突然无声logcat报W/AudioRecord: obtainBuffer timed outgetMinBufferSize()返回值极小1000手动设actualBufferSize getMinBufferSize() * 4线程优先级不足后台运行时录音断续top -H -p pid看音频线程CPU占用率10%Process.setThreadPriority(Process.THREAD_PRIORITY_AUDIO)厂商ROM限制某些定制系统如OPPO ColorOS需手动开启“允许后台录音”设置里找不到相关开关引导用户进「设置→应用管理→你的App→电池→允许后台高耗电」排查技巧用adb shell am broadcast -a android.intent.action.MEDIA_BUTTON --es key play模拟媒体键看是否触发AudioFocus变更。这是验证焦点逻辑最直接的方法。6.2 播放破音/延迟抖动的根因分析播放破音分两种一种是规律性咔哒声每秒1-2次多因缓冲区欠载另一种是随机爆音大概率是数据损坏。我总结了高频场景规律咔哒声audioTrack.write()调用间隔不稳定。比如用Thread.sleep(20)但系统调度不准实际sleep了50ms导致缓冲区空了。解决方案是改用audioTrack.getPlaybackHeadPosition()监控播放进度动态调整sleep时长“当前播放位置缓冲区一半长度”作为下次写入时机。首帧延迟大audioTrack.play()后第一段PCM要等几百毫秒才出声。这是因为AudioTrack内部有预热缓冲区。解决方法是在play()后立即write()一段静音数据如全0字节数组强制填充缓冲区。音调变高/变低播放采样率与录音不一致。比如录音48kHz播放设44.1kHz音调升高约9%。用adb shell dumpsys media.audio_flinger查看SampleRate字段确认AudioFlinger实际使用值。耳机/扬声器切换无声Android系统在设备变更时会暂停AudioTrack。必须注册AudioManager.OnAudioFocusChangeListener和BroadcastReceiver监听AudioManager.ACTION_AUDIO_BECOMING_NOISY在回调里调用audioTrack.pause()和audioTrack.play()恢复。6.3 设备兼容性避坑指南那些必须实测的机型不同SoC对AudioRecord/AudioTrack的支持差异极大。我列出2023年仍活跃的5款典型机型及其雷区机型SoC录音问题播放问题应对策略小米13骁龙8 Gen2高通44.1kHz下偶发ERROR_BAD_VALUE无固定用48kHzgetMinBufferSize()*4华为Mate 50麒麟9000S华为AudioRecord初始化慢500msAudioTrack首次播放延迟高启动时预创建AudioRecord实例冷启动不阻塞UIOPPO Reno10天玑8200联发科CHANNEL_IN_STEREO始终返回单声道数据STREAM_VOICE_CALL模式下音量偏小录音强制MONO播放用STREAM_MUSICsetVolume(1.0f)vivo X90天玑9200联发科getMinBufferSize()返回值异常大10KB无不依赖getMinBufferSize()按sampleRate*2*2*0.0220ms公式计算三星S23骁龙8 Gen2高通MediaRecorder.AudioSource.VOICE_RECOGNITION在后台失效AudioTrack在Doze模式下自动暂停后台录音改用VOICE_COMMUNICATION播放加WakeLock实操心得所有机型测试必须在“开发者选项→关闭MIUI优化/EMUI省电”后进行。厂商优化常把音频线程判定为后台任务直接冻结关掉才能暴露真实问题。7. 性能与功耗优化让音频链路更轻量更持久音频处理是耗电大户尤其长时间录音。优化不是单纯减少采样率而是系统性降负载缓冲区大小平衡actualBufferSize越大CPU唤醒次数越少省电但内存占用高、延迟大。我推荐动态策略语音通话用*3低延迟语音笔记用*6稳帧率后台录音用*10防丢帧。用android.os.PowerManager监听屏幕状态自动切换。JNI层优化Kotlin/Java层处理PCM数据如音量调节、降噪会触发大量GC。把核心计算移到C层用ByteBuffer.allocateDirect()分配堆外内存避免Java堆拷贝。实测某降噪算法JNI实现比纯Java快3.2倍功耗降40%。线程休眠精度Thread.sleep()在Android上误差可达50ms。改用System.nanoTime()忙等spin-wait组合“计算下次写入时间戳→nanoTime()轮询→差值1ms时再sleep”。虽耗一点CPU但换来了音频时序精准度。释放资源时机audioRecord.release()和audioTrack.release()必须在onDestroy()或onPause()里调用且加try-catch。某些老旧机型如Android 5.1在release时抛NullPointerException不捕获会导致ANR。最后分享一个硬核技巧用adb shell dumpsys batterystats --checkin导出电池统计过滤AudioRecord和AudioTrack关键词看音频组件耗电占比。如果15%说明你的缓冲区或线程策略有问题。这是唯一能客观衡量优化效果的数据依据。我在某教育App里把录音模块从*2缓冲区升级到*6再加JNI降噪后台录音8小时耗电从18%降到6.3%用户投诉“录音时手机发烫”直接归零。技术优化的价值就藏在这些数字背后。
阅读完成 · 觉得有帮助?