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

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

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


播放视频


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 源码启示录 - 唐子玄的专栏

目录
相关文章
|
4月前
|
Web App开发 编解码 资源调度
在阿里云直播解决方案中,当使用ARTC协议观看直播并进行清晰度切换时出现画面卡顿或马赛克现象,可能存在以下几种原因
【6月更文挑战第30天】阿里云直播中,ARTC协议下清晰度切换出现卡顿或马赛克可能由网络带宽、缓冲策略、转码效率、播放器解码、协议特点及服务器资源调度引起。解决措施包括优化网络、智能切换算法、播放器与服务器优化。通过监控和日志分析定位问题,参照官方最佳实践进行优化。
155 1
|
2月前
|
编解码 vr&ar 图形学
惊世骇俗!Unity下如何实现低至毫秒级的全景RTMP|RTSP流渲染,颠覆你的视觉体验!
【8月更文挑战第14天】随着虚拟现实技术的进步,全景视频作为一种新兴媒体形式,在Unity中实现低延迟的RTMP/RTSP流渲染变得至关重要。这不仅能够改善用户体验,还能广泛应用于远程教育、虚拟旅游等实时交互场景。本文介绍如何在Unity中实现全景视频流的低延迟渲染,并提供代码示例。首先确保Unity开发环境及所需插件已就绪,然后利用`unity-rtsp-rtmp-client`插件初始化客户端并设置回调。通过FFmpeg等工具解码视频数据并更新至全景纹理,同时采用硬件加速、调整缓冲区大小等策略进一步降低延迟。此方案需考虑网络状况与异常处理,确保应用程序的稳定性和可靠性。
40 1
|
2月前
|
编解码 Dart 网络协议
"震撼揭秘!Flutter如何玩转超低延迟RTSP/RTMP播放,跨平台视频流体验大升级,让你的应用秒变直播神器!"
【8月更文挑战第15天】Flutter作为跨平台UI框架,以其高效性和丰富生态著称。本文详述如何利用flutter_vlc_player等插件在Flutter中实现低延迟RTSP/RTMP播放,并提供代码示例。通过优化播放器设置,如禁用缓冲、启用帧丢弃等,可进一步减少延迟,提升用户观看体验,展现了Flutter在视频流媒体应用中的强大潜力。
39 0
|
5月前
|
存储 数据处理 API
视觉智能平台常见问题之通用视频生成接口声音和画面对不上如何解决
视觉智能平台是利用机器学习和图像处理技术,提供图像识别、视频分析等智能视觉服务的平台;本合集针对该平台在使用中遇到的常见问题进行了收集和解答,以帮助开发者和企业用户在整合和部署视觉智能解决方案时,能够更快地定位问题并找到有效的解决策略。
|
5月前
文字转语音后的音频结束以后,再播放一段时间的背景音乐。什么方案能实现
【2月更文挑战第13天】文字转语音后的音频结束以后,再播放一段时间的背景音乐。什么方案能实现
53 2
|
12月前
|
编解码 人工智能 前端开发
如何实现丝滑转码?
如何实现丝滑转码?
53 0
|
缓存 Java 索引
浅浅地优化下视频流播放体验(下)
浅浅地优化下视频流播放体验
326 0
|
机器学习/深度学习 算法
【OpenVI—视觉生产系列之视频插帧实战篇】几行代码,尽享流畅丝滑的视频观感
随着网络电视、手机等新媒体领域的快速发展,用户对于观看视频质量的要求也越来越高。当前市面上所广为传播的视频帧率大多仍然处于20~30fps,已经无法满足用户对于高清、流畅的体验追求。而视频插帧算法,能够有效实现多倍率的帧率提升,有效消除低帧率视频的卡顿感,让视频变得丝滑流畅。配合其它的视频增强算法,更是能够让低质量视频焕然一新,让观众享受到极致的播放和观看体验。
588 0
【OpenVI—视觉生产系列之视频插帧实战篇】几行代码,尽享流畅丝滑的视频观感
|
存储 缓存 网络协议