AI 应用井喷式爆发,语音相关功能也不再局限于 IM 等社交应用,逐渐被广泛使用。语音正在从“聊天里的一个按钮”,变成 AI 产品的主要交互入口之一。

今天来分享一下 Android 中录音与回放的基础实现。

录制语音

录音需要 RECORD_AUDIO,它是危险权限。

<manifest ...>
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
</manifest>

除了在 Manifest 里声明,还得在运行时向用户申请。

class RecorderActivity : AppCompatActivity() {
    ...
    private val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
        if (it) {
            startRecording()
        } else {
            showToast(R.string.mic_permission_denied)
        }
    }
    private fun requestMicPermissionThenStart() {
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
            startRecording()
        } else {
            launcher.launch(Manifest.permission.RECORD_AUDIO)
        }
    }
}

在获取权限后,开始录音。

class RecorderActivity : AppCompatActivity() {
    ...
    private var isRecording = false
    private var recorder: MediaRecorder? = null
    private val outputFile: File
        get() = File(cacheDir, "record_demo.m4a")

    private fun startRecording() {
        if (isRecording) return
        recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            MediaRecorder(this)
        } else {
            @Suppress("DEPRECATION")
            MediaRecorder()
        }
        try {
            recorder?.apply {
                setAudioSource(MediaRecorder.AudioSource.MIC)
                setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
                setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
                setAudioEncodingBitRate(128_000)
                setAudioSamplingRate(44_100)
                setAudioChannels(1)
                setOnInfoListener { mr, what, extra ->
                    if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED ||
                        what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED
                    ) {
                        stopRecording()
                    }
                }
                setOnErrorListener { mr, what, extra ->
                    if (what == MediaRecorder.MEDIA_RECORDER_ERROR_UNKNOWN) {
                        stopRecording()
                        showToast(R.string.record_error)
                    }
                }
                setMaxDuration(60_000)
                setMaxFileSize(10L * 1024 * 1024)
                setOutputFile(outputFile.absolutePath)
                prepare()
                start()
            }
            isRecording = true
            showToast(R.string.recording_started)
        } catch (e: Exception) {
            recorder?.release()
            recorder = null
            showToast(R.string.record_failed)
        }
    }

    private fun stopRecording() {
        val current = recorder ?: return
        try {
            current.stop()
        } catch (e: RuntimeException) {
            // 过早 stop() 或没有采集到有效数据时,文件不可用
            outputFile.delete()
        } finally {
            current.release()
            recorder = null
            isRecording = false
        }
        showToast(R.string.recording_stopped)
    }

    override fun onPause() {
        super.onPause()
        // 退出前台时结束录音,避免后台占用麦克风
        if (isRecording) stopRecording()
    }

    override fun onDestroy() {
        super.onDestroy()
        recorder?.release()
        recorder = null
    }
}

首先定义录音状态变量 isRecording,用于记录当前是否正在录音。

我们使用 MediaRecorder 来实现录音功能。MediaRecorder() 无参构造在 API 31 被标记为废弃,官方建议改用带 Context 的版本,所以这里做了版本分支。

MediaRecorder 第一步要确定从哪收声,也就是音频源:

  • AudioSource.MIC:主麦克风,最通用的选择
  • AudioSource.VOICE_COMMUNICATION:针对通话场景优化,会开启回声消除和降噪
  • AudioSource.VOICE_RECOGNITION:针对语音识别优化,同样带降噪
  • AudioSource.CAMCORDER:与摄像头方向绑定的麦克风
  • AudioSource.UNPROCESSED:未经处理的原始信号(API 24+),并非所有设备支持

「录一段话」这种场景,MIC 就够了。如果你录的同时还在外放,或者做实时通话,就该用 VOICE_COMMUNICATION 来避免回声。

输出格式和编码器的组合,我用的是 MPEG_4 + AAC,后缀 .m4a,通用性最好。采样率和码率可以显式指定,我用的是 44.1kHz 128kbps,你也可以不指定,系统会自动选择默认值。

同时可选配置还有声道数、最大录音时长、最大文件大小等。当触发最大时长或最大文件大小时,需要在 OnInfoListener 回调中处理停止录音。

MediaRecorder 内部维护了一个严格的状态机,各方法只能在特定状态下调用:

MediaRecorder State Diagram
状态 进入方式 此刻能调用
Initial 构造 / reset() setAudioSource()
Initialized setAudioSource() setOutputFormat()
DataSourceConfigured setOutputFormat() setAudioEncoder()、setOutputFile()、setMaxDuration()、prepare()
Prepared prepare() start()、release()
Recording start() stop()、getMaxAmplitude()
Error 任意非法操作 reset()
Released release() 无

换句话说,下面这个顺序是官方文档给出的标准流程:

setAudioSource()      // 必须第一个
setOutputFormat()     // 必须在 setAudioSource() 之后
setAudioEncoder()     // 必须在 setOutputFormat() 之后
setOutputFile()       // 文档要求在 setOutputFormat() 之后、prepare() 之前
prepare()             // 配置收尾
start()               // 开始

其中真正由框架强制校验的硬链条是 setAudioSource() → setOutputFormat() → setAudioEncoder() → prepare() → start(),这几步都是 native 方法,颠倒任何一步都会立刻抛 IllegalStateException。

setOutputFile() 是个例外:它的 Java 层实现只是把路径/文件存进成员字段,并不做状态检查,真正的校验被推迟到 prepare() 内部的 native 调用。所以实践中把它放到前面往往也不报错——但这只是当前实现的宽松,文档契约写的是「在 setOutputFormat() 之后」,换设备或系统版本可能翻车,建议仍按标准顺序写。

另外 prepare() 是同步阻塞的,低性能设备上可能耗掉几百毫秒,可以考虑放到子线程里执行。

停止录音这块有个必须写的 try-catch,不是防御式编程,是真的会抛异常。官方文档写得很明确:stop() 时如果还没收到有效的音视频数据,会故意抛一个 RuntimeException 出来,让应用自己去清理这个没构造完的输出文件。所以用户手抖点一下就松开,是可能踩到这个分支的。捕获之后务必把文件删掉,否则用户会播到一个损坏文件。

播放录音

播放录音可以用 MediaPlayer,它能满足简单的播放需求。不过现在官方其实更推荐 Jetpack Media3,它更现代、更灵活,但这是另外的话题。

class RecorderActivity : AppCompatActivity() {
    ...
    private var player: MediaPlayer? = null
    private var isPlaying = false
    private val outputFile: File
        get() = File(cacheDir, "record_demo.m4a")

    private fun startPlaying() {
        if (isPlaying) return
        if (outputFile.exists().not()) {
            showToast(R.string.no_record_file)
            return
        }
        try {
            player = MediaPlayer().apply {
                setDataSource(outputFile.absolutePath)
                setOnCompletionListener {
                    releasePlayer()
                }
                setOnErrorListener { mp, what, extra ->
                    releasePlayer()
                    Log.e(TAG, "playback error: what=$what extra=$extra")
                    true
                }
                prepare()
                start()
            }
            isPlaying = true
            showToast(R.string.playing_started)
        } catch (e: Exception) {
            player?.release()
            player = null
            showToast(R.string.play_failed)
        }
    }

    private fun stopPlaying() {
        player?.let {
            try {
                it.stop()
            } catch (e: IllegalStateException) {
                e.printStackTrace()
            }
            it.release()
        }
        player = null
        isPlaying = false
    }

    private fun releasePlayer() {
        player?.release()
        player = null
        isPlaying = false
    }

    override fun onPause() {
        super.onPause()
        if (isPlaying) stopPlaying()
    }

    override fun onDestroy() {
        super.onDestroy()
        releasePlayer()
    }
}

MediaPlayer 同样是状态机,但比 MediaRecorder 宽容一点。

  • pause() 之后可以直接 start() 恢复,但 stop() 之后必须重新 prepare() 才能再播。
  • setOnErrorListener 返回 true 表示错误已被消费,这样回调链就到此为止,不会误触发 OnCompletionListener。
MediaPlayer State Diagram

写在最后

到这里,一个能录、能停、能回放的最小闭环就跑通了。MediaRecorder 负责采集并直接编码落盘,MediaPlayer 负责把文件播出来,两者都是状态机,把调用顺序和 release() 的时机守住,基本就不会出大问题。

还有几件事这篇文章没展开,也许你会碰到:

  • 后台录音:Android 9(API 28)起后台应用拿不到麦克风,系统不报错,只是默默喂静音。要长时间录音得配前台服务,Android 10 起还要声明 android:foregroundServiceType="microphone"。
  • 音频焦点:播放前用 AudioManager 申请焦点,避免和音乐、通话互相打断,播完记得释放。
  • 播放器选型:MediaPlayer 做单次回放够用,但播放列表、后台播放、通知栏控制这些需求一来,直接上 Jetpack Media3 更省心。

麻烦的地方从来不在「怎么录」,而在录完之后那些边界情况——权限被拒、录到一半切后台、文件损坏、异常打断等。把这些兜住,功能才算真的做完。