1. 为什么是RecyclerView而不是ViewPager各位老铁今天咱们来聊聊怎么在Android上搞一个抖音那样的视频流。我刷抖音的时候经常就在想这玩意儿到底是怎么做出来的作为一个在Android圈子里摸爬滚打了十来年的老码农我试过不少方案最后发现用自定义RecyclerView配合VideoView来实现是性价比最高、也最容易上手的一条路。你可能要问了为啥不用ViewPager2呢它看起来不也挺像的吗这里面的门道我踩过坑所以可以跟你唠唠。ViewPager2的底层其实就是RecyclerView但它默认带了预加载。对于图片轮播这种场景预加载是好事能让你滑动更流畅。但到了抖音这种全屏视频流问题就大了。你想啊一个视频占满一屏如果预加载了下一个甚至下下个视频那内存和CPU的压力得有多大后台偷偷加载视频不仅耗电用户流量也扛不住啊。我实测过用ViewPager2做视频流滑动是快了那么一丢丢但App的整体内存占用能飙升30%以上在低端机上卡顿、发热非常明显。反过来看RecyclerView它的核心优势就是按需加载。它只负责创建和绑定当前屏幕内你看得见的那些Item。对于全屏视频流来说理想状态下同一时间就只有一个Item视频是完全活跃的。RecyclerView的回收复用机制在这里简直是天作之合滑走的视频View被回收滑进来的新视频复用这个View只是换一下数据源。这样内存始终维持在一个很低的水平性能自然就上去了。所以结论很明确要实现抖音式的高性能、低内存占用的视频流自定义RecyclerView的LayoutManager是必经之路。ViewPager2更像是一个“黑盒”定制化空间小而RecyclerView则把所有的控制权都交给了我们开发者想怎么玩就怎么玩。接下来我就带你一步步把这个“玩具”造出来。2. 搭建舞台基础布局与适配器动手写代码之前咱们得先把舞台搭好。这个舞台就是我们的Activity主页面和每个视频的Item。别小看这些基础布局它们决定了后续功能实现的难易度。2.1 主页面布局极简才是王道抖音的主页非常干净就是一个全屏的视频流上面叠了一点导航栏下面有个底部Tab。我们的目标也是先实现核心的播放流所以布局要尽量简单。我建议直接用RelativeLayout或者ConstraintLayout作为根布局里面就放一个全屏的RecyclerView。那些顶部的城市、关注、推荐标签以及底部的Tab栏都用android:layout_alignParentTop和android:layout_alignParentBottom属性叠在RecyclerView上面。记住要给这些覆盖层设置一定的透明度或者确保它们不会拦截RecyclerView的触摸事件不然用户就滑不动了。这里有个小技巧RecyclerView的背景色可以设成一个深色比如#121212。这样在视频加载前或者切换时屏幕不会出现刺眼的白色闪屏体验上会更接近原版抖音。2.2 视频Item布局自定义VideoView是关键每个视频Item的布局是重中之重。核心就是一个能全屏播放的VideoView。但是Android原生的VideoView有个坑它在wrap_content模式下测量逻辑有点问题不一定能完美撑满父容器。所以我们通常需要自定义一个。我的做法是继承VideoView重写它的onMeasure方法。在这个方法里我们直接告诉系统“我就要占满父容器给的所有空间”。代码很简单但很管用public class FullScreenVideoView extends VideoView { public FullScreenVideoView(Context context) { super(context); } // ... 其他构造方法 Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 直接使用父容器给出的最大建议尺寸 int width MeasureSpec.getSize(widthMeasureSpec); int height MeasureSpec.getSize(heightMeasureSpec); setMeasuredDimension(width, height); } }在Item的布局XML里把这个FullScreenVideoView的宽高都设为match_parent。在VideoView下面我们还要放一个ImageView作为视频封面mThumb在视频开始播放前显示播放开始后淡出。另外像点赞、评论、分享按钮以及作者头像、视频描述等UI元素都用android:layout_alignParentRight、android:layout_alignParentBottom等属性定位在VideoView的上面。记得给中间加一个播放/暂停按钮mPlay初始状态是透明的点击后控制视频播放。2.3 适配器无限循环与数据绑定抖音的推荐流是刷不完的我们的Adapter也要模拟这个效果。最简单的方式是让getItemCount()返回一个很大的数比如Integer.MAX_VALUE。在onBindViewHolder里用position % realDataSize来循环使用真实的数据集。数据绑定部分要注意性能。比如视频封面最好不要每次都用MediaMetadataRetriever去实时提取视频第一帧这个操作是IO操作比较耗时在快速滑动时会造成卡顿。我建议在服务器端或本地预处理时生成封面图Adapter里直接加载图片。如果非要本地提取一定要做好缓存并且放在子线程进行。在onBindViewHolder里我们只做静态数据的设置设置封面图、设置视频URI、设置文案。千万不要在这里开始播放视频播放的触发必须由LayoutManager来精确控制否则会乱套。这里只是把“弹药”装好什么时候“开枪”由别处决定。3. 灵魂所在自定义LayoutManager前面都是准备工作现在轮到核心主角登场了——自定义LayoutManager。这是实现抖音式滑动体验的灵魂。我们不需要从零开始写一个那样太复杂。继承LinearLayoutManager然后重写几个关键方法就能达到目的。3.1 利用PagerSnapHelper实现翻页效果首先我们要让RecyclerView像ViewPager一样一次滑动一页并且自动对齐。Android官方提供了一个非常好用的工具类PagerSnapHelper。它的作用就是辅助RecyclerView实现翻页滚动。我们在自定义LayoutManager的构造方法里创建它并在onAttachedToWindow方法中将它附着到RecyclerView上public class TiktokLayoutManager extends LinearLayoutManager { private PagerSnapHelper mPagerSnapHelper; public TiktokLayoutManager(Context context) { super(context, VERTICAL, false); // 垂直方向不反向 mPagerSnapHelper new PagerSnapHelper(); } Override public void onAttachedToWindow(RecyclerView view) { super.onAttachedToWindow(view); mPagerSnapHelper.attachToRecyclerView(view); // ... 其他初始化 } }就这么几行代码你的RecyclerView就已经具备基础的翻页特性了。PagerSnapHelper会帮你处理松手后的惯性滚动并自动定位到最近的Item保证每次只显示一个完整的视频。3.2 监听Item的生命周期翻页效果有了接下来要解决最关键的问题什么时候播放视频什么时候停止播放这需要精确知道每个Item何时进入屏幕即将被看到何时离开屏幕不再被看到。RecyclerView提供了一个监听器RecyclerView.OnChildAttachStateChangeListener。它有两个回调方法onChildViewAttachedToWindow(View view): 当Item的视图被添加到窗口即即将显示时调用。onChildViewDetachedFromWindow(View view): 当Item的视图从窗口移除即完全不可见时调用。我们的LayoutManager可以实现这个接口并在onAttachedToWindow中注册给RecyclerView。这样每个Item的进出我们都能感知到。但是这里有个大坑这两个方法的调用并不是成对、有序的。在快速滑动时可能Attached了新的Item但旧的Item还没来得及Detached。而且我们还需要知道用户是向上滑看下一个还是向下滑看上一个因为这会影响到我们该释放哪个视频、准备哪个视频。3.3 判断滑动方向与状态同步为了解决上面的问题我们需要自己判断滑动方向。重写scrollVerticallyBy方法它可以捕获每次滚动的距离dy。dy 0表示手指向上滑内容向下走看下一个视频dy 0表示手指向下滑看上一个视频。我们把这个值存起来。private int mDrift; // 滑动位移用于判断方向 Override public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { this.mDrift dy; return super.scrollVerticallyBy(dy, recycler, state); }现在我们在onChildViewAttachedToWindow里就能根据mDrift知道新进来的Item是因为向上滑看下一个进来的还是向下滑看上一个进来的。这个信息至关重要我们需要把它通过一个自定义接口回调给Activity。同样在onChildViewDetachedFromWindow里我们也根据mDrift判断被移除的Item是位于当前播放项的上方还是下方并通知Activity去释放对应的视频资源。但是仅仅这样还不够。因为onChildViewAttachedToWindow可能在滑动过程中就触发了而此时手指还没松开页面可能还没稳定停住。我们还需要监听滚动状态。重写onScrollStateChanged方法当状态变为SCROLL_STATE_IDLE滚动停止时通过PagerSnapHelper.findSnapView()找到当前真正“贴紧”的Item这才是最终需要播放的视频项。在这里再做一次播放回调能确保万无一失。把这几个方法组合起来我们就构建了一个完整的、能精准控制视频播放生命周期的状态机。滑动方向、Item附着/脱离、滚动停止状态三者信息结合才能做出最准确的播放/暂停决策。4. 联动控制视频播放与释放LayoutManager已经能告诉我们“该播谁”和“该停谁”了接下来就是在Activity里实现具体的播放逻辑。这里我们用原生的VideoView虽然功能简单但足以演示核心流程。4.1 定义通信接口首先在自定义的TiktokLayoutManager里定义一个接口public interface OnPageSlideListener { // 当页面被释放不可见时调用 // isNext: true表示释放的是下一个页面即向上滑动时false表示释放的是上一个页面 void onPageRelease(boolean isNext, int position); // 当页面被选中可见时调用 void onPageSelected(int position, boolean isBottom); }在LayoutManager的各个回调方法里根据前面判断的逻辑调用这个接口的对应方法。4.2 Activity中的播放控制器在Activity里我们实现这个接口并设置给LayoutManager。播放视频 (playVideo):通过mRecycler.getChildAt(0)获取当前屏幕中央的Item视图。因为用了PagerSnapHelper索引0就是当前Snap的View。从这个View里找到FullScreenVideoView、封面图mThumb和播放按钮mPlay。调用videoView.start()开始播放。设置setOnInfoListener在MEDIA_INFO_VIDEO_RENDERING_START视频开始渲染事件中将封面图淡出alpha(0)实现平滑过渡。为播放按钮设置点击事件控制视频的暂停与继续。释放视频 (releaseVideo):根据参数index0或1代表当前视图的上一个或下一个获取对应的Item视图。注意这里获取的是RecyclerView中缓存的View不一定是当前显示的。找到该视图中的VideoView调用stopPlayback()。这个方法比pause()更彻底会释放MediaPlayer资源。将该视图的封面图alpha设回1播放按钮隐藏。这里有一个非常重要的细节onPageRelease回调中的position参数是Item在Adapter中的逻辑位置。但我们释放视频时需要操作的是具体的View对象。由于RecyclerView的视图是复用的我们不能直接通过position去找View可能找不到或找错。所以我在示例中用了取巧的办法根据isNext参数去拿mRecycler.getChildAt(0)或getChildAt(1)。这依赖于一个假设——RecyclerView的child列表顺序和显示顺序有对应关系。在更严谨的实现中可能需要通过LayoutManager.findViewByPosition(position)来获取并做好空值判断。4.3 处理边界情况在实际测试中你会遇到一些边界情况需要处理快速连续滑动可能触发多个onPageSelected但前一个视频还没开始播。需要确保每次播放前先停止上一个正在准备的视频。滑动一半又退回Item经历了Attached又立刻Detached视频可能被错误释放。这就需要结合onScrollStateChanged的IDLE状态做最终裁决。视频加载慢网络视频可能加载很久期间用户又滑走了。需要在onPageRelease中取消正在加载的视频请求如果使用其他播放器。我自己的经验是在playVideo方法里先检查目标VideoView是否已经在播放同一个资源避免重复调用start()。在releaseVideo里除了stopPlayback()最好再把VideoView的setVideoURI(null)彻底清空。5. 性能优化与进阶思考基础功能跑通后咱们得想想怎么让它更流畅、更省电、更像抖音。这里有几个我实战中总结的优化点。5.1 预加载与缓存策略抖音的流畅一部分得益于其精准的预加载。我们的简单实现是“看到才加载”但可以在滑动开始时就预加载下一个视频的部分数据。例如在onScrollStateChanged进入SCROLL_STATE_DRAGGING开始拖动状态时根据滑动方向提前初始化下一个位置的VideoView调用setVideoURI但不start。这样当页面停止时视频可以几乎立刻开始渲染。视频封面图一定要用强大的图片加载库如Glide、Coil进行缓存和加载。Glide可以非常方便地实现淡入淡出效果并且能自动管理请求生命周期。5.2 播放器升级原生的VideoView在兼容性和功能上比较弱。对于严肃的项目我强烈建议替换成更专业的播放器内核比如基于ExoPlayer或ijkplayer封装的库。ExoPlayer是Google亲儿子功能强大支持格式多自适应流HLSDASH效果好而且社区活跃。用它替换VideoView后你能获得更精准的缓冲控制、更好的画质和音效支持以及更丰富的事件监听。替换时你需要自定义一个PlayerView来代替FullScreenVideoView并在Adapter和播放控制逻辑里将VideoView的start/pause/stop换成ExoPlayer的对应方法。架构上最好能抽象出一个IVideoPlayer接口方便日后切换不同的播放器实现。5.3 内存与生命周期管理这是最容易出问题的地方。视频播放器是内存消耗大户。一定要在Activity/Fragment的onPause和onResume中妥善管理播放状态。我的习惯是onPause: 暂停播放但不一定释放。如果是短暂离开如来电保留状态。onStop: 如果页面完全不可见则停止播放并释放资源。onDestroy: 必须释放所有播放器实例并清空RecyclerView的适配器数据。此外在releaseVideo时确保播放器回到了初始状态。对于ExoPlayer需要调用player.stop(); player.clearMediaItems(); player.release()。5.4 交互细节打磨抖音的体验藏在细节里双击点赞可以在Item的根布局上设置一个GestureDetector监听双击事件触发点赞动画。滑动亮度/音量调节监听触摸事件根据手指在屏幕左半部分还是右半部分垂直滑动分别调节亮度和系统音量。这需要用到WindowManager.LayoutParams调节屏幕亮度和AudioManager调节音量。播放进度条对于长视频可以添加一个隐藏式的进度条在视频开始播放几秒后自动淡出。播放失败与重试监听播放器的错误回调在封面图上显示一个重试按钮提升用户体验。实现这些功能意味着你的自定义LayoutManager和Item布局会变得更加复杂但这也是让应用从“能用”到“好用”的关键一步。每加一个功能都要仔细考虑它对滑动流畅度的影响尽量把耗时操作放到后台线程。6. 避坑指南我踩过的那些雷做了这么多年这个方案我反反复复优化过很多次也踩过不少坑。这里分享几个典型的希望能帮你节省时间。坑一视频声音重叠。这是最常见的问题。原因是上一个视频的VideoView在onPageRelease时没有彻底停止。videoView.pause()只是暂停资源还在。必须用videoView.stopPlayback()或videoView.suspend()。如果用ExoPlayer要确保调用了player.setPlayWhenReady(false)和player.stop()。坑二快速滑动卡顿或白屏。可能的原因1. 封面图太大加载慢。务必压缩封面图并使用缓存。2. 在onBindViewHolder里做了耗时操作如计算。所有耗时操作必须异步。3. 布局层次太深。用Layout Inspector工具检查尽量扁平化Item布局。坑三滑动不跟手有顿挫感。检查是否在onScrollStateChanged或onChildViewAttachedToWindow回调里做了太多事情比如IO读写。这些回调都在UI线程必须轻量。复杂的逻辑可以post到消息队列稍后执行。坑四内存泄漏。特别注意在Adapter的ViewHolder或播放控制器里如果持有了Context、Activity或View的引用必须在适当的时候置空。比如在onViewRecycled方法里释放播放器并清空引用。使用LeakCanary工具定期检测。坑五列表滑动与视频播放手势冲突。如果你在Item里加入了左右滑动点赞等手势可能会干扰RecyclerView的垂直滑动判断。这时候需要用GestureDetectorCompat或自定义OnTouchListener来区分手势方向并在适当的时候调用requestDisallowInterceptTouchEvent(true)来让父容器不拦截事件。把这些坑都填上你的仿抖音视频流基本就非常稳健了。整个过程就像搭积木从最核心的滑动播放联动开始一步步把性能、体验、功能这些“积木块”加上去。最终的效果完全取决于你在细节上花了多少心思。