简介在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外层容器持有一个ViewPager2VideoListAdapterViewPager2的Adapter填充每一页内容VideoDetailFragment或者VideoViewHolder单页内容内部持有一个GsyVideoPlayer实例VideoDataManager负责数据源处理分页加载、接口缓存PlayerManager单例用来管理当前播放的GsyVideoPlayer实例负责切换时暂停上一个如果你用Fragment作为ViewPager2的页面那Fragment的生命周期会和ViewPager2联动onPause、onResume、onDestroyView这些回调是处理播放器状态的关键节点。如果不用Fragment直接用RecyclerView.Adapter的onBindViewHolder和onViewDetachedFromWindow来管理也完全可以而且更轻量。我的建议是如果视频页只有视频、标题、作者信息、点赞按钮这些简单内容直接用AdapterViewHolder就够了如果页面里还有复杂的互动模块、评论区、分享面板那就用Fragment方便单独管理各自的状态。下面我主要以AdapterViewHolder方案来展开。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 )这里要注意videoWidth和videoHeight不要只当成摆设GsyVideoPlayer设置旋转角度、缩放模式时需要知道视频的宽高比拿到接口数据时可以顺手存下来。如果接口没给那播放器会等视频第一帧加载完才能确定比例竖屏视频可能会有黑边闪烁。Item布局是一个FrameLayout里面从底往上叠三层最底层是GSYVideoPlayer中间层是封面图ImageView最上层是信息区作者、标题、点赞按钮和渐变遮罩封面图单独占一层是有原因的在视频还没开始播放或正在切换时用封面图兜底视觉上不会出现黑屏而且封面图可以走图片加载框架的缓存比视频首帧更快。FrameLayout com.shuyu.gsyvideoplayer.video.StandardGSYVideoPlayer android:idid/video_player android:layout_widthmatch_parent android:layout_heightmatch_parent / ImageView android:idid/iv_cover android:layout_widthmatch_parent android:layout_heightmatch_parent / !-- 渐变遮罩和文字信息 -- /FrameLayout很多第一次写的人会把封面图放在播放器的setUp之前或之后搞混。正确的顺序是先让封面图显示出来再调用setUp。这样即使视频加载慢用户看到的也是一张清晰的封面而不是白屏。2.2 ViewPager2适配器与页面复用ViewPager2的Adapter有两种写法FragmentStateAdapter和RecyclerView.Adapter。前者适合页面数量不确定、且每个页面有独立状态的情况后者适合页面结构固定、希望极致复用的情况。视频流场景我用的是RecyclerView.Adapter因为每个页面的布局完全一样只有数据不同用Fragment有点重。核心代码骨架如下class VideoPagerAdapter( private val data: ListVideoEntity ) : RecyclerView.AdapterVideoPagerAdapter.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内部要做的事很明确先释放掉上一个正在播放的播放器获取当前页面的ViewHolder调用当前页面的播放器startPlayLogic()把当前播放实例记录到一个全局管理器里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它支持设置bufferTimeMax和playTag也就是预先缓冲时间。在短视频场景中我不建议把预缓冲时间设得太长因为视频普遍是10到30秒拉流拉得太快反而浪费用户流量。一般bufferTimeMax设置在3到5秒比较合理保证快速起播的同时不至于把所有内容都下载完。3.2 页面切换时的暂停、恢复与释放逻辑页面切换是视频流最容易出问题的环节。这里把生命周期和播放器的关系彻底理清以后就不会乱了。场景一用户滑动到新页面。此时旧页面进入不可见状态需要暂停新页面进入可见状态需要播放。我们的playAt(position)已经处理了。场景二用户按Home键退到后台。这时VideoListActivity会走onPause和onStop。你需要在Activity的onPause里暂停播放在onResume里根据当前页面状态决定是否恢复播放。更严谨的做法是在onStop里暂停因为onPause在系统弹窗、下拉通知栏时也会触发这时音乐如果停了用户下拉个状态栏视频就断了体验不好。GsyVideoPlayer自身也有和生命周期绑定的方法但它毕竟是一个View管不到你的Activity生命周期。所以正确的姿势是在Activity的onPause里调用当前播放器的onVideoPause()在onResume里调用onVideoResume()。场景三整个页面销毁了。在onDestroy或onDestroyView里要彻底释放播放器资源。GsyVideoPlayer提供的是releaseAllVideos()它可以清空当前正在播放的实例和缓冲队列。这里我再补充一个容易踩的坑不要在onPause里调用releaseAllVideos()这会导致回到页面后要重新创建播放器、重新拉流视觉上就是明显的白屏转圈。onPause用onVideoPause足够releaseAllVideos留给真正的销毁场景。3.3 音频焦点、网络变化和来电处理这些属于播放器的「外围状态」但恰恰是影响体验上限的部分。音频焦点如果用户在刷视频时手机里还有另一个App在放歌那么短视频App应该怎么处理行业标准做法是在视频开始播放时请求音频焦点如果焦点被抢占就暂停在视频暂停时释放焦点。GsyVideoPlayer的setUp里没有自动处理音频焦点需要自己监听AudioManager.OnAudioFocusChangeListener。来电处理来电时Android系统会自动把音频焦点拿走如果你没有监听焦点变化视频会继续播放但声音没了。正确的做法是在onAudioFocusChange里判断LOSS_TRANSIENT、LOSS等状态对应暂停播放。网络变化如果用户正在用流量看视频切换到了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把不可见页面的播放器彻底释放而不是仅仅暂停。暂停只是不播了但缓冲区、解码器占的内存还在。所以在快速滑动时我建议只保留当前播放页和相邻预加载页的播放器实例其他全部释放。第二层优化封面图用Glide的override设置成屏幕宽度的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 视频黑屏/白屏问题黑屏或白屏是视频播放器最常见的表现之一。如果你的页面在滑动后出现短暂白屏大概率是封面图没有正确显示或者播放器在setUp和startPlayLogic之间封面被隐藏了。排查步骤确认ImageView的visibility在startPlayLogic之前一直是VISIBLE确认setUp的第二个参数isLooping没有传错这个参数和黑屏没关系但和控件状态有关确认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 播放器在低端机上启动慢低端机启动慢的根因通常是解码器创建和视频大小。解决思路有几条第一帧显示前先用封面图兜底让用户感知不到卡顿调低视频播放的初始分辨率如果接口能返回低码率地址可以优先播放低码率版本然后在onPrepared之后升级清晰度将播放器的setPlayScaleType设置为TYPE_FIT_STYLE或TYPE_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可能抖动。你本地跑得好好的不代表线上所有人播放都正常。把播放失败率、卡顿率、平均起播时长这些指标收集起来才知道自己做的播放列表到底稳不稳。如果你正在做类似的功能我希望这篇内容能帮你少走一些弯路。视频播放列表的难点不在某一个单点技术上而在播放器状态和页面生命周期之间的协调只要把这个核心关系理清了剩下的实现都是水到渠成的事。本文还有配套的精品资源点击获取