这一篇将从零开始,一步步解决如下这些问题:如何播放单个视频?如何将播放器模块化?如何实现视频流?如何优化视频播放内存?如何优化视频流播放体验?
播放视频
ExoPlayer 基本使用
市面上比较有名的播放器有:ExoPlayer,ijkplayer,GSYVideoPlayer。
其中包体积最小,GitHub 更新的最勤快的是 ExoPlayer,就选它了。
使用 ExoPlayer
,添加依赖如下:
implementation 'com.google.android.exoplayer:exoplayer-core:2.18.5'//核心库必选 implementation 'com.google.android.exoplayer:exoplayer-ui:2.18.5'// ui库可选
使用 ExoPlayer 播放视频只需6行代码:
//1. 构建播放器实例 val player = ExoPlayer.Builder(context).build() //2. 构建播放源 val mediaItem = MediaItem.fromUri("https://xxxx.mp4") //3. 设置播放源 player.setMediaItem(mediaItem) //4. 准备播放 player.prepare() //5. 播放 player.playWhenReady = ture //6. 将播放器和视图绑定(styledPlayerView来自ui库) styledPlayerView.player = player
其中 styledPlayerView 定义在 xml 中:
<?xml version="1.0" encoding="utf-8"?> <com.google.android.exoplayer2.ui.StyledPlayerView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" xmlns:app="http://schemas.android.com/apk/res-auto" app:surface_type="texture_view" android:background="#000000"> </com.zenmen.exodemo.view.StyledPlayerView>
如果不想重复下载已经播放过的视频,得开启文件缓存:
val mediaItem = MediaItem.fromUri("https://xxxx.mp4") //1. 构建缓存文件 val cacheFile = context.cacheDir.resolve(”cache_file_name“) //2. 构建缓存实例 val cache = SimpleCache(cacheFile, LeastRecentlyUsedCacheEvictor(MAX_CACHE_BYTE), StandaloneDatabaseProvider(context)) //3. 构建 DataSourceFactory val dataSourceFactory = CacheDataSource.Factory().setCache(cache).setUpstreamDataSourceFactory(DefaultDataSource.Factory(context)) //4. 构建 MediaSource val mediaSource = ProgressiveMediaSource.Factory(cacheDataSourceFactory).createMediaSource(mediaItem) //5. 设置给播放器 player.setMediaSource(mediaSource)
如果想自定义缓冲参数可以这样做(缓冲是将将来要播放的视频加载到内存中,而缓存是将网络视频资源存储在本地):
//1. 自定义 DefaultLoadControl 参数 val MIN_BUFFER_MS = 5_000 // 最小缓冲时间, val MAX_BUFFER_MS = 7_000 // 最大缓冲时间 val PLAYBACK_BUFFER_MS = 700 // 最小播放缓冲时间,只有缓冲到达这个时间后才是可播放状态 val REBUFFER_MS = 1_000 // 当缓冲用完,再次缓冲的时间 val loadControl = DefaultLoadControl.Builder() .setPrioritizeTimeOverSizeThresholds(true)//缓冲时时间优先级高于大小 .setBufferDurationsMs(MIN_BUFFER_MS, MAX_BUFFER_MS, PLAYBACK_BUFFER_MS, REBUFFER_MS) .build() } //2. 将 loadControl 设置给 ExoPlayer.Builder val player = ExoPlayer.Builder(context) .setLoadControl(loadControl) .build()
如果想监听播放器状态,可以设置监听器:
//1. 构建监听器 val listener = object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { when (playbackState) { Player.STATE_ENDED -> {// 播放结束} Player.STATE_BUFFERING -> {// 正在缓冲} Player.STATE_IDLE -> {// 空闲状态} Player.STATE_READY -> {// 可以被播放状态} } } override fun onPlayerError(error: PlaybackException) { // 播放出错 } override fun onRenderedFirstFrame() { // 第一帧已渲染 } } } //2. 设置给播放器 player.addListener(listener)
如果要播放 m3u8 视频,需要添加如下依赖:
implementation 'com.google.android.exoplayer:exoplayer-hls:2.18.5'
并在构建视频源的时候使用如下代码:
val mediaItem = MediaItem.fromUri("https://xxxx.m3u8") val mediaSource = HlsMediaSource.Factory(cacheDataSourceFactory).createMediaSource(mediaItem) player.setMediaSource(mediaSource)
视频格式选择
视频格式的选择在 mp4 和 m3u8 之间纠结,最终选择了 m3u8,原因如下:
- 虽然它们都支持边下边播,但当定位到未缓冲位置时,mp4的策略是会重新发起一个http请求下载同一个mp4文件不同部分(通过头部的range字段指定字节范围)。经测试,来回拖动进度条,下载的若干mp4的总大小比单个完整mp4大不少,也就是说字节范围有交集。m3u8 的分片就没有这个问题。
- m3u8 支持自适应码率,即可以在网络环境比较差时自动降低码率,网络环境好时自动恢复,而且是无缝切换。
- m3u8 天然支持视频加密,即对视频二进制内容加密,防盗资源。
- 当视频资源很大时,mp4的头信息也会相应增大,使得首帧渲染时间变长。
播放器封装
上述这些操作对于不同播放器有不同的实现,定义一层接口屏蔽这些差异:
interface VideoPlayer : View { // 视频url var url: URL? // 视频控制器,用于上层绘制进度条 var playControl: MediaPlayerControl // 视频状态回调 var listener: IVideoStateListener? // 播放视频 fun play() // 加载视频 fun load() // 停止视频 fun stop() // 释放资源 fun relese() }
该接口为上层提供了操纵播放器的统一接口,这样做的好处是向上层屏蔽了播放器实现的细节,为以后更换播放器提供了便利。
其中IVideoStateListener
是播放状态的抽象:
interface IVideoStateListener { fun onStateChange(state: State) } //视频状态 sealed interface State { //第一帧被渲染 object FirstFrameRendered : State //缓冲结束,随时可播放。 object Ready : State //播放出错 class Error(val exception: Exception) : State //播放中 object Playing : State //播放手动停止 object Stop : State //播放结束 object End : State //缓冲中 object Buffering : State }
ExoPlayer 对于上述接口的实现如下,它作为一个单独的库 player-exo 存在:
class ExoVideoPlayer(context: Context) : FrameLayout(context), VideoPlayer { private var playerView: StyledPlayerView? = null private val skipStates = listOf(Player.STATE_BUFFERING, Player.STATE_ENDED) private val exoListener: Listener by lazy { object : Listener { override fun onPlaybackStateChanged(playbackState: Int) { when (playbackState) { Player.STATE_ENDED -> listener?.onStateChange(State.End) Player.STATE_BUFFERING -> listener?.onStateChange(State.Buffering) Player.STATE_IDLE -> resumePosition = _player.currentPosition Player.STATE_READY -> listener?.onStateChange(State.Ready) } } override fun onRenderedFirstFrame() { listener?.onStateChange(State.FirstFrameRendered) } override fun onIsPlayingChanged(isPlaying: Boolean) { if (isPlaying) { listener?.onStateChange(State.Playing) } else { if (_player.playbackState !in skipStates && _player.playerError != null) { listener?.onStateChange(State.Stop) } } } override fun onPlayerError(error: PlaybackException) { listener?.onStateChange(State.Error(error)) } } } private var _player = ExoPlayer.Builder( context, DefaultRenderersFactory(context).apply { setEnableDecoderFallback(true) }) .build().also { player ->player.addListener(listener} override var listener: IVideoStateListener? = null private var cache: Cache? = null private var mediaItem: MediaItem? = null private fun buildMediaSource(context: Context): MediaSource { if (mediaItem == null) mediaItem = MediaItem.fromUri(url.toString()) val cacheFile = context.cacheDir.resolve(CACHE_FOLDER_NAME + File.separator + abs(mediaItem.hashCode())) cache = SimpleCache( cacheFile, LeastRecentlyUsedCacheEvictor(MAX_CACHE_BYTE), StandaloneDatabaseProvider(context) ) return run { val cacheDataSourceFactory = CacheDataSource.Factory().setCache(cache) .setUpstreamDataSourceFactory(DefaultDataSource.Factory(context)) .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) if (url.toString().endsWith("m3u8")) { HlsMediaSource.Factory(cacheDataSourceFactory).createMediaSource(mediaItem!!) //m3u8 } else { ProgressiveMediaSource.Factory(cacheDataSourceFactory).createMediaSource(mediaItem!!) } } } init { playerView = LayoutInflater.from(context).inflate(R.layout.playerview, null) as StyledPlayerView this.addView(playerView) playerView?.player = _player } override var url: URL? = null get() = field set(value) { field = value mediaItem = MediaItem.fromUri(value.toString()) } override var playControl: MediaController.MediaPlayerControl = PlayerControl(_player) override fun play() { if (_player.isPlaying) return if (_player.playbackState == Player.STATE_ENDED) { _player.seekTo(0) } _player.playWhenReady = true } override fun load() { _player.takeIf { !it.isLoading }?.apply { setMediaSource(buildMediaSource(context)) prepare() } } override fun stop() { _player.stop() } override fun release() { _player.release() } }
然后在一个单独库 player-pseudo 中定义一个构建VideoPlayer
的抽象行为:
package com.demo.player fun createVideoPlayer(context: Context): VideoPlayer = throw NotImplementedError()
在库 player-exo 中同样的包名下,定义一个同样的文件,并给出基于 ExoPlayer 的实现:
package com.demo.player fun createVideoPlayer(context: Context): VideoPlayer = ExoVideoPlayer(context)
这些库的上层有一个管理器库 player-manager,它作为业务层使用播放器的入口:
object PlayerManager { fun getVideoPlayer(context: Context) = createVideoPlayer(context) }
player-manager 库需要依赖 player-pseudo:
compileOnly project(':player-pseudo')
使用 compileOnly 是为了在编译时不报错并且不将 player-pseudo 源码打入包。在打包时 player-manager 真正应该依赖的是 player-exo。所以最上层的 app 依赖关系应该如下:
implementation project('player-manager') implementation project('player-exo')
这样就通过 gradle 实现了依赖倒置,即上层(player-manager)不依赖于下层(player-exo)具体的实现,上层和下层都依赖于中间的抽象层(player-pseudo)
视频流
上一小节解决了播放单个视频的问题,这一节介绍如何构建视频流。
视频流就是像抖音那样的纵向列表,每一个表项都是一个全屏视频。
我使用 ViewPager2 + Fragment 实现。
下面是 Fragment 的实现:
class VideoFragment(private val url: String) : Fragment() { private val player by lazy { PlayerManager.getVideoPlayer() } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val itemView = inflater.inflate(R.layout.playerview, container, false) as StyledPlayerView return itemView.also { it.player = player } } override fun onResume() { super.onResume() player.url = url player.load() player.play() } }
然后在 FragmentStateAdapter 中构建 Frament 实例:
class VideoPageAdapter( private val fragmentManager: FragmentManager, lifecycle: Lifecycle, private val urls: List<String> ) : FragmentStateAdapter(fragmentManager, lifecycle) { override fun getItemCount(): Int { return urls.size } override fun createFragment(position: Int): Fragment { return VideoFragment(urls[position]) } }
最后为业务界面的 ViewPager2 设置适配器:
class VideoActivity : AppCompatActivity() { private lateinit var viewPager: ViewPager2 private var videoPageAdapter: VideoPageAdapter? = null private val urls = listOf {"xxx","xxx"} override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.video_player_activity) videoPageAdapter = VideoPageAdapter(supportFragmentManager, lifecycle, urls) viewPager = findViewById(R.id.vp) viewPager.apply { orientation = ViewPager2.ORIENTATION_VERTICAL offscreenPageLimit = 1 // 预加载一个视频 adapter = videoPageAdapter } } }
一个简单的视频流就完成了。
预加载及其原理
上述代码使用了ViewPager2.offscreenPageLimit = 1
实现预加载一个视频。该参数的意思是 “将视窗上下都扩大1” 。默认的视窗大小是1,如下图所示:
上图表示 ViewPager2 正在展示索引为2的视频,其视窗大小为1(视窗占满屏幕),只有当手向上滑动视频3从视窗的底部出现时,才会触发视频3的加载。
若 offscreenPageLimit = 1,则表示视窗在当前屏幕的上下拓宽了一格:
图中的红色+蓝色区域就是视窗大小,只有当列表项进入视窗后才会发出其加载。当前屏幕停留在视频2,当手向上滑动视频4会进入视窗底部,所以当你滑动到视频3时,视频4已经被预加载了。
从源码上,ViewPager2 是基于 RecyclerView 实现的,在内部它自定义了一个 LinearLayoutManager:
// ViewPager2 内部自定义的 LayoutManager private class LinearLayoutManagerImpl extends LinearLayoutManager { // 在布局表项时,计算额外布局空间 @Override protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state, NonNull int[] extraLayoutSpace) { int pageLimit = getOffscreenPageLimit(); // 如果 OffscreenPageLimit 等于 OFFSCREEN_PAGE_LIMIT_DEFAULT,则不进行预加载 if (pageLimit == OFFSCREEN_PAGE_LIMIT_DEFAULT) { super.calculateExtraLayoutSpace(state, extraLayoutSpace); return; } // 进行预加载表现为“额外布局 OffscreenPageLimit 个 page“ final int offscreenSpace = getPageSize() * pageLimit; extraLayoutSpace[0] = offscreenSpace; extraLayoutSpace[1] = offscreenSpace; } }
ViewPager2 重写了calculateExtraLayoutSpace()
方法,它用于计算在滚动时是否需要预留额外空间以布局更多表项,若需要则将额外空间赋值给extraLayoutSpace
,它是一个数组,第一个元素表示额外的宽,第二元素表示额外的高。当设置了 offscreenPageLimit 后,ViewPager2 申请了额外的宽和高。
额外的宽高会被记录在LinearLayoutManager.mLayoutState.mLayoutState
中:
// androidx.recyclerview.widget.LinearLayoutManager private void updateLayoutState(int layoutDirection, int requiredSpace,boolean canUseExistingSpace, RecyclerView.State state) { mReusableIntPair[0] = 0; mReusableIntPair[1] = 0; // 计算额外布局空间 calculateExtraLayoutSpace(state, mReusableIntPair); int extraForStart = Math.max(0, mReusableIntPair[0]); int extraForEnd = Math.max(0, mReusableIntPair[1]); // 存储额外布局空间 mLayoutState.mExtraFillSpace = layoutToEnd ? extraForEnd : extraForStart; ... }
额外布局空间最终会在填充表项时被使用:
public class LinearLayoutManager{ // 向列表中填充表项 int fill(RecyclerView.Recycler recycler, LayoutState layoutState, RecyclerView.State state, boolean stopOnFocusable) { ... // 计算剩余空间=现有空间+额外空间 int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace; // 循环填充表项,直到没有剩余空间 while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) { layoutChunkResult.resetInternal(); // 填充单个表项 layoutChunk(recycler, state, layoutState, layoutChunkResult); ... // 在列表剩余空间中扣除刚填充表项所消耗的空间 if (!layoutChunkResult.mIgnoreConsumed || layoutState.mScrapList != null || !state.isPreLayout()) { layoutState.mAvailable -= layoutChunkResult.mConsumed; remainingSpace -= layoutChunkResult.mConsumed; } ... } ... } }
关于 RecyclerView 如何填充表项的更多细节,可以点击RecyclerView 源码启示录 - 唐子玄的专栏。