浅浅地优化下视频流播放体验

作者:唐子玄

这一篇将从零开始,一步步解决如下这些问题:如何播放单个视频?如何将播放器模块化?如何实现视频流?如何优化视频播放内存?如何优化视频流播放体验?

播放视频

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,原因如下:

  1. 虽然它们都支持边下边播,但当定位到未缓冲位置时,mp4的策略是会重新发起一个http请求下载同一个mp4文件不同部分(通过头部的range字段指定字节范围)。经测试,来回拖动进度条,下载的若干mp4的总大小比单个完整mp4大不少,也就是说字节范围有交集。m3u8 的分片就没有这个问题。
  2. m3u8 支持自适应码率,即可以在网络环境比较差时自动降低码率,网络环境好时自动恢复,而且是无缝切换。
  3. m3u8 天然支持视频加密,即对视频二进制内容加密,防盗资源。
  4. 当视频资源很大时,mp4的头信息也会相应增大,使得首帧渲染时间变长。

播放器封装

上述这些操作对于不同播放器有不同的实现,定义一层接口屏蔽这些差异:

interface VideoPlayer : View {// 视频urlvar 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? = nullprivate 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.currentPositionPlayer.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? = nullprivate var cache: Cache? = nullprivate var mediaItem: MediaItem? = nullprivate 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 StyledPlayerViewthis.addView(playerView)playerView?.player = _player}override var url: URL? = nullget() = fieldset(value) {field = valuemediaItem = MediaItem.fromUri(value.toString())}override var playControl: MediaController.MediaPlayerControl = PlayerControl(_player)override fun play() {if (_player.isPlaying) returnif (_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.playerfun createVideoPlayer(context: Context): VideoPlayer = throw NotImplementedError()

在库 player-exo 中同样的包名下,定义一个同样的文件,并给出基于 ExoPlayer 的实现:

package com.demo.playerfun createVideoPlayer(context: Context): VideoPlayer = ExoVideoPlayer(context)

这些库的上层有一个管理器库 player-manager,它作为业务层使用播放器的入口:

package com.demo.playerfun createVideoPlayer(context: Context): VideoPlayer = ExoVideoPlayer(context)

player-manager 库需要依赖 player-pseudo:

object PlayerManager {fun getVideoPlayer(context: Context) = createVideoPlayer(context)
}

使用 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 StyledPlayerViewreturn itemView.also { it.player = player }}override fun onResume() {super.onResume()player.url = urlplayer.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: ViewPager2private var videoPageAdapter: VideoPageAdapter? = nullprivate 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_VERTICALoffscreenPageLimit = 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 {// 在布局表项时,计算额外布局空间@Overrideprotected 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;}...}...}
}

有限的解码资源

用上面的代码实现视频流,当不停地往下翻看视频时,视频会加载不出来,ExoPlayer会抛如下异常:

com.google.android.exoplayer2.ExoPlaybackException: MediaCodecAudioRenderer error, index=1, format=Format(null, null, null, audio/raw, null, -1, null, [-1, -1, -1.0], [2, 48000]), format_supported=YEScom.google.android.exoplayer2.audio.AudioSink$InitializationException: AudioTrack init failed 0 Config(48000, 12, 65600) ... 13 more Caused by: java.lang.UnsupportedOperationException: Cannot create AudioTrack

音频解码错误,源于无法创建音轨。

手机的音轨资源是有限的,如果每个视频都占用一个音轨并且不释放的话,就会导致上述问题。

可以使用下面这个命令查看当前手机音轨占用情况:

adb shell dumpsys media.audio_flinger

打印出来的日志长这个样子:

3 Tracks of which 1 are activeType     Id Active Client Session Port Id S  Flags   Format Chn mask  SRate ST Usg CT  G db  L dB  R dB  VS dB   Server FrmCnt  FrmRdy F Underruns  Flushed   Latency25136     no  15781   82753   25105 P  0x400 00000001 00000003  44100  3   1  0  -inf     0     0     0  0000485A  11025   11025 A         0        0  293.91 k25137    yes  15781   82761   25106 A  0x000 00000001 00000003  44100  3   1  0   -26     0     0     0  0001102E  11025   11025 A         0        0  307.29 t25138     no  15781   82737   25107 I  0x000 00000001 00000003  44100  3   1  0  -inf     0     0     0  00000000  11025    6144 I         0        0       new

该日志表示已经创建3个音轨,其中一个是活跃的。

每一个新的 ExopPlayer 实例就会重新申请解码资源,而不是复用已有资源。

上述代码中,每次构建新的VideoFragment,都会新建ExoVideoPlayer实例,而其内部对应一个ExoPlayer实例。气人的是 ViewPager2 中 Fragment 的实例并不会被复用,而是每次新建,这就导致滑动过程中,ExoPlayer 实例被不断地新建,最终导致音轨资源被耗尽。

那就得及时释放播放器持有的资源:

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 StyledPlayerViewreturn itemView.also { it.player = player }}override fun onResume() {super.onResume()player.url = urlplayer.load()player.play()}override fun onDestroy() {super.onDestroy()player.release()}
}

在 Fragment 生命周期方法 onDestroy() 中调用 release() 释放播放器资源。

这样不管往下翻多少视频,都不会报异常了。

播放器生命周期控制

我本以为当视频流向下滑动时,播放器的构建及回收时机如下图所示:

即当表项移入视窗时对应的 Fragment 被构建(播放器实例也被构建),当表项移出视窗时对应的 Fragment 被销毁(播放器资源被释放)。

但 ViewPager2 内部机制不是这样的,它会缓存比预想更多的 Fragment:

上图中索引为4的红色表示当前正在播放的视频,两块蓝色的表示因预加载而保留在内存中的视图(预加载数=1)。

虽然视频1和2也移出了屏幕,但它们依然存在于内存中(不会回调onDestroy()),这是 RecyclerView 的 cached View 缓存机制,本意是缓存移出屏幕的视图以便回滚时快速展示。ViewPager 的实现基于 RecyclerView,复用了这套机制。

当手向上滑动,视频6进入视窗开始预加载(onResume()),视频1被回收(onDestroy())。

ViewPager2 持有比预期更多的 Fragment 除了对内存造成压力之外,还会占用更多解码资源,当有多个视频流叠加时依然会耗尽解码资源(比如从推荐流点击作者头像进入作者视频流)。

ViewPager2 是有预加载(offscreenPageLimit),RecyclerView 中 cached View 机制意义已经不大了。但 ViewPager2 是 final 类型了,而且也并未公开其内部的 RecyclerView 实例。

所以只能将 ViewPager2 的源码都原样拷贝出来:

把源码中的这些类拷贝出来后,所有源码中的报错都可以消除。

修改其中的 FragmentStateAdapter:

public abstract class FragmentStateAdapter extends RecyclerView.Adapter<FragmentViewHolder> implements StatefulAdapter {// 持有所有活跃的 Fragment 实例public final LongSparseArray<Fragment> mFragments = new LongSparseArray<>();// 将下面的方法改为 public@Overridepublic void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {if (mFragmentMaxLifecycleEnforcer != null) throw new IllegalArgumentException();mFragmentMaxLifecycleEnforcer = new FragmentMaxLifecycleEnforcer();mFragmentMaxLifecycleEnforcer.register(recyclerView);}// 将下面的方法改为 public@Overridepublic void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {mFragmentMaxLifecycleEnforcer.unregister(recyclerView);mFragmentMaxLifecycleEnforcer = null;}// 新增方法:获取指定 Fragmentpublic Fragment getFragment(long key) {return mFragments.get(key);}...
}

修改onAttachedToRecyclerView()onDetachedFromRecyclerView()为 public,让子类可以重写该方法。并且新增方法,使得子类中可以方便地获取到指定的 Fragment 实例。

然后改写视频流适配器:

class VideoPageAdapter(private val fragmentManager: FragmentManager,lifecycle: Lifecycle,private val urls: List<String>
) : FragmentStateAdapter(fragmentManager, lifecycle) {override fun onViewAttachedToWindow(holder: FragmentViewHolder) {super.onViewAttachedToWindow(holder)// 获取刚进入视窗的 Fragment 实例val attachFragment = getFragment(getItemId(holder.absoluteAdapterPosition))(attachFragment as? VideoFragment)?.load()}override fun onViewDetachedFromWindow(holder: FragmentViewHolder) {// 获取刚移出视窗的 Fragment 实例val detachFragment = getFragment(getItemId(holder.absoluteAdapterPosition))(detachFragment as? VideoFragment)?.release()}
}

重写列表中视图依附窗口/脱离窗口的回调,在其中获取对应的 Fragment 实例,并触发播放/回收资源。

现在播放器的生命周期不再基于 Fragment 的生命周期,改为基于列表滚动时视图的生命周期,而视图生命周期是相对于当前屏幕对称的。(就像本小节的第一张图所示)

这样一来,内存中播放器的数量就可以进一步减少,并且可以更精准地控制预加载/释放视频资源。

播放器数量控制

上述代码虽然可以精准控制播放器的生命周期,但依然无法避免不停地销毁/重建播放器造成的内存抖动。

有没有什么办法将整个App中播放器的实例控制在一个固定的数值之下?

有!播放器池!

在使用播放器池之前,还有一个障碍,回看一下之前对播放器接口的抽象:

interface VideoPlayer : View {// 视频urlvar url: URL? // 视频控制器,用于上层绘制进度条var playControl: MediaPlayerControl // 视频状态回调var listener: IVideoStateListener? // 播放视频fun play()// 加载视频fun load()// 停止视频fun stop()// 释放资源fun relese()
}

这个接口设计将播放器和视图混为一体,即从接口层面规定一个播放器实例对应一个视图,且生命周期同步。当视频流滚动时,播放器会随着视图被不断地新建。

如果在这个接口基础上使用播放器池,则会造成内存泄漏。因为播放器池是一个单例,它的生命周期要长于视图,但由于接口设计的不合理,播放器就是视图,视图就是播放器,存在着交叉持有关系,导致内存泄漏(最终导致解码资源耗尽)。

播放器和视图分离

从播放流畅度、内存占用、CPU 使用率方面考虑,ExoPlayer 官方建议将单个播放器实例复用于多个播放视图。因为每新建一个播放器实例,就会重新申请解码资源,这是一个耗时/耗资源的过程。

为了实现播放器实例的复用,不得不重构上层接口,将原先的接口拆分成职责更单一的多个接口:

  1. 播放器视图接口
// 播放器视图接口(向上层屏蔽不同播放器视图实现的细节)
interface VideoPlayerView : View {// 视频重力方位,用于指定从哪个方位裁剪视频var gravity: Int// 将播放视图和播放器解绑fun clearPlayer()// 将视频宽高传递给视图fun setResizeMode(width: Int, height: Int)
}
  1. 播放器接口
// 播放器接口(向上层屏蔽不同播放器实现的细节)
interface VideoPlayer {// 资源地址var url: URL?// 视频控制器,用于上层绘制进度条var playControl: MediaPlayerControl// 状态监听器var listener: IVideoStateListener?// 开始播放fun play()// 加载视频fun load()// 停止播放fun stop()// 销毁资源 fun release()// 将播放器和视图绑定fun attachPlayerView(view: VideoPlayerView)
}

视图归视图,播放器归播放器。前者的生命周期由 ViewPager 控制,后者的生命周期通过一个播放器池来管理:

  1. 播放器池接口
// 播放器池
interface VideoPlayerPool {// 获取播放器实例fun getVideoPlayer(index: Int): VideoPlayer// 清空池fun clear()
}

上层通过VideoPlayerPool接口获取播放器实例。

播放器池接口实现如下:

class VideoPlayerPool : VideoPlayerPool {// 池大小val POOL_SIZE = 2 * manager.config.movie.prefetchCount + 1 + 1// 池设计为循环数组private val pool: Array<VideoPlayer?> = arrayOfNulls(POOL_SIZE)// 构建新的播放器实例private fun createVideoPlayer(context: Context): VideoPlayer {return ExoVideoPlayer(context)}// 从池中获取播放器实例override fun getVideoPlayer(index: Int): VideoPlayer {val realIndex = index.mod(POOL_SIZE)return pool[realIndex] ?: createVideoPlayer(ContextUtil.applicationContext).also {pool[realIndex] = it}}// 释放池中所有播放器资源override fun clear() {(pool.indices).forEach { index ->pool[index]?.release()pool[index] = null}}
}

播放器池长度

播放器池通过一个固定长度的循环数组实现播放器复用:

上图表示当前正在播放视频流中索引为2的视频。此时播放器池中有四个播放器实例正好用于视频流中的前四个视图。

视频流索引和播放器池索引的对应关系通过取余实现,即播放器池索引 = 视频流索引对池长度取余。当列表向上滚动,即索引为4的视图进入视窗,它加载视频会复用到池中索引为0的播放器实例。通过取余运算实现循环数组复用机制。

理论上池大小应该等于视窗大小,但使用 ViewPager2 实现视频流有一个特殊情况会导致播放器实例复用失败。还是以上图的场景举例:

此图表示播放器池大小为3,所以视频3会复用索引为0的播放器实例。

当用手拖住视频2向上滚动一点点(即视频3从屏幕底部露出一点点)后松手,此时不会产生翻页而是停留在视频2,但滚动使得视频4进入了视窗触发了加载,它会复用到索引为1的播放器实例,导致视频1的内容被抹去,但视频1又在视窗内,所以回滚到视频1时并不会触发它再次加载,最终导致黑屏。

为了避免这种情况,播放器池大小要大于视窗大小。

播放器池数量

多个视频流共存的场景也很常见,比如从推荐流跳转到剧集流。随着业务的迭代,共存的视频流可以无限叠加。

让共存的视频流共享同一个播放器池应该怎么实现?

“在发生视频流跳转时,释放当前池中所有播放器资源以便在新流中复用。”

这个方案有一个缺点,当返回旧视频流时,当前视频需重新加载,即会先黑屏一下再开始播放。

可不可以保留当前正播放的实例,释放其余播放器资源?

可是可以,但增加了播放器和视图映射关系的复杂度,即新视频流中不能简单地按索引值取余的方式拿到复用的播放器实例,得跳过保留的播放器。或者将保留播放器挪到池尾,并将池长度-1。这样的话,每有一个新的视频流,播放器池长度就减少1,这限制了共存视频流的数量。

最终采用的方案是:

每个视频流分配一个播放器池。在跳转到新视频流时,释放旧池中播放器资源(除了当前视频和下一个视频,因为大概率返回时会往下滑)。在返回时,清空新建的播放器池。

滑动体验

视频流滑动过程中,通过复用播放器实例避免了内存抖动、减少了 GC 次数、加速了视频解码速度(解码资源复用),一定程度上提升了滑动的流畅度。除此之外,松手之后的动画也会影响滑动的手感。

若使用 ViewPager2 默认的滑动实现,在松手后,视频也会匀速地滚动到下一页。在参考了各大视频平台App之后,发现在松手之后,会有一个加速滑动,到终点之前又逐渐减速的过程。

ViewPager2 松手后自动滑至下一页是通过自定义的 SnapHelper 实现的:

class PagerSnapHelperImpl extends PagerSnapHelper {PagerSnapHelperImpl() {}private float MILLISECONDS_PER_INCH = 100f;private int MAX_SCROLL_ON_FLING_DURATION = 120;// 新增减速插值器private Interpolator interpolator =new DecelerateInterpolator(2.1f);// 重写 createScroller 以使用自定义的插值器@Nullable@Overrideprotected RecyclerView.SmoothScroller createScroller( RecyclerView.LayoutManager layoutManager) {if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {return null;}return new LinearSmoothScroller(mRecyclerView.getContext()) {@Overrideprotected void onTargetFound(View targetView, RecyclerView.State state, Action action) {int[] snapDistances = calculateDistanceToFinalSnap(mRecyclerView.getLayoutManager(),targetView);final int dx = snapDistances[0];final int dy = snapDistances[1];final int time = calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));if (time > 0) {// 使用自定义插值器action.update(dx, dy, time, interpolator);}}@Overrideprotected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;}@Overrideprotected int calculateTimeForScrolling(int dx) {return Math.min(MAX_SCROLL_ON_FLING_DURATION, super.calculateTimeForScrolling(dx));}};}
}

将 ViewPager2 源码拷贝出来,修改其中的PagerSnapHelperImpl的实现,重写createScroller()方法,并将插值器替换为DecelerateInterpolator。该插值器的参数越大,松手后的滑动速度就越快,而到达终点的速度就会越慢。还可以通过调整MAX_SCROLL_ON_FLING_DURATION来改变整个动画的持续时间。

无缝播放体验

除了预加载、复用播放器实例、滑动动画插值器之外,播放视频的时机也是影响视频流体验的因素之一。

如果在 Fragment.onResume() 中才开始播放视频,就意味着下一个视频要等到滚动动画完成后才开始播放视频,视觉上的体验就是下一个视频的第一帧会卡一下再播。

更好的方案是在松手时就暂停上一个视频并播放下一个视频:

viewPager2.registerOnPageChangeCallback(object :OnPageChangeCallback(){// 上一次滚动偏移量private var lastOffset = 0f// 是否向下滚动private var isScrollDown = falseoverride fun onPageScrollStateChanged(state: Int) {super.onPageScrollStateChanged(state)// 当松手后if(state == SCROLL_STATE_SETTLING){// 获取下一个播放视频的索引val playIndex = if(isScrollDown) viewPager.currentItem  +1 else viewPager.currentItem - 1;// 播放下一个视频videoPageAdapter?.getFragment(playIndex)?.play()// 暂停当前视频videoPageAdapter?.getFragment(viewPager.currentItem)?.pause()}}override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {super.onPageScrolled(position, positionOffset, positionOffsetPixels)// 如果偏移量在变大意味着向下滚动isScrollDown = positionOffset > lastOffsetlastOffset = positionOffset}
})

通过onPageScrollStateChanged()中的SCROLL_STATE_SETTLING状态捕捉松手时机,通过onPageScrolled()中的偏移量判定滚动方向,以此确定该暂停哪个视频,该播放哪个视频。

Android 学习笔录

Android 性能优化篇:https://qr18.cn/FVlo89
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集:https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/188462.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

HashMap 是怎么解决哈希冲突的?

&#xff08;本文摘自mic老师面试文档&#xff09; 常用数据结构基本上是面试必问的问题&#xff0c;比如 HashMap、LinkList、 ConcurrentHashMap 等。 关于 HashMap&#xff0c;有个学员私信了我一个面试题说&#xff1a; “HashMap 是怎么解决哈希冲突 的&#xff1f;” …

网络安全基础之php开发文件上传的实现

前言 php是网络安全学习里必不可少的一环&#xff0c;简单理解php的开发环节能更好的帮助我们去学习php以及其他语言的web漏洞原理 正文 在正常的开发中&#xff0c;文件的功能是必不可少&#xff0c;比如我们在论坛的头像想更改时就涉及到文件的上传等等文件功能。但也会出…

前端通过导入editor.md库实现markdown功能

小王学习录 今日摘录前言jquery下载editor下载editor和jquery的导入初始化editor总结 今日摘录 满招损&#xff0c;谦受益 前言 要想通过editor.md实现markdown的功能&#xff0c;需要经过如下四步&#xff1a; 下载editor.md到本地将本地editor导入到前端代码中编写少量代…

No source control providers registered

使用vscode时碰到这个问题 git扩展没启动

LeetCode(3)删除有序数组中的重复项【数组/字符串】【简单】

目录 1.题目2.答案3.提交结果截图 链接&#xff1a; 26. 删除有序数组中的重复项 1.题目 给你一个 非严格递增排列 的数组 nums &#xff0c;请你** 原地** 删除重复出现的元素&#xff0c;使每个元素 只出现一次 &#xff0c;返回删除后数组的新长度。元素的 相对顺序 应该保…

推荐这款机器学习的特征筛选神器!

大家好&#xff0c;特征选择是机器学习建模流程中最重要的步骤之一&#xff0c;特征选择的好坏直接决定着模型效果的上限&#xff0c;好的特征组合甚至比模型算法更重要。除了模型效果外&#xff0c;特征选择还有以下几点好处&#xff1a; 提高模型性能并降低复杂性&#xff08…

华为ensp:交换机接口划分vlan

现在要把 e0/0/1 接口放入vlan1 e0/0/2 接口放入vlan2 e0/0/3 接口放入vlan3 默认所有接口都在vlan1所以 e0/0/0 接口不用动 1.创建vlan 进入系统视图模式 直接输入 vlan 编号 即可创建对应vlan vlan 编号 vlan 2 创建vlan2 vlan 3 创建vlan3 2.将接口进入vlan…

Spring Boot自动配置原理、实战、手撕自动装配源码

Spring Boot自动配置原理 相比较于传统的 Spring 应用&#xff0c;搭建一个 SpringBoot 应用&#xff0c;我们只需要引入一个注解 SpringBootApplication&#xff0c;就可以成功运行。 前面四个不用说&#xff0c;是定义一个注解所必须的&#xff0c;关键就在于后面三个注解&a…

Flink SQL自定义表值函数(Table Function)

使用场景&#xff1a; 表值函数即 UDTF&#xff0c;⽤于进⼀条数据&#xff0c;出多条数据的场景。 开发流程&#xff1a; 实现 org.apache.flink.table.functions.TableFunction 接⼝实现⼀个或者多个⾃定义的 eval 函数&#xff0c;名称必须叫做 eval&#xff0c;eval ⽅法…

OpenCV-Python小应用(九):通过灰度直方图检测图像异常点

OpenCV-Python小应用&#xff08;九&#xff09;&#xff1a;通过灰度直方图检测图像异常点 前言前提条件相关介绍实验环境通过灰度直方图检测图像异常点代码实现输出结果 参考 前言 由于本人水平有限&#xff0c;难免出现错漏&#xff0c;敬请批评改正。更多精彩内容&#xff…

centos7安装linux版本的mysql

1.下载linux版本的mysql 进入mysql官网&#xff0c;点击社区版本下载&#xff1a; https://dev.mysql.com/downloads/mysql/ 选择版本&#xff0c;可以跟着我下面这个图进行选择&#xff0c;选择红帽版本的既可&#xff0c;都是linux版本的。 2.上传解压linux版本的mysql安装包…

linux安装nodejs

写在前面 因为工作需要&#xff0c;需要使用到nodejs&#xff0c;所以这里简单记录下学习过程。 1&#xff1a;安装 wget https://nodejs.org/dist/v14.17.4/node-v14.17.4-linux-x64.tar.xz tar xf node-v14.17.4-linux-x64.tar.xz mkdir /usr/local/lib/node // 这一步骤根…

力扣138:随机链表的复制

力扣138&#xff1a;随机链表的复制 题目描述&#xff1a; 给你一个长度为 n 的链表&#xff0c;每个节点包含一个额外增加的随机指针 random &#xff0c;该指针可以指向链表中的任何节点或空节点。 构造这个链表的 深拷贝。 深拷贝应该正好由 n 个 全新 节点组成&#xff…

【从0到1设计一个网关】上岸大厂的秘诀之一

文章目录 前言【从0到1设计一个网关】什么是网关&#xff1f;以及为什么需要自研网关&#xff1f;【从0到1设计一个网关】自研网关的设计要点以及架构设计【从0到1设计一个网关】自研网关的架构搭建【从0到1设计一个网关】网络通信框架Netty的设计【从0到1设计一个网关】整合Na…

智能指针,c++11,单例,类型转换

c11 unique_ptr 防拷贝 shared_ptr / weak_ptr: 引用计数,支持拷贝 面试 手写shared_ptr 各种ptr的特性对比, 不会问定制删除器和weak_ptr,但是问shared_ptr时,可以往这边延展. 单例 保证一写数据在一个进程中,只有一份,并且方便访问修改. 饿汉模式 在main函数之前就创…

竞赛 车道线检测(自动驾驶 机器视觉)

0 前言 无人驾驶技术是机器学习为主的一门前沿领域&#xff0c;在无人驾驶领域中机器学习的各种算法随处可见&#xff0c;今天学长给大家介绍无人驾驶技术中的车道线检测。 1 车道线检测 在无人驾驶领域每一个任务都是相当复杂&#xff0c;看上去无从下手。那么面对这样极其…

win10网络和Internet设置

win10网络设置 win10进入网络设置的常用入口有两个 第一个入口 桌面右下角右键网络图标&#xff0c;然后打开“网络和Internt设置” 第二个入口 桌面的“我的网络”快捷方式&#xff0c;或者我的电脑进去后&#xff0c;左侧栏找到“网络” 右键“属性” 可以看到&#xff0c;…

图论10-哈密尔顿回路和哈密尔顿路径+状态压缩+记忆化搜索

文章目录 1 哈密尔顿回路2 哈密尔顿回路算法实现2.1 常规回溯算法2.2 引入变量记录剩余未访问的节点数量 3 哈密尔顿路径问题4 状态压缩4.1 查看第i位是否为14.2 设置第i位是为1或者04.3 小结4.4 状态压缩在哈密尔顿问题中的应用 5 记忆化搜索5.1 记忆化搜索与递推区别5.2 记忆…

基于单片机的空调智能控制器的设计

**单片机设计介绍&#xff0c;基于单片机的空调智能控制器的设计 文章目录 一 概要二、功能设计设计思路 三、 软件设计原理图 五、 程序六、 文章目录 一 概要 基于单片机的空调智能控制器需要具备输入输出端口、定时器、计数器等模块&#xff0c;以便对空调进行精确控制。下…

补坑:Java的字符串String类(3):再谈String

不太熟悉字符串的可以看看这两篇文章 补坑&#xff1a;Java的字符串String类&#xff08;1&#xff09;-CSDN博客 补坑&#xff1a;Java的字符串String类&#xff08;2&#xff09;&#xff1a;一些OJ题目-CSDN博客 字符串创建对象 public static void main(String[] args) …