使用 ExoPlayer 流式传输媒体内容

1. 准备工作

973495692ed95e42.png

屏幕截图:使用 ExoPlayer 作为其视频播放器的 Android 版 YouTube 应用。

ExoPlayer 是一款基于 Android 中的低层级媒体 API 构建的应用级媒体播放器。它是 Google 应用(包括 YouTube 和 Google TV)使用的开源项目。ExoPlayer 具有高度的可定制性和可扩展性,因此能够用于许多高级用例。它支持多种媒体格式,包括 DASH 和 SmoothStreaming 等自适应格式。

前提条件

  • 适度了解 Android 开发和 Android Studio

实践内容

  • 创建一个 ExoPlayer 实例,用于准备和播放来自各种来源的媒体。
  • 将 ExoPlayer 与应用的 activity 生命周期集成,以在单窗口或多窗口环境中支持后台运行、前台运行和继续播放媒体内容功能。
  • 使用 MediaItem 创建播放列表。
  • 播放自适应视频串流(根据可用带宽调整媒体质量)。
  • 注册事件监听器,监听播放状态并展示如何使用监听器来衡量播放质量。
  • 使用标准 ExoPlayer 界面组件,然后根据应用的样式对其进行自定义。

所需条件

  • 安装了最新稳定版 Android Studio,并了解其使用方法。确保您的 Android Studio、Android SDK 和 Gradle 插件是最新版本。
  • 搭载 JellyBean (4.1) 或更高版本的 Android 设备,最好是 Nougat [7.1] 或更高版本,因为这些版本支持多窗口。

2. 进行设置

获取代码

首先,请下载 Android Studio 项目:

或者,您也可以克隆 GitHub 代码库:

git clone https://github.com/googlecodelabs/exoplayer-intro.git

目录结构

通过克隆或解压缩,您会得到一个根文件夹 (exoplayer-intro),其中包含一个 Gradle 项目以及多个模块(一个应用模块,以及此 Codelab 的每个步骤的模块),还有您需要的所有资源。

导入项目

  1. 启动 Android Studio。
  2. 依次选择 File > New > Import Project
  3. 选择根 build.gradle 文件。

128162a042143d68.png

屏幕截图:导入时的项目结构

构建完成后,您会看到 6 个模块:app 模块(类型为“application”),以及 5 个名为 exoplayer-codelab-N 的模块(其中的 N0004,,类型均为“library”)。app 模块实际上是空的,其中只包含一个清单。当使用 app/build.gradle 中的 Gradle 依赖项构建应用时,当前指定的 exoplayer-codelab-N 模块中的所有内容都会被合并。

app/build.gradle

dependencies {
   implementation project(":exoplayer-codelab-00")
}

媒体播放器 activity 保存在 exoplayer-codelab-N 模块中。之所以将其保存在单独的库模块中,是为了让您可以在针对不同目标平台(例如,移动设备和 Android TV)的 APK 之间共享它。它还允许您充分利用各种功能,例如 Dynamic Delivery。使用 Dynamic Delivery 功能后,只有当用户需要时,系统才允许安装您的媒体播放功能。

  1. 部署并运行应用,以检查是否一切正常。应用应该以黑色为背景填充屏幕。

9c330b9a6231f72a.png

屏幕截图:空白应用正在运行

3. 流式传输!

添加 ExoPlayer 依赖项

ExoPlayer 是 Jetpack Media3 库的一部分。每个版本均以采用如下格式的字符串作为唯一标识:

androidx.media3:media3-exoplayer:X.X.X

您只需导入 ExoPlayer 的类和界面组件,即可将 ExoPlayer 添加到您的项目。它非常小巧,其收缩占用空间约为 70-300 kB,具体大小取决于包含的功能和支持的格式。ExoPlayer 库分为不同的模块,开发者只需导入自己需要的功能。如需详细了解 ExoPlayer 的模块化结构,请参阅添加 ExoPlayer 模块

  1. 打开 exoplayer-codelab-00 模块的 build.gradle 文件。
  2. 将以下代码行添加到 dependencies 部分并同步项目。

exoplayer-codelab-00/build.gradle

def mediaVersion = "1.0.0-alpha03"
dependencies {
    [...]

    implementation "androidx.media3:media3-exoplayer:$mediaVersion"
    implementation "androidx.media3:media3-ui:$mediaVersion"
    implementation "androidx.media3:media3-exoplayer-dash:$mediaVersion"
}

添加 PlayerView element

  1. 打开 exoplayer-codelab-00 模块中的布局资源文件 activity_player.xml
  2. 将光标放在 FrameLayout 元素内。
  3. 开始输入 <PlayerView,并让 Android Studio 自动补全 PlayerView 元素。
  4. widthheight 使用 match_parent
  5. 将 ID 声明为 video_view

activity_player.xml

<androidx.media3.ui.PlayerView
   android:id="@+id/video_view"
   android:layout_width="match_parent"
   android:layout_height="match_parent"/>

从现在起,您要引用该界面元素作为视频视图。

  1. PlayerActivity 中,您现在可以获取对在您刚才修改的 XML 文件中创建的视图树的引用。

PlayerActivity.kt

private val viewBinding by lazy(LazyThreadSafetyMode.NONE) {
    ActivityPlayerBinding.inflate(layoutInflater)
}
  1. 将视图树的根目录设为您的 activity 的内容视图。此外,还要检查 viewBinding 引用上的 videoView 属性是否可见,以及其类型是否为 PlayerView
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(viewBinding.root)
}

创建 ExoPlayer

若要播放流媒体,您需要一个 ExoPlayer 对象。若要创建该对象,最简单的方法是使用 ExoPlayer.Builder 类。顾名思义,就是使用构建器模式来构建 ExoPlayer 实例。

ExoPlayerPlayer 接口的一个方便、通用的实现。

添加私有方法 initializePlayer 来创建您的 ExoPlayer

PlayerActivity.kt

private var player: ExoPlayer? = null
[...]
private fun initializePlayer() {
    player = ExoPlayer.Builder(this)
        .build()
        .also { exoPlayer ->
            viewBinding.videoView.player = exoPlayer
        }
}

根据您的上下文创建 ExoPlayer.Builder,然后调用 build 来创建 ExoPlayer 对象。然后,系统会将其分配给 player,您需要将其声明为成员字段。然后,您可以使用 viewBinding.videoView.player 可变属性将 player 绑定到其对应视图。

创建媒体项

现在,player 需要一些可以播放的内容。为此,您要创建一个 MediaItem。有许多不同类型的 MediaItem,但您首先要针对互联网上的 MP3 文件创建一个。

若要创建 MediaItem,最简单的方法是使用 MediaItem.fromUri,后者会接受媒体文件的 URI。使用 player.setMediaItemMediaItem 添加到 player

  1. 将以下代码添加到 also 块中的 initializePlayer

PlayerActivity.kt

private fun initializePlayer() {
    [...]
        .also { exoPlayer ->
            [...]
            val mediaItem = MediaItem.fromUri(getString(R.string.media_url_mp3))
            exoPlayer.setMediaItem(mediaItem)
        }
}

请注意,R.string.media_url_mp3strings.xml 中定义为 https://storage.googleapis.com/exoplayer-test-media-0/play.mp3

根据 activity 生命周期实现精细播放

我们的 player 可能会占用大量资源,包括内存、CPU、网络连接和硬件编解码器。其中许多资源都很短缺,尤其是对于硬件编解码器(可能只有一个)来说更是如此。当您不使用这些资源时(例如,当您的应用已置于后台时),请务必释放这些资源以供其他应用使用。

换句话说,播放器的生命周期应该与应用的生命周期相关联。若要实现这一点,您需要替换 PlayerActivity 的 4 个方法:onStartonResumeonPauseonStop

  1. PlayerActivity 处于打开状态时,依次点击 Code menu > Override methods…
  2. 选择 onStartonResumeonPauseonStop
  3. 根据 API 级别,在 onStartonResume 回调中初始化播放器。

PlayerActivity.kt

public override fun onStart() {
    super.onStart()
    if (Util.SDK_INT > 23) {
        initializePlayer()
    }
}

public override fun onResume() {
    super.onResume()
    hideSystemUi()
    if ((Util.SDK_INT <= 23 || player == null)) {
        initializePlayer()
    }
}

Android API 级别 24 及更高版本支持多窗口。由于您的应用在分屏模式下可见,但不处于活动状态,因此您需要在 onStart 中初始化播放器。由于 Android API 级别 23 及更低版本要求您尽可能多等些时间,直到获取到资源为止,因此您要等到 onResume 再初始化播放器。

  1. 添加 hideSystemUi 方法。

PlayerActivity.kt

@SuppressLint("InlinedApi")
private fun hideSystemUi() {
    WindowCompat.setDecorFitsSystemWindows(window, false)
    WindowInsetsControllerCompat(window, viewBinding.videoView).let { controller ->
        controller.hide(WindowInsetsCompat.Type.systemBars())
        controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
    }
}

hideSystemUi 是在 onResume 中调用的辅助方法,借助该方法,您可以实现全屏体验。

  1. onPauseonStop 中使用 releasePlayer(稍后就会创建)释放资源。

PlayerActivity.kt

public override fun onPause() {
    super.onPause()
    if (Util.SDK_INT <= 23) {
        releasePlayer()
    }
}

public override fun onStop() {
    super.onStop()
    if (Util.SDK_INT > 23) {
        releasePlayer()
    }
}

对于 API 级别 23 及更低版本,系统并不一定会调用 onStop,因此您必须尽早在 onPause 中释放播放器。对于 API 级别 24 及更高版本(具备多窗口模式和分屏模式),系统一定会调用 onStop。在暂停状态下,您的 activity 仍然可见,因此您要等到 onStop 再释放播放器。

您现在需要创建一个 releasePlayer 方法,用于释放播放器的资源并销毁播放器。

  1. 将以下代码添加到相应 activity:

PlayerActivity.kt

private var playWhenReady = true
private var currentItem = 0
private var playbackPosition = 0L
[...]

private fun releasePlayer() {
    player?.let { exoPlayer ->
        playbackPosition = exoPlayer.currentPosition
        currentItem = exoPlayer.currentMediaItemIndex
        playWhenReady = exoPlayer.playWhenReady
        exoPlayer.release()
    }
    player = null
}

在释放和销毁播放器之前,请存储以下信息:

  • 使用 playWhenReady 存储播放/暂停状态。
  • 使用 currentPosition 存储当前播放位置。
  • 使用 currentMediaItemIndex 存储当前媒体内容索引。

这样一来,您即可从用户停止播放的位置继续播放。您需要做的就是在初始化播放器时提供这些状态信息。

最终准备

现在,您要做的就是在初始化期间将您保存在 releasePlayer 中的状态信息提供给播放器。

  1. 将以下代码添加到 initializePlayer 中:

PlayerActivity.kt

private fun initializePlayer() {
    [...]
    exoPlayer.playWhenReady = playWhenReady
    exoPlayer.seekTo(currentItem, playbackPosition)
    exoPlayer.prepare()
}

此时会发生下列情况:

  • playWhenReady 告知播放器是否在获取所有播放资源后立即开始播放。由于 playWhenReady 最初为 true,因此在第一次运行应用时,播放会自动开始。
  • seekTo 告知播放器在特定媒体项内寻找某个位置。currentItemplaybackPosition 都初始化为零,以便在应用第一次运行时从头开始播放。
  • prepare 告知播放器获取播放所需的所有资源。

播放音频

最后,一切就绪!启动应用即可播放 MP3 文件并看到嵌入的海报图片。

16f18510848103d7.png

屏幕截图:应用正在播放一个曲目。

测试 activity 生命周期

测试应用在 activity 生命周期的所有不同状态下是否都能正常运行。

  1. 启动另一个应用,然后重新将您的应用置于前台。您的应用是否在正确的位置继续播放?
  2. 暂停应用,将其移到后台,然后重新移到前台。在移到后台进入暂停状态后,它是否始终保持暂停状态?
  3. 旋转应用。如果您将屏幕方向从竖屏改为横屏,然后再改回来,它的行为是怎样的?

播放视频

如果要播放视频,只需将媒体项 URI 修改为 MP4 文件即可。

  1. initializePlayer 中的 URI 更改为 R.string.media_url_mp4
  2. 重新启动应用,同样在视频播放中将应用移到后台,并测试这之后的行为。

PlayerActivity.kt

private fun initializePlayer() {
    [...]
    val mediaItem = MediaItem.fromUri(getString(R.string.media_url_mp4))
    [...]
}

PlayerView 会完成所有相关操作。视频(而非海报图片)会全屏呈现。

b1a45ab2c7cb818d.png

屏幕截图:应用正在播放视频。

真厉害!您刚刚制作了一款能够在 Android 上全屏流式传输媒体的应用,以及配套的生命周期管理、保存状态和界面控件!

4. 创建播放列表

您当前的应用可以播放单个媒体文件,但如果您想让多个媒体文件相继播放,该怎么办呢?为此,您需要使用播放列表。

如需创建播放列表,您可以使用 addMediaItemplayer 添加多个 MediaItem。这样可以实现无缝播放,并且系统会在后台处理缓冲,因此在更改媒体项时,用户不会看到“正在缓冲”旋转图标。

  1. 将以下代码添加到 initializePlayer

PlayerActivity.kt

private void initializePlayer() {
    [...]
    exoPlayer.addMediaItem(mediaItem) // Existing code

    val secondMediaItem = MediaItem.fromUri(getString(R.string.media_url_mp3))
    exoPlayer.addMediaItem(secondMediaItem)
    [...]
}

检查播放器控件的行为方式。您可以使用 d92346ced6303230.pnge9346dea9156c627.png 在媒体项序列中跳转。

8385e35505ef5983.png

屏幕截图:显示“下一个”和“上一个”按钮的播放控件

这很方便!如需了解详情,请参阅有关媒体项播放列表的开发者文档,以及有关 Playlist API 的这篇文章

5. 自适应流式传输

自适应流式传输是一种媒体流式传输技术,可根据可用网络带宽改变串流质量。借助该技术,用户可体验其带宽支持的最高质量的媒体内容。

通常,同一媒体内容会分为多个轨道,其质量(比特率和分辨率)各有不同。播放器会根据可用的网络带宽来选择轨道。

每个轨道会分成给定时长的数据块,其时长通常在 2 到 10 秒之间。这样一来,播放器就可以随着可用带宽的变化快速切换不同轨道。播放器负责将这些数据块拼接在一起以实现无缝播放。

自适应轨道选择

自适应流式传输的核心是为当前环境选择最合适的轨道。您可以使用自适应轨道选择功能将您的应用更新为播放流媒体。

  1. 请使用以下代码更新 initializePlayer

PlayerActivity.kt

private fun initializePlayer() {
    val trackSelector = DefaultTrackSelector(this).apply {
        setParameters(buildUponParameters().setMaxVideoSizeSd())
    }
    player = ExoPlayer.Builder(this)
        .setTrackSelector(trackSelector)
        .build()
    [...]
}

首先,创建一个 DefaultTrackSelector,它将负责选择媒体项中的轨道。然后,告知 trackSelector 只选择标准清晰度或更低清晰度的轨道,这是以牺牲质量为代价节省用户流量的好方法。最后,将 trackSelector 传递给构建器,以便在构建 ExoPlayer 实例时使用。

构建自适应 MediaItem

DASH 是一种广泛使用的自适应流式传输格式。如需流式传输 DASH 内容,您需要像以前一样创建 MediaItem。不过,这一次,我们必须使用 MediaItem.Builder,而不是 fromUri

这是因为 fromUri 使用文件扩展名来确定底层媒体格式,但 DASH URI 没有文件扩展名,因此我们在构造 MediaItem 时必须提供 APPLICATION_MPDMIME 类型

  1. 更新 initializePlayer,具体代码如下所示:

PlayerActivity.kt

private void initializePlayer() {
    [...]

    // Replace this line...
    val mediaItem = MediaItem.fromUri(getString(R.string.media_url_mp4));

    // ... with this
     val mediaItem = MediaItem.Builder()
         .setUri(getString(R.string.media_url_dash))
         .setMimeType(MimeTypes.APPLICATION_MPD)
         .build()

    // Keep this line
    exoPlayer.setMediaItem(mediaItem)

    // Remove the following lines
    val secondMediaItem = MediaItem.fromUri(getString(R.string.media_url_mp3))
    exoPlayer.addMediaItem(secondMediaItem)
}
  1. 重启应用,并查看包含 DASH 的自适应视频串流的实际效果。借助 ExoPlayer,您可以轻松实现这项操作!

其他自适应流式传输格式

其他常用的自适应流式传输格式为 HLS (MimeTypes.APPLICATION_M3U8) 和 SmoothStreaming (MimeTypes.APPLICATION_SS),二者均受 ExoPlayer 支持。如需详细了解其他自适应媒体来源的构造方式,请参阅 ExoPlayer 演示版应用

6. 监听事件

在前面的步骤中,您学习了如何流式传输渐进式媒体串流和自适应媒体串流。ExoPlayer 会在幕后为您处理大量工作,其中包括以下各项:

  • 分配内存
  • 下载容器文件
  • 从容器中提取元数据
  • 解码数据
  • 向屏幕和扬声器呈现视频、音频和文本

有时,了解 ExoPlayer 在运行时执行的操作有助于了解和改进用户的播放体验。

例如,您可能想要通过执行以下操作来反映界面中的播放状态变化:

  • 在播放器进入缓冲状态时显示“正在加载”旋转图标
  • 在轨道播放结束后显示叠加层和“接下来观看”选项

ExoPlayer 提供了几个监听器接口,用于提供对有用事件的回调。您可以使用监听器来记录播放器所处的状态。

监听

  1. PlayerActivity 类之外创建一个 TAG 常量(后面您将使用该常量进行日志记录)。

PlayerActivity.kt

private const val TAG = "PlayerActivity"
  1. PlayerActivity 类之外的工厂函数中实现 Player.Listener 接口。这用于通知您重要的播放器事件,其中包括错误和播放状态变化。
  2. 添加以下代码,替换 onPlaybackStateChanged

PlayerActivity.kt

private fun playbackStateListener() = object : Player.Listener {
    override fun onPlaybackStateChanged(playbackState: Int) {
        val stateString: String = when (playbackState) {
            ExoPlayer.STATE_IDLE -> "ExoPlayer.STATE_IDLE      -"
            ExoPlayer.STATE_BUFFERING -> "ExoPlayer.STATE_BUFFERING -"
            ExoPlayer.STATE_READY -> "ExoPlayer.STATE_READY     -"
            ExoPlayer.STATE_ENDED -> "ExoPlayer.STATE_ENDED     -"
            else -> "UNKNOWN_STATE             -"
        }
        Log.d(TAG, "changed state to $stateString")
    }
}
  1. PlayerActivity 中声明一个 Player.Listener 类型的私有成员。

PlayerActivity.kt

class PlayerActivity : AppCompatActivity() {
    [...]

    private val playbackStateListener: Player.Listener = playbackStateListener()
}

当播放状态发生变化时,系统会调用 onPlaybackStateChanged。新状态由 playbackState 参数提供。

播放器可能会处于以下 4 种状态之一:

状态

说明

ExoPlayer.STATE_IDLE

播放器已实例化,但尚未准备就绪。

ExoPlayer.STATE_BUFFERING

播放器无法从当前位置开始播放,因为已缓冲的数据不足。

ExoPlayer.STATE_READY

播放器可以立即从当前位置开始播放。这意味着如果播放器的 playWhenReady 属性为 true,播放器将自动开始播放媒体。如果该属性为 false,播放器会暂停播放。

ExoPlayer.STATE_ENDED

播放器已完成媒体播放。

注册监听器

如需调用您的回调,您需要向播放器注册 playbackStateListener。该操作可在 initializePlayer 中完成。

  1. 请在播放准备就绪之前注册该监听器。

PlayerActivity.kt

private void initializePlayer() {
    [...]
    exoPlayer.seekTo(currentWindow, playbackPosition)
    exoPlayer.addListener(playbackStateListener)
    [...]
}

同样,您需要进行整理以避免来自播放器的悬空引用,这可能会导致内存泄漏。

  1. releasePlayer 中移除该监听器:

PlayerActivity.kt

private void releasePlayer() {
    player?.let { exoPlayer ->
        [...]
        exoPlayer.removeListener(playbackStateListener)
        exoPlayer.release()
    }
    player = null
}
  1. 打开 logcat 并运行应用。
  2. 使用界面控件对播放进行跳转、暂停和恢复。您应该会在日志中看到播放状态的变化。

继续深入

ExoPlayer 提供了许多其他监听器,有助于您了解用户的播放体验。其中包括音频和视频的监听器以及 AnalyticsListener(其中包含来自所有监听器的回调)。以下是一些最重要的方法:

  • 当视频的第一帧呈现时,系统会调用 onRenderedFirstFrame。根据这项信息,您可以计算用户必须等待多长时间才能在屏幕上看到有意义的内容。
  • 当视频丢帧时,系统会调用 onDroppedVideoFrames。丢帧表示播放不流畅,且用户体验可能很差。
  • 当发生音频欠载时,系统会调用 onAudioUnderrun。欠载会导致出现声音故障,并且比视频丢帧更明显。

可以使用 addListenerAnalyticsListener 添加到 player。音频和视频监听器也有对应的方法。

Player.Listener 接口还包含更常见的 onEvents 回调,只要播放器出现任何状态变化,该回调就会被触发。在某些情况下这很有用,例如同时响应多种状态变化,或者以相同的方式响应多种不同的状态变化。如需查看有关何时可能需要使用 onEvents 回调(而非个别状态更改回调)的更多示例,请参阅参考文档。

您需要考虑哪些事件对您的应用和用户很重要。如需了解详情,请参阅监听播放器事件。事件监听器就讲到这里!

7. 恭喜

恭喜!您已经学习了很多关于如何将 ExoPlayer 与您的应用集成的知识。

了解详情

如需详细了解 ExoPlayer,请查看开发者指南源代码,并订阅 ExoPlayer 博客