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 自动释放资源。


🏁 结语

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

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

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

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

    的完整语音管理模块。

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

相关推荐
沐怡旸3 小时前
【底层机制】【Android】Binder架构与原理
android·面试
Jeled3 小时前
Jetpack —> Media3的分析和使用
android
木易士心5 小时前
Android setContentView源码与原理分析
android
马尚来5 小时前
掌握Kotlin编程,从入门到精通:视频教程
后端·kotlin
00后程序员张5 小时前
iOS混淆与IPA文件加固全流程实战 防止苹果应用被反编译的工程级方案
android·ios·小程序·https·uni-app·iphone·webview
用户41659673693556 小时前
Jetpack Compose 进阶:实现列表嵌套悬停(LazyColumn & HorizontalPager)
android
2501_915106327 小时前
iOS 应用加固与苹果软件混淆指南,如何防止 IPA 被反编译与二次打包?
android·ios·小程序·https·uni-app·iphone·webview
努力犯错7 小时前
AI视频修复技术入门:从Sora水印谈起,我们如何“抹去”未来影像的瑕疵?
大数据·人工智能·语言模型·开源·音视频
huibin1478523698 小时前
不能识别adb/usb口记录
android·adb