GsyVideoPlayer + ViewPager2 实现仿抖音视频列表的详细指南
2026/8/27 22:58:24 网站建设 项目流程

简介:在Android开发中,实现类似抖音的竖屏滑动视频列表是常见的业务需求,其核心在于视频播放器与列表容器的无缝协作。理解播放器生命周期管理、懒加载与预加载机制,是构建流畅体验的技术基础。借助GsyVideoPlayer等成熟库,可大幅降低封装成本,配合ViewPager2实现页面复用与滑动切换。此方案适用于信息流、短视频App等内容密集型应用,能有效优化内存占用与启动速度。本文将从技术选型出发,梳理播放器状态切换、缓存设计及性能优化要点,帮助开发者落地一套可上线的视频列表方案。 做仿抖音的竖屏滑动视频列表,是很多Android开发者在进阶阶段都会想尝试的东西,也是不少公司业务里的刚需。网上能搜到的方案不少,但要么是demo级别太粗糙,要么是封装太重改不动。我去年用GsyVideoPlayer加ViewPager2从零搭过一版,整个过程踩了不少坑,也沉淀了一些可以直接用的设计思路,今天把它完整拆开来讲。

先说明一下,这套方案的核心思路是:用ViewPager2做整页滑动容器,每个页面是一个RecyclerView(或者直接是VideoPlayer的容器),配合GsyVideoPlayer来做视频的加载、播放、缓存和释放。之所以选GsyVideoPlayer而不是自己基于Media3或ExoPlayer封装,是因为它把播放器切换、列表复用、生命周期绑定这些高频需求都已经处理好了,而且在它的issue区里能找到大量已经被踩平的坑,对业务开发来说可以省掉一个月的调试时间。

本文适合的人群:已经在用Android Studio写业务、对RecyclerView和普通列表比较熟悉、想实现一个能上线的短视频播放列表的开发者。如果你只是想跑通一个demo,那这篇内容可能会有点超额,但如果你希望做完之后不会随便滑动几下就内存爆掉,那这篇文章里的每一个小节都值得过一遍。

1. 项目整体设计与技术选型思路

1.1 为什么选GsyVideoPlayer而不是手写播放器

做视频列表第一个要做的决定就是播放器内核怎么选。你可以直接用MediaPlayer,也可以接ExoPlayer或IjkPlayer,但如果你服务的是业务项目而不是开源库,我建议不要从零开始。

GsyVideoPlayer是目前Android生态里比较成熟的视频播放器库,它基于IjkPlayer和ExoPlayer做了两层封装,对外提供统一的API。它解决的不只是「播放视频」这件事,还包括了列表播放场景下的几个关键问题:

  • 多个播放器实例切换时,旧的播放器能不能正确释放掉
  • 列表滚动时,是不可见的item还在继续拉流
  • 播放器在后台、锁屏、切到别的Activity时,生命周期事件怎么派发
  • 网络连接变化的监听、重试机制、缓冲进度

这些如果全部自己实现,工作量不亚于写一个基础播放器SDK。GsyVideoPlayer把这些能力都做好了,而且它支持动态切换内核,比如你不想用IjkPlayer,也可以切到ExoPlayer,只需要改一行配置。对业务开发来说,这是最均衡的取舍。

从我实际用的感受来说,GsyVideoPlayer最舒服的一点是:它的GSYVideoPlayer本身就是一个View,可以直接塞进布局里,不需要像手写SurfaceView那样做一堆兼容。它的setUp()方法传递url和标题,一个播放器就绪了,这对列表型业务是非常友好的。

1.2 页面容器选型:ViewPager2 + RecyclerView的组合

抖音风格的视频列表,从交互形态上分成两种:一种是纯ViewPager2,一页一个视频;另一种是页面内还有嵌套的RecyclerView,比如你点进一个用户主页,里面是一个横向滑动的作品列表,点进去之后是纵向的视频流。

我们的核心场景是第一种:全屏竖屏视频流,一页一个视频,手指上下滑切换。ViewPager2在AndroidX里已经稳定很久了,它内部本身就是RecyclerView,所以天然支持复用、差分动画、懒加载。用它来做「一页一个视频」的容器,比ViewPager传统实现优雅很多,也不用自己处理Fragment的缓存销毁。

这里要强调一个关键点:ViewPager2的OffscreenPageLimit默认是1,也就是说它会预加载相邻的一个页面。在视频场景里,这个预加载不是无脑提前播放,而是要「初始化但不播放」,具体怎么做我放到后面的预加载策略里讲。

如果你的场景是用户主页里的视频网格,点进去是播放页,那可以做成:外层一个RecyclerView,点击某个item进入一个全屏VideoDetailActivity,里面是一个ViewPager2,初始位置是你点击的那个item的index,整体结构是RecyclerView + ViewPager2的组合。这种方案我们用过,体验很好,实现也不复杂。

1.3 整体架构分层

我最后落地的代码结构是这样的:

  • VideoListActivity:外层容器,持有一个ViewPager2
  • VideoListAdapter:ViewPager2的Adapter,填充每一页内容
  • VideoDetailFragment或者VideoViewHolder:单页内容,内部持有一个GsyVideoPlayer实例
  • VideoDataManager:负责数据源,处理分页加载、接口缓存
  • PlayerManager:单例,用来管理当前播放的GsyVideoPlayer实例,负责切换时暂停上一个

如果你用Fragment作为ViewPager2的页面,那Fragment的生命周期会和ViewPager2联动,onPauseonResumeonDestroyView这些回调是处理播放器状态的关键节点。如果不用Fragment,直接用RecyclerView.AdapteronBindViewHolderonViewDetachedFromWindow来管理,也完全可以,而且更轻量。

我的建议是:如果视频页只有视频、标题、作者信息、点赞按钮这些简单内容,直接用Adapter+ViewHolder就够了;如果页面里还有复杂的互动模块、评论区、分享面板,那就用Fragment,方便单独管理各自的状态。下面我主要以Adapter+ViewHolder方案来展开。

2. 核心实现:列表与播放器的无缝衔接

2.1 数据源与Item布局设计

视频源的数据结构,我们业务里大概长这样:

data class VideoEntity( val videoId: String, val videoUrl: String, val coverUrl: String, val authorName: String, val likeCount: Long, val commentCount: Long, val shareCount: Long, val videoWidth: Int = 720, val videoHeight: Int = 1280 )

这里要注意,videoWidthvideoHeight不要只当成摆设,GsyVideoPlayer设置旋转角度、缩放模式时需要知道视频的宽高比,拿到接口数据时可以顺手存下来。如果接口没给,那播放器会等视频第一帧加载完才能确定比例,竖屏视频可能会有黑边闪烁。

Item布局是一个FrameLayout,里面从底往上叠三层:

  • 最底层是GSYVideoPlayer
  • 中间层是封面图ImageView
  • 最上层是信息区(作者、标题、点赞按钮)和渐变遮罩

封面图单独占一层是有原因的:在视频还没开始播放或正在切换时,用封面图兜底,视觉上不会出现黑屏,而且封面图可以走图片加载框架的缓存,比视频首帧更快。

<FrameLayout> <com.shuyu.gsyvideoplayer.video.StandardGSYVideoPlayer android:id="@+id/video_player" android:layout_width="match_parent" android:layout_height="match_parent" /> <ImageView android:id="@+id/iv_cover" android:layout_width="match_parent" android:layout_height="match_parent" /> <!-- 渐变遮罩和文字信息 --> </FrameLayout>

很多第一次写的人会把封面图放在播放器的setUp之前或之后搞混。正确的顺序是:先让封面图显示出来,再调用setUp。这样即使视频加载慢,用户看到的也是一张清晰的封面,而不是白屏。

2.2 ViewPager2适配器与页面复用

ViewPager2的Adapter有两种写法:FragmentStateAdapterRecyclerView.Adapter。前者适合页面数量不确定、且每个页面有独立状态的情况;后者适合页面结构固定、希望极致复用的情况。

视频流场景,我用的是RecyclerView.Adapter,因为每个页面的布局完全一样,只有数据不同,用Fragment有点重。核心代码骨架如下:

class VideoPagerAdapter( private val data: List<VideoEntity> ) : RecyclerView.Adapter<VideoPagerAdapter.VideoHolder>() { inner class VideoHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val player: GSYVideoPlayer = itemView.findViewById(R.id.video_player) val cover: ImageView = itemView.findViewById(R.id.iv_cover) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VideoHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_video_page, parent, false) return VideoHolder(view) } override fun onBindViewHolder(holder: VideoHolder, position: Int) { val entity = data[position] // 设置封面 Glide.with(holder.itemView.context) .load(entity.coverUrl) .into(holder.cover) // 初始化播放器,但不立即播放 holder.player.setUp(entity.videoUrl, false, null, null) holder.player.setLooping(true) } override fun getItemCount(): Int = data.size }

这里有一个很重要但容易被忽略的点:onBindViewHolder里调用了setUp,但这并不代表就要开始播放。setUp只是把播放器的数据源绑定好,同时做一些初始化。真正的播放动作由后续的onPageSelected触发。这样设计可以做到:滑到哪一页,哪一页播放;相邻页面虽然已经setUp了,但因为没有start,不会立刻请求视频流。

setUp其实会预创建播放器的内核,但不会拉流,消耗很小。真正开始拉流是在startPlayLogic被调用之后。这个时间差设计得很巧妙,既能保证切换页面时快速起播,又不会让多个页面同时占网。

2.3 滑动监听与播放触发策略

ViewPager2有registerOnPageChangeCallback,这是实现「滑到才播、滑走就停」的核心入口。

viewPager2.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { super.onPageSelected(position) playAt(position) } })

playAt内部要做的事很明确:

  1. 先释放掉上一个正在播放的播放器
  2. 获取当前页面的ViewHolder
  3. 调用当前页面的播放器startPlayLogic()
  4. 把当前播放实例记录到一个全局管理器里
private fun playAt(position: Int) { // 获取当前显示的ViewHolder val holder = viewPager2.findViewHolderForAdapterPosition(position) as? VideoPagerAdapter.VideoHolder ?: return // 先暂停当前播放 PlayerManager.releaseCurrent() // 开始播放新的 holder.player.startPlayLogic() PlayerManager.setCurrent(holder.player) }

PlayerManager是一个简单的单例,持有当前正在播放的播放器引用。它的releaseCurrent()做的事情是:如果当前播放器不为空且正在播放,就onVideoPause()。这样在快速滑动时,就不会出现两个视频声音叠在一起的情况。

这里再补一个细节:onPageSelected在快速滑动时可能会连续回调好几次,所以PlayerManager里的状态判断一定要做好,避免对一个已经暂停的播放器重复调用暂停。

如果你的视频页还有「点击暂停/继续播放」的需求,那也需要通过这个全局播放器引用来操作,直接在PlayerManager里加一个togglePlay方法就行。

3. 播放器生命周期管理与状态切换

3.1 GsyVideoPlayer的懒加载与预加载配置

前面提到ViewPager2默认会预加载相邻页面,这个行为在视频场景里需要配合GsyVideoPlayer的懒加载配置来控制。

GsyVideoPlayer提供了一个核心开关:setLazyLoading(true)。开启这个开关后,即使调用了setUp,播放器也不会真正初始化内核、不会创建播放器实例,直到你第一次调用startPlayLogic才真正创建。这个开关的妙处在于:你用onBindViewHolder初始化所有页面时,开销极小;而真正滑动到那一页时,才发生一次完整的内核创建和拉流。

holder.player.setLazyLoading(true) holder.player.setUp(entity.videoUrl, false, null, null) holder.player.setLooping(true)

这里有个经验:setLazyLoading必须在setUp之前设置。如果先setUp再开懒加载,实际上是无效的,因为播放器已经初始化了。

预加载还有一个点很实用:GsyVideoPlayer自带一个GSYVideoManager,它支持设置bufferTimeMaxplayTag,也就是预先缓冲时间。在短视频场景中,我不建议把预缓冲时间设得太长,因为视频普遍是10到30秒,拉流拉得太快反而浪费用户流量。一般bufferTimeMax设置在3到5秒比较合理,保证快速起播的同时不至于把所有内容都下载完。

3.2 页面切换时的暂停、恢复与释放逻辑

页面切换是视频流最容易出问题的环节。这里把生命周期和播放器的关系彻底理清,以后就不会乱了。

场景一:用户滑动到新页面。此时旧页面进入不可见状态,需要暂停;新页面进入可见状态,需要播放。我们的playAt(position)已经处理了。

场景二:用户按Home键退到后台。这时VideoListActivity会走onPauseonStop。你需要在Activity的onPause里暂停播放,在onResume里根据当前页面状态决定是否恢复播放。更严谨的做法是在onStop里暂停,因为onPause在系统弹窗、下拉通知栏时也会触发,这时音乐如果停了,用户下拉个状态栏视频就断了,体验不好。

GsyVideoPlayer自身也有和生命周期绑定的方法,但它毕竟是一个View,管不到你的Activity生命周期。所以正确的姿势是:在Activity的onPause里调用当前播放器的onVideoPause(),在onResume里调用onVideoResume()

场景三:整个页面销毁了。在onDestroyonDestroyView里,要彻底释放播放器资源。GsyVideoPlayer提供的是releaseAllVideos(),它可以清空当前正在播放的实例和缓冲队列。

这里我再补充一个容易踩的坑:不要在onPause里调用releaseAllVideos(),这会导致回到页面后要重新创建播放器、重新拉流,视觉上就是明显的白屏+转圈。onPauseonVideoPause足够,releaseAllVideos留给真正的销毁场景。

3.3 音频焦点、网络变化和来电处理

这些属于播放器的「外围状态」,但恰恰是影响体验上限的部分。

音频焦点:如果用户在刷视频时,手机里还有另一个App在放歌,那么短视频App应该怎么处理?行业标准做法是:在视频开始播放时请求音频焦点,如果焦点被抢占,就暂停;在视频暂停时释放焦点。GsyVideoPlayer的setUp里没有自动处理音频焦点,需要自己监听AudioManager.OnAudioFocusChangeListener

来电处理:来电时Android系统会自动把音频焦点拿走,如果你没有监听焦点变化,视频会继续播放但声音没了。正确的做法是在onAudioFocusChange里判断LOSS_TRANSIENTLOSS等状态,对应暂停播放。

网络变化:如果用户正在用流量看视频,切换到了Wi-Fi,或者网络断开又恢复,播放器需要能感知。GsyVideoPlayer内置了网络状态变化监听,可以在NetWorkErrorListener里做重试逻辑。我自己的业务里还做了一层:在CONNECTIVITY_CHANGE广播里判断网络是否恢复,如果恢复了且当前页有播放失败记录,就自动重新startPlayLogic

4. 性能优化关键点:流畅度、内存与加载速度

4.1 预加载队列设计

做视频流不预加载,体验大概率是灾难级的。因为视频接口返回数据后,用户滑到第二页才去请求第二页的视频流,中间会有明显的等待。想要做到「划过去就开始播」,必须提前把相邻页面的播放器准备好。

我的做法是维护一个预加载队列:在onPageSelected回调里,除了播放当前页,还主动去setUp相邻两页的数据源,并开启预渲染。

private fun preload(position: Int) { val count = viewPager2.adapter?.itemCount ?: return val targets = mutableListOf(position - 1, position + 1) for (index in targets) { if (index in 0 until count) { val holder = viewPager2.findViewHolderForAdapterPosition(index) as? VideoPagerAdapter.VideoHolder ?: continue // 预加载VideoManager,但不真正播放 GSYVideoManager.instance().prepareVideoForPlay(holder.player) } } }

prepareVideoForPlay是GsyVideoPlayer在某个版本后提供的能力,它可以预加载视频的数据源、创建解码器等,但不会把画面渲染出来。实测下来,配合OffscreenPageLimit设为2,可以让相邻页面达到「即滑即播」的效果。

预加载不是多多益善。预加载3页及以上,在弱网环境下反而会抢占当前视频的带宽,导致当前页卡顿、加载变慢。我测试后的结论是:预加载1到2页是甜点区,3页以上收益递减且有负作用。

4.2 内存与渲染优化

视频播放本身就是内存消耗大户。一个长宽为720x1280的视频,解码后的帧缓冲可能在几十MB量级。如果处理不好,列表滑几下就OOM,尤其是在低端机上。

第一层优化:控制同时存活的播放器实例数。用releaseAllVideos把不可见页面的播放器彻底释放,而不是仅仅暂停。暂停只是不播了,但缓冲区、解码器占的内存还在。所以在快速滑动时,我建议只保留当前播放页和相邻预加载页的播放器实例,其他全部释放。

第二层优化:封面图用Glideoverride设置成屏幕宽度的1/2甚至更小。因为封面图只是兜底,不需要加载原图。全屏VideoView的封面图用原图,纯属浪费内存。

第三层优化:开启播放器的缓存。GsyVideoPlayer基于proxy缓存,同一个视频地址如果网络允许,会自动把下载的视频写到本地缓存目录。这样用户重复观看同一个视频时,几乎零加载速度。缓存目录建议放在getExternalCacheDir()下,这样应用卸载后系统能自动清理,不会在用户手机里留垃圾。

代码示例:

GSYVideoManager.instance().cacheManager.enableCache = true GSYVideoManager.instance().cacheManager.cacheDir = File(context.externalCacheDir, "video_cache")

4.3 RecyclerView滑动卡顿的排查思路

ViewPager2内部就是RecyclerView,所以它的滑动卡顿问题,本质上就是RecyclerView的卡顿问题。

常见的卡顿来源有三个:

第一个是onBindViewHolder里做耗时操作。比如在绑定数据时直接调Glide.with(...).load(...).into(...),这个没问题;但如果有人在绑定数据时同步读数据库、做IO操作、解析大JSON,那卡顿是必然的。要把所有非UI操作挪到异步线程。

第二个是播放器TextureView/ SurfaceView的创建。GsyVideoPlayer默认用的是SurfaceView,在某些机型上,切换SurfaceView会导致整页黑屏闪烁。如果在低端机上比较明显,可以换成TextureView。但TextureView的绘制性能比SurfaceView低,一般视频场景还是要权衡。我自己的策略是:中高端机用SurfaceView,低端机如果滑动卡顿明显,再考虑TextureView

第三个是onPageSelected里做了太多的耗时工作。有些开发者喜欢在页面切换时一次性做打点上报、加载评论、刷新UI、初始化广告,这些都会阻塞主线程。建议把非关键任务放到Handler.postDelay或协程的Dispatchers.Default里。

4.4 边播边缓存与清晰度切换

针对短视频场景,清晰度切换不一定是刚需,但如果你要做,最好是做成「切换后重新走一遍播放流程」。GsyVideoPlayer支持setUp传入多个清晰度地址,在onPrepared回调里刷新清晰度列表。

我这里提醒一个很容易被忽略的问题:切换清晰度时,如果之前的缓冲没有清干净,会出现新地址的视频数据和旧缓存交错,导致播放错乱。安全做法是:切换清晰度时先onVideoPause(),再setUp新地址,最后startPlayLogic(),中间不要手动去碰播放器的内部缓存状态。

5. 实战中的常见问题与排查技巧

5.1 视频黑屏/白屏问题

黑屏或白屏是视频播放器最常见的表现之一。如果你的页面在滑动后出现短暂白屏,大概率是封面图没有正确显示,或者播放器在setUpstartPlayLogic之间,封面被隐藏了。

排查步骤:

  1. 确认ImageViewvisibilitystartPlayLogic之前一直是VISIBLE
  2. 确认setUp的第二个参数isLooping没有传错,这个参数和黑屏没关系,但和控件状态有关
  3. 确认onAutoCompletion回调里没有错误地把封面隐藏

如果是一进去就黑屏,优先检查视频源是否支持竖屏播放,以及playTag是否正确。有些视频源是横屏的,播放器默认按横屏渲染,在竖屏容器里就会有很多黑边,看起来像黑屏。

5.2 声音和画面不同步

不同步通常有两个原因:第一个是网络环境导致的音视频分离,这种属于网络抖动,播放器自己会慢慢纠正,不用管。第二个是SurfaceView在某些机型上的时序问题,SurfaceView需要Surface创建完成后才能渲染图像,但音频线程是独立的,如果Surface创建慢,就会出现「先有声音后有画面」的情况。

第二种情况的解决方式是:在onSurfaceCreated回调前不要调用startPlayLogic。GsyVideoPlayer其实内部处理了这个问题,但如果你用了自定义布局或者覆写了太多生命周期方法,可能会导致时序被提前。

如果你实在排查不出来,可以尝试调用setRotation或者setSpeedPlaying之类的接口强制刷新一下渲染层,有时候能缓解。

5.3 ViewPager2复用导致的状态残留

ViewPager2的item被回收和重新绑定时,播放器的状态可能还停留在上一次的数据上。比如你滑到第5页,然后快速滑回第3页,第3页的ViewHolder是复用的,它的播放器可能还处于「播放中」的状态,但你已经在onPageSelected里调用了playAt(3),这时有两个播放器同时在播。

我的解决办法是:在onBindViewHolder里强制重置播放器状态:

holder.player.release() // 释放之前的所有资源 holder.player.setLazyLoading(true) holder.player.setUp(entity.videoUrl, false, null, null) holder.player.setLooping(true)

这里release()setUp()的顺序不能反。如果直接setUp,旧的内核实例可能没有释放干净,内存会持续增长。

5.4 播放器在低端机上启动慢

低端机启动慢的根因通常是解码器创建和视频大小。解决思路有几条:

  1. 第一帧显示前,先用封面图兜底,让用户感知不到卡顿
  2. 调低视频播放的初始分辨率,如果接口能返回低码率地址,可以优先播放低码率版本,然后在onPrepared之后升级清晰度
  3. 将播放器的setPlayScaleType设置为TYPE_FIT_STYLETYPE_FILL_SCALE,避免不必要的算法计算

5.5 快速滑动时内存OOM

快速滑动OOM,几乎都是因为多个播放器的实例没有被及时释放。检查三件事:

  • onPageSelected里是否调用了PlayerManager.releaseCurrent()
  • onViewDetachedFromWindow里是否做了播放器资源的释放
  • 是否给RecyclerView设置了足够大的RecycledViewPool。如果你的Adapter复用了大量的ViewHolder,且每页的播放器都不便宜,那复用池设置得越大,内存占用越高。视频流场景反而不建议设置太大的复用池。

6. 用GsyVideoPlayer + ViewPager2完成后的最后几点建议

写到这里,核心思路基本都交代清楚了。最后分享几个我在实际开发和上线后积累的判断,不一定写到代码注释里,但对长期维护很有用。

第一,一定要在项目早期就把播放器相关的能力封装成一个独立模块,不要在每个页面里直接newGSYVideoPlayer。因为视频播放器涉及的配置太多了,统一封装后,后面接广告、接埋点、接清晰度切换都会方便很多。我自己的封装里至少包括了:播放器初始化、封面管理、播放状态回调、埋点上报、缓存配置、网络监听这六件事。

第二,不要把预加载的数据量弄太大。有些开发者为了让滑动不卡,把预加载做到3页甚至4页,结果弱网环境下一进列表,5个视频同时拉流,当前页反而变成最卡的。短视频场景,1到2页的预加载就是比较合理的区间。

第三,视频播放列表的稳定性验证,一定要用真机,特别是中低端安卓机。模拟器上的调试结果和真机差距很大,尤其是SurfaceView的渲染表现、内存占用和快速滑动时的GC情况。多用几台不同品牌、不同性能的真机跑一遍,比写100个单元测试都管用。

第四,上线之后一定要接播放失败的上报。视频源是会变的,链接可能过期、防盗链可能更新、CDN可能抖动。你本地跑得好好的,不代表线上所有人播放都正常。把播放失败率、卡顿率、平均起播时长这些指标收集起来,才知道自己做的播放列表到底稳不稳。

如果你正在做类似的功能,我希望这篇内容能帮你少走一些弯路。视频播放列表的难点不在某一个单点技术上,而在播放器状态和页面生命周期之间的协调,只要把这个核心关系理清了,剩下的实现都是水到渠成的事。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询