简介:面向Android应用开发者的仿抖音上下滑动切换视频完整工程资源,针对短视频App中垂直滑动翻页的核心交互场景,解决列表视频切换卡顿、滑动定位不准、播放器协同困难等常见问题,适合有一定Android基础、希望快速复刻抖音式浏览体验的开发者参考学习。资源共1486个文件,压缩包约58.73MB,以flat布局文件、dex字节码、class编译类、json配置、xml资源及jar依赖库为主,同时包含java源码、so动态库和mp4示例视频,整体为可直接参考的Android Studio工程结构。已有4845人学习下载。压缩包内完整呈现基于RecyclerView、SnapHelper与自定义LayoutManager的视频列表实现,涵盖ExoPlayer播放器集成、Glide异步加载、手势监听与DiffUtil数据对比等关键环节,并附有多类型资源文件与示例媒体,便于开发者对照源码理解每次滑动停稳一屏的布局计算与动画过渡逻辑,可直接迁移到自己的短视频类项目中。
1. 仿抖音上下滑动切换视频这个标题,看着是手势实现,实际是列表复用与播放器生命周期问题
把仿抖音上下滑动切换视频拆开看,真正难的不是“滑动”,而是“切换”。单页视频谁都会放,难在一只手连续滑 20 条、每一条都秒开不黑屏、快速滑不串台、切后台不炸内存。很多新手拿到需求先去做 LinearLayoutManager 加 ScrollListener,最后发现要么卡在播放器释放上,要么卡在 SnapHelper 和 fling 速度打架。这篇文章会用 Android 常见的 RecyclerView + PagerSnapHelper + Media3 这套组合,把上下滑整页切换从选型到避坑完整讲一遍,适合已经会写 RecyclerView 基础 adapter、但没做过全屏视频列表的开发者照着落地。
2. 整页滑动的技术选型:RecyclerView + SnapHelper 为什么比 ViewPager2 更值得优先考虑
2.1 三种实现路径对比:不用写太多代码,但选错后面全是坑
做仿抖音上下滑动切换视频,业内常见的做法有三条:
| 实现路径 | 滑动对齐能力 | 列表复用 | 播放器生命周期控制 | 落地代价 |
|---|---|---|---|---|
| RecyclerView + PagerSnapHelper | 滑动停止后自动吸附整页 | RecyclerView 原生复用 | 通过 ViewHolder attach/detach 精确控制 | 低,适合绝大多数场景 |
| ViewPager2 | 自带整页滑动,内部也是 RecyclerView | 内部复用,可配置 offscreenPageLimit | 需要自己接管 Fragment 或 View 状态 | 低,但二次定制受限 |
| 自绘 LayoutManager | 对齐逻辑可完全自定义 | 需要自己写回收和复用 | 自由度最高 | 高,只适合有性能洁癖的团队 |
ViewPager2 表面看最适合,它连 PagerAdapter 都帮你组织好了。但如果你长期做短视频信息流,会发现 ViewPager2 有两个地方拧巴:第一,它强制用 PageAdapter 的层级,视频页里的复杂 UI 一旦要参与复用,配置成本比 RecyclerView 直接写 ViewHolder 更高。第二,ViewPager2 对当前页的前后预加载策略更偏向 ViewPager 传统场景,不是为“快速连续滑动短视频”优化的。
所以我一般第一版方案会直接选 RecyclerView + PagerSnapHelper。RecyclerView 的 Item 复用、setRecycledViewPool、prefetch 这些都是现成的,我们能拿到 layoutManager 的实时位置,也能在 ViewHolder attach 到窗口时精确绑定播放器。后期如果要改成 2 列、或者加无限循环,这套底座不用动。
2.2 为什么我优先选择 RecyclerView:控制粒度决定排查难度
先说清楚 PagerSnapHelper 的原理,它做两件事:滑动结束后找离目标最近的 View,然后让列表平移到那个 View 的起始位置。这里“起始位置”很关键,和 LinearSnapHelper 默认对齐中心不一样,PagerSnapHelper 对齐的是页面的开始边,也就是第一个 item 的 top 坐标。
因此每个 item 的宽高必须严格等于 RecyclerView 的宽高。这句话是仿抖音上下滑的第一条铁律。如果 item 比屏幕矮 1 个像素,SnapHelper 就能在两次滑动之间找到一个“既可以吸上来又可以吸下去”的死角,表现出来就是视频页老停不到正中间。
基础接入是这样:
// 核心配置,一般在 Activity/Fragment 初始化时执行 val layoutManager = LinearLayoutManager(this) layoutManager.orientation = RecyclerView.VERTICAL recyclerPager.layoutManager = layoutManager recyclerPager.setHasFixedSize(true) recyclerPager.itemAnimator = null val pagerSnapHelper = PagerSnapHelper() pagerSnapHelper.attachToRecyclerView(recyclerPager)解释一下这段代码里的逻辑。setHasFixedSize(true)告诉 RecyclerView item 尺寸不会因为内容变化而重算,视频页的宽高由屏幕决定,开启后能省掉多余测量。itemAnimator = null是为了避免滑动吸回来时 item 自身出现位移动画,视频场景下任何轻微位移动画都会被用户看成卡顿。attachToRecyclerView是把 Snapper 挂上去,挂完的 RecyclerView 滑出去一松手,会自动找最近页对齐。
2.3 需要先摸清的 5 个参数:itemViewCacheSize、cluster 区、prefetch 和 fling 速度
参数不对,手感完全不一样。我每次接这需求都会先把下面几个参数调到一个保守值,再看真机表现。
// 调整缓存与预取,保证快速滑动时页面不闪白 recyclerPager.setItemViewCacheSize(3) layoutManager.isItemPrefetchEnabled = true layoutManager.setInitialPrefetchItemCount(2) val pool = RecyclerView.RecycledViewPool() pool.setMaxRecycledViews(0, 3) recyclerPager.setRecycledViewPool(pool)setItemViewCacheSize(3)是让 RecyclerView 额外缓存 3 个最近的 item,共 5 个;短视频一屏带前后预加载,5 个内存量很合理。setInitialPrefetchItemCount(2)是给 LayoutManager 预取相邻 item 用的,快速滑动时 RecyclerView 会提前创建 2 个 ViewHolder。RecycledViewPool控制的是 ViewHolder 被回收后仍可复用的数量,我推荐放到 3,不要无限放大,否则播放器实例和封面大图都会堆在内存里。
调完这些你还没法判断“切到哪了”。需要加一个日志监听,把滑动停止后的真正吸附位置打印出来:
recyclerPager.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { if (newState == RecyclerView.SCROLL_STATE_IDLE) { val lm = recyclerView.layoutManager as LinearLayoutManager val snapView = pagerSnapHelper.findSnapView(lm) val position = snapView?.let { lm.getPosition(it) } ?: lm.findFirstVisibleItemPosition() Log.d("SnapCheck", "idle 后停在第 $position 页") } } })这段代码用了findSnapView,它能拿到 SnapHelper 认定的吸附 View 而不是当前第一个可见 View,更接近用户真实看到的页面。真机上跑几轮,如果 position 不是连续变化,或者每次都要跳页,重点查 item 测量高度是否和列表一致。
3. 最小可运行版本落地:布局、ViewHolder 和播放器绑定
3.1 先搭一个全屏 item 布局,封面层和播放器层分离
仿抖音上下滑的 item 布局原则是“播放器垫底、封面盖在播放器上”。首帧视频还没就绪时,封面先给用户一个画面,避免黑屏。资源上要有两层,一层 TextureView 或 PlayerView,一层 ImageView,再叠一层文字信息。
<!-- item_play_page.xml --> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.media3.ui.PlayerView android:id="@+id/playerView" android:layout_width="match_parent" android:layout_height="match_parent" /> <ImageView android:id="@+id/coverIv" android:layout_width="match_parent" android:layout_height="match_parent" android:contentDescription="@string/cover" /> <TextView android:id="@+id/titleTv" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="start|bottom" android:padding="12dp" android:textSize="14sp" android:textColor="@android:color/white" /> </FrameLayout>最大坑点:不要在这个布局里给 ImageView 设置 background 或者 scaleType 之外的复杂效果,比如阴影、圆角、渐变遮罩,这些叠加 View 越多,RecyclerView 在快速滑动时就越吃 GPU 合成性能。封面层要的是“一次性便宜绘制”,不是视觉精致。
3.2 数据模型与 Adapter:onBindViewHolder 里不要碰播放器
视频源就用一个最简单的数据类代表:
data class VideoItem( val videoUrl: String?, // 视频路径,实际接入时由网络层下发 val coverUrl: String?, // 封面路径,列表首帧显示用 val title: String )Adapter 的核心是 ViewHolder。我习惯只做轻绑定,把重量级操作留给窗口可见逻辑。
class SlidePageAdapter( private val items: List<VideoItem> ) : RecyclerView.Adapter<SlidePageAdapter.VideoVH>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VideoVH { val binding = ItemPlayPageBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return VideoVH(binding) } override fun onBindViewHolder(holder: VideoVH, position: Int) { val item = items[position] holder.bindCover(item) // 故意不在 bind 里创建/启动播放器,等 attach 到窗口再处理 } override fun getItemCount(): Int = items.size }这里必须解释一个重要逻辑:RecyclerView 的onBindViewHolder会在布局阶段被提前调用,prefetch 机制会让它提前 30 到 50ms 创建下一页 ViewHolder。如果你在这个回调里启动播放器,用户还没真正滑过去,播放器就已经在预读视频,而且预读时机不可控。等真正滑到时,播放器要么已经播放了你好几秒,要么因为持锁失败报错。
正确的做法是把播放器启动放到onViewAttachedToWindow,这个回调才代表 ViewHolder 真正可见。代码加在 Adapter 内:
class VideoVH(private val binding: ItemPlayPageBinding) : RecyclerView.ViewHolder(binding.root) { fun bindCover(item: VideoItem) { binding.titleTv.text = item.title binding.coverIv.setImageURI(item.coverUrl) } fun attachPlayer(player: ExoPlayer) { binding.playerView.player = player binding.coverIv.alpha = 0.75f // 视频首帧出来后由播放器回调把封面盖掉 player.playWhenReady = true } fun detachPlayer() { binding.playerView.player = null } }PlayerView.player = player这行不是简单地“塞进去”,PlayerView 会自己监听 player 的 surface 变化、状态变化,并把画面绘制到 Surface 上。把 player 置回 null 时,PlayerView 会主动释放 Surface 引用,避免上一页画面残留在当前 Surface 上。
3.3 用 attach/detach 控制 ExoPlayer 的启动与暂停
完整的开关逻辑应该放在 Adapter 的窗口回调里:
override fun onViewAttachedToWindow(holder: VideoVH) { super.onViewAttachedToWindow(holder) val position = holder.bindingAdapterPosition if (position == RecyclerView.NO_POSITION || position >= items.size) return val item = items[position] val player = SimplePlayerPool.acquire(item.videoUrl) holder.attachPlayer(player) } override fun onViewDetachedFromWindow(holder: VideoVH) { super.onViewDetachedFromWindow(holder) holder.detachPlayer() SimplePlayerPool.releaseCurrent() }这段写出来你就能看懂,它把播放器生命周期和“是否可见”强绑定。滑走的那一帧,detach就把 player 和 PlayerView 切离;滑入的那一帧,attach再获取或新建播放器。这样不会出现“视频播放到下一页才响”或者“上一页还在放声音”的问题。
有个参数要注意:bindingAdapterPosition在快速滑动时可能是NO_POSITION,因为临时绑定还没定稿。所以必须判空,否则你会拿到一个不在数据范围内的 position,播放器会绑定到错误的数据 url 上,表现就是串台。
4. 仿抖音上下滑的避坑指南:五个真实踩过的问题
4.1 切换后画面还是上一页的最后一帧
现象:滑动到下一页后,新视频画面已经播放,但屏幕角落仍然残留上一页最后一帧,甚至能看到上一条视频的文字信息。
原因:PlayerView 的 Surface 在页面滑出时没有被 Clear。ViewHolder 被复用后,Surface 仍旧绑在旧 player 上,TextureView 的 buffer 不会自动清空,于是旧画面留在屏幕上。
解决:在onViewDetachedFromWindow里必须先把binding.playerView.player = null,让 Surface 和旧播放器解绑;同时把binding.coverIv重新设置为当前 item 的封面,用封面覆盖旧帧。很多团队的“视频串台”其实不是数据串,是 TextureView 历史帧没清理。
4.2 快速滑动四五页后播放器突然崩溃
现象:快速连滑时偶发崩溃,日志里出现Media3 ... IllegalStateException: Video in another surface或Decoder init failed。
原因:短视频滑动速度很快时,ViewHolder 会被频繁回收复用,同一个播放器可能在一秒内被 attach/detach 多次。Media3 对同一个 ExoPlayer 同时绑定两个 Surface 是直接拒绝的,操作稍快,前一个 player 还没来得及 release,第二个 ViewHolder 又把新 Surface 塞进去了。
解决:播放器一定不能 new 一个绑定一个,必须做池化。池子里同一时间维护两个播放器实例轮换,一个正在播放,一个预加载。切换时先 detach 旧 player 的 Surface,再让新 player 去绑 Surface,两件事之间做小间隔,不要在同一帧里同时执行。可以用Handler.post把挂载动作挪到下一帧。
4.3 首帧黑屏 300ms,用户感觉像卡住
现象:滑动切换后视频从开始播放到真正出来画面需要大约 0.3 秒,这段时间屏幕是黑的。
原因:videourl 拉流需要时间,解码器初始化也需要时间。封面图和视频首帧之间没有衔接,用户看到的就是黑屏。
解决:分两段做。先把封面上来,封面加载用常规图片库,但一定要在 Adapter bind 时就加载好;等到 ExoPlayer 首帧渲染回调触发后,再把封面透明度动画降到 0。Media3 里监听Player.STATE_BUFFERING和onVideoSizeChanged,能拿到首帧是否已渲染的信号。黑屏本身不可怕,可怕的是黑屏前面没有过渡。
4.4 松手后列表回弹又跳出去一页
现象:手指松掉后,页面已经停在视频 A,但不到一秒又自动滚到视频 B 或回到视频 B 的一半位置。
原因:Fl 时 PagerSnapHelper 的 fling 速度和 RecyclerView 自带惯性速度打架。如果 item 高度与列表高度不一致,SnapHelper 的吸附结果就会在两个 item 边缘来回震荡。
解决:先检查 item 根布局是否match_parent。如果 matches 没问题,则添加一个被动监听,在 SCROLL_STATE_IDLE 后再做一次微调:
recyclerPager.post { val lm = recyclerPager.layoutManager as LinearLayoutManager pagerSnapHelper.findSnapView(lm)?.let { snapView -> val position = lm.getPosition(snapView) val distance = pagerSnapHelper.calculateDistanceToFinalSnap(lm, snapView) distance?.let { if (it[1] != 0 || it[0] != 0) { recyclerPager.smoothScrollBy(it[0], it[1]) } } } }这段会在列表静止后再算一次吸附距离,如果不为零就做一次无动画滚动补位。多数回弹问题在这一步就解决了。
4.5 页面切后台后声音还在放
现象:Home 键退到后台,视频还在响。
原因:ExoPlayer 的playWhenReady一直为 true,Activity onPause 没调用 player.pause。
解决:在 Activity/Fragment 的onPause里统一暂停所有 player,onResume里再恢复当前页播放。不要在每次onViewAttachedToWindow里都设playWhenReady = true,它会覆盖掉后台暂停状态。我的习惯是引入一个AutoPlayController,只允许它来改 playWhenReady。
5. 预加载与播放器池:把“看不到的视频”也管理起来
5.1 前后各预加载一页,不要贪多
很多团队做仿抖音上下滑时会把预加载页数调到 3 页以上,结果内存暴涨。短视频一页全屏视频,多预加载一条,等于多一个解码器、多一份 Surface 队列。真实场景里用户从第 2 页滑到第 10 页,每一页都需要能立刻播放,但我们不可能把 10 页都驻留。
我用的预加载范围是:当前页的前一页与后一页,最多 2 个预备 player。也就是核心区域 3 页:正在播放的 player + 上一页的播放器(暂停)+ 下一页的播放器(暂停)。
RecyclerView.setItemViewCacheSize(3)只能解决 ViewHolder 复用,解决不了播放器的加载。所以要单独建一个播放器池:
object SimplePlayerPool { private val idlePlayers = ArrayDeque<ExoPlayer>() private var maxIdle = 2 fun acquire(context: Context, videoUrl: String?): ExoPlayer { if (videoUrl.isNullOrBlank()) { // 没有视频地址时返回空 player 只显示封面,避免崩溃 return ExoPlayer.Builder(context).build() } val player = idlePlayers.removeFirstOrNull() ?: ExoPlayer.Builder(context).build() player.setMediaItem(MediaItem.fromUri(Uri.parse(videoUrl))) player.prepare() return player } fun recycle(player: ExoPlayer) { player.playWhenReady = false player.stop() if (idlePlayers.size < maxIdle) { idlePlayers.addLast(player) } else { player.release() } } }acquire里的prepare()是关键,它让播放器开始缓冲数据,但画面不渲染。这样用户滑到下一页时,播放器已经在内存里准备好了,只需要playWhenReady = true就能出声出画面。
5.2 当前页和下一页的播放器状态怎么协调
我一般会在 ScrollListener 里把newState为 SCROLL_STATE_DRAGGING 或 SCROLL_STATE_SETTLING 时,把当前可见页的播放器暂停,然后等 SCROLL_STATE_IDLE 时再决定哪一页播放。
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { if (newState == RecyclerView.SCROLL_STATE_IDLE) { val snapPos = ... autoPlay(snapPos) } else { pauseAll() } }注意,不要让onBindViewHolder去调用 pauseAll。RecyclerView 的 bind 顺序不等于可见顺序,它可能在滑动过程中先 bind 了第二页,然后又 bind 回第一页,结果播放状态全乱掉。所有播放判定都应以 SCROLL_STATE_IDLE 为最终信号源。
表格里简单记录这两者的区别:
| 控制点 | 时机 | 作用 |
|---|---|---|
| onBindViewHolder | 列表布局阶段 | 只绑定封面与文本数据 |
| onViewAttachedToWindow | ViewHolder 可见 | 获取 player,挂 Surface |
| onScrollStateChanged(DRAGGING/SETTLING) | 正在滑动 | 暂停所有 player,避免滞音 |
| onScrollStateChanged(IDLE) | 滑动完全停止 | 计算目标页并播放 |
5.3 播放器复用的三个内存临界点
第一条:ExoPlayer.Builder(context)里的 context 必须用 ApplicationContext,不要用 Activity。Activity 被销毁后,如果播放器还持有 Activity 引用,整个页面逃不掉内存泄漏。
第二条:PlayerView不要每个 ViewHolder 都 new,用 inflate 后的缓存 View。PlayerView 内部持有 TextureView 或 SurfaceView,SurfaceView 数量太多会耗尽系统 surface 配额。
第三条:封面 ImageView 加载网络图必须走统一图片库的磁盘缓存。如果视频封面是同样 URL 的图,同一张图每页拿一个实例没问题,但千万别用setImageURI直接加载网络 URI,它不走缓存,会瞬间打崩 IO 线程。
如果你遇到一页明显比其他页卡,先看是不是这一页封面图特别大。把封面压成宽 1080 的内存图,视频首帧出来之后直接把 ImageView 释放给null,能省下约 6MB 一级内存。
6. 提高切换跟手度的一个实用技巧:不要用位置判断播放,用吸附距离判断
很多仿抖音上下滑项目做到最后,最大的体验问题不是播放器,而是“滑过一多半了但依然播放上一页”。只依赖findFirstVisibleItemPosition判断当前页不够细腻,因为第一项可见不代表用户已经完成了翻页。
我的方案是在 SCROLL_STATE_IDLE 后用calculateDistanceToFinalSnap测量吸附间距。当吸附距离小于屏幕高度的 1/3 时,把当前停留页作为目标播放页;超过 1/3 时再往下一页补一次滚动,让列表自动滑完剩余距离。
val snapView = pagerSnapHelper.findSnapView(lm) ?: return val distance = pagerSnapHelper.calculateDistanceToFinalSnap(lm, snapView) ?: return val targetPos = lm.getPosition(snapView) if (abs(distance[1]) > screenHeight / 3 && distance[1] < 0) { // 滑过三分之一但没吸附,应该继续往下一页滚 recyclerPager.smoothScrollBy(0, -distance[1]) } else { autoPlay(targetPos) }这里的distance[1]是垂直方向的剩余吸附距离。用这个阈值切分,比直接依赖位置号靠谱得多,尤其在真机横竖屏切换、比例差异大的场景下。
验证方法也简单,开开发者选项里的 GPU 呈现模式分析,把列表快速上下滑 20 轮,看柱状图里高度超过红标的帧占比。如果每次都是切页那两三帧超框,说明资源抢占发生在播放器启动那一帧;只要这部分占比不超过 5%,体验就是可接受的。
我最早做这个需求时没管阈值,只盯着 position,结果总是“滑到一半就自动播放”,用户投诉就像误触。后来把吸附距离当成唯一的切换信号,问题当天就清了。一条经验:短视频切换的播放判定,宁可等列表完全停下,也不要在一个半中间的滚动状态里猜目标页。希望帮到你。
本文还有配套的精品资源,点击获取