ARTICLE DETAIL

资讯详情

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

仿抖音上下滑动切换视频完整实现:手势判定、预加载与播放器生命周期管理

仿抖音上下滑动切换视频完整实现:手势判定、预加载与播放器生命周期管理 简介面向 Android 开发者的仿抖音上下滑动切换视频完整工程基于 RecyclerView 与 SnapHelper、自定义 LayoutManager 实现全屏视频列表的浏览体验适合需要掌握列表复用、滑动吸附、播放器联动等进阶技巧的中高级开发人员。包内含 1486 个文件编译产物、依赖库与源码并存其中 dex/class 为构建结果jar 为第三方依赖xml/java 对应布局与业务代码png/so 则覆盖图标资源和底层库整体压缩包大小 58.73MB可直接导入 Android Studio 查看运行。项目重点展示了 PagerSnapHelper 在垂直滑动中的应用、自定义 LayoutManager 的 onLayoutChildren 重写思路以及 ExoPlayer 集成、异步视频加载、手势识别和 ItemAnimator 过渡动画等关键环节并包含性能优化与 DiffUtil 数据更新示例。目前已有 4844 人学习下载适合想深入理解抖音式交互实现细节的读者参考借鉴。1. 仿抖音上下滑动切换视频从手势识别到预加载的一次完整实现做短视频类应用时“上下滑动切换视频”是用户感知最强、却最容易做砸的一个交互点。抖音式滑动之所以顺并不只是因为动画调得快而是背后有一套“手势判定 页面二次确认 预加载 生命周期接管”的配合逻辑。如果你只是给 RecyclerView 开一个 LinearSnapHelper会发现滑动总是差半格、视频黑屏、切页卡顿、切走还在出声这些都能让用户一秒卸载App。这篇文章会把仿抖音上下滑动切换视频的完整方案拆开讲清楚从指关节离开屏幕前的判定到离屏视频的回收到预加载策略的参数怎么写再到列表复用导致的“换页播了上一页的声”这种玄学问题全链路给出可直接抄作业的代码和配置。这是一篇偏落地实战的笔记适合正在做短视频列表、直播卡片流、或者任何全屏翻页交互的移动端开发。默认用 Android RecyclerView 作为主载体但手势判定的思路和生命周期接管方案同样可以平移到 Flutter 或其他跨端方案上。看完后你能收获一套不依赖任何商业 SDK、纯自家代码就能跑起来的仿抖音上下滑动切换视频的最小实现以及这些实现背后为什么不能省。2. 先把抖音式滑动拆开TouchEvent 与 RecyclerView 的边界在哪里2.1 抖音式滑动不是普通列表滚动它隐含着“一次只能停在一页”抖音上下滑的本质是一个单列全屏的纵向列表需求端要求它“一页一页地停”。这和微博信息流那种自由滚动完全不同用户的每一次甩动要么翻到上一页要么翻到下一页停在两个视频中间的阻尼位置是不能接受的。但 RecyclerView 天然是“内容多高我就滚多远”的模型。你把它铺满全屏它也可以稳稳地停在两个 item 的中间——只是视觉上你会看到上下两个视频各露出一个边这就算翻车。所以第一步不是写代码而是确定交互协议一次滑动结束以后目标 position 怎么算、怎样允许越界回弹、怎样屏蔽多指同时滑动。这是整个仿抖音上下滑动切换视频项目的起点协议不定后面写再多也只是让翻车翻得更好看。处理这个问题的常见做法是自定义 LayoutManager而不是在默认 LinearLayoutManager 上打补丁。LinearLayoutManager 加 SnapHelper 确实能吸附到某个 position但 SnapHelper 只在滑动距离超过阈值时才触发快速连滑时很容易丢失目标而且它不支持动态控制每个 item 的高度——抖音是全屏但你后续如果要在顶栏和底栏之间做视差固定 item 高度就是给自己上铐。自定义 LayoutManager 可以完全接管 scroll 的每一次像素变化把“滑动到哪一页”变成你代码里一个确定的变量。2.2 先跑一个最小翻页 LayoutManager核心是 scrollToPosition 加二次确认我一般会先写一个极简的垂直分页 LayoutManager只做一件事让 RecyclerView 永远只能在整页位置停下来。它的核心逻辑听起来简单但细节很多。public class PagerLayoutManager extends RecyclerView.LayoutManager { private int currentPosition 0; private int pendingPosition -1; Override public RecyclerView.LayoutParams generateDefaultLayoutParams() { return new RecyclerView.LayoutParams( RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.MATCH_PARENT); } Override public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { if (getItemCount() 0) return; detachAndScrapAttachedViews(recycler); int offsetY 0; for (int i 0; i getItemCount(); i) { View view recycler.getViewForPosition(i); addView(view); measureChildWithMargins(view, 0, 0); int width getDecoratedMeasuredWidth(view); int height getDecoratedMeasuredHeight(view); layoutDecorated(view, 0, offsetY, width, offsetY height); offsetY height; } // 初始显示第一页 if (pendingPosition 0 pendingPosition getItemCount()) { currentPosition pendingPosition; pendingPosition -1; } scrollToPosition(currentPosition); } Override public boolean canScrollVertically() { return true; } Override public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { // 禁止自由滚动所有滚动交给手势确认后的 scrollToPositionWithOffset return 0; } public void smoothScrollToNextPage() { if (currentPosition getItemCount() - 1) { pendingPosition currentPosition 1; scrollToPositionWithOffset(pendingPosition, 0); currentPosition pendingPosition; } } public void smoothScrollToPrevPage() { if (currentPosition 0) { pendingPosition currentPosition - 1; scrollToPositionWithOffset(pendingPosition, 0); currentPosition pendingPosition; } } }关键就在这里scrollVerticallyBy 直接返回 0表示“我不允许普通的拖动滚动”。外部一切的滑动意图都要绕开它走暴露的两个翻页方法。这样设计的好处是翻页位置永远可控不会因为手势速度天然产生“差半截”的状态。但你也不可能完全屏蔽滚动否则滑动手势只是点到为止。正确的做法是外面监听 TouchEvent识别出用户意图需要翻页时调用 smoothScrollToNextPage 或 smoothScrollToPrevPage。scrollToPositionWithOffset 会触发 RecyclerView 的 SmoothScroller 动画滚动动画结束就刚好停在下一页顶边这个动画时长我一般设置在 280 到 350 毫秒之间小于 250 毫秒看起来太急超过 400 毫秒又显得拖沓。2.3 手势方向判定不能只看 ACTION_UP把速度矢量也带进来翻页的触发动作是用户手指在屏幕上往上或往下一甩然后离开屏幕。如果只看 ACTION_UP 时的位移差值会出现一种反直觉的结果用户明明想快速连续刷三条但因为第一条只划了 90 像素就抬手位移阈值未到页面纹丝不动。抖音的做法是把速度也算进去——即使位移不大只要 fling 速度够快同样认定为用户意图。下面这段处理方案把位移和速度同时纳入判定代码可以在任何一个 ViewGroup 中复用private float downY 0; private float lastY 0; private long downTime 0; private static final int SLIDE_DISTANCE_THRESHOLD 120; // dp 实际需转 px private static final int SLIDE_VELOCITY_THRESHOLD 1200; // px/s Override public boolean onTouchEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: downY event.getRawY(); lastY downY; downTime System.currentTimeMillis(); getParent().requestDisallowInterceptTouchEvent(true); return true; case MotionEvent.ACTION_MOVE: lastY event.getRawY(); return true; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: float deltaY lastY - downY; float durationMs System.currentTimeMillis() - downTime; float velocity Math.abs(deltaY) / (durationMs / 1000f 0.001f); boolean byDistance Math.abs(deltaY) dp2px(SLIDE_DISTANCE_THRESHOLD); boolean byVelocity velocity SLIDE_VELOCITY_THRESHOLD; if (deltaY 0 (byDistance || byVelocity)) { pagerLayoutManager.smoothScrollToNextPage(); } else if (deltaY 0 (byDistance || byVelocity)) { pagerLayoutManager.smoothScrollToPrevPage(); } return true; } return false; }这里有三个容易翻车的地方。第一个是 dp2px 转换阈值如果是 120dp在 2.75 倍屏的机器上实际是 330px直接用 120 做像素判断会发现小屏灵敏大屏迟钝。第二个是 requestDisallowInterceptTouchEvent 必须写否则父容器尤其是外层有纵向滚动的页面会拦截掉你的滑动事件导致手势根本到不了你手里。第三个是 ACTION_CANCEL 也要做重置否则手势中途被系统打断会以错误状态进入上一次的滑动分支。我都会在 cancel 分支加上一个 dropTouchVelocity 的标记保证状态干净。3. 让每个 VideoView 物尽其用预加载、缓存复用和离屏回收的三层设计3.1 滑动到一半就要开始预加载不能等完全停住才拉流仿抖音上下滑动切换视频项目里最影响“流畅感”的其实是网络层。用户从第 3 条滑向第 4 条手指还在屏幕上第 4 条的视频地址如果这时候才开始请求等手势结束、动画停稳、播放器启动少说要 600 到 1200 毫秒的白屏。这就是为什么“先切换、再加载”看起来代码逻辑正确但实际体验永远差一口气。正确做法是监听滑动位置——不用等 onPageSelected在布局管理器偏移量越过当前页一半的时候就判定“下一页是目标”然后立刻把下一页的播放地址传给预加载器。预加载器干两件事一是让 MediaPlayer 或 ExoPlayer 的实例先 prepare 到 prepared 状态不 start二是把图片帧封面缓存到 Glide 的 LruCache 里这样就算视频尚未 ready用户先看到覆盖在播放器上的封面图视觉上是“这张图早就有了”黑屏就变成了短暂的定格封面。public void onPageChange(int currentPosition, float percentChanged) { // percentChanged 0.5f 说明正在滑向下一页 if (percentChanged 0.5f) { int target currentPosition 1; if (target data.size()) { preloadVideo(target, data.get(target).videoUrl); } } else if (percentChanged -0.5f) { int target currentPosition - 1; if (target 0) { preloadVideo(target, data.get(target).videoUrl); } } }判断 percentChanged 的方法有很多我习惯在 PagerLayoutManager 里暴露一个滚动偏移监听把总共滚过的距离除以 item 高度就能得到浮点页位置。这样预加载触发时机可以精确到滑过了 30%、50% 还是 80%调参时只需要改一个阈值常量不用改业务逻辑。3.2 RecyclerView 复用导致的半个视频ViewHolder 必须整体重置很多人会把一个 VideoView 放进 ViewHolder然后在 onBindViewHolder 里给不同的 item set 不同的播放地址。这在普通列表中没问题但在全屏视频列表里会踩一个大坑ViewHolder 滑出屏幕后会被入池下一个即将滑入屏幕的 item 复用了这个 ViewHolder但 ExoPlayer 的实例还是上一个视频的你会看到新的 item 短暂播放了几帧上一个视频的画面看起来就像“换页播了上一页的声”。这个问题的根治方式是 ViewHolder 不持有一个“始终存在的播放器”而是持有一个“播放器容器 FrameLayout”。onBindViewHolder 时把容器里的旧播放器彻底移除、释放或回收然后重新 addView 一个全新的播放器视图。public final class VideoViewHolder extends RecyclerView.ViewHolder { FrameLayout playerContainer; TextureView textureView; ImageView coverView; public void resetForNewItem() { // 移除并回收旧播放器 if (textureView ! null) { ((ViewGroup) textureView.getParent()).removeView(textureView); } if (player ! null) { player.stop(); player.release(); player null; } textureView null; coverView.setVisibility(View.VISIBLE); } }代价是播放器需要重建会有一点创建开销。但 ExoPlayer 创建实例的耗时通常在 10 毫秒量级相比黑屏感知来说几乎可以忽略。不要试图做播放器实例池来规避重建因为池里的播放器状态难以保证干净而且播放器持有 Surface 关联乱复用极易出现花屏和声画不同步。我还要强调另一个细节TextureView 不能复用。同一个 TextureView 被反复 attach/detach中高端机器上偶尔正常低端机上频繁出现黑屏。我的做法是每次 createPlayer 时连带 new 一个 TextureView用完之后一起丢。这个策略成本极低但能消灭大量“偶现黑屏”的玄学 bug。3.3 离屏三屏回收列表缓存区间的经验值与实测抖音式列表不是无限滚动的当前页、上一页、下一页一共三个位置有视频在良性状态当前页播放上一页暂停下一页预加载。而更远的 item 需要立刻释放内存否则滑动到第 50 条时内存里堆了十几个视频的缓冲数据OOM 只是时间问题。RecyclerView 本身有回收池但回收池只管 View不管播放器资源。所以你要在 LayoutManager 的 onLayoutChildren 或 scroll 回调里做额外的资源回收逻辑Override public void onScrollStateChanged(int state, int firstVisiblePos, int lastVisiblePos) { if (state ! RecyclerView.SCROLL_STATE_IDLE) return; for (int i 0; i getChildCount(); i) { int pos getChildLayoutPosition(getChildAt(i)); if (pos currentPosition - 1 || pos currentPosition 1) { // 超过三屏的item释放播放器资源 VideoViewHolder vh (VideoViewHolder) recyclerView .findContainingViewHolder(getChildAt(i)); if (vh ! null) { vh.resetForNewItem(); } } } }这里有一个取舍要不要保留四屏或五屏我观察下来保留三屏是“体验和内存平衡点”的经验值。只保留两屏当前页和下一帧预加载页内存最省但用户快速连滑三条时预加载来不及准备即将显示的第三条白屏率上升保留三屏则多留出一个缓冲位置代价是内存大约多占用一个视频缓冲短视频平均在 10 到 30MB 左右。如果你做直播列表三屏的内存开销会更大直播流缓冲比点播更凶猛我建议直播场景只保留两屏而且预加载不是预加载地址而是预加载播放器实例的 URL 绑定让用户点进去就秒连。4. 仿抖音上下滑动切换视频的播放器生命周期控制一页出声、离页闭嘴、回页续播4.1 当前页播放、上一页暂停、其他页停止状态机的三个状态抖音式滑动里最容易做错的是“暂停和停止不分”。从业务逻辑上上一页的视频是用户刚刚看过的页面虽然没有完全离开视觉范围但用户已经看不到了如果还让它在后台出声就干扰当前页的声音。所以离上一页必须暂停但如果完全停止、丢进度用户再次滑回来就得从头播放体验也不好。所以播放器应该维护一个三态状态机PLAYING、PAUSED、STOPPED。当前页是 PLAYING相邻页是 PAUSED保持播放进度和缓冲数据再远一点的页是 STOPPED释放掉 Surface 和大部分内存只保留播放地址和进度值。触发的核心代码并不复杂关键在于状态切换时要先判断当前状态避免多余的 seek、play、pause 调用因为 pause 一个已经 paused 的播放器在部分机型上会造成微小卡顿。public void updatePlayerState(int currentPos, RecyclerView recyclerView) { for (int i 0; i recyclerView.getChildCount(); i) { View child recyclerView.getChildAt(i); int pos recyclerView.getChildLayoutPosition(child); if (pos 0) continue; VideoViewHolder vh (VideoViewHolder) recyclerView.findContainingViewHolder(child); if (vh null) continue; if (pos currentPos) { vh.playIfReady(); } else if (pos currentPos - 1 || pos currentPos 1) { vh.pauseIfPlaying(); } else { vh.stopAndReleaseTexture(); } } }在此基础上还有一个必须处理的分支列表从后台切回前台。短视频 App 切后台再切回来抖音自己的行为是暂停且保留进度但很多仿写工程在这里不做处理导致后台播放器继续渲染回来时画面已经卡在后台的最后一帧声画严重不同步。我会在 Activity 的 onStop 里对所有播放器做 pause在 onRestart 里对当前页做 seekTo(currentPosition) 而不是重新 play这样既避免误报“还在播放”也保证回前台时画面不会跳帧。4.2 Surface 释放与重建避免切出去再切回来黑屏的关键播放器生命周期里最阴间的一个坑是 Surface 的释放时机。视频是渲染到 Surface 上的多数人会在离页时只做 pause而 Surface 仍然被 TextureView 持有。这个方案在大部分手机上没问题但在部分骁龙和天玑机型上长时间持有 Surface 会导致显存泄漏表现为切了几十页之后画面开始发绿、卡顿、甚至弹出“内存不足”的崩溃。解决这类问题的强烈建议离页超过三屏以后素材要真正释放的不只是播放器还有 TextureView 的 SurfaceTexture。释放 SurfaceTexture 的代码长这样private void releaseSurfaceTexture() { if (textureView ! null textureView.getSurfaceTexture() ! null) { textureView.getSurfaceTexture().release(); } textureView null; }但这里有个血泪教训SurfaceTexture.release() 调用后如果你再把它当参数传给 ExoPlayer 的 setSurface会得到 IllegalArgumentException。所以释放 Surface 与重建播放器的顺序必须是“先 removeView、再 release、最后重建”。顺序反了轻则黑屏一次重则 Crash而且 Crash 发生在 Native 层堆栈信息极其难定位。4.3 音频焦点的抢占多个播放实例共存时谁出声短视频列表里其实同时存在最多三四个播放器实例当前页、上页、下页预加载如果每个播放器都注册了 AudioManager 的音频焦点监听就会出现焦点被互相抢占的僵局。我的做法是只给当前页播放器设置 AUDIOFOCUS_GAIN相邻页在 pause 时主动 abandonAudioFocus。并且监听 onAudioFocusChange 回调时如果拿到的是 AUDIOFOCUS_LOSS_TRANSIENT被来电或语音短暂打断记住标志位等焦点回来时恢复播放如果拿到的是 AUDIOFOCUS_LOSS被其他 App 永久抢占当前页播放器要停下来并且清空 currentPosition 的进度标志防止焦点回来以后自动续播吓到用户。下面这段是专门处理焦点丢失的代码private AudioManager.OnAudioFocusChangeListener focusChangeListener new AudioManager.OnAudioFocusChangeListener() { Override public void onAudioFocusChange(int focusChange) { switch (focusChange) { case AudioManager.AUDIOFOCUS_LOSS: isFocusLostPermanent true; pausePlayer(); break; case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: isFocusLostPermanent false; pausePlayer(); break; case AudioManager.AUDIOFOCUS_GAIN: if (!isFocusLostPermanent shouldAutoResume) { resumePlayer(); } break; } } };这之中“shouldAutoResume”是一个业务开关如果用户在滑动过程中切到了系统设置再回来要不要继续播放取决于你在 onStop 时保存的状态。我倾向于用户手动滑走再回来就恢复但如果页面切到后台超过 5 分钟回来就不自动续播让用户看到封面帧点一下再播。这个细节是抖音真实存在的“防尴尬”逻辑仿写时照抄收益不小。5. 仿抖音上下滑动切换视频的疑难杂症避坑3个高频翻车场景定位5.1 快速连踹三下页面错位跳页被中间页打断现象用户从第 3 页快速往上甩三下预期停在 6但实际停在了 4 或 5页面位置出现偏差。原因smoothScrollToPosition 过程中再次调用 smoothScrollToPosition会产生两个动画目标竞争。RecyclerView 的 scroll 动画是叠加计算的第一次动画未结束时第二次动画会让总位移量变成两段之和如果资源和时机不对最终停下的位置就是两次动画的中间态。解决每次发起新的翻页动画前取消上一次动画并直接跳到当前目标页。代码如下public void scrollToPositionWithOffset(int position, int offset) { if (position 0 || position getItemCount()) return; if (currentPosition position) return; // 使用 jumpTo 清除残留动画 recyclerView.stopScroll(); recyclerView.smoothScrollToPosition(position); currentPosition position; // 恢复当前页播放器 updatePlayerState(position, recyclerView); }你需要把 updatePlayerState 放在动画启动的同时调而不是等 onScrollStateChanged 的 IDLE 再调——因为快速连滑时 IDLE 可能滞后几百毫秒这段时间里已经停到新位置了但播放器状态还停留在旧位置用户会看到上一页的播放器还在出声。这是一个“页面已经翻过去了但声音没翻过去”的高频 Bug。5.2 预加载的视频永远播不出来prepare 后没有 start却被回收了现象预加载了第 4 页视频而且 prepare 已经回调 onPrepared但滑到第 4 页时播放器直接 onError日志显示 MEDIA_ERROR_IO 或 ERROR_CODE_IO_UNSPECIFIED。原因预加载完成并不代表数据已经全部进入内存只是缓冲区达到播放阈值。如果你在 prepare 完成后把播放器停在一个“只 prepare 不 start”的状态长时间不动某些播放器内核特别是 ExoPlayer 的默认缓冲策略会超时释放缓冲数据。等真正滑到时再 start触发重新缓冲但网络抖动一下就直接 error。解决预加载的播放器在 prepare 完成后不要挂机而是调用 setVolume(0f) start()静音播放三秒、让它持续消费网络数据铺满缓冲区。等真正滑到时把音量恢复到 1 并 seekTo(0) 即可。这一步叫“暗流播放”是目前工业界最通用的视频秒开手段。但要注意“暗流播放”是有网络流量成本的三个预加载页如果全部暗流用户滑动十条新的视频就会额外消耗几十 MB 流量。我的做法是只对相邻下一页做暗流且固定只拉 3 秒缓冲超过 3 秒强制暂停这在 4G / 5G 与 Wi-Fi 环境下都能保证流量开销足够收敛。5.3 封面闪现后直接黑屏Glide 把图片加载进内存但播放器还没就绪现象滑动后封面先正常显示然后突然黑屏 0.5 秒再恢复播放。看上去像闪烁。原因封面显示逻辑用的是 Glide 的 onResourceReady 回调回调后把 ImageView visibility 设为 VISIBLE但播放器新建后是和你 Surface 的 TextureView等 Surface 帧渲染出来需要 100 到 300 毫秒。这段时间里封面被移除或覆盖了可视频第一帧还没上来于是露出黑底。解决不要依赖 ImageView 的 setVisibility 去关封面要用 ColorFilter 控制透明通道或者把封面 ImageView 一直放在播放器视图之上直到播放器第一帧渲染完成才 remove。第一帧渲染完成的标志是 TextureView 的 SurfaceTextureListener.onSurfaceTextureUpdated 回调触发。textureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() { Override public void onSurfaceTextureUpdated(SurfaceTexture surface) { if (isFirstFrameRendered) return; if (coverView ! null) { coverView.setVisibility(View.GONE); } isFirstFrameRendered true; } Override public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { // 播放器在这里绑定Surface } Override public void onSurfaceTextureDestroyed(SurfaceTexture surface) { return true; } });唯一要注意的是onSurfaceTextureUpdated 在每次视频帧更新时都会触发所以要加 isFirstFrameRendered 做去重只在首次触发时隐藏封面。如果你对内存敏感也可以在隐藏后立即 clear 掉封面图占用的 Bitmap 内存不过这会略微增加重新滑回时的恢复成本。6. 让滑动和播放彻底解耦页面切换完成后只做一件事当所有模块都工作之后最后要解决的是它们之间的协作顺序。我最终把仿抖音上下滑动切换视频的架构整理成一个简单的发布订阅模型布局管理器只负责滚动位置不管播放和预加载手势监听只负责解释用户意图不直接调用播放器播放器管理器只订阅“当前页变化”事件对应执行状态切换。三者各干各事通过一个 PageChangeDispatcher 串起来。这个结构的价值在后期维护中会越来越明显。你加一个点赞动效、加一个评论区半屏弹层、甚至改造成横屏全屏播放都不需要动滑动逻辑只需要新增一个订阅者。抖音自己也是这么迭代的不可能让列表和播放器耦合在一个类里。回看我自己做过的短视频需求最值得分享的一条经验是不要迷信网上流传的“万能方案”。滑动切换是 UI 层播放器是媒体层预加载是网络层三层各自有各自的坑必须分别防守。而三层之间最常见的积怨——滑快了内存暴涨、播视频时掉帧、切回来黑屏——根因基本都在边界处不在单层内部。把这层边界理清楚仿抖音上下滑动切换视频的体验就不会差到哪里去。希望这篇能帮你的 App 少踩几个我当年踩过的坑。本文还有配套的精品资源点击获取
返回列表