YCBlogs Android 仿抖音滑动分页视频实战:ViewPager 与 RecyclerView 双实现方案与性能优化

发布时间:2026/10/10 5:43:36
YCBlogs Android 仿抖音滑动分页视频实战:ViewPager 与 RecyclerView 双实现方案与性能优化
教程技术博客文档【免费下载链接】YCBlogs技术博客笔记大汇总包括Java基础线程并发数据结构Android技术博客等等常用设计模式常见的算法网络协议知识点部分flutter笔记还包括平时开发中遇到的bug汇总当然也在工作之余收集了大量的面试题长期更新维护并且修正持续完善……开源的文件是markdown格式的转载请注明出处谢谢项目地址https://gitcode.com/gh_mirrors/yc/YCBlogs点击查看免费下载本文是 YCBlogs 开源仓库中「仿抖音滑动分页视频」方案的完整技术解读围绕竖直方向一次滑动一页、滑动超过 1/2 翻页、视频随页面切换初始化和销毁这三个核心诉求给出 ViewPager 与 RecyclerView 两套可落地的实现路径。读完本文你将掌握自定义竖向 ViewPager 的坐标交换原理、RecyclerView PagerSnapHelper 的翻页监听体系、以及从滑动阈值、滑动速度到视频资源释放、翻页卡顿与黑屏等一连串线上级优化手段。01. 需求拆解一次滑动一页的视频列表要满足哪些约束在动手写代码之前先把需求翻译成可量化的技术约束。原文档中提出的核心诉求如下竖直方向一次滑动一页滑动效果对齐抖音短视频纵向滑动、单页停靠且手动触摸滑动超过 1/2 时松开可以滑动到下一页没有超过 1/2 则回弹到原页。视频播放状态与页面强绑定手指拖动页面时只要没有切换到其他页面当前视频保持播放一旦切换页面上一个视频销毁新页面开始初始化并播放。过渡效果自然切换页面时避免闪屏、跳帧保证视觉流畅。把这些诉求转成技术要点就是方向要「竖」、翻页要「停」、阈值要「半屏」、视频要「随页生灭」、动画要「顺」。这五个要点恰好分别对应了两套实现方案中不同的技术动作下面逐一展开。02. 方案选型ViewPager 与 RecyclerView 的对比2.1 ViewPager 方案能用但有致命短板ViewPager 自带的滑动效果可以完全满足「一次一页、带动画」的场景且支持 Fragment / View 两种 UI 绑定方式只需对布局和触摸事件稍作修改即可把横向改为竖向。原文档明确指出了该方案的三点不足没有复用是致命问题在onLayout方法中所有子 View 会被实例化并一字排开在布局上。当 item 数量很大时将是很大的性能浪费。可见性判断复杂很多人以为 Fragment 在onResume时就是可见的但 ViewPager 中的 Fragment 恰恰是反例——尤其是多个 ViewPager 嵌套时会同时有多个父 Fragment 和子 Fragment 处于onResume状态却只有其中一个真正可见。除非放弃 ViewPager 的预加载机制否则在曝光上报等场景需要同时判断onResumed、setUserVisibleHint、setOnPageChangeListener等多个条件。MediaPlayer 生命周期难控制早期按 item 位置去初始化/移除 SurfaceView 的思路存在 MediaPlayer 生命周期不好控制、容易内存泄漏以及连续三个 item 都是视频时来回滑动出现「上个视频最后一帧画面」残留的 bug。2.2 RecyclerView 方案更推荐的做法RecyclerView 天然具备四级缓存机制可参考 RecyclerView缓存原理 中的讲解item 复用、滑动效果都优于 ViewPager且事件冲突处理更友好——即使外层嵌套下拉刷新、上拉加载布局也不影响后期事件冲突处理。其实现思路为自定义一个继承LinearLayoutManager的 LayoutManager重写onScrollStateChanged拿到滑动状态借助PagerSnapHelper实现一次滑动切换一页十分方便在 Adapter 的onCreateViewHolder中初始化视频操作在onViewRecycled中销毁视频资源定义回调接口在滚动页面以及 attach / detach 时暴露初始化、页面销毁等方法给开发者。对比维度ViewPager 方案RecyclerView 方案滑动效果自带、满足场景更好配合 PagerSnapHelper 可精确一次一页item 复用无复用onLayout 全量排布四级缓存 ViewHolder 复用事件冲突嵌套时处理繁琐处理更友好可见性判断需叠加多个条件通过滑动状态与 attach/detach 即可精确感知推荐度数据量小、要求不高时可用线上短视频场景推荐03. ViewPager 方案实现自定义竖向 ViewPager Fragment3.1 自定义 VerticalViewPager坐标交换与 PageTransformer把横向 ViewPager 改成竖向核心是「交换 X/Y 轴」。原文档给出的VerticalViewPager代码如下/** * pre * author 杨充 * time : 2019/6/20 * desc : 自定义ViewPager主要是处理边界极端情况 * /pre */ public class VerticalViewPager extends ViewPager { private boolean isVertical false; private long mRecentTouchTime; public VerticalViewPager(NonNull Context context) { super(context); } public VerticalViewPager(NonNull Context context, Nullable AttributeSet attrs) { super(context, attrs); } private void init() { setPageTransformer(true, new HorizontalVerticalPageTransformer()); setOverScrollMode(OVER_SCROLL_NEVER); } public boolean isVertical() { return isVertical; } public void setVertical(boolean vertical) { isVertical vertical; init(); } private class HorizontalVerticalPageTransformer implements PageTransformer { private static final float MIN_SCALE 0.25f; Override public void transformPage(NonNull View page, float position) { if (isVertical) { if (position -1) { page.setAlpha(0); } else if (position 1) { page.setAlpha(1); // Counteract the default slide transition float xPosition page.getWidth() * -position; page.setTranslationX(xPosition); //set Y position to swipe in from top float yPosition position * page.getHeight(); page.setTranslationY(yPosition); } else { page.setAlpha(0); } } else { int pageWidth page.getWidth(); if (position -1) { // [-Infinity,-1) // This page is way off-screen to the left. page.setAlpha(0); } else if (position 0) { // [-1,0] // Use the default slide transition when moving to the left page page.setAlpha(1); page.setTranslationX(0); page.setScaleX(1); page.setScaleY(1); } else if (position 1) { // (0,1] // Fade the page out. page.setAlpha(1 - position); // Counteract the default slide transition page.setTranslationX(pageWidth * -position); page.setTranslationY(0); // Scale the page down (between MIN_SCALE and 1) float scaleFactor MIN_SCALE (1 - MIN_SCALE) * (1 - Math.abs(position)); page.setScaleX(scaleFactor); page.setScaleY(scaleFactor); } else { // (1,Infinity] // This page is way off-screen to the right. page.setAlpha(0); } } } } /** * 交换x轴和y轴的移动距离 * param event 获取事件类型的封装类MotionEvent */ private MotionEvent swapXY(MotionEvent event) { //获取宽高 float width getWidth(); float height getHeight(); //将Y轴的移动距离转变成X轴的移动距离 float swappedX (event.getY() / height) * width; //将X轴的移动距离转变成Y轴的移动距离 float swappedY (event.getX() / width) * height; //重设event的位置 event.setLocation(swappedX, swappedY); return event; } Override public boolean onInterceptTouchEvent(MotionEvent ev) { mRecentTouchTime System.currentTimeMillis(); if (getCurrentItem() 0 getChildCount() 0) { return false; } if (isVertical) { boolean intercepted super.onInterceptTouchEvent(swapXY(ev)); swapXY(ev); // return touch coordinates to original reference frame for any child views return intercepted; } else { return super.onInterceptTouchEvent(ev); } } Override public boolean onTouchEvent(MotionEvent ev) { if (getCurrentItem() 0 getChildCount() 0) { return false; } if (isVertical) { return super.onTouchEvent(swapXY(ev)); } else { return super.onTouchEvent(ev); } } }几个关键点说明swapXY交换坐标把触摸事件的 Y 位移换算成 X 位移swappedX (event.getY() / height) * widthX 换算成 Y这样底层 ViewPager 仍然按横向逻辑处理但用户感知到的是竖向滑动。注意在onInterceptTouchEvent中先swapXY(ev)交给父类判断后再swapXY(ev)换回原始坐标系避免影响子 View 的坐标参考系。PageTransformer控制方向竖向模式下把position映射为 Y 轴位移yPosition position * page.getHeight()同时抵消默认的 X 轴平移动画xPosition page.getWidth() * -position反向设置从而实现上下翻页的过渡效果超出屏幕的页面直接setAlpha(0)隐藏保证边界状态不露馅。getCurrentItem() 0 getChildCount() 0的边界判断在首屏尚未填充任何子 View 时直接返回false避免空列表场景下触摸事件被误消费。3.2 ViewPager FragmentStatePagerAdapter Fragment 的配合方案整体采用ViewPager FragmentStatePagerAdapter Fragment的组合。选择FragmentStatePagerAdapter的核心原因是更省内存它会销毁不可见的 Fragment 而只保留其状态代价是销毁后重新创建需要时间。因此它适合「条目数量特别多」的展示场景如果页面少且要求页面常驻则FragmentPagerAdapter更合适。关于 PagerAdapter 两类实现的更多细节可参考仓库中的 PagerAdapter详细介绍。Activity 中初始化 ViewPager 的代码private void initViewPager() { ListVideo list new ArrayList(); ArrayListFragment fragments new ArrayList(); for (int a 0; a DataProvider.VideoPlayerList.length ; a){ Video video new Video(DataProvider.VideoPlayerTitle[a], 10,,DataProvider.VideoPlayerList[a]); list.add(video); fragments.add(VideoFragment.newInstant(DataProvider.VideoPlayerList[a])); } vp.setOffscreenPageLimit(1); vp.setCurrentItem(0); vp.setOrientation(DirectionalViewPager.VERTICAL); FragmentManager supportFragmentManager getSupportFragmentManager(); MyPagerAdapter myPagerAdapter new MyPagerAdapter(fragments, supportFragmentManager); vp.setAdapter(myPagerAdapter); } class MyPagerAdapter extends FragmentStatePagerAdapter{ private ArrayListFragment list; public MyPagerAdapter(ArrayListFragment list , FragmentManager fm){ super(fm); this.list list; } Override public Fragment getItem(int i) { return list.get(i); } Override public int getCount() { return list!null ? list.size() : 0; } }注意setOffscreenPageLimit(1)只保留相邻一页的 Fragment配合FragmentStatePagerAdapter的销毁回收机制在「连续多页都是视频」的场景下能显著降低内存占用。Fragment 中处理视频的初始化逻辑核心是把视频的播放生命周期收敛到 Fragment 生命周期内public class VideoFragment extends Fragment{ public VideoPlayer videoPlayer; private String url; private int index; Override public void onStop() { super.onStop(); VideoPlayerManager.instance().releaseVideoPlayer(); } public static Fragment newInstant(String url){ VideoFragment videoFragment new VideoFragment(); Bundle bundle new Bundle(); bundle.putString(url,url); videoFragment.setArguments(bundle); return videoFragment; } Override public void onCreate(Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); Bundle arguments getArguments(); if (arguments ! null) { url arguments.getString(url); } } Nullable Override public View onCreateView(NonNull LayoutInflater inflater, Nullable ViewGroup container, Nullable Bundle savedInstanceState) { View view inflater.inflate(R.layout.fragment_video, container, false); return view; } Override public void onViewCreated(NonNull View view, Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); videoPlayer view.findViewById(R.id.video_player); } Override public void onActivityCreated(Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); Log.d(初始化操作,------index); VideoPlayerController controller new VideoPlayerController(getActivity()); videoPlayer.setUp(url,null); videoPlayer.setPlayerType(ConstantKeys.IjkPlayerType.TYPE_IJK); videoPlayer.setController(controller); ImageUtils.loadImgByPicasso(getActivity(),, R.drawable.image_default,controller.imageView()); } }这里有几个实践要点通过newInstant(url)工厂方法 setArguments传参避免无参构造带来的状态恢复问题在onActivityCreated中完成setUp(url)、选择播放器类型IjkPlayer、绑定 Controller 并加载封面图封面由controller.imageView()持有onStop时统一走VideoPlayerManager.instance().releaseVideoPlayer()释放资源。针对「视频初始化完成前显示第一帧图片」的方案原文档提醒过直接拿视频首帧图做封面会出现首帧图与视频实际第一帧信息不符的情况需要通过代码比对校正或者干脆用默认占位图兜底。3.3 修改滑动距离阈值翻页determineTargetPage 的 0.4/0.6 分析需求要求「滑动超过 1/2 翻页未超过回弹」这对应 ViewPager 源码中determineTargetPage的逻辑。该方法在ACTION_UP时被调用用于计算接下来要滑到哪一页private int determineTargetPage(int currentPage, float pageOffset, int velocity, int deltaX) { int targetPage; if (Math.abs(deltaX) this.mFlingDistance Math.abs(velocity) this.mMinimumVelocity) { targetPage velocity 0 ? currentPage : currentPage 1; } else { float truncator currentPage this.mCurItem ? 0.4F : 0.6F; targetPage currentPage (int)(pageOffset truncator); } if (this.mItems.size() 0) { ViewPager.ItemInfo firstItem (ViewPager.ItemInfo)this.mItems.get(0); ViewPager.ItemInfo lastItem (ViewPager.ItemInfo)this.mItems.get(this.mItems.size() - 1); targetPage Math.max(firstItem.position, Math.min(targetPage, lastItem.position)); } return targetPage; }四个参数的含义currentPage当前 ViewPager 显示的页面pageOffset用户滑动的页面偏移量velocity滑动速率deltaXX 方向移动的距离。调试后发现翻页阈值的关键就在0.4f和0.6f这两个truncator值上0.6f代表「用户滑动到多少偏移量即可翻页」因此滑动半屏以上pageOffset 0.6 1就会切到下一页未过半则回弹。这也是为什么默认行为「必须滑动超过 1/2 才翻页」的原因。该方法同时具备「重定向」能力从第一页滑动但未达到翻页条件时会把 Page 重新定向回第一页。如果觉得反射改truncator麻烦也可以直接控制onTouchEvent与onInterceptTouchEvent中的触摸事件逻辑——这是从根源入手、基本万能的方式但代码量更大、更容易出错。3.4 修改滑动速度反射替换 mScroller手指滑动时速度由手势距离决定但通过setCurrentItem编程式翻页时页面会「唰」地直接闪过去。要让编程式翻页也带平滑动画需要控制滑动的持续时间。分析setCurrentItem源码会走到scrollToItem其中真正执行滑动的是mScroller.startScroll(sx, sy, dx, dy, duration)因此可以通过反射替换ViewPager的mScroller字段来定制滑动时长TargetApi(Build.VERSION_CODES.KITKAT) public void setAnimationDuration(final int during){ try { // viewPager平移动画事件 Field mField ViewPager.class.getDeclaredField(mScroller); mField.setAccessible(true); // 动画效果与ViewPager的一致 Interpolator interpolator new Interpolator() { Override public float getInterpolation(float t) { t - 1.0f; return t * t * t * t * t 1.0f; } }; Scroller mScroller new Scroller(getContext(),interpolator){ final int time 2000; Override public void startScroll(int x, int y, int dx, int dy, int duration) { // 如果手工滚动,则加速滚动 if (System.currentTimeMillis() - mRecentTouchTime time) { duration during; } else { duration / 2; } super.startScroll(x, y, dx, dy, duration); } Override public void startScroll(int x, int y, int dx, int dy) { super.startScroll(x, y, dx, dy,during); } }; mField.set(this, mScroller); } catch (NoSuchFieldException | IllegalAccessException | IllegalArgumentException e) { e.printStackTrace(); } }实现要点自定义Scroller时同时覆写两个startScroll重载保证setCurrentItem两种调用路径都能套用自定义时长通过mRecentTouchTime区分「手指触摸」与「编程触发」手指滑动时加快动画duration / 2编程式翻页时使用传入的during值五阶缓动t^5的插值器与 ViewPager 默认动画一致过渡更自然反射必须 try-catch捕获NoSuchFieldException、IllegalAccessException、IllegalArgumentException防止低版本系统字段差异导致崩溃。04. RecyclerView 方案实现自定义 LayoutManager PagerSnapHelper4.1 自定义 LayoutManager继承 LinearLayoutManager 绑定 PagerSnapHelper自定义 LayoutManager 直接继承LinearLayoutManager即可获得横/竖两种排向能力。要实现「一次滑动一页」借助PagerSnapHelper是最省事的方式——它与LinearSnapHelper同属于 SnapHelper 家族两者对比可参考 SnapHelper.mdLinearSnapHelper让列表 item 居中显示支持快速滑动多页常用于横向轮播/画廊PagerSnapHelper让 RecyclerView 像 ViewPager 一样每次只滑动一页同样以 item 居中对齐。在onAttachedToWindow中绑定 RecyclerView 与 PagerSnapHelper/** * 该方法必须调用 * param recyclerView recyclerView */ Override public void onAttachedToWindow(RecyclerView recyclerView) { if (recyclerView null) { throw new IllegalArgumentException(The attach RecycleView must not null!!); } super.onAttachedToWindow(recyclerView); this.mRecyclerView recyclerView; if (mPagerSnapHelpernull){ init(); } mPagerSnapHelper.attachToRecyclerView(mRecyclerView); mRecyclerView.addOnChildAttachStateChangeListener(mChildAttachStateChangeListener); }onChildViewAttachedToWindow在 item 被添加进 window 时回调先于draw执行适合做视频初始化等准备工作。4.2 滑动状态监听与 OnPagerListener 回调接口滑动翻页必然伴随视频的初始化与释放关键在于监听「停在哪一页」。重写onScrollStateChanged其中三种状态含义为SCROLL_STATE_IDLE空闲滑动停止SCROLL_STATE_DRAGGING手指拖动中SCROLL_STATE_SETTLING松手后向最终位置移动。我们需要的是「停止」时刻拿到当前 View 的 position。这里原文档特别提示通过getPosition拿到的 position 直接去取 item 可能报错这与 RecyclerView 的缓存机制有关详情见 RecyclerView缓存原理打印 Log 会发现getChildCount()常常为 1 或 2。因此更稳妥的做法是定义回调接口把状态与 position 传递给外部public interface OnPagerListener { /** * 初始化完成 */ void onInitComplete(); /** * 释放的监听 * param isNext 是否下一个 * param position 索引 */ void onPageRelease(boolean isNext,int position); /*** * 选中的监听以及判断是否滑动到底部 * param position 索引 * param isBottom 是否到了底部 */ void onPageSelected(int position,boolean isBottom); }重写LinearLayoutManager.onScrollStateChanged获取空闲时选中的 item/** * 滑动状态的改变 * 缓慢拖拽- SCROLL_STATE_DRAGGING * 快速滚动- SCROLL_STATE_SETTLING * 空闲状态- SCROLL_STATE_IDLE * param state 状态 */ Override public void onScrollStateChanged(int state) { switch (state) { case RecyclerView.SCROLL_STATE_IDLE: View viewIdle mPagerSnapHelper.findSnapView(this); int positionIdle 0; if (viewIdle ! null) { positionIdle getPosition(viewIdle); } if (mOnViewPagerListener ! null getChildCount() 1) { mOnViewPagerListener.onPageSelected(positionIdle, positionIdle getItemCount() - 1); } break; case RecyclerView.SCROLL_STATE_DRAGGING: View viewDrag mPagerSnapHelper.findSnapView(this); if (viewDrag ! null) { int positionDrag getPosition(viewDrag); } break; case RecyclerView.SCROLL_STATE_SETTLING: View viewSettling mPagerSnapHelper.findSnapView(this); if (viewSettling ! null) { int positionSettling getPosition(viewSettling); } break; default: break; } }关键点空闲态下通过mPagerSnapHelper.findSnapView(this)找到停靠的 view再用getPosition换算成 adapter positiononPageSelected(position, isBottom)的isBottom由positionIdle getItemCount() - 1得出用于「滑动到最后一页时拉取下一批数据」的分页业务findSnapView的返回值必须判空否则空列表或边界场景会崩溃详见 5.3。4.3 通过 scrollVerticallyBy / scrollHorizontallyBy 感知滚动方向scrollVerticallyBy与scrollHorizontallyBy可以拿到滑动偏移量据此判断滑动方向为 4.4 中「释放上一页还是下一页」提供依据/** * 监听竖直方向的相对偏移量 * param dy y轴滚动值 * param recycler recycler * param state state滚动状态 * return int值 */ Override public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { if (getChildCount() 0 || dy 0) { return 0; } this.mDrift dy; return super.scrollVerticallyBy(dy, recycler, state); } /** * 监听水平方向的相对偏移量 * param dx x轴滚动值 * param recycler recycler * param state state滚动状态 * return int值 */ Override public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) { if (getChildCount() 0 || dx 0) { return 0; } this.mDrift dx; return super.scrollHorizontallyBy(dx, recycler, state); }dy为正表示向下滚动内容上移、翻到下一页为负表示向上滚动翻回上一页把该值存入成员变量mDrift供 detach 时判断释放方向。4.4 attach / Detached 时机与视频资源释放滚动停止时选中页已经确定接下来要解决「什么时候释放、释放上一页还是下一页」。打印getChildCount()的日志会发现SCROLL_STATE_DRAGGING时为 1SCROLL_STATE_SETTLING时为 2SCROLL_STATE_IDLE有时为 1 有时为 2——这同样源于 RecyclerView 的缓存机制。要精确掌握释放时机应使用OnChildAttachStateChangeListenerprivate RecyclerView.OnChildAttachStateChangeListener mChildAttachStateChangeListener new RecyclerView.OnChildAttachStateChangeListener() { /** * 第一次进入界面的监听可以做初始化方面的操作 * param view view */ Override public void onChildViewAttachedToWindow(NonNull View view) { if (mOnViewPagerListener ! null getChildCount() 1) { mOnViewPagerListener.onInitComplete(); } } /** * 页面销毁的时候调用该方法可以做销毁方面的操作 * param view view */ Override public void onChildViewDetachedFromWindow(NonNull View view) { if (mDrift 0){ if (mOnViewPagerListener ! null) { mOnViewPagerListener.onPageRelease(true , getPosition(view)); } }else { if (mOnViewPagerListener ! null) { mOnViewPagerListener.onPageRelease(false , getPosition(view)); } } } };onChildViewAttachedToWindow第一次进入界面、首个 item 挂载完成时回调onInitComplete做视频初始化onChildViewDetachedFromWindow页面被移除item 滚出屏幕被回收时根据mDrift的正负判断是「向下翻页释放下一页」还是「向上翻页释放上一页」回调onPageRelease(isNext, position)。该监听需要在 attach 时注册、detach 时移除防止泄漏/** * attach到window窗口时该方法必须调用 * param recyclerView recyclerView */ Override public void onAttachedToWindow(RecyclerView recyclerView) { //这里省略部分代码 mRecyclerView.addOnChildAttachStateChangeListener(mChildAttachStateChangeListener); } /** * 销毁的时候调用该方法需要移除监听事件 * param view view * param recycler recycler */ Override public void onDetachedFromWindow(RecyclerView view, RecyclerView.Recycler recycler) { super.onDetachedFromWindow(view, recycler); if (mRecyclerView!null){ mRecyclerView.removeOnChildAttachStateChangeListener(mChildAttachStateChangeListener); } }在 Adapter 侧视频资源回收配合onViewRecycled完成item 滚出屏幕、ViewHolder 进入回收池时销毁视频资源实现「随页生灭」。05. 结合源码的优化点详谈5.1 ViewPager 滑动灵敏度反射修改 mFlingDistance 与 mMinimumVelocityViewPager 判断「快速滑动翻页」依赖两个阈值字段mFlingDistance滑动惯性默认 75与mMinimumVelocity最小滑动速度默认 1200。可以通过反射动态调整滑动灵敏度/** * 修改滑动灵敏度 * param flingDistance 滑动惯性默认是75 * param minimumVelocity 最小滑动值默认是1200 */ public void setScrollFling(int flingDistance , int minimumVelocity){ try { Field mFlingDistance ViewPager.class.getDeclaredField(mFlingDistance); mFlingDistance.setAccessible(true); Object o mFlingDistance.get(this); Log.d(setScrollFling,o.toString()); //默认值75 mFlingDistance.set(this, flingDistance); Field mMinimumVelocity ViewPager.class.getDeclaredField(mMinimumVelocity); mMinimumVelocity.setAccessible(true); Object o1 mMinimumVelocity.get(this); Log.d(setScrollFling,o1.toString()); //默认值1200 mMinimumVelocity.set(this,minimumVelocity); } catch (Exception e){ e.printStackTrace(); } }这两个字段直接参与determineTargetPage中「Fling 判定」分支只有当滑动距离超过mFlingDistance且速率超过mMinimumVelocity时才按速度方向强制翻页否则走 0.4/0.6 的偏移量阈值逻辑。因此调整二者可以改变「快速甩动翻页」的触发手感。同样反射操作必须 try-catch。5.2 PagerSnapHelper 的 OnFlingListener 重复设置异常PagerSnapHelper 使用中常见异常IllegalStateException: An instance of OnFlingListener already set.。结合仓库中 SnapHelper源码分析 的讲解可以看清根因public void attachToRecyclerView(Nullable RecyclerView recyclerView) throws IllegalStateException { if (mRecyclerView recyclerView) { return; // nothing to do } if (mRecyclerView ! null) { destroyCallbacks(); } mRecyclerView recyclerView; if (mRecyclerView ! null) { setupCallbacks(); mGravityScroller new Scroller(mRecyclerView.getContext(), new DecelerateInterpolator()); snapToTargetExistingView(); } }private void setupCallbacks() throws IllegalStateException { if (mRecyclerView.getOnFlingListener() ! null) { throw new IllegalStateException(An instance of OnFlingListener already set.); } // ... 设置滑动回调和 Fling 回调 }attachToRecyclerView会先destroyCallbacks()清理旧监听再通过setupCallbacks()设置新监听而setupCallbacks源码中直接判断「若OnFlingListener已存在则抛异常」。所以多次初始化比如页面重复 attach就会触发该 bug。修复方式是手动增强判断、捕获异常try { //attachToRecyclerView源码上的方法可能会抛出IllegalStateException异常这里手动捕获一下 RecyclerView.OnFlingListener onFlingListener mRecyclerView.getOnFlingListener(); //源码中判断了如果onFlingListener已经存在的话再次设置就直接抛出异常那么这里可以判断一下 if (onFlingListenernull){ mPagerSnapHelper.attachToRecyclerView(mRecyclerView); } } catch (IllegalStateException e){ e.printStackTrace(); }5.3 自定义 LayoutManager 的三个崩溃防范点线上 app 首要目标是降低崩溃率。原文档结合网上的自定义 LayoutManager 实现总结了三个必须注意的点findSnapView结果必须判空通过 SnapHelper 的findSnapView方法拿到的 view 可能为 null空列表、未布局完成等直接使用极易崩溃scrollVerticallyBy需增加getChildCount() 0判断子 View 为 0 时直接返回 0避免对空布局执行滚动逻辑onDetachedFromWindow移除监听在 detach 回调中把OnChildAttachStateChangeListener等监听事件 remove 掉避免重复注册与内存泄漏。5.4 视频播放逻辑前后台切换与返回键处理视频页的 Activity / Fragment 需要处理四个生命周期节点前台切后台暂停、后台切前台恢复、页面销毁释放、返回键退出全屏/小窗Override protected void onStop() { super.onStop(); //从前台切到后台当视频正在播放或者正在缓冲时调用该方法暂停视频 VideoPlayerManager.instance().suspendVideoPlayer(); } Override protected void onDestroy() { super.onDestroy(); //销毁页面释放内部的播放器被释放掉同时如果在全屏、小窗口模式下都会退出 VideoPlayerManager.instance().releaseVideoPlayer(); } Override public void onBackPressed() { //处理返回键逻辑如果是全屏则退出全屏如果是小窗口则退出小窗口 if (VideoPlayerManager.instance().onBackPressed()){ return; }else { //销毁页面 VideoPlayerManager.instance().releaseVideoPlayer(); } super.onBackPressed(); } Override protected void onRestart() { super.onRestart(); //从后台切换到前台当视频暂停时或者缓冲暂停时调用该方法重新开启视频播放 VideoPlayerManager.instance().resumeVideoPlayer(); }suspendVideoPlayer前台切后台时暂停正在播放或缓冲中的视频resumeVideoPlayer后台切回前台时恢复播放releaseVideoPlayer销毁页面时释放播放器同时退出全屏、小窗口模式onBackPressed优先处理全屏/小窗口退出处理成功则拦截返回键否则销毁页面。5.5 视频逻辑解耦与播放器单例翻页必然涉及视频的初始化与销毁首先要保证全屏同一时刻只有一个视频在播放——滑动到分页一半时不可能让两个页面同时播放。实现方式是让视频播放器VideoPlayer作为单例对象类似VideoPlayerManager.instance()的全局管理保证唯一性。其次无论 ViewPager 还是 RecyclerView页面不可见被销毁或 view 被回收时都必须销毁视频资源。因此建议把视频播放能力整体封装为独立模块初始化、播放、暂停、释放、封面设置等对外暴露方法页面在不同生命周期状态调用对应方法即可实现视频功能与列表/业务逻辑充分解耦。实际 app 中点赞、评论、分享、查看作者等业务接口以及滑动到最后一页拉取下一批视频数据的逻辑都应与视频播放模块隔离避免相互纠缠。5.6 翻页卡顿优化onBindViewHolder 轻量化、setHasFixedSize、分页拉取用 RecyclerView 实现滑动翻页时为提升流畅度可关注三点onBindViewHolder不做耗时操作ViewHolder 复用的核心机制可参考 RecyclerView问题汇总 中关于onCreateViewHolder/onBindViewHolder调用时机的分析决定了绑定阶段的高频性任何耗时操作都应移出绑定流程固定布局高度避免重复测量调用RecyclerView.setHasFixedSize(true)让 item 高度固定跳过 measure 阶段高度重算分页拉取数据建议一次拉取 10 条数据可与服务端协定数量而不是滑动一页加载一页避免频繁网络请求造成的滑动卡顿。5.7 快速翻页黑屏封面兜底「上拉很快翻页黑屏」是因为多数播放器初始化时背景色为黑色视频尚未就绪的瞬间露出黑底。最简单的解决办法是给播放器加一个封面把封面背景色/封面图铺在视频层之下如 3.2 中通过controller.imageView()设置占位图视频初始化完成前用户看到的是封面而不是黑屏从而消除闪屏感。06. 总结与选型建议回到最初的三个需求两套方案都能满足取舍如下ViewPager 方案实现成本低、动画自带但无复用、可见性判断复杂、MediaPlayer 生命周期难控制适合条目数量少、业务简单的场景需通过坐标交换、determineTargetPage阈值分析和反射替换mScroller来补齐「竖向 半屏翻页 可控速度」RecyclerView 方案复用机制完善、滑动流畅、事件冲突易处理配合PagerSnapHelper 自定义LinearLayoutManagerOnChildAttachStateChangeListener可以精确控制「初始化和释放的时机与方向」是线上短视频场景更推荐的方案。无论选择哪种方案5.x 的优化点都通用视频播放器单例保证唯一性、生命周期与页面状态严格绑定、反射操作必须 try-catch、findSnapView判空、封面兜底防黑屏。这些正是仿抖音滑动分页视频从「能跑」到「线上可用」的关键差距所在。扩展阅读仓库内相关文档SnapHelper.mdSnapHelper 家族Linear / Pager的定位与基础用法SnapHelper源码分析attachToRecyclerView、setupCallbacks、onFling等核心方法源码解析RecyclerView缓存原理理解getChildCount()为何常为 1 或 2、ViewHolder 四级缓存的底层机制RecyclerView问题汇总ViewHolder 复用、onCreateViewHolder/onBindViewHolder调用时机等高频问题PagerAdapter详细介绍FragmentPagerAdapter与FragmentStatePagerAdapter的取舍依据。赞分享教程技术博客文档【免费下载链接】YCBlogs技术博客笔记大汇总包括Java基础线程并发数据结构Android技术博客等等常用设计模式常见的算法网络协议知识点部分flutter笔记还包括平时开发中遇到的bug汇总当然也在工作之余收集了大量的面试题长期更新维护并且修正持续完善……开源的文件是markdown格式的转载请注明出处谢谢项目地址https://gitcode.com/gh_mirrors/yc/YCBlogs点击查看免费下载相关推荐YCBlogs Android 实战自定义 AppBarLayout.Behavior 彻底解决 CoordinatorLayout 滑动抖动YCBlogs Android 实战自定义 AppBarLayout.Behavior 彻底解决 CoordinatorLayout 滑动抖动 本文来自 YC教程技术博客文档YCBlogs 系列RecyclerView 拖拽排序与侧滑删除实战——ItemTouchHelper 原理与优化YCBlogs 系列RecyclerView 拖拽排序与侧滑删除实战——ItemTouchHelper 原理与优化 导读 本文基于 YCBlogs 仓库 an教程技术博客文档突破滑动边界仿抖音短视频无限加载实现全解析突破滑动边界仿抖音短视频无限加载实现全解析 在移动应用开发中无限滑动列表Infinite Scroll List是提升用户体验的关键技术。本文基于Git前端移动开发短视频上一篇如何3分钟将Android Studio变为中文界面新手友好的完整汉化指南下一篇MiroThinker 从零跑通一条命令起服务跑通首个深度研究任务创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考