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

您的媒体播放器 Activity 保存在 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. 将视图树的根设置为 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 后,单击代码菜单 > 重写方法...
  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. 使用 releasePlayer(您将很快创建)在 onPauseonStop 中释放资源。

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时,我们必须提供MIME类型APPLICATION_MPD

  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 开发者博客,以便成为第一批了解最新更新的人!