57、多媒体之音频:MediaPlayer播放、SoundPool音效、AudioRecord录音

音频处理,是移动开发里绕不开的一块硬骨头。说实话,我刚入行那会儿,觉得播放个MP3能有多难?结果第一次做语音社交App,就被各种状态回调、生命周期管理、权限申请折腾得够呛。今天咱们就把Android音频三剑客——MediaPlayer、SoundPool、AudioRecord,一次性讲透。

核心知识点速览

  • MediaPlayer:适合长音频、音乐播放,支持流媒体
  • SoundPool:适合短促音效、游戏反馈,低延迟
  • AudioRecord:原始PCM录音,适合语音处理、实时分析
Android 音频三剑客 MediaPlayer 长音频 / 音乐播放 • 支持本地/网络音频 • 状态机管理(6种状态) • 需处理生命周期 • 适合后台播放 • 支持多种格式 • 可设置音量/循环 SoundPool 短音效 / 游戏反馈 • 低延迟播放 • 预加载到内存 • 支持同时播放多个 • 可控制音调/速率 • 适合UI交互音效 • 最大流数可配置 AudioRecord 原始PCM录音 • 获取原始音频数据 • 需处理缓冲区 • 支持实时处理 • 需要RECORD_AUDIO权限 • 可指定采样率/通道 • 适合语音识别/分析 选择依据:音频时长 > 延迟要求 > 是否需要原始数据

一、MediaPlayer:播放音乐的正确姿势

MediaPlayer是个状态机,这一点我踩过坑。记得有次线上反馈说播放器点了没反应,查了半天发现是在错误状态调了start()。说白了,你得把它当个有限状态机来伺候。

1.1 基本使用流程

MediaPlayer mediaPlayer = new MediaPlayer();
try {
    // 设置数据源(本地或网络)
    mediaPlayer.setDataSource(context, Uri.parse("file:///sdcard/music.mp3"));
    // 同步准备(简单场景)
    mediaPlayer.prepare();
    // 开始播放
    mediaPlayer.start();
} catch (IOException e) {
    e.printStackTrace();
}

我的习惯:网络音频一定要用异步准备,否则主线程卡住用户直接骂娘。

mediaPlayer.setOnPreparedListener(mp -> {
    // 准备完成,可以播放了
    mp.start();
});
mediaPlayer.prepareAsync(); // 异步准备,不阻塞UI

1.2 状态管理要点

MediaPlayer有6种核心状态:Idle、Initialized、Prepared、Started、Paused、Stopped。嗯,这里要注意——Released状态后不能再使用

当前状态 可调用方法 下一个状态
Idle setDataSource() Initialized
Initialized prepare() / prepareAsync() Prepared
Prepared start() Started
Started pause() / stop() Paused / Stopped
Paused start() Started
Stopped prepare() / prepareAsync() Prepared

我曾经踩过的坑:在Activity的onDestroy里直接调mediaPlayer.release(),但没判断是否为空。结果用户快速旋转屏幕,空指针直接闪退。后来我统一用LifecycleObserver管理,省心多了。

1.3 生命周期最佳实践

public class AudioPlayerViewModel extends ViewModel {
    private MediaPlayer mediaPlayer;

    @Override
    protected void onCleared() {
        super.onCleared();
        if (mediaPlayer != null) {
            mediaPlayer.release();
            mediaPlayer = null;
        }
    }
}

把MediaPlayer放到ViewModel里,配合Lifecycle,旋转屏幕不会中断播放。这个方案我在多个项目里验证过,稳得很。

二、SoundPool:音效播放的轻骑兵

SoundPool适合短促音效,比如按钮点击、游戏中的射击声。它把音频文件预加载到内存,播放延迟极低。你想想看,要是每次点击都new一个MediaPlayer,那延迟和内存开销谁也受不了。

2.1 初始化与加载

SoundPool soundPool;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    AudioAttributes attrs = new AudioAttributes.Builder()
            .setUsage(AudioAttributes.USAGE_GAME)
            .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
            .build();
    soundPool = new SoundPool.Builder()
            .setMaxStreams(5)  // 同时播放的最大流数
            .setAudioAttributes(attrs)
            .build();
} else {
    soundPool = new SoundPool(5, AudioManager.STREAM_MUSIC, 0);
}

// 加载音效,返回soundID
final int soundId = soundPool.load(context, R.raw.click_sound, 1);

关键参数:setMaxStreams(5)表示最多同时播放5个音效。如果超过,系统会停止优先级最低的那个。我在做射击游戏时设了8个,结果枪声和脚步声互相抢资源,后来改成4个就正常了。

2.2 播放与控制

// 播放音效
soundPool.play(
    soundId,       // 音效ID
    1.0f,          // 左声道音量 (0.0 - 1.0)
    1.0f,          // 右声道音量
    1,             // 优先级 (0=最低)
    0,             // 循环次数 (0=不循环, -1=无限循环)
    1.0f           // 播放速率 (0.5 - 2.0)
);

播放速率这个参数很有意思。调成2.0f,声音会变尖变快,适合加速效果;调成0.5f,声音变低沉,适合慢动作场景。我在一个跑酷游戏里就用这个参数做时间减速特效,玩家反馈很带感。

2.3 释放资源

@Override
protected void onDestroy() {
    super.onDestroy();
    if (soundPool != null) {
        soundPool.release();
        soundPool = null;
    }
}

别忘了释放。SoundPool占用的内存虽然不大,但累积起来也会出问题。我见过一个App切了20次页面后音效全没了,就是没释放导致的。

三、AudioRecord:录音与原始音频处理

AudioRecord获取的是原始PCM数据,说白了就是未经压缩的音频裸数据。它不像MediaRecorder那样直接输出MP3文件,但胜在灵活——你可以做实时处理、音频分析、甚至自己写编码器。

3.1 权限与配置

注意:Android 6.0以上需要动态申请RECORD_AUDIO权限。我曾经在测试机上忘了处理权限回调,结果录音一直静默失败,排查了半天才发现是权限被拒了。

// 配置参数
int sampleRate = 44100;        // 采样率,常用44100Hz
int channelConfig = AudioFormat.CHANNEL_IN_MONO;  // 单声道
int audioFormat = AudioFormat.ENCODING_PCM_16BIT; // 16位PCM

// 计算缓冲区大小
int bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat);

AudioRecord audioRecord = new AudioRecord(
    MediaRecorder.AudioSource.MIC,  // 麦克风输入
    sampleRate,
    channelConfig,
    audioFormat,
    bufferSize
);

3.2 开始录音与读取数据

audioRecord.startRecording();

byte[] audioData = new byte[bufferSize];
boolean isRecording = true;

// 在子线程中读取数据
new Thread(() -> {
    while (isRecording) {
        int bytesRead = audioRecord.read(audioData, 0, bufferSize);
        if (bytesRead > 0) {
            // 处理音频数据,比如保存到文件或实时分析
            processAudioData(audioData, bytesRead);
        }
    }
}).start();

我的建议:read()方法是阻塞的,一定要放在子线程。如果在主线程调用,UI会直接卡死。另外,bufferSize不要设得太小,否则频繁读取会消耗大量CPU。

3.3 停止与释放

isRecording = false;  // 停止循环
audioRecord.stop();
audioRecord.release();
audioRecord = null;

释放顺序很重要:先stop(),再release()。我见过有人直接调release(),结果底层资源没释放干净,下次录音时直接崩溃。

3.4 实战:保存为WAV文件

AudioRecord输出的是PCM裸数据,要保存为可播放的音频文件,需要自己加WAV头。说白了就是往PCM数据前面塞44个字节的文件头。

private void saveToWav(byte[] pcmData, int sampleRate, File file) throws IOException {
    FileOutputStream fos = new FileOutputStream(file);
    
    // WAV文件头
    int totalDataLen = pcmData.length + 36;
    int channels = 1;  // 单声道
    int byteRate = sampleRate * channels * 2;  // 16位 = 2字节
    
    // 写RIFF头
    writeString(fos, "RIFF");
    writeInt(fos, totalDataLen);
    writeString(fos, "WAVE");
    
    // 写fmt块
    writeString(fos, "fmt ");
    writeInt(fos, 16);  // 子块大小
    writeShort(fos, (short)1);  // PCM格式
    writeShort(fos, (short)channels);
    writeInt(fos, sampleRate);
    writeInt(fos, byteRate);
    writeShort(fos, (short)(channels * 2));  // 块对齐
    writeShort(fos, (short)16);  // 位深度
    
    // 写data块
    writeString(fos, "data");
    writeInt(fos, pcmData.length);
    fos.write(pcmData);
    
    fos.close();
}

避坑指南:WAV头里的数据长度一定要算对,否则播放器会报错。我刚开始写的时候,totalDataLen少算了36字节,结果生成的WAV文件在Windows Media Player里能播,在手机自带播放器里就报格式错误。后来统一用标准计算方式才解决。

四、三者的选择策略

说了这么多,到底该用哪个?我总结了一个简单的判断逻辑:

  • 播放音乐/长音频 → MediaPlayer。它支持流媒体、后台播放、多种格式,功能最全。
  • 短促音效/游戏反馈 → SoundPool。低延迟、可同时播放多个、资源占用小。
  • 录音/实时音频处理 → AudioRecord。获取原始PCM数据,灵活性最高。

当然,实际项目中经常组合使用。比如一个语音社交App:用AudioRecord录音,用MediaPlayer播放对方的声音,用SoundPool播放消息提示音。各司其职,互不干扰。

音频这块坑不少,但掌握了这三个核心组件,大部分场景都能应对。嗯,今天就先聊到这儿,下次咱们聊聊视频播放和CameraX。


公众号:蓝海资料掘金营,微信deep3321