ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android音频设备加载实战:架构、API与避坑指南

Android音频设备加载实战:架构、API与避坑指南 在Android开发里音频这块一直是个容易踩坑但又绕不开的领域。我写“Android音频学习”这个系列初衷就是把自己在项目中趟过的浑水、翻过的源码、调过的BUG记录成册方便自己回头看也方便后来者少走弯路。到了第十四篇终于要碰一个既基础又关键的话题——加载音频设备。这里的“设备”不单单指扬声器和耳机而是整个音频输入输出的硬件链路抽象。很多应用卡在无声、延迟、切换异常等问题上根子往往就在于对“加载”这一步的理解不够透彻。这篇我尽量讲清楚Android系统里音频设备是怎么被加载、管理和切换的以及我们在应用层该用哪些API去正确操作它们。这篇文章适合谁看适合那些已经在用MediaPlayer或者SoundPool做过简单播放但想进一步了解音频底层工作方式、想解决实际设备兼容性问题的Android开发者。我会把音频设备从系统架构到应用API的使用穿成一条线辅以代码示例和实战排查经验。不敢说面面俱到但至少涵盖了我在项目中遇到的绝大部分真实场景。1. 音频框架与设备加载的整体认知1.1 理解Android音频架构的分层设计很多人一上来就写代码直接用AudioTrack丢PCM数据遇到无声就一头雾水其实是因为没理解Android音频是分层设计的。从底到顶大致是这样一条链路音频硬件Codec、DSP、喇叭/麦克风——设备驱动Linux kernel里的ALSA——HALHardware Abstraction Layer——AudioFlinger系统音频服务——AudioPolicyManager音频策略管理——应用层APIAudioTrack/AudioRecord/AudioManager等。打个生活化的比方硬件就像一家餐厅的厨房设备驱动是水电管线HAL是厨房里的操作台AudioFlinger是总厨AudioPolicyManager是排菜员而我们应用层就是点菜的客人。你可以直接对着后厨喊绕过系统用底层API但大多数情况下还是得通过排菜员AudioPolicyManager来决定哪个菜送到哪张桌子。这个排菜的过程本质上就是加载音频设备、管理音频路由的过程。自己在项目里遇到过的一个典型问题App里的提示音总是从听筒而不是扬声器出来。一开始以为是播放代码写错了后来追查才发现是AudioPolicyManager根据当时的“通话状态”把音频输出路由到了听筒。这不是bug而是策略生效的结果。理解分层之后就不会再对着错误的方向排查耗时间了。1.2 音频设备在系统中扮演的角色在Android系统视角里音频设备分为输入设备和输出设备。输出设备常见的有扬声器Speaker、有线耳机Wired Headset、蓝牙A2DP设备Bluetooth A2DP、USB音频设备等输入设备则包括内置麦克风Built-in Mic、耳机麦克风Headset Mic、蓝牙SCO设备、USB麦克风等。每种设备都有自己的设备地址和属性在AudioPolicyManager里通过一个AudioDeviceDescriptor来维护。加载音频设备这个动作放在系统层面看就是“发现并初始化硬件端点建立逻辑流与物理端点之间的映射关系”。放在应用层看其实就是通过AudioManager/AudioTrack等接口把音频数据正确送达到当前路由所指定的物理设备。系统通常会自动处理好绝大多数设备加载工作但开发者要想做精细控制比如指定外放、监听耳机插拔、切换蓝牙通道就必须了解这些底层映射规则。这里还要提一个概念Audio Device的Address与Type。Type决定的是设备类别比如AudioManager.ROUTE_EARPIECE、ROUTE_SPEAKER这些Address则是更细粒度的标识比如蓝牙设备的MAC地址、USB设备的Card Number等。在Android 6.0之后系统推荐用AudioDeviceInfo来枚举所有可用设备这比早期版本里的getDevices方法更规范能够拿到设备的类型、地址、采样率、通道数等完整信息是加载设备时最可靠的参考依据。2. 核心API选型不同场景该用谁2.1 AudioTrack与MediaPlayer的选择逻辑很多初学者分不清AudioTrack和MediaPlayer的区别其实一句话就能概括如果你手里已经是PCM裸数据想直接往音频硬件送用AudioTrack如果手里是音频文件或者网络流想省事解码播放用MediaPlayer。从“加载音频设备”的角度看AudioTrack更贴近设备端因为你要自己负责把数据写进缓冲区系统只负责传输与播放。AudioTrack的关键初始化参数有三个采样率、声道格式、音频格式。以播放一段44.1kHz、双声道、16bit的PCM数据为例核心代码大概长这样int sampleRate 44100; int channelConfig AudioFormat.CHANNEL_OUT_STEREO; int audioFormat AudioFormat.ENCODING_PCM_16BIT; int bufferSizeInBytes AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat); AudioTrack audioTrack new AudioTrack.Builder() .setAudioAttributes(new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build()) .setAudioFormat(new AudioFormat.Builder() .setSampleRate(sampleRate) .setChannelMask(channelConfig) .setEncoding(audioFormat) .build()) .setBufferSizeInBytes(bufferSizeInBytes) .setTransferMode(AudioTrack.MODE_STREAM) .build(); audioTrack.play(); audioTrack.write(pcmData, 0, pcmData.length);这里再说说选择AudioTrack的核心理由如果你要实现低延迟播放、音频实时处理、变速变调、自定义混音等高级需求MediaPlayer往往满足不了必须用AudioTrack或者OpenSL ES。AudioTrack本质上是往系统AudioFlinger里“投喂”数据的通道只要缓冲区和写入节奏控制好延迟可以做到很低。反过来如果你只是放个音乐、响个提示音用MediaPlayer就行系统内部会帮你处理解码、缓冲、资源释放等一揽子事情别自己折磨自己。2.2 SoundPool与音频池的实战用法SoundPool是处理短促、高频播放音效的利器比如游戏里的点击音、射击音、消息提示音。它之所以适合这种场景是因为它会一次性把音频文件解码并加载进内存后续播放时几乎无延迟。相比之下MediaPlayer每次播放都要重新走一遍准备和缓冲的流程短音效频繁触发时就容易产生明显卡顿。使用SoundPool加载音频设备的完整流程是new SoundPool.Builder()设置最大流数然后load加载资源文件等setOnLoadCompleteListener回调后再播放。注意load是异步的必须在加载完成后再调play否则会拿到非法的soundID。SoundPool.Builder builder new SoundPool.Builder(); builder.setMaxStreams(4); SoundPool soundPool builder.build(); final int[] soundId new int[1]; soundPool.setOnLoadCompleteListener(new SoundPool.OnLoadCompleteListener() { Override public void onLoadComplete(SoundPool soundPool, int sampleId, int status) { if (status 0) { soundPool.play(sampleId, 1.0f, 1.0f, 1, 0, 1.0f); } } }); soundId[0] soundPool.load(this, R.raw.click_sound, 1);这里有一个实战小陷阱SoundPool在Android 5.0之后推荐使用Builder构造旧版的构造方法已经被废弃。另一个是小技巧——如果你在项目中频繁播放同一个短音效可以考虑在Application初始化时就把SoundPool创建好并加载音频避免每次进入页面都重新加载。实测下来这种预加载策略能极大减少音效首次播放的延迟感。2.3 AudioRecord采集端的设备加载说完播放再来说采集。AudioRecord负责从麦克风采集音频数据它需要访问的是输入设备。和AudioTrack对称AudioRecord也需要配置采样率、声道格式、音频格式然后调用startRecording开始采集通过read方法循环读取数据。采集端有一个常被忽视的细节设备类型的选择。系统里输入设备也是分类型的比如DEFAULT、MIC、VOICE_RECOGNITION、CAMCORDER等。如果你做的是语音识别最好使用MediaRecorder.AudioSource.VOICE_RECOGNITION作为音频源这样系统会针对识别场景做降噪处理并且不会把提示音、铃声混进来。如果你做的是普通录音用MIC就够了。从“加载音频设备”的视角看AudioRecord的核心在于把输入设备的数据流接进你的应用里。它内部的buffer轮转和底层AudioFlinger线程之间的配合非常紧密所以缓冲区大小的设置会直接影响采集的稳定性。正常情况下用AudioRecord.getMinBufferSize()返回的最小缓冲区大小就够了但如果你发现采集时有明显的咔嚓声或溢出可以把缓冲区设为最小值的两倍甚至四倍牺牲一点延迟换取稳定性这在低端机型上尤其管用。3. 实战在应用中正确加载和切换音频设备3.1 操作前的权限与环境准备音频设备加载虽然不像定位、相机那样权限敏感但采集音频必须有录音权限这个跑不掉。在Android 6.0以上API 23需要在运行时动态申请RECORD_AUDIO权限而且用户在Andorid 11及以上还能进一步细粒度授权比如只允许使用麦克风而不允许访问通话录音。如果拿不到录音权限AudioRecord创建能成功但startRecording时会抛出异常。还需要重点检查的权限是MODIFY_AUDIO_SETTINGS。这个权限在Android源码里被标记为签名权限普通应用无法获取。但它真正影响的是能否变更系统全局音频设置一般应用基本用不上。开发阶段如果遇到setSpeakerphoneOn失效反而更可能是API使用姿势不对而不是缺权限。到了Android 12API 31系统又引入了新的BLUETOOTH_CONNECT权限限制蓝牙音频设备的连接和状态查询都需要这个权限。如果要监听蓝牙耳机的连接状态或控制蓝牙设备上的音频路由必须记得在AndroidManifest里加上权限并在运行时申请。实测下来忘了这个权限最常见的表现是能播放声音到手机外放但无法切换到蓝牙耳机。3.2 AudioTrack加载外部音频文件的完整流程在真实项目里AudioTrack最常见的任务就是播放从网络下载或本地读取的音频文件。不能直接把文件字节丢进AudioTrack因为AudioTrack只吃PCM裸数据。所以完整流程是读取文件——解码成PCM——喂给AudioTrack。在Android原生的MediaCodec和MediaExtractor配合下这个过程并不复杂。下面我给出一段较完整的代码框架支持播放常见的WAV和MP3文件。WAV文件的PCM数据可以直接提取MP3则需要经过MediaExtractor和MediaCodec解码。private static final int SAMPLE_RATE 44100; private static final int CHANNEL_MASK AudioFormat.CHANNEL_OUT_STEREO; private static final int ENCODING AudioFormat.ENCODING_PCM_16BIT; private AudioTrack audioTrack; private MediaExtractor extractor; private MediaCodec codec; private ByteBuffer[] inputBuffers; private ByteBuffer[] outputBuffers; private MediaCodec.BufferInfo bufferInfo new MediaCodec.BufferInfo(); public void startPlayFile(String path) { extractor new MediaExtractor(); extractor.setDataSource(path); int trackIndex selectAudioTrack(extractor); extractor.selectTrack(trackIndex); MediaFormat format extractor.getTrackFormat(trackIndex); try { codec MediaCodec.createDecoderByType(format.getString(MediaFormat.KEY_MIME)); } catch (IOException e) { e.printStackTrace(); } codec.configure(format, null, null, 0); codec.start(); // 根据解码输出的格式初始化AudioTrack initAudioTrack(format); audioTrack.play(); // 进入解码-播放循环此处只展示核心逻辑省略线程管理 decodeAndPlay(); } private MediaCodec.BufferInfo decodeAndPlay() { boolean inputDone false; boolean outputDone false; while (!outputDone) { if (!inputDone) { int inputIndex codec.dequeueInputBuffer(10000); if (inputIndex 0) { inputBuffers codec.getInputBuffers(); int sampleSize extractor.readSampleData(inputBuffers[inputIndex], 0); if (sampleSize 0) { codec.queueInputBuffer(inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM); inputDone true; } else { codec.queueInputBuffer(inputIndex, 0, sampleSize, extractor.getSampleTime(), 0); extractor.advance(); } } } int outputIndex codec.dequeueOutputBuffer(bufferInfo, 10000); if (outputIndex 0) { outputBuffers codec.getOutputBuffers(); ByteBuffer outputData outputBuffers[outputIndex]; byte[] pcmData new byte[bufferInfo.size]; outputData.get(pcmData); audioTrack.write(pcmData, 0, pcmData.length); codec.releaseOutputBuffer(outputIndex, false); if ((bufferInfo.flags MediaCodec.BUFFER_FLAG_END_OF_STREAM) ! 0) { outputDone true; } } } return bufferInfo; }这里要注意一点解码后PCM的采样率、声道数、位深是由音频文件本身决定的不一定是固定的44100Hz。我用codec.getOutputFormat()可以在解码完成后拿到真实的格式信息然后据此初始化AudioTrack。如果硬用固定的参数去初始化AudioTrack解码输出和播放参数不匹配要么变调要么噼里啪啦噪声。这是我在做播放器时候踩过的一个大坑。3.3 监听音频设备插拔与自动切换设备加载不是一锤子买卖耳机拔了、蓝牙断了系统都需要动态调整路由。应用如果播放音乐时不关心这些变化可能就会出现拔掉耳机声音瞬间从外放爆出来蓝牙断开后播放立刻卡顿的情况。要做出体验良好的播放器必须监听设备变化。Android提供了AudioDeviceCallback来监听设备连接变化但它有个局限无法感知设备“断开”的精确时机只能感知listener注册之后的状态变化。还有更常用的方案是监听ACTION_HEADSET_PLUG广播和BluetoothDevice的A2DP连接状态广播。private BroadcastReceiver headsetReceiver new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { if (intent.getAction().equals(Intent.ACTION_HEADSET_PLUG)) { int state intent.getIntExtra(state, -1); if (state 1) { // 耳机插入 onDevicePluggedIn(); } else if (state 0) { // 耳机拔出 onDeviceUnplugged(); } } } };实际操作中设备切换有一个通用策略应用始终跟随系统当前默认路由。也就是说如果你不强制指定路由系统会自动在有线耳机插入时把声音切到耳机拔出时切回外放。这不是需要应用处理的bug而是正常的系统策略。但如果你的应用使用了AudioTrack且没有正确处理路由变化可能会出现拔出耳机后播放线程还在往旧路由写数据的情况这时候要主动做一下AudioTrack状态同步拔插事件发生时暂停播放等路由稳定后再恢复。蓝牙设备场景更复杂一点。蓝牙耳机的连接和音频路由是两个步骤连接成功 ≠ 音频已经路由到蓝牙。你需要先确认A2DP profile的连接状态再检查AudioManager.isBluetoothA2dpOn()确认音频确实在走蓝牙通道。有些国产手机在这个状态同步上做得不够及时连接成功后音频仍然外放这时候轮询状态比如1秒查一次持续查5秒会比单纯依赖回调更可靠。4. 音频焦点与设备状态管理4.1 音频焦点申请与释放加载音频设备之后应用能不能正常独占音频通道是一个经常被忽略的问题。Android里有一套音频焦点机制多个应用同时发声时系统根据焦点优先级决定谁播放谁暂停。比如你在听音乐突然来一条导航语音音乐应该降音量导航播完再恢复音量。从“加载音频设备”的角度看申请音频焦点其实是在往系统声明我要占用这个音频输出了。如果不申请焦点你播放时很容易被其他应用打断或者你的播放会干扰其他应用。焦点申请代码大致如下AudioManager audioManager (AudioManager) getSystemService(Context.AUDIO_SERVICE); AudioAttributes attributes new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build(); AudioFocusRequest focusRequest new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) .setAudioAttributes(attributes) .setOnAudioFocusChangeListener(focusChangeListener) .build(); int result audioManager.requestAudioFocus(focusRequest); if (result ! AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { // 焦点获取失败说明有其他更高级别的音频流占用 }我做音乐播放器时在焦点处理上犯过一个错只在开始播放时申请了焦点没有监听焦点变化的回调。结果来电时铃声响起我的应用没有任何反应音乐还在大声播用户体验极差。后来才补上onAudioFocusChange的完整逻辑AUDIOFOCUS_LOSS时暂停播放AUDIOFOCUS_LOSS_TRANSIENT时暂停播放但保留恢复位置AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK时降低音量。4.2 音频设备的音量与路由管理加载设备之后的音量控制也是一门学问。系统按音频流类型STREAM_MUSIC、STREAM_VOICE_CALL、STREAM_ALARM等来管理音量。即便你的应用是用AudioTrack播放只要AudioAttributes里的Usage设置成了USAGE_MEDIA它就跟STREAM_MUSIC绑定在一起受媒体音量控制。实际操作中要注意AudioTrack的setVolume是应用内音量系统音量由AudioManager控制。不要在应用里依赖setVolume去做大声量的增益提升因为超过1.0f的数值在有些设备上会被钳制。更合理的做法是先用AudioManager的setStreamVolume把系统媒体音量调整到目标值再用setVolume做微调。路由管理上有一个坑用setSpeakerphoneOn(true)强制外放的方式在老版本API里有效但在Android 5.0之后的设备上已经被弱化尤其是通话场景。推荐改用setCommunicationDevice或者AudioRouting接口来精确控制路由。AudioRouting接口是在API 23中引入的通过它可以查询和切换音频输出设备if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { AudioTrack audioTrack ...; AudioDeviceInfo[] devices audioTrack.getRoutedDevices(); // 遍历devices找到你想切换的设备 boolean success audioTrack.setPreferredDevice(targetDevice); }setPreferredDevice是应用层能做出的“请求”系统并不保证一定会切到目标设备但大多数情况下会尊重应用的偏好。这在需要固定输出到蓝牙外设或者USB声卡的场景下格外有用。4.3 常见状态异常与恢复策略音频设备状态异常往往表现为无声音、单声道、滋滋声、卡顿等原因可能出在设备驱动、系统音频服务、应用代码任何一个环节。我总结了几条高频异常与恢复策略无声音但播放不报错优先检查音频焦点是否被其他应用抢占、音量是否为0、设备的AudioTrack是否还在PLAYSTATE_PLAYING状态。焦点和音量是最容易被忽略的两项。刚开始播放有短暂爆音大概率是AudioTrack还没有完全进入PLAYSTATE_PLAYING就写入了大量数据。解决套路是先写入一段静音缓冲等音频服务稳定后再写真实数据。拔插耳机后音频无输出这是典型的设备路由未刷新问题。可以在ACTION_HEADSET_PLUG回调里延迟100ms重新查询AudioManager.getDevices并调用AudioTrack的flush和reconfigure强制系统重新走一遍路由流程。蓝牙设备播放卡顿蓝牙A2DP传输存在带宽限制高采样率高码率音频容易卡顿。可以先检查蓝牙设备的采样率能力强制AudioTrack用低规格播放比如44.1kHz/16bit/双声道这是兼容性最好的配置。正常项目里建议实现一个兜底的“音频重置”逻辑检测到AudioTrack异常时依次执行stop、flush、release然后重新创建AudioTrack实例。这比在现有实例上做各种reset操作要可靠得多实测在绝大多数设备上都能恢复播放能力。5. 常见问题排查与避坑指南5.1 无声、卡顿、延迟问题的定位思路遇到无声问题第一反应不应该是怀疑代码而是先通过系统自带的日志和状态工具定位问题范围。Android系统里音频播放链路的问题大多会反映在logcat的AudioFlinger、AudioPolicyManager相关Tag下。你可以先用adb shell dumpsys media.audio_policy命令查看当前的音频策略与设备路由状态看看系统认为当前输出设备是什么。另一个实用工具是adb shell dumpsys audio。它能查看每个AudioTrack和AudioRecord实例的状态、采样率、声道、缓冲大小对排查参数配置问题帮助极大。比如你能看到当前正在播放的AudioTrack的采样率已经变成了96000Hz而你期望的是44100Hz那就说明音频被某个环节重采样了延迟变高也就不奇怪了。延迟问题的排查思路如果是从AudioTrack.write到声音发出的延迟过大重点检查缓冲区大小和写入节奏。缓冲区越大延迟越高但卡顿风险越低缓冲区越小延迟越低但低端机上容易卡顿。针对延迟敏感场景比如音乐演奏类App建议用AudioTrack.getUnderrunCount实时监测缓冲欠载次数若欠载频繁再适度加大缓冲区。5.2 设备插拔闪退的坑设备插拔导致闪退的原因最常见的是AudioTrack还在播放中但底层设备已经被移除应用没有捕获到异常。在老版本SDK中直接访问一个已经失效的AudioTrack实例很容易触发IllegalStateException。我推荐的防护策略所有AudioTrack相关的操作都放在一个HandlerThread里并且在设备插拔回调里不再直接操作AudioTrack而是向HandlerThread发送消息由它统一处理pause、flush、release等动作。这样可以避免跨线程同时操作同一个AudioTrack实例的问题。一个容易忽略的细节AudioTrack实例释放后不要再去调用任何方法包括getPlayState()。有些开发者在release之后还想查一次状态结果直接Crash。记得把audioTrack引用置null并在所有访问点判空。5.3 调试工具与日志分析技巧最后分享几个效率翻倍的调试手段。第一个是logcat过滤调试音频相关问题时重点过滤这几个TagAudioTrack、AudioFlinger、AudioPolicyManager、AudioService、MediaCodec。不要只看app的TAG因为音频服务的错误信息往往不会出现在应用进程的日志里需要同时打adb logcat的底层Tag。第二个是结合dumpsys追踪路由在播放过程中执行adb shell dumpsys audio | grep -A 5 routed device能看到当前音频路由到了哪个设备。如果显示输出的设备和你预期不符权限问题或者焦点问题基本跑不掉。第三个是开启系统音频的debug模式在开发者选项里可以打开“显示surface更新”和“GPU呈现模式分析”但音频没有专门的开发调试开关。所以本质上还是要靠logcat和dumpsys来拿到系统状态。熟能生巧多排查几次不同机型的无声问题你就能很快练就一套定位敏感度。站在我个人的使用经验上来说Android音频这块最忌讳的是“想当然”。凡是症状和预期不一致先把系统的实际状态用dumpsys拉出来看看再判断是应用层的问题还是系统层的问题基本能避免无效调试。加载音频设备这个动作听起来抽象但落到实际操作上无非就是理清设备列表、选对API、处理好路由和焦点最后再兜底一下代码的健壮性。把这几个环节打通了音频相关的需求也就稳了。最后再分享一个我在项目里一直在用的小经验音频模块最好统一封装成一个单例管理器不要在各个页面里散落创建AudioTrack和SoundPool。统一管理的好处是遇到设备插拔、焦点变化、资源释放这些全局事件时只需要在一个地方做处理排查问题时思路也清晰得多。如果你正在被音频问题折磨不妨先按这个思路重构一遍代码很多莫名其妙的bug会自己消失。
返回列表