基于ViewPager2与GsyVideoPlayer实现仿抖音视频流及优化
简介本资源是一个完整的Android仿抖音短视频播放实战项目面向Android开发初学者与进阶者解决垂直滑动视频流、多视频无缝切换及沉浸式播放体验等核心需求。项目基于GsyVideoPlayer实现高性能视频解码与控制结合ViewPager2定制垂直翻页逻辑支持上下滑动切换视频、自动播放/暂停、全屏适配、亮度音量调节等抖音式交互功能。压缩包共792个文件包含107个XML布局与配置文件、179个Flat资源、120个JSON数据模拟文件、43个PNG图标及5个MP4示例视频另有大量Kotlin/Java源码、Gradle构建脚本与编译产物dex/class/so整体大小81.91MB结构清晰模块划分明确。已有2595人学习下载提供可直接运行的完整工程、关键注释说明、生命周期管理范式及性能优化实践如预加载策略与内存泄漏规避是掌握Android视频流滑动容器协同开发的优质参考案例。 最近在做一个短视频信息流类的需求产品那边给出的交互参考就是抖音首页那种上下滑动、视频无缝衔接的效果。技术栈选型的时候没有太多犹豫播放器用了GsyVideoPlayer容器用了ViewPager2这两者在各自的领域里都属于久经考验的成熟方案。这个组合做下来的整体感受是思路清晰、坑也不少但所有的问题都有迹可循不是那种玄学级疑难杂症。这篇文章把整个实现过程、遇到的坑、以及最后沉淀下来的优化经验完整记录下来给后面要做类似需求的同学一个参考。1. 为什么是ViewPager2 GsyVideoPlayer而不是其他组合动手之前先把方案在脑子里过了一遍这里的选择直接决定了后面开发的平滑程度。1.1 视频流容器的方案对比仿抖音的信息流本质上是全屏翻页这个场景下容器无非就几种选择传统的ScrollView、RecyclerView PagerSnapHelper、ViewPager2、以及早期版本的ViewPager。先说ScrollView这基本不用考虑一次性加载全部数据内存直接爆炸翻页位置的计算也极其痛苦。RecyclerView PagerSnapHelper这个组合其实可行很多项目也确实在用它可以利用RecyclerView的ViewHolder复用机制加上PagerSnapHelper来做翻页对齐。但问题在于它等于要把ViewPager2的一部分能力自己重新实现一遍比如页面切换的生命周期回调、当前页的精确监听、页面状态恢复等这套逻辑自己写起来容易漏。ViewPager2是Android官方基于RecyclerView封装的分页容器它同时继承了ViewPager的页面切换语义和RecyclerView的复用机制。这意味着它天生支持懒加载、支持页面销毁重建、有官方的页面切换回调还支持垂直滑动。用它来做抖音流的容器是顺理成章的事。另外ViewPager2内部是一个RecyclerView这个特点在后面的性能优化里成为了关键的突破口。1.2 播放器选型的考量GsyVideoPlayer在这个圈子里的地位不用多说它核心封装了IJKPlayer和ExoPlayer两套播放内核对外暴露统一的API接口。选择它主要看重三个点第一API使用非常简单初始化一个播放器、设置视频源、开始播放核心只需要三个方法调用。第二它内部处理了SurfaceView和TextureView的切换逻辑、音频焦点管理、播放器状态的回调分发这些如果自己基于MediaPlayer或ExoPlayer的裸API去处理工作量会大很多且很容易出边界条件的遗漏。第三社区活跃度足够高出现问题通过搜索引擎基本能够找到解决方案。当然GsyVideoPlayer只是这个方案里合适的解之一如果你只想用一个非常轻量的播放器ExoPlayer的PlayerView自己封装也是完全可以的。但如果你追求的是快速见效、开箱即用GsyVideoPlayer确实能省下很多时间。2. 核心架构播放器与容器的关系梳理这个项目整体结构并不复杂但需要先把播放器和容器之间的协作关系想清楚否则后面编码会是乱套的。2.1 页面的核心Layout层次这里只有两个核心布局一个是承载ViewPager2的Activity另一个是ViewPager2的item布局。Activity的主布局很简单就是一个全屏的ViewPager2设置方向为垂直。item布局里则是视频播放器的容器因为GsyVideoPlayer自带完整的控制面板UI、loading转圈、播放按钮遮罩所以item布局里不额外放太多自定义控件最基本的就是一个GsyVideoPlayer的实例再加一个简单的封面图ImageView作为加载时的占位。值得注意的是GsyVideoPlayer本身是一个FrameLayout的子类它可以作为item布局的根布局直接使用也可以内嵌在某个外层布局里。我建议直接把它作为item的根部View这样在后续做播放器释放、SurfaceView的窗口管理时层级最直观、最好调试。2.2 核心类职责划分项目里我拆了几层每个类的职责尽量做到单一。MainActivity负责初始化ViewPager2和适配器注册页面切换回调管理播放器生命周期。VideoAdapter继承自RecyclerView.Adapter负责创建和绑定ViewHolder处理列表数据的展示。VideoViewHolder持有GsyVideoPlayer实例暴露出绑定数据和播放控制的方法。VideoPlayManager一个单例类负责管理当前正在播放的播放器实例提供统一的播放、暂停、释放接口。这个结构的关键点在于VideoPlayManager。因为ViewPager2的复用机制会导致多个ViewHolder里的播放器实例同时存在如果不在一个统一的管理者里去协调很容易出现多个播放器同时出声的情况。这个Manager就是整个播放器管理的神经中枢。3. 一步一步实现仿抖音首页视频流这部分是实际操作的部分我会贴出实现的关键代码并对每个环节做必要的说明。3.1 环境准备与依赖引入首先在build.gradle里引入GsyVideoPlayer。这里我使用的是当前的稳定版本具体版本号可以以仓库最新版本为准。dependencies { implementation com.github.CarGuo.GSYVideoPlayer:gsyVideoPlayer-java:v8.3.4-release implementation com.github.CarGuo.GSYVideoPlayer:gsyVideoPlayer-ex_so:v8.3.4-release implementation com.github.CarGuo.GSYVideoPlayer:gsyVideoPlayer-arm64:v8.3.4-release implementation com.github.CarGuo.GSYVideoPlayer:gsyVideoPlayer-armv7a:v8.3.4-release implementation com.github.CarGuo.GSYVideoPlayer:gsyVideoPlayer-x86:v8.3.4-release }这里需要注意一点如果只引入了gsyVideoPlayer-java它是不会包含任何播放内核的so库的还必须根据实际需要引入对应的so库模块。如果是模拟器调试记得加上x86模块否则只有arm架构的so库可能部分模拟器上会无法播放。3.2 MainActivity的初始化与ViewPager2配置MainActivity的代码核心就是初始化ViewPager2设置适配器再注册监听。public class MainActivity extends AppCompatActivity { private ViewPager2 viewPager2; private VideoAdapter videoAdapter; private ListVideoBean videoList; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); viewPager2 findViewById(R.id.viewPager2); videoList loadVideoData(); videoAdapter new VideoAdapter(this, videoList); viewPager2.setAdapter(videoAdapter); // 核心垂直方向滑动 viewPager2.setOrientation(ViewPager2.ORIENTATION_VERTICAL); // 预加载当前页前后的页面 viewPager2.setOffscreenPageLimit(2); viewPager2.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { Override public void onPageSelected(int position) { super.onPageSelected(position); // 当页面选定时通知播放管理器播放当前页的视频 VideoPlayManager.getInstance().playVideo(position); } }); } }setOrientation(ViewPager2.ORIENTATION_VERTICAL)这行代码是把ViewPager2从默认的横向翻页改成纵向翻页的关键这也就是抖音上下滑的视觉基础。setOffscreenPageLimit(2)表示会预加载当前位置前后各2个页面。设置这个参数的目的是让用户在滑动到下一页时页面已经创建好能够立刻展示避免出现白屏等待。但这个值也不宜设置得过大因为每个页面里都持有一个播放器实例和可能的SurfaceView这会占用不少内存。2这个值在我的实践下来看是内存占用和滑动体验都比较平衡的点。3.3 item布局与ViewHolderitem布局很简单核心就是一个GsyVideoPlayer再加一个封面图。?xml version1.0 encodingutf-8? FrameLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent com.example.videoplayer.GsyVideoPlayer android:idid/player android:layout_widthmatch_parent android:layout_heightmatch_parent / ImageView android:idid/iv_cover android:layout_widthmatch_parent android:layout_heightmatch_parent android:scaleTypecenterCrop / /FrameLayoutViewHolder里做的事情稍微多一些它需要持有播放器实例和封面ImageView并提供绑定数据的方法。public class VideoViewHolder extends RecyclerView.ViewHolder { private GsyVideoPlayer gsyVideoPlayer; private ImageView ivCover; public VideoViewHolder(View itemView) { super(itemView); gsyVideoPlayer itemView.findViewById(R.id.player); ivCover itemView.findViewById(R.id.iv_cover); } public void bindData(VideoBean videoBean) { // 设置封面图 Glide.with(itemView.getContext()) .load(videoBean.getCoverUrl()) .into(ivCover); // 初始化播放器配置 gsyVideoPlayer.setUp(videoBean.getVideoUrl(), false, null); // 不显示底部控制栏模拟沉浸式播放 gsyVideoPlayer.getTitleTextView().setVisibility(View.GONE); gsyVideoPlayer.getBackButton().setVisibility(View.GONE); gsyVideoPlayer.setIsTouchWiget(false); gsyVideoPlayer.setLooping(true); } public GsyVideoPlayer getPlayer() { return gsyVideoPlayer; } }这里有一个细节值得展开说明。gsyVideoPlayer.setUp(videoBean.getVideoUrl(), false, null)这行代码第二个参数是isShowTitle表示是否显示标题栏这里传false第三个参数是缓存路径这里传null表示不启用缓存。关于缓存这里多说一句如果视频源是网络地址且用户流量比较敏感建议配置一个缓存目录GsyVideoPlayer内部是基于VideoCache的代理缓存思路实现的同一个URL第二次播放时会从本地缓存读取体验提升非常明显。setIsTouchWiget(false)这个设置会禁用播放器内部的手势触摸操作包括调节音量、亮度、进度条拖动等。因为这是全屏滑动播放的列表如果不禁用这些手势用户在滑动列表时可能误触到播放器的手势区域导致音量或进度被误调。抖音本身在全屏视频流里也是禁用了部分手势的。setLooping(true)是设置循环播放因为短视频通常在20秒以内循环播放是基本操作。3.4 适配器的实现适配器就是比较标准的写法了但有一个非常重要的细节ViewHolder的复用。public class VideoAdapter extends RecyclerView.AdapterVideoViewHolder { private ListVideoBean dataList; private Context context; public VideoAdapter(Context context, ListVideoBean dataList) { this.context context; this.dataList dataList; } NonNull Override public VideoViewHolder onCreateViewHolder(NonNull ViewGroup parent, int viewType) { View view LayoutInflater.from(context).inflate(R.layout.item_video, parent, false); return new VideoViewHolder(view); } Override public void onBindViewHolder(NonNull VideoViewHolder holder, int position) { holder.bindData(dataList.get(position)); } Override public int getItemCount() { return dataList.size(); } }这里我不推荐在onBindViewHolder里直接调用播放器的start()方法启动播放因为在ViewPager2快速滑动时onBindViewHolder的调用时机与页面完全展示出来的时机并不完全同步。如果在这里播放可能会导致用户还在浏览上一个页面时下一个页面的视频已经开始出声了。正确做法是在onPageSelected回调里去精准控制播放绑定数据的步骤只做播放器配置和封面加载。3.5 VideoPlayManager单例管理播放器这个Manager是这个方案里比较核心的业务逻辑层它的职责是解决什么时候播放谁的问题。public class VideoPlayManager { private static volatile VideoPlayManager instance; private GsyVideoPlayer currentPlayer; private int currentPosition -1; private VideoPlayManager() { } public static VideoPlayManager getInstance() { if (instance null) { synchronized (VideoPlayManager.class) { if (instance null) { instance new VideoPlayManager(); } } } return instance; } public void playVideo(int position) { if (position currentPosition) { // 同一个位置不做处理 return; } // 暂停上一个播放器 if (currentPlayer ! null) { currentPlayer.onVideoPause(); currentPlayer.setVisibility(View.INVISIBLE); } currentPosition position; currentPlayer null; } public void setCurrentPlayer(GsyVideoPlayer player, int position) { this.currentPlayer player; this.currentPosition position; play(); } public void play() { if (currentPlayer ! null) { currentPlayer.setVisibility(View.VISIBLE); currentPlayer.startPlayLogic(); } } public void pause() { if (currentPlayer ! null) { currentPlayer.onVideoPause(); } } public void release() { if (currentPlayer ! null) { currentPlayer.onVideoReset(); currentPlayer null; } currentPosition -1; } public void resetToPosition(int position) { currentPosition position; } }这里的设计思路是这样的每个ViewHolder在绑定数据时会把自己持有的播放器注册到Manager中但不做播放。当onPageSelected触发时Manager检查目标位置与当前播放位置是否一致如果是新的位置就暂停掉上一个播放器然后通知当前的播放器开始播放。有一个边界情况需要注意当onPageSelected被触发时目标位置的ViewHolder可能还没有完成onBindViewHolder也就是说Manager当前还拿不到目标位置的播放器实例。为了解决这个问题ViewHolder在bindData里通过VideoPlayManager.getInstance().setCurrentPlayer(player, position)进行注册onPageSelected里则通过playVideo(int position)来触发。这两者之间的协作逻辑用户滑动到位置N。onPageSelected(N)被触发Manager将currentPosition记录为N并暂停掉旧的播放器。系统创建/复用位置N的ViewHolder调用onBindViewHolder。ViewHolder在bindData里调用setCurrentPlayerManager把当前的播放器指向这个新实例并立即调用play()。但如果位置N的ViewHolder在onPageSelected之前就已经存在比如预加载的页面那么playVideo被调用后会暂停旧播放器并更新currentPosition然而此时currentPlayer仍然指向旧的播放器实例这就会导致新的位置没有播放。所以在playVideo方法里当发现position ! currentPosition时把currentPlayer置空等setCurrentPlayer来填充。这是一个配套的设计逻辑需要前后看才能理解整体运作。4. 播放卡顿、画面闪烁、声音串台这些坑我全都踩过这个方案最大的挑战从来不在实现而在调试。下面几个问题是我在这个项目里实际遇到过并且排查过的也是同类需求里最容易被问到的。4.1 ViewPager2快速滑动时声音串台现象用户快速上滑状态栏能看到短视频App的播放画面已经切换了但音频还在播上一个视频的声音持续时间大概几百毫秒体验很突兀。根因跨页滑动时onPageSelected会触发但旧的页面可能还处于可见状态。这时候如果旧的播放器没有及时暂停它会继续播放音频而新的播放器又立刻开始了播放两个播放器的声音叠加在一起形成串台。解决办法也非常朴素onPageSelected触发时立刻暂停所有非当前页面的播放器。在我的实际代码里就是让所有ViewHolder把自己持有的播放器注册到一个全局的播放器集合中在onPageSelected回调时遍历这个集合除了目标位置的那个播放器其他全部调用onVideoPause()。这个方案虽然粗暴但效果非常好。因为每个item里的播放器在bindData时都会重新设置视频源暂停操作不会对后续的重新播放造成影响。4.2 滑动时封面图闪烁现象视频加载时有封面图但滑动列表时新位置的画面会先闪一下上一个位置的封面然后才加载出正确封面。根因RecyclerView的ViewHolder复用机制导致。当滑动到新位置时复用了上一个位置的item在异步加载新的封面图之前ImageView里残留的还是上一个位置的图片。解决办法在bindData里先把ImageView的背景置为透明或者直接设置一个通用的占位图再开始异步加载。holder.ivCover.setImageDrawable(null); Glide.with(context) .load(videoBean.getCoverUrl()) .placeholder(R.drawable.bg_placeholder) .into(holder.ivCover);这个问题的本质是列表复用导致的异步加载错位不只是封面图任何在item中异步加载的数据比如文本、用户头像都容易出现类似问题。RecyclerView这个复用机制是一把双刃剑用好了高效用不好就是各种幽灵数据。4.3 播放器黑屏但音频正常现象视频只有声音画面黑屏但卡一会儿后画面又自己出来了。根因GsyVideoPlayer默认使用TextureView也有用到SurfaceView的场景。TextureView和ViewPager2的页面切换存在一个窗口管理上的协调问题。当页面切换时TextureView的Surface可能被销毁重建如果播放器的渲染通路没有及时恢复就会出现黑屏。解决方案分两步第一代码里在页面切换完成后的onPageSelected里不仅要启动播放还需要调用播放器的onVideoResume()注意不是startPlayLogic来恢复Surface的渲染。这个调用的作用类似于把播放器从后台切换到前台时的处理。第二渲染View的模式切换。GsyVideoPlayer提供了setEncodeTexture和setGLPlayer等接口来切换渲染模式。如果默认的TextureView模式在你的设备上表现不佳可以尝试切换成SurfaceView模式或者相反。这个需要针对不同设备做下测试不同芯片方案的设备对TextureView和SurfaceView的支持不完全一致。这是这个项目里最耗时的排查过程后来查了GsyVideoPlayer的源码发现在startPlayLogic内部如果检测到Surface还没有完全准备好会进入等待状态只有当Surface的surfaceCreated回调通知到播放器后才会真正开始渲染画面。所以在做切页恢复播放时如果只是简单地重复调用startPlayLogic可能并没有真正触发等待Surface的逻辑而调用了onVideoResume则会通知播放器Surface已恢复渲染链路会重新打通。4.4 Activity进入后台后播放器没有自动暂停现象用户按下Home键视频还在播放声音。这属于比较基础的问题了。解决办法在MainActivity的生命周期里做对应的处理。Override protected void onPause() { super.onPause(); VideoPlayManager.getInstance().pause(); } Override protected void onResume() { super.onResume(); VideoPlayManager.getInstance().play(); } Override protected void onDestroy() { super.onDestroy(); VideoPlayManager.getInstance().release(); }我在onPause里调用pause在onResume里调用play在onDestroy里彻底释放播放器。这个方法比较简单但有一个需要留意的地方onResume里如果直接调用play()可能会在页面还没有完全可见时就开始加载视频带来不必要的资源消耗。更精细的做法是记录当前位置在onResume时确认页面处于可见状态后再调用播放。4.5 列表数据刷新后播放器状态错乱现象当用户下拉刷新或者加载更多列表数据更新后正在播放的视频没有自动切换到当前位置的对应视频甚至出现了黑屏或者播放了错误位置的视频。根因onPageSelected里记录了currentPosition但列表数据更新后视图的位置和数据的对应关系可能发生了变化。特别是如果在列表头部插入了新数据所有位置的索引都发生了位移但currentPosition没有更新。解决办法在通知Adapter数据变更的前后重置VideoPlayManager的状态。private void refreshData() { VideoPlayManager.getInstance().release(); videoAdapter.notifyDataSetChanged(); VideoPlayManager.getInstance().resetToPosition(-1); }release会暂停并释放播放器resetToPosition(-1)会把记录的位置清空这样下一次onPageSelected触发时就会强制更新播放器。这里有个细节notifyDataSetChanged之后已经创建的ViewHolder内容会在下一帧刷新但它们的索引可能已经变了。如果你的数据结构里每个item有唯一ID用notifyItemRangeChanged配合setHasStableIds(true)会更准确可以把错误的出现概率降得更低。5. 从能用到好用体验优化的一些细节基础功能做完可以跑通之后剩下的就是体验优化。这部分更琐碎但是用户能感知到的差异基本都集中在这里。5.1 视频预加载让用户感觉零等待预加载可以分成两个层面。第一个层面是页面级预加载这个通过setOffscreenPageLimit已经实现了。它保证用户滑到下一页时页面布局与ViewHolder已经创建完成。第二个层面是数据级预加载。也就是在用户还在看第N个视频时已经把第N1个视频的数据缓冲到本地。GsyVideoPlayer提供了setUp时的cachePath参数实现的就是这个逻辑。我建议在业务层做一个简单的预加载机制当onPageSelected触发时除了当前视频再调用一个预加载器去加载接下来1-2个视频的视频字节。做法通常是用一个后台的Runnable或协程去请求视频的URL让它走一遍缓存逻辑或者直接把下载好的字节码放到播放器的缓存目录里。实际验证下来这个预加载的逻辑对体感提升非常明显。不做预加载时用户可能会在快速滑动时看到1秒左右的loading转圈做了预加载后基本可以做到衔接零等待。代价是消耗了这部分视频的流量对于接入WiFi的场景或者流量充足的情况下这个取舍是非常值得的。5.2 画面流畅度布局层级的优化GsyVideoPlayer的布局本身足够精简但它的内部ControlPanel在初始时是GONE的状态如果使用不当会带来一点性能损失。这里有两个习惯在bindData时立刻把控制面板相关的View设置为GONE避免它抢占不必要的测量布局工作。给ViewPager2的item根布局设置android:clipChildrenfalse和android:clipToPaddingfalse可以减少滑动时某些设备上出现的边缘裁剪问题。另外如果把封面加载这部分做精细还有一个思路封面图不要加载原图而是加载一个低分辨率的模糊图作占位等视频加载出第一帧后再替换。GsyVideoPlayer提供了getLeftProgressDrawable等接口控制进度条样式但针对封面这一层用Glide的thumbnail(float)参数加载一个小比例的图比加载全尺寸封面再裁剪要流畅非常多。5.3 内存抖动与OOM风险每页一个播放器实例如果播放器内部使用SurfaceView那么每个页面还会持有一个独立的Surface这部分内存开销是可观的。我在8GB运存的测试机上观察过打开5个页面时播放器相关的Surface内存占用大概在200-300MB之间这个数字在小内存设备上会有OOM风险。降低内存占用的几个方案将setOffscreenPageLimit从2改为1。大多数场景下1的预加载已经足够覆盖快速滑动的需求内存能省下来一截。在onPageSelected监听中主动回收非当前页和邻近页的视频Surface。这个需要访问播放器的渲染ViewGsyVideoPlayer提供了onVideoReset()调用后可以释放Surface资源但这会导致该页面下次绑定数据时重新初始化会稍微增加一点系统开销。实测下来这个方案对内存的改善非常明显推荐在低内存设备上启用。观察LeakCanary的日志确保在页面切换或销毁后没有播放器和View持有Activity的引用导致泄漏。LeakCanary对这个项目的检测结果大多数泄漏都是因为GsyVideoPlayer的实例里持有了View和Context而GC没能及时回收。解决方案是确保在列表不可见时调用release而不是只调用pause。5.4 替换视频源时的状态重置在非全屏视频流场景下一个页面可能在短时间内多次绑定不同的数据比如加载更多后某个位置的视频被替换。每次bindData时都要先调用一次播放器的重置。gsyVideoPlayer.onVideoReset(); gsyVideoPlayer.setUp(newUrl, false, null);onVideoReset会清理掉播放器的内部状态包括清除上一次播放的画面、重置进度和状态回调。如果漏掉这一步你可能会看到画面残留或者播放进度错乱的情况。6. 关于SurfaceView与TextureView的渲染模式选择关于GsyVideoPlayer的渲染模式值得单独说一段。它默认使用TextureView因为TextureView支持硬件加速、支持截图、支持调节透明度这些特性在普通视频播放里很实用。但在列表快速滑动的场景下TextureView有个天生的缺点它必须通过硬件层的SurfaceTexture来渲染画面在页面切换过程中如果SurfaceTexture没有及时更新就会出现花屏或绿屏现象。SurfaceView则不同它有一个独立的窗口渲染优先级更高在部分设备上滚动流畅度反而比TextureView好。但是SurfaceView也有自己的麻烦它不能做旋转、缩放等View级别的动画因为它不在View树中走常规的绘制流程。所以我最终的方案是在低版本设备上比如Android 8以下使用TextureView在Android 8以上使用SurfaceView。通过GsyVideoPlayer的setSurfaceType来做开关。这个配置既规避了低版本设备上SurfaceView的兼容性问题也在主流设备上获得了更好的滑动体验。7. 实际项目中的业务扩展从视频流到直播间这个项目做完后产品又提出了一个需求在视频流里偶尔插入一个直播间的卡片点击后进入直播间页面。这里涉及到一个取舍直播间页面是复用这个视频流的容器还是用一个新的页面来做我的做法是直播间卡片同样作为ViewPager2的一个item但是它的资源消耗远大于普通视频页所以我给这个item单独定义了一个VIEW_TYPEAdapter返回不同的ViewType在onCreateViewHolder里创建不同的布局和ViewHolder。这样既保证了滑动到直播间时的流畅度页面已经预加载了又不会对普通的视频页造成任何性能影响。这个扩展思路可以灵活地套用到很多业务场景里例如广告卡片、图文详情卡片、搜索推荐卡片原理都是一样的通过多ViewType机制在单一的视频流容器里承载多种内容形态。8. 最后分享几个排查问题的思路整个项目做完下来我最大的体会是技术方案本身并不复杂真正考验人的是排查问题的思路是否清晰。这里整理几条我自己的排查习惯。定位播放问题时先确认是不是GsyVideoPlayer内部的问题再看是不是和ViewPager2的交互问题。区分方法很简单把播放器从ViewPager2里拿出来单独放在一个单页Activity里播放同一个视频如果正常说明问题出在容器与播放器的协作层面如果依然异常那基本可以确定是播放器本身或视频源的问题。这比直接看日志要快得多。关于ViewPager2的滑动位置判断千万别用getCurrentItem()去和onPageScrollStateChanged里的状态组合做复杂的运算。onPageSelected已经是精准的页面完全切换完成的回调了虽然它可能在某些边界场景下触发时机略有延后但配合onPageScrollStateChanged一起使用完全可以覆盖绝大部分业务。我在项目里就是用这两个回调互相配合来实现完整的状态机SCROLL_STATE_DRAGGING开始拖拽适合做清空当前播放器画面的操作。SCROLL_STATE_SETTLING松手后的惯性滑动阶段。SCROLL_STATE_IDLE静止状态此时onPageSelected一定已经触发了适合做播放新页面的操作。对了针对GsyVideoPlayer的宽高在item布局里我使用的是match_parent而不是wrap_content或者具体的dp值。如果你遇到画面变形或者上下有黑边的情况可以检查一下是不是宽高的配置问题。短视频App里视频默认是填充全屏的这种情况下用match_parent是合理的牺牲一点画面裁切来换取视觉上的沉浸感用户基本不会注意到。另外还有一个关于内存泄漏的排查工具推荐如果你的App在页面退出后GC日志里总是显示有GsyVideoPlayer相关的Activity引用没有释放优先检查是不是VideoPlayManager这个单例中还持有播放器的引用。单例最容易被忽略但它是最容易造成泄漏的。所以我在release方法里特意把currentPlayer置空就是为了打断这个引用链。本文还有配套的精品资源点击获取