使用 ExoPlayer 进行媒体流传输

1. 开始之前

5b481fba585c2691.png

截图:使用 ExoPlayer 作为其视频播放器的 YouTube Android 应用程序。

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

先决条件

  • 对 Android 开发和 Android Studio 有中等了解

您将要做什么

  • 创建一个 ExoPlayer 实例,该实例准备并播放来自各种来源的媒体。
  • 将 ExoPlayer 集成到应用程序的活动生命周期中,以支持在单窗口或多窗口环境中进行后台处理、前台处理和播放恢复。
  • 使用 MediaItem 实例创建播放列表。
  • 播放自适应视频流,该流会根据可用带宽调整媒体质量。
  • 注册事件侦听器以监控播放状态,并展示如何使用侦听器来衡量播放质量。
  • 使用标准 ExoPlayer UI 组件,然后根据您应用程序的样式对其进行自定义。

您需要什么

  • 最新稳定版本的 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 项目;一个应用程序模块和一个用于此代码实验室的每个步骤的模块,以及您需要的所有资源。

导入项目

  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")
}

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

  1. 部署并运行应用程序以检查一切正常。应用程序应使用黑色背景填充整个屏幕。

a674d5014bb3f798.png

截图:空白应用程序正在运行

3. 流式传输!

添加 ExoPlayer 依赖项

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

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

您可以通过简单地导入其类和 UI 组件来将 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"/>

展望未来,您将此 UI 元素称为视频视图。

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

PlayerActivity.kt

private val viewBinding by lazy(LazyThreadSafetyMode.NONE) {
    ActivityPlayerBinding.inflate(layoutInflater)
}
  1. 将视图树的根设置为活动的 content view。还要检查 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

与活动生命周期协调良好

我们的 player 可以占用大量资源,包括内存、CPU、网络连接和硬件编解码器。许多资源都很短缺,尤其是硬件编解码器,因为可能只有一个。在不使用这些资源时,例如将应用程序置于后台时,应释放这些资源以供其他应用程序使用。

换句话说,播放器的生命周期应与 应用程序的生命周期 相绑定。要实现这一点,您需要覆盖 PlayerActivity 的四个方法:onStartonResumeonPauseonStop

  1. 在打开 PlayerActivity 后,单击代码菜单 > 覆盖方法...
  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。在暂停状态下,您的活动仍然可见,因此您需要等到 onStop 之后才能释放播放器。

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

  1. 向活动添加以下代码

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

屏幕截图:应用程序播放单个曲目。

测试活动生命周期

这是测试应用程序是否在活动生命周期的所有不同状态下都能正常运行的最佳时机。

  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 完成了所有工作。视频以全屏显示,而不是艺术作品。

7d2a8278e36f0f4b.png

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

太棒了!您刚刚创建了一个用于在 Android 上进行全屏媒体流的应用程序,它包含生命周期管理、保存状态和 UI 控件!

4. 创建播放列表

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

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

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

查看播放器控件的行为。可以使用 d92346ced6303230.pnge9346dea9156c627.png 来导航媒体项目的顺序。

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. 使用 UI 控件进行搜索、暂停和恢复播放。您应该在日志中看到播放状态发生变化。

更多事件和分析

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

  • onRenderedFirstFrame 在渲染视频的第一帧时被调用。使用此方法,您可以计算出用户在屏幕上看到有意义的内容之前需要等待多长时间。
  • onDroppedVideoFrames 在视频帧被丢弃时被调用。丢弃的帧表明播放不流畅,用户体验可能很差。
  • onAudioUnderrun 在发生音频欠载时被调用。欠载会导致声音中出现可听见的故障,比丢弃的视频帧更容易察觉。

AnalyticsListener 可以使用 addListener 添加到 player 中。音频和视频监听器也有相应的方法。

Player.Listener 接口还包括更通用的 onEvents 回调,该回调会在播放器中的任何状态发生变化时触发。您可能会发现此回调在以下情况下很有用:响应同时发生的多个状态更改或以相同的方式响应多个不同的状态更改。查看参考文档以获取更多关于何时可能想要使用 onEvents 回调而不是单个状态更改回调的示例。

考虑对您的应用程序和用户来说哪些事件很重要。有关更多信息,请参阅 监听播放事件。事件监听器就介绍到这里了!

7. 恭喜

恭喜! 您已经学习了有关将 ExoPlayer 集成到应用程序中的很多知识。

了解更多

要了解更多关于 ExoPlayer 的信息,请查看 开发者指南源代码,并订阅 Android 开发者博客,以便成为第一个了解最新更新的人!