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),仅供参考

相关新闻

semantic-router 配置契约深度解析:v0.3 规范布局、字段所有权与 canonical 加载管线

semantic-router 配置契约深度解析:v0.3 规范布局、字段所有权与 canonical 加载管线

后端API网关模型推理服务AI Agent 【免费下载链接】semantic-router An open, programmable decision layer for models and compute. 项目地址: https://gitcode.com/gh_mirrors/sem/semantic-router 点击查看 免费下载 semantic-router 是 vLLM 项目下的可编程模…

2026/10/10 5:43:39 阅读更多 →
Electron + UEditor图片转存:从base64到本地文件的完整方案

Electron + UEditor图片转存:从base64到本地文件的完整方案

做跨平台客户端的时候,很多团队会在壳子里塞一个Web富文本编辑器,UEditor是绕不开的老熟人。用得久了,一个逃不掉的问题就是图片转存:用户在编辑器里粘贴截图,默认是base64数据直接塞进content里,文章一多&…

2026/10/10 5:42:39 阅读更多 →
import gdal报错排查:从ModuleNotFoundError到DLL加载失败

import gdal报错排查:从ModuleNotFoundError到DLL加载失败

很久没在技术交流群里冒泡,一露头就被一个老问题砸回来:python 里运行 import gdal 报错,红色的 traceback 看着挺吓人,可细看不过就是一行 No module named gdal。我本来想直接回一句“装一下 gdal 就好了”,但转念一…

2026/10/10 5:42:39 阅读更多 →

最新新闻

深度学习图像识别系统开发实战:从数据准备到模型部署

深度学习图像识别系统开发实战:从数据准备到模型部署

技术博客 Python PyTorch torchvision ONNX Runtime 在人工智能快速发展的今天,图像识别已经不是什么遥不可及的黑科技。从手机相册自动识别人脸,到工厂流水线检测产品缺陷,再到智能安防、自动驾驶,背后都离不开计算机视觉和…

2026/10/10 6:19:52 阅读更多 →
生成式引擎优化(GEO)的范式重构:AI入口商业化下的价值迁移与落地路径

生成式引擎优化(GEO)的范式重构:AI入口商业化下的价值迁移与落地路径

AI 入口商业化之后,GEO 是死了,还是换了形态【摘要】拆解AI回答的4层决策机制,对比2类主流AI入口的商业化演进路径,梳理6项可落地的新GEO执行动作,明确旧GEO模式的3类失效边界,论证商业化进程中信任资产的溢…

2026/10/10 6:19:52 阅读更多 →
Matlab同步相量测量算法对比:FFT、窗函数、HHT与小波变换的工程实现

Matlab同步相量测量算法对比:FFT、窗函数、HHT与小波变换的工程实现

同步相量测量这事儿,这几年在电力系统里越来越绕不开。从广域测量系统(WAMS)到新能源场站的控制策略,再到故障录波分析,几乎每个环节都要跟“相量”打交道。但真正在Matlab里动手算过的人都知道,把一个正弦…

2026/10/10 6:19:52 阅读更多 →
AI外呼系统意向分级怎么设置?拆解一次通话,看懂6大核心技术如何给客户“贴标签“

AI外呼系统意向分级怎么设置?拆解一次通话,看懂6大核心技术如何给客户“贴标签“

AI外呼系统的意向分级,是指在通话过程中通过语音识别(ASR)、大语言模型(LLM)、对话管理等技术,实时判断客户购买意愿强弱,并将客户自动划分为高意向、中意向、低意向、无意向四档的过程。 主流A…

2026/10/10 6:19:52 阅读更多 →
SpringBoot+Vue养老院管理系统实战:业务闭环、状态流转与部署细节

SpringBoot+Vue养老院管理系统实战:业务闭环、状态流转与部署细节

接到“基于SpringBoot白龙桥敬老院养老院管理系统开发”这个题目时,很多人第一反应是“又一个标准的毕设CRUD”。但真正动起手来你会发现,养老院的管理业务远比想象中琐碎:老人入院要评估、床位要分配、每天的护理要留痕、月底费用要结算&…

2026/10/10 6:19:52 阅读更多 →
Microchip系列教程(一) Libero soc 2023.2 软件安装与环境设置

Microchip系列教程(一) Libero soc 2023.2 软件安装与环境设置

官方参考文档:libero_download_license_quickstart.pdf Libero soc 2023.2 软件包下载 下载连接:FPGA Software Downloads | Microchip Technology 官网需要注册账户,可以选择下载的环境和版本。 我这里下载的windows版本2023.2&#xff1b…

2026/10/10 6:18:51 阅读更多 →

日新闻

卫星轨道分类全解析:从LEO到GEO的选型逻辑与工程实践

卫星轨道分类全解析:从LEO到GEO的选型逻辑与工程实践

1. 从“卫星轨道分类”这个标题说起:为什么值得花时间搞懂第一次接触“卫星轨道分类”这个概念,很多人会觉得它离自己很远——不就是天上的星星怎么转吗?但如果你正在做航天任务规划、遥感数据接收、星座设计,甚至只是准备一场航天…

2026/10/10 0:00:39 阅读更多 →
Spring AOP 核心原理与实战:从概念到日志切面落地

Spring AOP 核心原理与实战:从概念到日志切面落地

1. 从一个真实痛点说起:为什么你的代码里到处都是重复逻辑刚入行那会儿,我写过一个用户管理模块,注册、登录、改密码、注销四个接口。每个接口里都塞了几乎一样的日志打印、参数校验、事务开启和提交。当时觉得没什么,能跑就行。直…

2026/10/10 0:00:40 阅读更多 →
Python招聘数据采集与分析可视化:从采集清洗到薪资技能城市可视化全链路

Python招聘数据采集与分析可视化:从采集清洗到薪资技能城市可视化全链路

简介:这是一套面向计算机相关专业学生与项目实战学习者的Python数据采集与分析可视化完整项目,以Boss直聘岗位数据为对象,适合用作毕业设计、课程设计或期末大作业。资源包共38个文件,约246KB,以13个py源码文件为核心&…

2026/10/10 0:00:40 阅读更多 →

周新闻

KT148A语音芯片外挂8002D功放的工程实践指南

KT148A语音芯片外挂8002D功放的工程实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 15:26:32 阅读更多 →
LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/10 1:36:08 阅读更多 →
ARM架构深度解析:从RISC设计理念到交叉编译实战

ARM架构深度解析:从RISC设计理念到交叉编译实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/9 10:11:06 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/10 5:23:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/9 21:32:20 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/9 6:17:20 阅读更多 →