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

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

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


播放视频


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 的分片就没有这个问题。


  1. m3u8 支持自适应码率,即可以在网络环境比较差时自动降低码率,网络环境好时自动恢复,而且是无缝切换。


  1. m3u8 天然支持视频加密,即对视频二进制内容加密,防盗资源。


  1. 当视频资源很大时,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,如下图所示:


image.png


上图表示 ViewPager2 正在展示索引为2的视频,其视窗大小为1(视窗占满屏幕),只有当手向上滑动视频3从视窗的底部出现时,才会触发视频3的加载。


若 offscreenPageLimit = 1,则表示视窗在当前屏幕的上下拓宽了一格:


image.png


图中的红色+蓝色区域就是视窗大小,只有当列表项进入视窗后才会发出其加载。当前屏幕停留在视频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 源码启示录 - 唐子玄的专栏

目录
相关文章
|
5月前
|
Web App开发 编解码 资源调度
在阿里云直播解决方案中,当使用ARTC协议观看直播并进行清晰度切换时出现画面卡顿或马赛克现象,可能存在以下几种原因
【6月更文挑战第30天】阿里云直播中,ARTC协议下清晰度切换出现卡顿或马赛克可能由网络带宽、缓冲策略、转码效率、播放器解码、协议特点及服务器资源调度引起。解决措施包括优化网络、智能切换算法、播放器与服务器优化。通过监控和日志分析定位问题,参照官方最佳实践进行优化。
221 1
|
3月前
|
编解码 vr&ar 图形学
惊世骇俗!Unity下如何实现低至毫秒级的全景RTMP|RTSP流渲染,颠覆你的视觉体验!
【8月更文挑战第14天】随着虚拟现实技术的进步,全景视频作为一种新兴媒体形式,在Unity中实现低延迟的RTMP/RTSP流渲染变得至关重要。这不仅能够改善用户体验,还能广泛应用于远程教育、虚拟旅游等实时交互场景。本文介绍如何在Unity中实现全景视频流的低延迟渲染,并提供代码示例。首先确保Unity开发环境及所需插件已就绪,然后利用`unity-rtsp-rtmp-client`插件初始化客户端并设置回调。通过FFmpeg等工具解码视频数据并更新至全景纹理,同时采用硬件加速、调整缓冲区大小等策略进一步降低延迟。此方案需考虑网络状况与异常处理,确保应用程序的稳定性和可靠性。
68 1
|
3月前
|
编解码 Dart 网络协议
"震撼揭秘!Flutter如何玩转超低延迟RTSP/RTMP播放,跨平台视频流体验大升级,让你的应用秒变直播神器!"
【8月更文挑战第15天】Flutter作为跨平台UI框架,以其高效性和丰富生态著称。本文详述如何利用flutter_vlc_player等插件在Flutter中实现低延迟RTSP/RTMP播放,并提供代码示例。通过优化播放器设置,如禁用缓冲、启用帧丢弃等,可进一步减少延迟,提升用户观看体验,展现了Flutter在视频流媒体应用中的强大潜力。
79 0
|
6月前
文字转语音后的音频结束以后,再播放一段时间的背景音乐。什么方案能实现
【2月更文挑战第13天】文字转语音后的音频结束以后,再播放一段时间的背景音乐。什么方案能实现
64 2
|
6月前
|
Linux C++ iOS开发
VLC源码解析:视频播放速度控制背后的技术
VLC源码解析:视频播放速度控制背后的技术
573 0
|
6月前
|
编解码
音视频录制播放原理
音视频录制播放原理
117 1
|
6月前
|
XML Java 调度
Android开发音效增强中铃声播放Ringtone及声音池调度SoundPool的讲解及实战(超详细 附源码)
Android开发音效增强中铃声播放Ringtone及声音池调度SoundPool的讲解及实战(超详细 附源码)
308 0
|
编解码 开发工具 C#
RTSP/RTMP播放端录像不可忽视的几个设计要点
很多开发者提到,拉取的摄像机(一般RTSP流)或RTMP流,如果需要录制,需要考虑哪些因素,本文以大牛直播SDK的Windows平台拉流端录像为例(github),做个简单的介绍:
168 0
|
缓存 Java 索引
浅浅地优化下视频流播放体验(下)
浅浅地优化下视频流播放体验
339 0