使用 ExoPlayer 进行媒体流式传输

1. 开始之前

5b481fba585c2691.png

截图:YouTube Android 应用,它使用 ExoPlayer 作为其视频播放器。

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/android/codelab-exoplayer-intro.git

目录结构

克隆或解压缩后,您将获得一个根文件夹 (exoplayer-intro),其中包含一个 Gradle 项目,该项目具有多个模块;一个应用模块,以及本 Codelab 的每个步骤对应的模块,以及您所需的所有资源。

导入项目

  1. 启动 Android Studio。
  2. 选择文件 > 新建 > 导入项目*.*
  3. 选择根 build.gradle 文件。

128162a042143d68.png

截图:导入时的项目结构

构建完成后,您将看到六个模块:app 模块(类型为应用)和五个名为 exoplayer-codelab-N 的模块(其中 N0004,每个模块类型为)。app 模块实际上是空的,只包含一个清单文件。当使用 app/build.gradle 中的 Gradle 依赖项构建应用时,当前指定的 exoplayer-codelab-N 模块中的所有内容都会合并。

app/build.gradle

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

您的媒体播放器 Activity 保存在 exoplayer-codelab-N 模块中。将其保留在单独的库模块中的原因是为了您可以在针对不同平台(如移动设备和 Android TV)的 APK 之间共享它。它还允许您利用动态交付等功能,这些功能允许您的媒体播放功能仅在用户需要时才安装。

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

a674d5014bb3f798.png

截图:空白应用运行中

3. 流式传输!

添加 ExoPlayer 依赖项

ExoPlayer 是 Jetpack Media3 库的一部分。每个版本都由以下格式的字符串唯一标识

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

您只需导入 ExoPlayer 的类和界面组件,即可将其添加到您的项目中。它非常小巧,占用空间约为 70 到 300 kB,具体取决于所包含的功能和支持的格式。ExoPlayer 库被拆分为模块,以便开发者仅导入他们所需的功能。有关 ExoPlayer 模块化结构的更多信息,请参阅添加 ExoPlayer 模块

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

exoplayer-codelab-00/build.gradle

def mediaVersion = "1.3.1"
dependencies {
    [...]
   
    implementation "androidx.media3:media3-exoplayer:$mediaVersion"
    implementation "androidx.media3:media3-ui:$mediaVersion"
    implementation "androidx.media3:media3-exoplayer-dash:$mediaVersion"
}

添加 PlayerView 元素

  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 的内容视图。还要检查 videoView 属性在您的 viewBinding 引用上是否可见,并且其类型是否为 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. 将以下代码添加到 initializePlayer 内的 also 块中

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 的四个方法:onStartonResumeonPauseonStop

  1. 打开 PlayerActivity,点击代码菜单 > Override methods...
  2. 选择 onStartonResumeonPauseonStop
  3. 根据 API 级别,在 onStartonResume 回调中初始化播放器。

PlayerActivity.kt

@OptIn(UnstableApi::class)
public override fun onStart() {
    super.onStart()
    if (Util.SDK_INT > 23) {
        initializePlayer()
    }
}

@OptIn(UnstableApi::class)
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

@OptIn(UnstableApi::class)
public override fun onPause() {
    super.onPause()
    if (Util.SDK_INT <= 23) {
        releasePlayer()
    }
}

@OptIn(UnstableApi::class)
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 mediaItemIndex = 0
private var playbackPosition = 0L
[...]

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

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

这允许您从用户上次离开的位置恢复播放。您只需在初始化播放器时提供此状态信息即可。

最后准备

您现在只需在初始化期间将您在 releasePlayer 中保存的状态信息提供给您的播放器。

  1. 将以下内容添加到 initializePlayer

PlayerActivity.kt

private fun initializePlayer() {
    [...]
    // Instead of exoPlayer.setMediaItem(mediaItem)
    exoPlayer.setMediaItems(listOf(mediaItem), mediaItemIndex, playbackPosition)
    exoPlayer.playWhenReady = playWhenReady
    exoPlayer.prepare()
}

发生的情况如下

  • playWhenReady 告诉播放器一旦获取了所有播放资源,是否立即开始播放。由于 playWhenReady 最初为 true,因此应用首次运行时播放会自动开始。
  • seekTo 告诉播放器定位到特定媒体项中的某个位置。mediaItemIndexplaybackPosition 都初始化为零,以便应用首次运行时播放从头开始。
  • prepare 告诉播放器获取播放所需的所有资源。

播放音频

最后,您大功告成!启动应用播放 MP3 文件并查看嵌入的艺术作品。

c5db1d3782d44450.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))
    [...]
}

The PlayerView 可以完成所有操作。视频全屏渲染,而不是显示艺术作品。

7d2a8278e36f0f4b.png

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

太棒了!您刚刚创建了一个 Android 全屏媒体流式传输应用,其中包含生命周期管理、保存状态和界面控件!

4. 创建播放列表

您当前的应用播放单个媒体文件,但如果您想播放多个媒体文件,一个接一个地播放,该怎么办?为此,您需要一个播放列表。

通过使用 addMediaItem 向您的 player 添加更多 MediaItem 对象,即可创建播放列表。这允许无缝播放,并且后台处理缓冲,因此用户在更改媒体项时不会看到缓冲微调器。

  1. 将以下代码添加到 initializePlayer

PlayerActivity.kt

private void initializePlayer() {
    [...]
    val mediaItem = MediaItem.fromUri(getString(R.string.media_url_mp4)) // Existing code

    val secondMediaItem = MediaItem.fromUri(getString(R.string.media_url_mp3))
    // Update setMediaItems to include secondMediaItem
    exoPlayer.setMediaItems(listOf(mediaItem, secondMediaItem), mediaItemIndex, playbackPosition)
    [...]
}

检查播放器控件的行为。您可以使用 下一个按钮上一个按钮 来导航媒体项序列。

8385e35505ef5983.png

截图:播放控件显示下一个和上一个按钮

这非常方便!有关更多信息,请参阅关于媒体项播放列表的开发者文档,以及关于播放列表 API 的这篇文章

5. 自适应流式传输

自适应流式传输是一种根据可用网络带宽调整流媒体质量的流式传输技术。这允许用户体验其带宽所允许的最佳质量媒体。

通常,相同的媒体内容被分成具有不同质量(比特率和分辨率)的多个曲目。播放器会根据可用网络带宽选择一个曲目。

每个曲目都分成给定持续时间(通常在 2 到 10 秒之间)的块。这允许播放器在可用带宽变化时快速切换曲目。播放器负责将这些块拼接在一起以实现无缝播放。

自适应曲目选择

自适应流式传输的核心是为当前环境选择最合适的曲目。通过使用自适应曲目选择来更新您的应用以播放自适应流媒体。

  1. 使用以下代码更新 initializePlayer

PlayerActivity.kt

private fun initializePlayer() {
    player = ExoPlayer.Builder(this)
        .build()
        .also { exoPlayer ->
            // Update the track selection parameters to only pick standard definition tracks
            exoPlayer.trackSelectionParameters = exoPlayer.trackSelectionParameters
                    .buildUpon()
                    .setMaxVideoSizeSd()
                    .build()
            [...]
        }
}

在这里,我们已将默认的 trackSelector 更新为仅选择标准清晰度或更低质量的曲目——这是以牺牲质量为代价节省用户数据的好方法。

构建自适应 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()

    // Remove the following line
    val secondMediaItem = MediaItem.fromUri(getString(R.string.media_url_mp3))

    // Remove secondMediaItem from setMediaItems
    exoPlayer.setMediaItems(listOf(mediaItem), mediaItemIndex, playbackPosition)
}
  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 参数给出。

播放器可以处于以下四种状态之一

状态

描述

ExoPlayer.STATE_IDLE

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

ExoPlayer.STATE_BUFFERING

播放器无法从当前位置播放,因为没有足够的 数据已缓冲。

ExoPlayer.STATE_READY

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

ExoPlayer.STATE_ENDED

播放器已完成媒体播放。

注册您的监听器

要调用您的回调,您需要向播放器注册您的 playbackStateListener。在 initializePlayer 中执行此操作。

  1. 在播放准备好之前注册监听器。

PlayerActivity.kt

private void initializePlayer() {
    [...]
    exoPlayer.playWhenReady = playWhenReady
    exoPlayer.addListener(playbackStateListener) // Add this line
    exoPlayer.prepare()
    [...]
}

同样,您需要清理,以避免播放器中可能导致内存泄漏的悬空引用。

  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 的信息,请查阅开发者指南源代码,并订阅Android 开发者博客,以便第一时间了解最新更新!