Kotlin 实现社交 App 音视频模块:语音录制、播放、暂停与进度控制全流程封装

🚀 本文将带你从 0 到 1 实现一个完整可复用的语音录制与播放模块,适用于社交类 App 场景。

使用 Kotlin 封装网易云信的 AudioRecorder,并结合 MediaPlayer 实现播放、暂停、进度监听、时间显示与生命周期管理。


🧭 目录


🧩 前言

在社交类应用中,语音聊天、语音评论等功能是非常常见的。

而在 Android 中实现一套录音 + 播放 + 暂停 + 恢复播放 + 时间显示的完整方案,往往涉及:

  • 录音管理(启动、停止、超时、失败处理)

  • 播放管理(暂停、恢复、播放完成)

  • 时间更新与 UI 同步

  • 生命周期内的资源释放

如果逻辑分散在各个界面中,会造成维护困难。

因此我们需要一个高内聚、低耦合的封装类。


🎯 功能设计目标

本模块需要满足以下需求:

功能 说明
🎤 录音控制 点击按钮开始录音,再次点击停止录音
⏱️ 时间显示 实时显示录音/播放时间(格式:00:06)
🎧 播放控制 支持播放、暂停、继续播放
🔁 状态切换 录音完成后自动进入播放状态
🧹 重置机制 播放完成或删除后重置为初始状态
🔒 限时录音 录音时长限制为 5~60 秒
🧩 生命周期安全 页面销毁时自动释放资源

🧱 模块结构设计

采用单一职责 + 状态驱动设计:

复制代码
VoiceRecordManager
 ├── AudioRecorder (网易云信)
 ├── MediaPlayer (系统播放)
 ├── Handler/Timer (时间进度)
 ├── State Enum (状态管理)
 └── Callbacks (状态、进度监听)

💡 核心类 SyncRecordManager

该类负责录音、播放、暂停、继续播放的完整逻辑:

Kotlin 复制代码
/**
 * @class SyncRecordManager
 * @desc 音频录制与播放管理类
 * 封装了语音的录制、播放、暂停、继续播放、计时、状态管理等逻辑。
 * 使用网易云信的 AudioRecorder 进行录制,不使用系统 MediaRecorder。
 *
 * 功能点:
 * - 支持录音时长限制(最短5秒,最长60秒)
 * - 自动回调录音/播放时间
 * - 支持暂停与恢复播放
 * - 状态统一管理(IDLE / RECORDING / RECORDED / PLAYING / PAUSED)
 * - 播放完成、录制过短/过长、状态变化等回调通知
 */
class NimRecordManager(private val context: Context) {

    /** 当前状态 */
    enum class State {
        IDLE,       // 空闲状态
        RECORDING,  // 正在录音
        RECORDED,   // 已录音完成
        PLAYING,    // 正在播放
        PAUSED      // 播放暂停
    }

    /** ============= 对外回调接口 ============= */
    var onStateChanged: ((State) -> Unit)? = null                // 状态变化回调
    var onRecordTimeUpdate: ((Int) -> Unit)? = null              // 录音时长更新(秒)
    var onPlayTimeUpdate: ((Int, Int) -> Unit)? = null           // 播放进度更新(当前秒,总秒)
    var onPlayComplete: (() -> Unit)? = null                     // 播放完成
    var onRecordTooShortOrLong: (() -> Unit)? = null             // 录音过短或过长

    /** ============= 内部成员变量 ============= */
    private var state: State = State.IDLE
        set(value) {
            field = value
            onStateChanged?.invoke(value)
        }

    private var recorder: AudioRecorder? = null
    private var mediaPlayer: MediaPlayer? = null
    private var recordFilePath: String? = null
    private var recordFile: File? = null
    private var recordDuration = 0 // 录音时长(秒)

    private val mainHandler = Handler(Looper.getMainLooper())
    private var recordTimerRunnable: Runnable? = null
    private var playTimerRunnable: Runnable? = null

    /** 录音时长限制 */
    private val maxRecordSeconds = 60
    private val minRecordSeconds = 5

    /** 网易云信录音回调 */
    private val recordCallback = object : IAudioRecordCallback {
        override fun onRecordStart(audioFile: File?, recordType: RecordType?) {
            // 开始录音时的回调,可用于更新 UI
        }

        override fun onRecordReady() {
            // 录音准备完成,可在此更新UI状态(可选)
        }

        override fun onRecordSuccess(audioFile: File?, audioLength: Long, recordType: RecordType?) {
            // 录音成功回调
            audioFile?.let { file ->
                if (!file.exists()) {
                    onRecordFail()
                    return
                }

                // 某些机型需等待文件写入完成
                val finalSize = waitForFileWrite(file)
                if (finalSize == 0L) {
                    onRecordFail()
                    return
                }

                recordFile = file
                recordFilePath = file.absolutePath
                recordDuration = (audioLength / 1000).toInt()

                // 校验录音时长合法性
                if (recordDuration in minRecordSeconds..maxRecordSeconds) {
                    state = State.RECORDED
                } else {
                    file.delete()
                    onRecordTooShortOrLong?.invoke()
                    reset(deleteFile = false)
                }
            } ?: run {
                onRecordFail()
            }
        }

        override fun onRecordFail() {
            // 录音失败处理
            stopRecordTimer()
            reset(deleteFile = true)
        }

        override fun onRecordCancel() {
            // 主动取消录音
            stopRecordTimer()
            reset(deleteFile = true)
        }

        override fun onRecordReachedMaxTime(duration: Int) {
            // 达到最大录音时长,自动停止
            stopRecord()
        }
    }

    //===================== 录音相关 =====================//

    /**
     * toggleAction:一键控制录音与播放状态转换
     * 根据当前状态自动切换对应操作
     */
    fun toggleAction() {
        when (state) {
            State.IDLE -> startRecord()
            State.RECORDING -> stopRecord()
            State.RECORDED -> startPlay()
            State.PLAYING -> pausePlay()
            State.PAUSED -> resumePlay()
        }
    }

    /** 开始录音 */
    private fun startRecord() {
        if (state != State.IDLE) return

        recordDuration = 0
        recordFile = null
        recordFilePath = null

        recorder = AudioRecorder(context, RecordType.AAC, maxRecordSeconds, recordCallback)
        recorder?.startRecord()

        state = State.RECORDING
        startRecordTimer()
    }

    /** 停止录音 */
    private fun stopRecord() {
        if (state != State.RECORDING) return
        recorder?.completeRecord(false)
        stopRecordTimer()
    }

    /** 启动录音计时器(每秒更新) */
    private fun startRecordTimer() {
        recordTimerRunnable = object : Runnable {
            override fun run() {
                recordDuration++
                onRecordTimeUpdate?.invoke(recordDuration)
                if (recordDuration >= maxRecordSeconds) {
                    stopRecord()
                } else {
                    mainHandler.postDelayed(this, 1000)
                }
            }
        }
        mainHandler.postDelayed(recordTimerRunnable!!, 1000)
    }

    /** 停止录音计时器 */
    private fun stopRecordTimer() {
        recordTimerRunnable?.let { mainHandler.removeCallbacks(it) }
        recordTimerRunnable = null
    }

    //===================== 播放相关 =====================//

    /** 开始播放录音文件 */
    private fun startPlay() {
        if (state != State.RECORDED) return

        recordFilePath?.let { path ->
            try {
                mediaPlayer = MediaPlayer().apply {
                    setDataSource(path)
                    prepare()
                    start()
                    setOnCompletionListener {
                        stopPlay()
                        onPlayComplete?.invoke()
                    }
                }
                state = State.PLAYING
                startPlayTimer()
            } catch (e: Exception) {
                e.printStackTrace()
                reset()
            }
        }
    }

    /** 暂停播放 */
    private fun pausePlay() {
        if (state != State.PLAYING) return
        mediaPlayer?.pause()
        state = State.PAUSED
        stopPlayTimer()
    }

    /** 继续播放 */
    private fun resumePlay() {
        if (state != State.PAUSED) return
        mediaPlayer?.start()
        state = State.PLAYING
        startPlayTimer()
    }

    /** 停止播放并释放资源 */
    private fun stopPlay() {
        mediaPlayer?.runCatching {
            stop()
            release()
        }
        mediaPlayer = null
        stopPlayTimer()
        state = State.RECORDED
    }

    /** 启动播放进度计时(每500ms更新一次UI) */
    private fun startPlayTimer() {
        playTimerRunnable = object : Runnable {
            override fun run() {
                mediaPlayer?.let {
                    if (it.isPlaying) {
                        val posSec = it.currentPosition / 1000
                        val durSec = it.duration / 1000
                        onPlayTimeUpdate?.invoke(posSec, durSec)
                        mainHandler.postDelayed(this, 500)
                    }
                }
            }
        }
        mainHandler.post(playTimerRunnable!!)
    }

    /** 停止播放计时器 */
    private fun stopPlayTimer() {
        playTimerRunnable?.let { mainHandler.removeCallbacks(it) }
        playTimerRunnable = null
    }

    //===================== 重置 & 工具方法 =====================//

    /**
     * 重置状态并释放资源
     * @param deleteFile 是否删除录音文件
     */
    fun reset(deleteFile: Boolean = true) {
        stopRecordTimer()
        stopPlayTimer()
        recorder?.completeRecord(true)
        recorder = null

        mediaPlayer?.runCatching {
            stop()
            release()
        }
        mediaPlayer = null

        if (deleteFile) {
            recordFilePath?.let { File(it).delete() }
            recordFile = null
            recordFilePath = null
        }

        recordDuration = 0
        state = State.IDLE
    }

    /**
     * 等待文件写入完成,确保录音文件可用
     */
    private fun waitForFileWrite(file: File, maxWaitMs: Long = 300): Long {
        val start = System.currentTimeMillis()
        var size = file.length()
        while (size == 0L && System.currentTimeMillis() - start < maxWaitMs) {
            Thread.sleep(20)
            size = file.length()
        }
        return size
    }

    /** ============= 对外工具方法 ============= */
    fun getFilePath(): String? = recordFilePath
    fun getFile(): File? = recordFile
    fun getDuration(): Int = recordDuration
}

🧩 使用方式

Kotlin 复制代码
private val voiceManager by lazy { VoiceRecordManager(requireContext()) }

binding.btnVoice.setOnClickListener {
    when (voiceManager.state) {
        VoiceRecordManager.State.IDLE -> {
            // 开始录音
            voiceManager.startRecord()
        }
        VoiceRecordManager.State.RECORDING -> {
            // 停止录音
            voiceManager.stopRecord()
        }
        VoiceRecordManager.State.RECORDED -> {
            // 播放录音
            voiceManager.startPlay()
        }
        VoiceRecordManager.State.PLAYING -> {
            // 暂停播放
            voiceManager.pausePlay()
        }
        VoiceRecordManager.State.PAUSED -> {
            // 继续播放
            voiceManager.resumePlay()
        }
    }
}

🧠 实现思路详解

状态管理

定义 State 枚举,保证流程转换清晰:

Kotlin 复制代码
enum class State { IDLE, RECORDING, RECORDED, PLAYING, PAUSED }

录音定时器

通过 Handler 定时更新录音时间:

Kotlin 复制代码
private fun startRecordTimer() {
    mainHandler.postDelayed({
        recordDuration++
        onRecordTimeUpdate?.invoke(recordDuration)
    }, 1000)
}

播放控制与进度监听

MediaPlayer 播放状态定期回调进度:

Kotlin 复制代码
val pos = it.currentPosition / 1000
val dur = it.duration / 1000
onPlayTimeUpdate?.invoke(pos, dur)

资源释放与重置机制

在异常、取消或销毁时释放资源:

Kotlin 复制代码
fun reset(deleteFile: Boolean = true) {
    recorder?.completeRecord(true)
    mediaPlayer?.release()
    state = State.IDLE
}

⚙️ 性能优化与注意事项

  1. 主线程安全:录音与播放逻辑尽量避免阻塞主线程。

  2. 播放进度防抖:更新频率建议控制在 500ms。

  3. 录音文件写入延迟:部分设备需等待文件完全写入。

  4. 录音时间限制:5s 以下、60s 以上的录音均视为无效。

  5. 生命周期安全 :可使用 DefaultLifecycleObserver 自动释放资源。


🏁 结语

通过本次封装,我们实现了一个:

  • 支持录音/播放/暂停/继续播放;

  • 带时间更新与状态回调;

  • 支持时长限制与生命周期安全;

    的完整语音管理模块。

这种封装思路可轻松拓展至视频录制、语音评论、语音私聊等功能场景,极大提升代码复用率与项目可维护性。

相关推荐
Kapaseker5 小时前
Compose 进阶—巧用 GraphicsLayer
android·kotlin
黄林晴5 小时前
Android17 为什么重写 MessageQueue
android
阿巴斯甜1 天前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker1 天前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq95271 天前
Andorid Google 登录接入文档
android
黄林晴1 天前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab2 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿2 天前
Android MediaPlayer 笔记
android
Jony_2 天前
Android 启动优化方案
android
阿巴斯甜2 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android