十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android仿抖音视频播放:GsyVideoPlayer+ViewPager2实现解析

Android仿抖音视频播放:GsyVideoPlayer+ViewPager2实现解析 简介本资源是一个完整的Android仿抖音视频浏览应用实战项目面向具备基础Android开发能力的中高级学习者解决短视频类App中垂直滑动翻页与高效视频播放的核心实现难题。项目基于GsyVideoPlayer实现流畅视频解码、全屏控制与生命周期管理结合ViewPager2定制垂直滑动方向完美模拟抖音上下刷视频的交互体验并集成数据绑定、预加载策略及异常处理等工程化细节。压缩包共792个文件包含107个XML布局与配置文件、120个JSON数据样例、179个Flat资源文件、43个PNG图标及5个MP4演示视频整体大小81.91MB结构清晰模块划分明确便于逐层理解视频容器嵌套、适配器设计与性能优化要点。目前已有2595人学习下载提供可直接运行的完整工程代码、详细注释及典型场景实现如双击全屏、滑动调节亮度/音量是掌握Android短视频UI架构与第三方播放器深度集成的优质实践案例。 大家平时刷抖音看那种上下滑动的沉浸式短视频信息流是不是觉得挺流畅的其实这种交互在Android里做起来并没有想象中那么玄乎。我最近在一个视频社交类App里就落地了一个类似的功能模块标题就叫“android仿抖音视频播放 基于GsyVideoPlayerViewPager2”今天就把整个实现过程、踩过的坑、还有性能优化思路一次性整理出来分享给有同样需求的朋友。这篇文章适合谁看呢如果你正在用ViewPager2做竖向滑动列表或者想在项目里集成GsyVideoPlayer做视频播放再或者你需要一个“既能保证流畅度、又不用重复造轮子”的播放列表方案这篇文章就是给你准备的。我会从最底层的需求拆解开始把技术选型的逻辑、每个关键代码的意图、以及线上才可能遇到的坑都讲清楚。1. 需求拆解与技术选型逻辑1.1 抖音式视频流的真实需求是什么“仿抖音”这三个字看着简单但把它拆成具体的功能点其实是完整的一套交互体系页面是垂直方向全屏显示的手指上下滑动可以切换不同视频。滑动过程中的过渡动画要跟手不能有卡顿。滑动停止后当前页的视频自动开始播放离开当前页后自动暂停。每个视频页面有类似抖音的UI元素比如左侧的头像、右侧的点赞评论分享按钮底部有用户昵称和视频描述。视频列表是动态加载的可能来自网络接口可能来自本地但最终都要以“列表”的形式无限滑动。核心痛点其实就两个一个是竖向滑动手势的流畅度另一个是视频播放实例的管理。如果把这两个问题拆开看前者是“列表滑动”问题后者是“播放器复用”问题。两个问题都有经典的解决方案但组合在一起就会产生一系列新的坑。比如滑动太快时播放器来不及释放比如旧播放器播放的声音还没停、新的页面已经滑过来了再比如ViewHolder被回收后播放器状态怎么保存。1.2 为什么用ViewPager2而不是RecyclerView直接做很多人第一反应是竖向滑动信息流用RecyclerView LinearLayoutManager然后设置reverseLayout不就行了确实可以RecyclerView是万能列表抖音列表本质上也是个列表。但“仿抖音”这种交互有几个特殊点更适合ViewPager2第一ViewPager2内部是基于RecyclerView实现的但它天然支持“页面切换”的概念。它有一个onPageSelected回调恰好对应“当前正在看哪一个页面”这和抖音里“当前播放哪一条视频”的对应关系是天然吻合的。如果你自己用RecyclerView需要自己计算当前居中Item还要处理fling状态来判定是否稳定下来这套逻辑虽然不算难写但容易出边界bug。第二ViewPager2有现成的预加载机制你设置setOffscreenPageLimit它就会在当前页前后多创建几个页面这样滑过去的时候不白屏。拿抖音来说预加载1到2个页面非常关键否则滑到下一屏视频才开始缓冲体验会大打折扣。第三ViewPager2有完整的生命周期感知。它的FragmentAdapter可以保证每个页面是独立的视图页面切换时能触发对应的生命周期回调或者如果你不用Fragment也可以用RecyclerView.Adapter但页面切换的语义就是ViewPager2的核心能力。相比之下如果用RecyclerView LinearSnapHelper强行对齐会有很多“页面是否停稳”的判断代码而且SnapHelper只是辅助对齐它并没有“当前页切换”的封装一切都得自己做。所以从工程效率、稳定性和代码可维护性来看ViewPager2是最合适的选择。1.3 GsyVideoPlayer到底解决了什么问题GsyVideoPlayer是一个开源的Android视频播放器框架它底层封装了IJKPlayer、ExoPlayer和系统MediaPlayer三种解码核心对外提供了一套高度统一的API。换句话说你用同一套代码可以在不同设备、不同Android版本上获得一致的基础播放体验。它有几个特别适合列表场景的特性支持播放器View的动态替换也就是说列表里可以只放一个播放器控件然后动态换数据源。支持多种视频格式基于IJKPlayer时对RTSP、RTMP这类流媒体协议也有不错的兼容性。自带缓存、倍速、清晰度切换、重力感应、列表播放器管理ListVideoUtil或ScrollDirectionDetector等能力。对播放器的生命周期管理做了封装比如onResume恢复播放、onPause暂停播放、onDestroy释放资源这些可以直接和Activity/Fragment生命周期绑定。简单说GsyVideoPlayer把“播放器内核的适配”“播放控制条UI”“手势处理”这些都做完了我需要做的就是把它嵌入到ViewPager2的页面体系里并把“当前播放页”的管理逻辑写好。我用它的核心原因倒不是因为它功能有多全而是因为它对“列表场景”有专门的支持GsyVideoPlayer的StandardGSYVideoPlayer或你自己的CustomVideoView可以通过设置playPosition来标记自己属于列表中的哪一项这样在列表回收复用时就不会串场。这个特性对仿抖音列表来说特别关键因为它能帮我避免“播放器被另一个Item拿去用了结果画面错乱”的经典问题。2. 核心功能实现与关键代码解析2.1 ViewPager2竖向适配与预加载ViewPager2默认是横向滑动的要改成抖音那种竖向滑动只需要一行代码viewPager2.setOrientation(ViewPager2.ORIENTATION_VERTICAL);这个API内部会自动调整RecyclerView的布局方向不需要额外配置LayoutManager。然后是预加载。ViewPager2的setOffscreenPageLimit默认值是-1表示用默认的懒加载模式即只会加载当前页。对视频流来说这肯定不够因为滑动到下一页再加载视频源缓冲等待时间会很致命。我一般设置为viewPager2.setOffscreenPageLimit(2);设置为2之后当前页的前后各两个页面都会被提前创建这样滑动过程中目标页面已经存在画面不会白屏。但要注意预加载页面会创建View并执行onBindViewHolder所以你的数据加载和播放器初始化逻辑要做得轻量不能在onBindViewHolder里做太多耗时操作。还有一个细节ViewPager2内部复用了RecyclerView滑动过程中ViewHolder会被回收再绑定每次绑定都会执行onBindViewHolder如果你的视频封面加载直接走网络会导致反复加载。所以我一般会把封面URL做成强引用在ViewHolder内部做图片加载去重或者直接用一个加载完成的位图来覆盖网络加载。2.2 GsyVideoPlayer的初始化与列表复用我在实战中用的播放器模式是“列表只有一个Player实例”还是“每页一个Player实例”这两者各有优劣。如果每页一个Player实例代码简单每个页面的状态独立切换不互扰但内存开销大因为视频播放器本身有Surface、解码器等重量级对象几十个页面全创建出来会OOM。如果全局只有一个Player实例内存最省但是需要维护“当前处于播放的Item位置”滑动切换时要将旧位置解绑、新位置绑定对应抖音这种场景其实很合适。GsyVideoPlayer官方本身就推荐这种模式它提供了ListVideoUtil类的封装但我们自己用ViewPager2实现时我更喜欢直接管理因为业务侧往往需要自定义更多行为。我最终的方案是这样的核心是一个自定义的StandardGSYVideoPlayer子类下面叫VideoPlayerView。这个实例放在Activity/Fragment的布局中位置在ViewPager2之上全屏铺满。ViewPager2的每个Item只是一个FrameLayout容器里面放置该视频的信息UI封面、标题、按钮等播放器的画面则直接统一由全局的那一个VideoPlayerView来显示。当ViewPager2滑到某个Item时通过回调拿到该Item的容器FrameLayout把VideoPlayerViewadd进或attach到这个容器里。这个方案的优点是不管ViewPager2预加载了多少页面播放器永远只有一个内存占用极低而且VideoPlayerView始终全局存在状态不会因为ViewHolder回收而丢失。缺点是需要精细管理attach和detach的时机否则会出现“播放器画面闪现、黑屏、错位”的问题。关于GsyVideoPlayer的初始化关键代码大致是这样的videoPlayerView new VideoPlayerView(this); videoPlayerView.setUp(videoUrl, true, null); videoPlayerView.setLooping(true); // 抖音视频一般循环播放 videoPlayerView.setIsTouchWiget(false); // 列表场景不需要触摸手势 videoPlayerView.getBackButton().setVisibility(View.GONE); // 隐藏返回键 videoPlayerView.setPlayPosition(currentPosition); videoPlayerView.startPlayLogic();注意setPlayPosition这个方法。它标记当前播放器工作在第几个Item位置当同一个播放器实例被复用到另一个Item时通过它来判断是否需要重新初始化。虽然这个方法是给内部ListVideo管理用的但我们在手动管理时也能用来做调试和状态判断。每个Item的容器布局我定义得非常简单FrameLayout android:idid/item_container android:layout_widthmatch_parent android:layout_heightmatch_parent/整个Item的UI结构是这样的item_container视频承载区iv_cover封面图加载中或播放前显示iv_avatar左侧头像ll_like右侧点赞区点赞、评论、分享tv_title底部标题tv_desc底部描述这些UI直接覆盖在播放器画面之上层级上播放器在最底层信息UI在顶层。2.3 滑动播放逻辑的闭环设计确定了“一个播放器实例”的方案后核心就是“当前页切换时干什么”。ViewPager2的注册回调方式是viewPager2.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { Override public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {} Override public void onPageSelected(int position) { // 切换播放 playAtPosition(position); } Override public void onPageScrollStateChanged(int state) {} });我的playAtPosition逻辑是这样的private void playAtPosition(int position) { VideoItem item videoList.get(position); // 找到当前Item的容器 RecyclerView.ViewHolder holder recyclerView.findViewHolderForAdapterPosition(position); if (holder ! null holder instanceof VideoViewHolder) { FrameLayout container ((VideoViewHolder) holder).itemContainer; // 如果播放器父View不是当前容器则重新添加 if (videoPlayerView.getParent() ! container) { if (videoPlayerView.getParent() ! null) { ((ViewGroup) videoPlayerView.getParent()).removeView(videoPlayerView); } container.addView(videoPlayerView, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); } // 如果播放地址变化则重新setUp String url item.videoUrl; if (!url.equals(currentPlayUrl)) { videoPlayerView.setUp(url, true, null); videoPlayerView.startPlayLogic(); currentPlayUrl url; } else { videoPlayerView.startPlayLogic(); // 已加载过则继续播放 } } }这里有一个很重要的点ViewPager2在滑动过程中会多次回调onPageSelected只有目标页面稳定后我们才真正开始播放。我是在onPageSelected里直接处理无需判断state因为onPageSelected本身就是页面切换完成的回调。但onPageSelected触发时当前页的容器可能还在动画中所以添加播放器到容器后最好延迟几十毫秒再startPlayLogic我通常用一个轻量的Handler.postDelayed200ms左右确保播放器的SurfaceHolder已经attach完成。“越界播放”问题也需要处理当播放器实例正在某个Item里播放用户快速连续滑动了好几页那么旧页面应该先将播放器detach再让新页面attach。这个场景下我的做法是在onPageScrollStateChanged里监听state如果state ViewPager2.SCROLL_STATE_DRAGGING就立刻暂停播放注意不是release只是onVideoPause这样滑动过程中不会有声音或者残留画面等onPageSelected确定新页面后再恢复播放。暂停而不是release是因为release会释放解码器破坏播放进度和资源导致每次切换都要重新初始化播放器卡顿明显。GsyVideoPlayer提供了onVideoPause和onVideoResume两个方法作用就是在不释放实例的情况下临时暂停和恢复非常适合这个场景。3. 完整工程实现流程3.1 工程依赖与基础配置如果是从零开始我建议直接用Android Studio新建一个空Activity项目然后在build.gradle里添加依赖以较新的稳定版本为准但我不会写死具体版本号大家根据自己工程来// ViewPager2 implementation androidx.viewpager2:viewpager2:1.0.0 // GsyVideoPlayer implementation com.shuyu:gsyVideoPlayer-java:8.x.x // 如果要用IJKPlayer内核再加 implementation com.shuyu:gsyVideoPlayer-ex_so:8.x.x // 图片加载我用的是Glide implementation com.github.bumptech.glide:glide:4.x.x需要注意GsyVideoPlayer有国内Maven和JitPack仓库之分有些版本在阿里云镜像仓库能直接拉取有些需要配置JitPack。我一般会在allprojects的repositories里同时加上mavenCentral和JitPack避免拉不到依赖。3.2 核心代码数据模型、适配器与Activity数据模型非常简单就是一个视频列表项public class VideoItem { public String videoUrl; // 视频地址 public String coverUrl; // 封面地址 public String title; // 标题 public String desc; // 描述 public Long id; }适配器方面我使用的是RecyclerView.Adapter而不是FragmentStateAdapter。原因是我这个播放方案里Item本身只是容器和UI信息不需要独立的生命周期管理用普通Adapter代码量更少。public class VideoPagerAdapter extends RecyclerView.AdapterVideoViewHolder { private ListVideoItem data; private Context context; public VideoPagerAdapter(Context context, ListVideoItem data) { this.context context; this.data data; } 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) { VideoItem item data.get(position); // 封面 Glide.with(context).load(item.coverUrl).into(holder.ivCover); holder.tvTitle.setText(item.title); holder.tvDesc.setText(item.desc); holder.bindData(item); } Override public int getItemCount() { return data null ? 0 : data.size(); } }注意onBindViewHolder里我只做UI绑定不涉及播放器操作。播放器操作统一走Activity的playAtPosition这样职责单一不容易写乱。ViewHolder里有一个成员变量itemContainer因为播放器要attach进来所以必须留着容器的引用。Activity的核心代码其实不长public class VideoFeedActivity extends AppCompatActivity { private ViewPager2 viewPager2; private VideoPagerAdapter adapter; private VideoPlayerView videoPlayerView; private ListVideoItem videoList new ArrayList(); private String currentPlayUrl ; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_video_feed); videoPlayerView new VideoPlayerView(this); viewPager2 findViewById(R.id.viewPager2); adapter new VideoPagerAdapter(this, videoList); viewPager2.setAdapter(adapter); viewPager2.setOrientation(ViewPager2.ORIENTATION_VERTICAL); viewPager2.setOffscreenPageLimit(2); loadData(); // 模拟网络请求填充videoList adapter.notifyDataSetChanged(); viewPager2.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { Override public void onPageSelected(int position) { super.onPageSelected(position); playAtPosition(position); } }); } Override protected void onPause() { super.onPause(); if (videoPlayerView ! null) { videoPlayerView.onVideoPause(); } } Override protected void onResume() { super.onResume(); if (videoPlayerView ! null) { videoPlayerView.onVideoResume(); } } Override protected void onDestroy() { super.onDestroy(); if (videoPlayerView ! null) { videoPlayerView.release(); } } }这段代码看起来简单但其实已经包含了完整功能ViewPager2竖向配置、全局播放器实例、滑动切换时播放对应视频、生命周期暂停/恢复。3.3 关键细节封面处理、循环播放、防串场抖音的视频一般都会循环播放所以我在创建VideoPlayerView时直接setLooping(true)。同时为了让封面在视频加载中不至于白屏我这里用了一个很实用的技巧在onPageScrolled里根据positionOffset判断如果用户正在滑向某个页面先显示该页面的封面图播放器画面由旧的播放器保留。这样视觉上会有一种“渐变过渡”的感觉不会突然闪白或者闪黑。GsyVideoPlayer的VideoPlayerView本身有设置封面的能力setThumbImageView但它更多是针对单个播放器场景。我们这种全局播放器管理封面会一直展示在Item的容器里播放器attach后把封面盖住。这样有个好处当播放器离开当前Item封面又会露出来视觉上比较平滑不会看到奇怪的黑色背景。防串场的核心在于播放器只能属于一个容器。旧容器removeView时新容器addView时要确保parent始终正确。我遇到过的问题就是removeView还没执行就addView到新容器导致崩溃“The specified child already has a parent”。所以每次都要严格按顺序来if (videoPlayerView.getParent() ! null) { ((ViewGroup) videoPlayerView.getParent()).removeView(videoPlayerView); } container.addView(videoPlayerView, ...);这段代码我会在前面加一个判断如果当前playPosition就是新位置且parent就是目标容器则什么都不做避免重复执行导致闪屏。4. 性能优化与常见问题排查4.1 性能优化内存、卡顿与网络这一块是真正影响“仿抖音”体验的地方。我踩过的坑和优化手段归纳起来有下面几点。内存优化上首先要限制ViewPager2的预加载页面数。2个是甜点多了浪费内存少了容易白屏。另外Item里只保留必要View不要有复杂的嵌套布局。封面图压缩到合适的尺寸我用Glide的override指定了屏幕宽度和高度的一半减少Bitmap内存占用。卡顿优化上最核心的还是“播放器初始化不能卡UI线程”。GsyVideoPlayer的播放器内核初始化和数据源prepare是异步的但如果你第一次创建VideoPlayerView就走网络视频地址它内部还是会解码器初始化和prepare耗时会略长。所以我在进入Activity时提前初始化VideoPlayerView并且把第一个视频地址setUp进去不release等第一页attach后直接startPlayLogic这样首屏就几乎没有等待时间。另外RecyclerView的adapter绑定要轻量不要在onBindViewHolder里做inflate、创建对象、网络请求等耗时操作一切UI绑定都是setText和Glide图片加载。网络优化上视频地址最好使用带鉴权或预热的CDN地址并且GsyVideoPlayer内部有缓存机制VideoPlayerManager或GSYVideoType缓存到本地后可以大大减少下次加载的时间。列表接口返回的数据如果能带上视频的宽高比和时长也能帮助提前计算封面和布局大小减少页面抖动。4.2 常见问题速查表直接上表格大家遇到问题可以直接对照。现象原因解决方案播放器画面闪黑/闪白播放器view被remove和add时造成Surface重建用全局单例播放器只在需要时attachattach前设置setVisibility(GONE)再VISIBLE数据未变时复用播放器快速滑动时声音残留滑动过程中未暂停播放器在onPageScrollStateChanged中监听SCROLL_STATE_DRAGGING立即onVideoPause切换到某页不自动播放ViewHolder被回收播放器attach失败通过findViewHolderForAdapterPosition获取容器如果holder为空用延迟重试播放器不在当前页但画面占住Item播放器释放/移除时机错误在onPageSelected的新页面逻辑中先remove旧parent再add新容器旧页封面需要重新加载显示内存不断增长最终OOMViewPager2预加载页面过多或有泄漏检查是否把Activity/Fragment持有在Adapter中建议用ApplicationContext加载图片在onDestroy中释放播放器视频加载慢首屏长时间封面网络差或者未预加载配合预加载列表数据播放器提前setUp横竖屏切换后播放器黑屏屏幕旋转导致Activity重建播放器状态丢失禁用屏幕旋转固定竖屏或使用GsyVideoPlayer的状态保存机制4.3 独家实战心得我在这套方案上踩过比较狠的一个坑是ViewPager2和全局播放器配合时的“首帧等待”问题。第一次滑动到第二页时新的视频地址setUp然后startPlayLogic但此时Surface可能还没有完成渲染用户会看到至少200到300毫秒的黑屏或封面。抖音那边看起来几乎没有等待是因为他们有非常复杂的预加载和首帧优化体系。我们项目在资源有限的情况下怎么做到尽量平滑呢我的做法是在setUp之前先去拿视频的封面图设置一个延时点击的loading样式并且在setUp的imageView上放一个透明的加载转圈动画。然后调用startPlayLogic播放器进入缓冲状态时我们在VideoPlayerView的onPrepared回调里关闭loading。这样视觉上用户永远看到的是封面图加loading转圈而不是黑屏或白屏。第二点心得是当视频流数据量很大时一定要支持“加载更多”。抖音的列表是无限刷的我这里的ViewPager2也可以做无限滑动当滑动到倒数第2个Item时去请求下一页数据并追加到videoList然后notifyItemRangeInserted。要注意的是追加数据时当前的播放位置保持不动所以不会导致播放中断。我用的是ScrollCallback监听来判断位置但有个小坑notifyItemRangeInserted会重新触发onPageSelected可能导致播放器误认为页面切换了所以我在PlayController里加了一个isAppending标志追加数据期间忽略onPageSelected回调。第三点心得是关于播放器实例的扩展。如果你需要支持点赞、评论等业务操作不要把这些操作监听写在播放器View里而是写在ViewHolder里这样可以保证UI的响应不依赖播放器的状态避免播放器卡顿时评论按钮点不了。如果你后续要做更多自定义比如加个模糊滤镜、加个转场动画GsyVideoPlayer也留了很多自定义接口Canvas、Renderer这些都能替换。但在一开始我建议先把播放列表的骨架做好播放器单例、ViewPager2竖向、生命周期绑定、封面过渡、加载状态这五个点打通之后这个模块就已经能直接上线了。5. 后续扩展方向这个方案虽然是以“仿抖音”为例但核心思想是完全通用的。你换成小红书那种双列瀑布流视频或者换成普通的Feed视频流思路都是一样的一个全局播放器实例 自适应容器管理 滑动状态驱动播放。我个人实际操作中的体会是很多项目把视频播放做得复杂其实不是视频播放本身复杂而是播放器和列表的生命周期没有解耦。GsyVideoPlayer ViewPager2的组合刚好能把这两个关注点分开——GsyVideoPlayer负责播放ViewPager2负责滑动和页面语义我只需要写很薄的一层胶水代码就能得到一个性能不错、代码也不难维护的视频流组件。如果还要往下做我建议可以从这几个方向切入预加载队列提前拉取即将滑到的下一个视频的首帧甚至可以提前解码到SurfaceTexture不过那是比较深的水了。播放器状态持久化退出App后记录用户上次看到第几条视频下次进来直接定位到那条。动态切换清晰度在播放器中监听网络类型WiFi下自动切高清流量下切流畅。多分辨率适配针对刘海屏、挖孔屏、折叠屏做安全区适配让视频画面真正全屏。这些小功能每一项单独拿出来都可以写一篇很长的文章但基础打好了之后这些都是水到渠成的事。最后再分享一个小技巧调试这种竖向滑动视频流时一定要用真机别用模拟器模拟器的Surface渲染和触摸滑动差异非常大很多问题在模拟器上根本复现不出来真机上才能看到真实效果。本文还有配套的精品资源点击获取
返回列表