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

news2024/11/17 13:57:26

作者:唐子玄

这一篇将从如何播放视频开始,接着介绍如何封装播放器,再将视频播放和列表结合形成视频流,然后一步步地优化视频流的播放体验。

播放视频

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.mp4")
val mediaSource = HlsMediaSource.Factory(cacheDataSourceFactory).createMediaSource(mediaItem)
player.setMediaSource(mediaSource)

播放器封装

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

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

    override fun onBindViewHolder(holder: FragmentViewHolder, position: Int, payloads: MutableList<Any>) {
        super.onBindViewHolder(holder, position, payloads)
    }

    fun getFragment(index: Int): VideoFragment? {
        return fragmentManager.findFragmentByTag("f${getItemId(index)}") as? VideoFragment
    }
}

最后为业务界面的 ViewPager2 设置适配器:

class VideoActivity : AppCompatActivity() {
    private lateinit var viewPager: ViewPager2
    private var exoAdapter: VideoPageAdapter? = null
    private val urls = listOf {"xxx","xxx"}

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.video_player_activity)

        exoAdapter = VideoPageAdapter(supportFragmentManager, lifecycle, urls)
        viewPager = findViewById(R.id.vp)
        viewPager.apply {
            orientation = ViewPager2.ORIENTATION_VERTICAL
            offscreenPageLimit = 1 // 预加载一个视频
            adapter = exoAdapter
        }
    }
}

一个简单的视频流就完成了。

预加载及其原理

上述代码使用了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;
            }
            ...
        }
        ...
    }
}

有限的解码资源

用上面的代码实现视频流,当不停地往下翻看视频时,视频会加载不出来,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=YES

com.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 active

    Type     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   Latency

          25136     no  15781   82753   25105 P  0x400 00000001 00000003  44100  3   1  0  -inf     0     0     0  0000485A  11025   11025 A         0        0  293.91 k

          25137    yes  15781   82761   25106 A  0x000 00000001 00000003  44100  3   1  0   -26     0     0     0  0001102E  11025   11025 A         0        0  307.29 t

          25138     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 StyledPlayerView
        return itemView.also { it.player = player }
    }

    override fun onResume() {
        super.onResume()
        player.url = url
        player.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
    @Override
    public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
        if (mFragmentMaxLifecycleEnforcer != null) throw new IllegalArgumentException();
        mFragmentMaxLifecycleEnforcer = new FragmentMaxLifecycleEnforcer();
        mFragmentMaxLifecycleEnforcer.register(recyclerView);
    }

    // 将下面的方法改为 public
    @Override
    public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
        mFragmentMaxLifecycleEnforcer.unregister(recyclerView);
        mFragmentMaxLifecycleEnforcer = null;
    }

    // 新增方法:获取指定 Fragment
    public 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 {
    // 视频url
    var 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.forEach { it?.release() }
    }
}

播放器池长度

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

上图表示当前正在播放视频流中索引为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
    @Override
    protected RecyclerView.SmoothScroller createScroller( RecyclerView.LayoutManager layoutManager) {
        if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
            return null;
        }
        return new LinearSmoothScroller(mRecyclerView.getContext()) {
            @Override
            protected 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);
                }
            }

            @Override
            protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
                return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
            }

            @Override
            protected int calculateTimeForScrolling(int dx) {
                return Math.min(MAX_SCROLL_ON_FLING_DURATION, super.calculateTimeForScrolling(dx));
            }
        };
    }
}

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

3. 无缝播放体验

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

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

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

viewPager2.registerOnPageChangeCallback(object :OnPageChangeCallback(){
    // 上一次滚动偏移量
    private var lastOffset = 0f
    // 是否向下滚动
    private var isScrollDown = false
    override 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 > lastOffset
        lastOffset = positionOffset
    }
})

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

如果有对音视频开发技术感兴趣的小伙伴,可以参考这个《音视频开发核心笔记》进行学习,如果进去需要面试的可以参考《音视频面试题》

《音视频开发核心笔记》:https://qr18.cn/Ei3VPD

1.音视频初级入门
2.音视频中级进阶:OpenSL ES 学习
3.音视频高级探究
4.音视频编解码技术
5.多媒体文件格式
6.FFmpeg
7.……

《音视频面试题》:https://qr18.cn/AcV6Ap

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

13个UI设计软件,一次满足你的UI设计需求

UI设计师的角色是当今互联网时代非常重要的一部分。许多计算机和移动软件都需要UI设计师的参与&#xff0c;这个过程复杂而乏味。这里将与您分享13个UI设计软件&#xff0c;希望帮助您正确选择UI设计软件&#xff0c;节省工作量&#xff0c;创建更多优秀的UI设计作品。 1.即时…

4.共享模型之管程

4.共享模型之管程 4.1 共享带来的问题 Java的体现 import lombok.extern.slf4j.Slf4j;/*** author xc* date 2023/5/6 13:00*/ Slf4j public class Test14 {static int i 0;public static void main(String[] args) throws InterruptedException {Thread t1 new Thread(()…

Android Framework开发前景分析~

Android Framework是Android操作系统中的重要组成部分&#xff0c;它提供了一系列的API&#xff08;应用程序接口&#xff09;和服务&#xff0c;方便开发人员创建Android应用程序。随着Android设备的普及和移动互联网市场的快速发展&#xff0c;Android Framework开发有着广泛…

Linux下进程守护Supervisor搭建

简介&#xff1a;Supervisor是在linux上的进程管理员&#xff0c;是一个管理工具。当进程停止的时候Supervisor能够自动启动它&#xff0c;可以运行在各种类unix的机器上&#xff0c;supervisor是使用python开发的一套通用的进程管理工具&#xff0c;能够把普通脚本、命令行进程…

一起Talk Android吧(第五百四十三回:如何实现流水动画)

文章目录 概念介绍实现方法平移动画逐帧动画 经验总结 各位看官们大家好&#xff0c;上一回中咱们说的例子是"无进度值ProgressBar",本章回中介绍的例子是" 如何实现流水动画"。闲话休提&#xff0c;言归正转&#xff0c;让我们一起Talk Android吧&#x…

Linux命令·ss

ss是Socket Statistics的缩写。顾名思义&#xff0c;ss命令可以用来获取socket统计信息&#xff0c;它可以显示和netstat类似的内容。但ss的优势在于它能够显示更多更详细的有关TCP和连接状态的信息&#xff0c;而且比netstat更快速更高效。 当服务器的socket连接数量变得非常…

triton 疑难手册

config.pbtxt 配置参数手册 backend或platform参数用于指示nvidia triton用对应的backend加载模型参数&#xff0c;它的使用示例如下&#xff1a; name: "xxx" platform: "pytorch_libtorch"max_batch_size: 8 input [ {name: "input0"data_ty…

算法加密与解密、字符编码与字符集

加密算法 加密保证数据不会被窃取或者修改 可逆和不可逆加密 区分在于加密后的结果是否可以还原 可逆加密&#xff1a;安全传输数据时使用(如jwt中的数据) AES:流加密 DES&#xff1a;块加密 RSA HS256 不可逆加密&#xff1a;同一个文件或内容每次加密的结果一…

GL绘制自定义线条2_手写曲线应用贝塞尔曲线

上一篇文章的曲线是由触摸点直接生成的&#xff0c;但触摸点并非连续的&#xff0c;而是离散的&#xff0c;而且屏幕触摸点采样的间隔时间其实不短&#xff0c;因此如果单纯只用触摸点生成OpenGL触摸曲线&#xff0c;在高速书写时会导致曲线看起来就像多个线段合起来一样&#…

为何ChatGPT一出现让巨头们都坐不住?

近几个月来&#xff0c;ChatGPT都是当仁不让的舆论话题。 上一次AI在全球范围内引起轰动&#xff0c;还是谷歌的AI机器人AlphaGO下棋战胜围棋世界冠军的时候。 ChatGPT的出现&#xff0c;让国内外几乎所有的科技巨头都坐立不安。 2月1日&#xff0c;谷歌母公司Alphabet首席执…

C++跨平台“点绘机” 代码解读

前言 球球大作战可以自定义皮肤&#xff0c;用画刷绘制。 想着用软件来绘制。 初次尝试&#xff0c;没有达成最终目的&#xff0c;不过也有很大收获。 仓库链接&#xff1a;https://github.com/sixsixQAQ/dolphin 问题 这个半成品&#xff0c;已经有了基本结构了&#xff…

高德地图api 地理编码(地址-->坐标)geocoder.getLocation在官方可以测试出结果,下载代码到本地却用不了 问题解决

问题 高德地图api 地理编码&#xff08;地址-->坐标&#xff09;功能&#xff0c;通过输入 地址信息 得到 经纬度信息。geocoder.getLocation在官方可以测试出结果&#xff0c;下载代码到本地却用不了。 官方示例测试&#xff0c;可以从地址得到坐标 下载官方代码本地运行却…

常见注意力机制解析

1.Squeeze-and-Excitation&#xff08;SE&#xff09; SE的主要思想是通过对输入特征进行压缩和激励&#xff0c;来提高模型的表现能力。具体来说&#xff0c;SE注意力机制包括两个步骤&#xff1a;Squeeze和Excitation。在Squeeze步骤中&#xff0c;通过全局平均池化操作将输…

【2023年Mathorcup杯数学建模竞赛C题】电商物流网络包裹应急调运与结构优化--完整作品分享

1.问题背景 2.论文摘要 为了应对电商物流网络中物流场地和线路电商物流网络中物流场地和线路上货量波动的情况&#xff0c; 设计合理的物流网络调整方案以保障物流网络的正常运行。本文运用 0-1 整数规划模型&#xff0c;多目标动 态规划模型&#xff0c;给出了问题的结果。 针…

深入讲解eMMC简介

1 eMMC是什么 eMMC是embedded MultiMediaCard的简称&#xff0c;即嵌入式多媒体卡,是一种闪存卡的标准&#xff0c;它定义了基于嵌入式多媒体卡的存储系统的物理架构和访问接口及协议&#xff0c;具体由电子设备工程联合委员会JEDEC订立和发布。它是对MMC的一个拓展&#xff0…

redi缓存使用

1、缓存的特征 第一个特征&#xff1a;在一个层次化的系统中&#xff0c;缓存一定是一个快速子系统&#xff0c;数据存在缓存中时&#xff0c;能避免每次从慢速子系统中存取数据。 第二个特征&#xff1a;缓存系统的容量大小总是小于后端慢速系统的&#xff0c;不可能把所有数…

GAMES101 计算机图形学 | 学习笔记 (上)

目录 环境安装什么是计算机图形学物体上点的坐标变换顺序齐次坐标光栅化如何判定一个点在三角形内光栅化填充三角形示例代码光栅化产生的问题 采样不足&#xff08;欠采样&#xff09;导致锯齿抗锯齿滤波算法 环境安装 1. C中安装opencv库 2. C中安装eigen库 3. C中安装open…

ChatGPT调教指北,技巧就是效率!

技巧就是效率 很多人都知道ChatGPT很火很强&#xff0c;几乎无所不能&#xff0c;但跨越了重重门槛之才有机会使用的时候却有些迷茫&#xff0c;一时间不知道如何使用它。如果你就是把他当作一个普通的智能助手来看待&#xff0c;那与小爱同学有什么区别&#xff1f;甚至还差劲…

热乎的面经——踏石留印

⭐️前言⭐️ 本篇文章记录博主面试北京某公司所记录的面经&#xff0c;希望能给各位带来帮助。 &#x1f349;欢迎点赞 &#x1f44d; 收藏 ⭐留言评论 &#x1f4dd;私信必回哟&#x1f601; &#x1f349;博主将持续更新学习记录收获&#xff0c;友友们有任何问题可以在评论…

Origin如何绘制三维图形?

文章目录 0.引言1.使用矩阵簿窗口2.三维数据转换3.三维绘图4.三维曲面图5.三维XYY图6.三维符号、条状、矢量图7.等高线图 0.引言 因科研等多场景需要&#xff0c;绘制专业的图表&#xff0c;笔者对Origin进行了学习&#xff0c;本文通过《Origin 2022科学绘图与数据》及其配套素…