Android 音视频播放:MediaPlayer 与 VideoView

前言

音视频播放是应用中一个很重要的功能。Android 提供了原生的 MediaPlayerVideoView 工具,来分别完成音频、视频的播放操作。但这两者的坑很多,稍微使用不当,就会造成严重问题,比如应用崩溃、资源泄露等。

我们来了解一下 MediaPlayerVideoView 的正确使用方法,再说说现代 Android 中推荐的Jetpack Media3 (ExoPlayer)

播放音频:MediaPlayer

使用 MediaPlayer,关键在于要了解其生命周期状态机 。几乎所有与 MediaPlayer 相关的崩溃问题都是因为在错误的状态下调用了方法。

图片来自于官方文档

核心方法

MediaPlayer 类中常用的控制方法:

方法名 功能描述
setDataSource() [状态: Idle -> Initialized] 设置媒体源。可以是一个路径、URI或文件描述符。
prepare() [状态: Initialized -> Prepared] 同步准备媒体。会阻塞UI线程,请谨慎使用
prepareAsync() [状态: Initialized -> Preparing -> Prepared] (生产级推荐) 异步准备媒体,通过监听器回调来通知完成。
start() [状态: Prepared/Paused/PlaybackCompleted -> Started] 开始或恢复播放。
pause() [状态: Started -> Paused] 暂停播放。
seekTo(int msec) 定位到指定的毫秒位置。此方法不改变状态。
stop() [状态: Started/Paused/Prepared/PlaybackCompleted -> Stopped] 停止播放。
reset() [状态: 任意 -> Idle] 将播放器重置到空闲状态。
release() [状态: 任意 -> End] (必须调用) 释放所有占用的资源,调用后对象就不可以再使用。
isPlaying() 判断是否正在播放。在非法状态(如End)下调用会抛出异常。
getDuration() 获取媒体总时长。必须在 Prepared 状态之后调用。

我们来说说 MediaPlayer 的标准工作流程:

首先,获取一个 MediaPlayer 实例,然后调用其 setDataSource() 方法设置音频文件的路径。接着,调用 prepareAsync() 方法进行异步准备,防止主线程卡顿。并且进行异步准备,需要设置 setOnPreparedListener 监听器来获取准备完成的通知。

最后,当播放器准备完成后,调用 start() 方法开始播放。在播放过程中,可以调用 pause() 方法暂停,调用 start 方法恢复。如果要完全停止播放,可以使用 stop() 方法。调用后播放器会进入 Stopped 状态,需要重新准备才能再次播放。

案例:简易音乐播放器

我们通过一个案例来演示一下。

首先,创建名为 PlayAudioTestEmpty Views Activity 项目。在其布局中,添加三个按钮,分别对应播放、暂停以及停止操作。

activity_main.xml 中的代码如下所示:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <Button
        android:id="@+id/play"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Play" />

    <Button
        android:id="@+id/pause"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Pause" />

    <Button
        android:id="@+id/stop"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Stop" />
</LinearLayout>

MediaPlayer 可播放网络、本地和应用程序安装包中的音频,这里为了简单起见,我们来播放应用程序安装包中的音频。

我们将准备好的音频文件放到 app/src/main/assets 目录下,因为这个目录会在项目打包时被一并打包到安装文件中。

资源下载 密码:snow

MainActivity 中,按照上述流程来完成音乐播放器的逻辑。代码如下:

kotlin 复制代码
class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private var mediaPlayer: MediaPlayer? = null
    private var isPrepared = false // 是否准备完成

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 初始化媒体播放器
        initMediaPlayer()

        binding.play.setOnClickListener {
            if (isPrepared && mediaPlayer?.isPlaying == false) {
                mediaPlayer?.start()
            }
        }

        binding.pause.setOnClickListener {
            if (mediaPlayer?.isPlaying == true) {
                mediaPlayer?.pause()
            }
        }

        binding.stop.setOnClickListener {
            if (mediaPlayer?.isPlaying == true || isPrepared) {
                mediaPlayer?.stop()
                // 此时播放器进入Stopped状态,需要重新准备
                isPrepared = false
                mediaPlayer?.prepareAsync()
            }
        }
    }

    private fun initMediaPlayer() {
        try {
            mediaPlayer = MediaPlayer()
            // 获取 AssetManager 实例,用于读取assets目录下的资源
            val assetManager = assets
            // 打开
            val fd = assetManager.openFd("music.mp3")
            // 设置数据源
            mediaPlayer?.setDataSource(fd.fileDescriptor, fd.startOffset, fd.length)

            // 使用异步准备,避免阻塞UI
            mediaPlayer?.prepareAsync()

            // 设置监听器以获取播放器准备完成的回调
            mediaPlayer?.setOnPreparedListener {
                Log.d("MediaPlayer", "Prepared!")
                isPrepared = true
            }
            // 获取播放器播放完成的回调
            mediaPlayer?.setOnCompletionListener {
                Toast.makeText(this, "播放完毕", Toast.LENGTH_SHORT).show()
                // 播放完成后,直接移到开头并暂停
                it.seekTo(0)
                it.pause()
            }
            // 获取播放器错误的回调
            mediaPlayer?.setOnErrorListener { mp, what, extra ->
                Log.e("MediaPlayer", "发生错误: what=$what, extra=$extra")
                isPrepared = false
                // 发生错误时,释放资源
                mp.release()
                mediaPlayer = null
                true // 表示已处理错误
            }
        } catch (e: IOException) {
            Log.e("MediaPlayer", "设置数据源失败", e)
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        // 必须释放资源,防止内存泄漏
        mediaPlayer?.release()
        mediaPlayer = null
    }
}

现在运行程序,点击播放按钮即可开始播放音乐。点击暂停按钮就会停止,再次点击播放按钮,会在之前暂停的位置接着播放。如果此时点击了停止按钮,会停止播放,但当再次播放音乐时,音乐会重新开始播放。

播放视频:VideoView

VideoView 是一个 UI 控件,它内部封装了 MediaPlayer,可以完成视频的播放。

核心方法

VideoView 中的常用控制方法:

方法名 功能描述
setVideoPath(String path) 设置本地视频文件的路径。
setVideoURI(Uri uri) 设置视频源的 URI,可用于本地 (android.resource://) 和网络视频。
start() / pause() 开始/暂停播放。
seekTo(int msec) 将播放头移动到指定的毫秒位置。
stopPlayback() 停止播放并释放资源。
suspend() 临时释放播放器资源。
resume() 从暂停的位置恢复播放。

案例:简易视频播放器

同样地,通过一个例子来理解。

新建名为 PlayVideoTestEmpty Views Activity 项目。其布局中也是添加三个按钮,分别表示播放、暂停和重新播放,另外放置一个 VideoView 控件,用于显示视频。

activity_main.xml 中的代码如下所示:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <Button
            android:id="@+id/play"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="Play" />

        <Button
            android:id="@+id/pause"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="Pause" />

        <Button
            android:id="@+id/replay"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="Replay" />
    </LinearLayout>

    <VideoView
        android:id="@+id/videoView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />
</LinearLayout>

我们将准备好的视频文件(video.mp4)放在 res/raw 目录下。你可能会问:为什么不放在刚刚的 asserts 目录下呢?

因为 res/raw 目录下的资源可以通过 android.resource:// 协议生成对一个对应的 Uri,让 VideoView 进行播放。而 asserts 目录下的文件没有这样的 Uri,播放起来比较复杂。

资源下载 密码:snow

MainActivity 中的代码:

kotlin 复制代码
class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    private val videoView get() = binding.videoView

    // 将视频资源解析成 Uri 对象
    private val videoUri by lazy { Uri.parse("android.resource://$packageName/${R.raw.video}") }


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 设置 Uri
        videoView.setVideoURI(videoUri)

        // 设置准备完成的监听器
        videoView.setOnPreparedListener { mediaPlayer ->
            // mediaPlayer 是 VideoView 内部的 MediaPlayer 实例
            Log.d("VideoView", "Prepared!")
        }

        // 设置错误监听器
        videoView.setOnErrorListener { _, what, extra ->
            Toast.makeText(this, "播放出错,错误码: what=$what, extra=$extra", Toast.LENGTH_LONG)
                .show()
            Log.e("VideoView", "Error: what=$what, extra=$extra")
            true // 返回 true 表示我们已经处理了该错误
        }

        // 设置播放完成监听器
        videoView.setOnCompletionListener {
            Toast.makeText(this, "播放完毕", Toast.LENGTH_SHORT).show()
        }

        binding.play.setOnClickListener {
            if (!videoView.isPlaying) {
                videoView.start()
            }
        }

        binding.pause.setOnClickListener {
            if (videoView.isPlaying) {
                videoView.pause()
            }
        }

        binding.replay.setOnClickListener {
            // 停止当前播放并释放内部播放器
            videoView.stopPlayback()

            // 重新设置视频 URI,创建新的 MediaPlayer 实例
            videoView.setVideoURI(videoUri)

            // 开始播放
            videoView.start()
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        // 停止播放并释放资源
        videoView.stopPlayback()
    }
}

现在运行程序,并点击播放按钮,就可以看到视频开始播放了。

VideoView 只是一个简单的视频播放工具类,并不能支持很多视频格式,以及视频播放效率较低。但你只是使用它来播放一些简单的视频,用它还是可以的。

使用 ExoPlayer

现在,谷歌推荐使用 ExoPlayer 作为音视频播放器,因为它支持的流媒体协议更多、易于扩展和定制、API 设计更可靠。对于大多数场景,应该优先使用它。

相关推荐
阿巴斯甜20 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker20 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq952721 小时前
Andorid Google 登录接入文档
android
黄林晴1 天前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab1 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿2 天前
Android MediaPlayer 笔记
android
Jony_2 天前
Android 启动优化方案
android
阿巴斯甜2 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇2 天前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_2 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android