这一篇将从零开始,一步步解决如下这些问题:怎样播映单个视频?怎样将播映器模块化?怎样完成视频流?怎样优化视频播映内存?怎样优化视频流播映体会?

播映视频

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 {
    // 视频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 源码启示录 – 唐子玄的专栏。

有限的解码资源

用上面的代码完成视频流,当不停地往下翻看视频时,视频会加载不出来,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.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
    @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来改动整个动画的持续时刻。

无缝播映体会

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

假如在 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()中的偏移量断定翻滚方向,以此确认该暂停哪个视频,该播映哪个视频。