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 设计更可靠。对于大多数场景,应该优先使用它。

相关推荐
小李飞飞砖2 小时前
Sophix、Tinker 和 Robust 三大主流 Android 热修复框架的详细对比
android
感觉不怎么会3 小时前
Android 12 - 部分相机横屏显示方案
android
人生游戏牛马NPC1号4 小时前
学习 Flutter (一)
android·学习·flutter
fundroid5 小时前
Swift 进军 Android,Kotlin 该如何应对?
android·ios
前端世界5 小时前
鸿蒙系统安全机制全解:安全启动 + 沙箱 + 动态权限实战落地指南
android·安全·harmonyos
_一条咸鱼_8 小时前
Vulkan入门教程:源码级解析
android·面试·android jetpack
嘉小华8 小时前
ThreadLocal 详解
android
wkj0018 小时前
php 如何通过mysqli操作数据库?
android·数据库·php
kymjs张涛10 小时前
零一开源|前沿技术周报 #7
android·前端·ios
wuwu_q12 小时前
RK3566/RK3568 Android11 修改selinux模式
android·rk3568