ARTICLE DETAIL

资讯详情

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

仿抖音上下滑动切换视频:ViewPager2与播放器调度全解析

仿抖音上下滑动切换视频:ViewPager2与播放器调度全解析 简介一份面向 Android 开发者的仿抖音上下滑动切换视频完整工程资源基于 RecyclerView、SnapHelper 与自定义 LayoutManager 实现整页滑动切换的核心机制覆盖 PagerSnapHelper 自动居中、视频异步加载、ExoPlayer 播放器集成、手势协同处理等关键技术点适用于短视频信息流、轮播卡片等交互场景能帮助开发者快速复现抖音式沉浸浏览体验。压缩包为 zip 格式共 1486 个文件含 flat、dex、class、json、xml、jar、java、so 等多种类型编译产物与源码并存便于对照阅读列表复用、滑动判定与布局测量逻辑整体约 58.73MB。包内提供可运行 APK、Gradle 构建配置、视频示例资源并涉及 DiffUtil 数据刷新、ItemAnimator 过渡动画、内存与流畅度优化等细节适合已有一定基础、希望深入理解整页滑动交互与列表性能优化的 Android 开发者。已有 4844 人学习该资源可直接作为实际项目中仿抖音浏览体验的参考工程。1. 仿抖音上下滑动切换视频为什么一个 ViewPager2 值得写一整篇第一次接到「仿抖音上下滑动切换视频」这个需求我的第一反应和多数人一样拿 ViewPager2 把方向竖过来不就行了等真正做完才发现滑动流畅度、视频播出的实际时机、首帧黑屏、播放器复用错乱每一项都能磨掉大半天。这个效果表面上是「上下滑一下切一页」实际上内核是一个「带吸附行为的视频播放容器」要同时管好滚动手势、页面生命周期、播放器状态和内存回收。这篇文章把我自己完整拆过一遍的竖向滑动视频方案写下来从选型、核心实现、预加载策略到五条真实翻车记录适合刚接触 Android 视频播放管理的开发者也适合想把手上的视频 Feed 做得更稳的熟手。2. 选型与原理ViewPager2 的纵向滚动机制和 Media3 播放器的复用边界2.1 为什么优先选 ViewPager2页面吸附和回调时机要复现抖音效果的「上下滑动切换」最关键的其实不是滑动的手感而是「页面切换必须落在整页上」。RecyclerView 当然也能做竖滑列表但普通 RecyclerView 松手后停在哪完全由惯性决定视频 Feed 里一旦出现「两个页面各露出一半」就会同时出现两个视频画面在抢焦点这几乎是 RecyclerView 手写方案最容易踩的坑。ViewPager2 内部本质上就是 RecyclerView但它通过 LinearLayoutManager 加上 SnapHelper 把滚动吸附到整页松手后只可能停在某个完整页面上。更重要的是ViewPager2 提供了onPageSelected(position)这个「最终落位」回调它会告诉你滚动稳定后当前是第几页视频播放逻辑只需要关心这个回调不需要自己去做复杂的 scroll 距离换算。选 ViewPager2 的第二个理由是它的纵向支持是官方内置的。直接把orientation设置为VERTICAL或者布局属性里写android:orientationvertical整个列表就会从横滑变成竖滑不需要额外嵌套一层横向 RecyclerView。这点在早期很多自研方案里是被忽略的大家习惯用横向 RecyclerView 转 90 度来实现结果触摸坐标、事件分发全部要跟着转调试成本陡增。第三个理由藏在 ViewPager2 的预创建机制里。它默认会预创建当前页相邻的页面这意味着滑到下一页时下一页的 ViewHolder 和布局已经存在而不需要像普通 RecyclerView 那样先创建 View 再加载数据。对视频场景来说这个「提前创建」直接决定了预加载能不能做起来。后面第 4 章我会专门讲如何利用这个机制把首帧延迟打掉。2.2 播放器选型Media3 ExoPlayer 与 MediaPlayer 的取舍视频播放器选型是这个项目里第二个容易走弯路的地方。早期方案里很多项目还在用系统 MediaPlayer 或者已经停止维护的 Vitamio、旧版 ExoPlayer 2.x。我现在的选择是 Media3 ExoPlayer也就是androidx.media3:media3-exoplayer。它是 Google 官方把 ExoPlayer 迁移到 AndroidX 后的产物对 HLS、DASH、MP4 的支持完整硬解优先顺序可配置也自带音轨和字幕切换更关键的是它在列表场景下的状态机和Player.Listener回调设计很清晰onPlaybackStateChanged、onIsPlayingChanged、onRenderedFirstFrame这些回调能直接对应到「缓冲完成」「正在播放」「第一帧上屏」这些 UI 状态。MediaPlayer 虽然零依赖但它把 buffer、surface 同步、音视频同步全部揉在一个黑匣子里出问题只能换策略而不是看事件流。在仿抖音这种需要精确控制「什么时候播、什么时候停、封面什么时候消失」的场景事件回调不够细会非常痛苦。播放器复用是选型之外必须想清楚的问题。这里有两个思路一个是全局单播放器谁可见就把播放器 attach 到谁的 PlayerView 上另一个是每个页面持有自己的播放器通过调度只让当前页真正播放。单播放器方案很性感内存最少但 surface 切换存在时序问题快速滑动时容易出现「声音是新页的、画面还是旧页的」这种错位。我自己的实践结论是对总量在几十个以内的视频 Feed收益撑不起复杂度。这篇文章里的方案是「每一页一个播放器 非相邻页面回收」这也是在真机上表现最稳、最容易讲清楚边界的一种。2.3 为什么不用全局单播放器快速滑动时的 surface 时序问题可能有人会问抖音自己不是全局播放器吗大厂能用不代表业务方能直接用。全局播放器意味着每次页面切换都要把视频画面从旧的 surface 切换到新的 surface而 surface 的绑定和解绑是异步的必须等surfaceDestroyed/surfaceCreated回调真正完成后才能安全处理。快速连滑时这些事件会交错最后停下来的那一页很可能处于「播放器已经 play 了但 surface 还没绑定好」的状态表现出来就是黑屏加声音。另外全局播放器还要自己做画面复用旧的帧不会自动消失需要在切页时手动把上一次的帧清掉否则新页还没出图旧页最后一帧会残留在新页上。按 position 去找 holder 的写法在快速滑动时尤其危险因为 RecyclerView 的 holder 是复用的findViewHolderForAdapterPosition拿到的可能是一个已经被复用去承载其他数据的 holder。这些坑不是不能绕只是对大多数项目来说为省几个播放器的内存去背这些时序问题不划算。下一章的核心实现里我会给出一个「每页一个播放器 aliveHolders 登记表」的稳定方案。3. 核心实现布局、Adapter 与 onPageSelected 调度3.1 依赖与布局竖屏 ViewPager2 的骨架先看依赖配置。新建项目后需要引入 ViewPager2、Media3 播放器和一个图片加载库我这边用 Glide。dependencies { implementation androidx.viewpager2:viewpager2:1.0.0 implementation androidx.media3:media3-exoplayer:1.4.0 implementation androidx.media3:media3-ui:1.4.0 implementation com.github.bumptech.glide:glide:4.16.0 }androidx.media3:media3-ui里的 PlayerView 自带 TextureView 渲染和控制器组件比单独用 TextureView 手写渲染省事。接下来是主页面的布局一个竖向的 ViewPager2 撑满全屏即可。androidx.viewpager2.widget.ViewPager2 android:idid/vp_videos android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical /android:orientationvertical是 ViewPager2 相对 ViewPager 的新增能力布局里声明方向比代码里 setOrientation 更直观两者效果一样。然后写单页的 item 布局结构是一个 FrameLayout底层放 PlayerView上面叠一层封面图再往上叠视频标题栏之类的 UI。FrameLayout xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:apphttp://schemas.android.com/apk/res-auto android:layout_widthmatch_parent android:layout_heightmatch_parent androidx.media3.ui.PlayerView android:idid/player_view android:layout_widthmatch_parent android:layout_heightmatch_parent app:use_controllerfalse / ImageView android:idid/iv_cover android:layout_widthmatch_parent android:layout_heightmatch_parent android:scaleTypecenterCrop android:visibilityvisible / LinearLayout android:idid/ll_video_info android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_gravitybottom android:orientationvertical android:padding16dp TextView android:idid/tv_title android:layout_widthwrap_content android:layout_heightwrap_content android:textColor#FFFFFF android:textSize16sp / /LinearLayout /FrameLayout细节在两层布局上封面 ImageView 放在 PlayerView 之上初始是visible状态用来挡住播放器还没出帧时的黑屏。app:use_controllerfalse关闭默认控制器因为视频列表页不需要显示进度条和播放按钮点击行为交给业务层自定义。信息栏用layout_gravitybottom固定在底部这样它不会抢占页面的触摸焦点避免影响 ViewPager2 的滑动事件。3.2 播放器调度aliveHolders 登记表代替 find viewHolder这一节是核心。真正的难点不在于创建播放器而在于「滑动停止后我该让哪个播放器播、让哪些停下来」。常规写法是在onPageSelected里用recyclerView.findViewHolderForAdapterPosition(position)去拿当前 holder然后操作里面的 player。这个写法在慢速滑动下没问题快速连滑就会踩中 holder 复用导致的错位因为findViewHolderForAdapterPosition返回的 holder 可能已经被 RecyclerView 回收并重新 bind 到另一个 position 上了。我采用的方案是让 Adapter 自己维护一张「当前存活 holder」登记表利用onViewAttachedToWindow和onViewDetachedFromWindow这两个生命周期回调来登记和摘除。这两个回调的触发时机和 ViewHolder 的实际可见性严格绑定比在onPageSelected里去 find 要可靠得多。完整代码如下。class VideoAdapter( private val videos: ListVideoData ) : RecyclerView.AdapterVideoAdapter.VideoHolder() { // 登记当前所有在屏幕上的 holderkey 是 holder 当前对应的 position private val aliveHolders HashMapInt, VideoHolder() // 调度入口让指定 position 播放其余全部暂停 fun playAt(position: Int) { aliveHolders.forEach { (holderPosition, holder) - if (holderPosition position) { holder.startPlayback() } else { holder.pausePlayback() } } } fun pauseAll() { aliveHolders.values.forEach { it.pausePlayback() } } fun releaseAll() { aliveHolders.values.forEach { it.release() } aliveHolders.clear() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VideoHolder { val itemView LayoutInflater.from(parent.context) .inflate(R.layout.item_video, parent, false) return VideoHolder(itemView) } override fun onBindViewHolder(holder: VideoHolder, position: Int) { holder.bind(videos[position]) } // holder 真正挂到窗口时才登记避免预创建但未展示的页面也被调度 override fun onViewAttachedToWindow(holder: VideoHolder) { aliveHolders[holder.bindingAdapterPosition] holder } // holder 离开窗口时摘除并暂停保证不播放屏幕外的视频 override fun onViewDetachedFromWindow(holder: VideoHolder) { aliveHolders.remove(holder.bindingAdapterPosition)?.pausePlayback() } // holder 被回收时彻底释放播放器归还内存 override fun onViewRecycled(holder: VideoHolder) { holder.release() } override fun getItemCount(): Int videos.size inner class VideoHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { private val playerView: PlayerView itemView.findViewById(R.id.player_view) private val ivCover: ImageView itemView.findViewById(R.id.iv_cover) private var player: ExoPlayer? null fun bind(data: VideoData) { if (player null) { player ExoPlayer.Builder(itemView.context).build() playerView.player player } ivCover.tag data.videoUrl ivCover.visibility View.VISIBLE ivCover.setImageDrawable(null) Glide.with(ivCover.context) .load(data.coverUrl) .centerCrop() .into(ivCover) val mediaItem MediaItem.fromUri(data.videoUrl) player?.setMediaItem(mediaItem) player?.prepare() player?.playWhenReady false player?.addListener(object : Player.Listener { override fun onRenderedFirstFrame() { // 只有当前封面还对应这个视频时才隐藏避免旧封面被提前移除 if (ivCover.tag data.videoUrl) { ivCover.visibility View.GONE } } }) } fun startPlayback() { player?.playWhenReady true } fun pausePlayback() { player?.playWhenReady false } fun release() { player?.release() player null playerView.player null } } }逻辑说明分三点。第一aliveHolders这张表只登记「真正挂在窗口上」的 holderViewPager2 预创建但还没显示出来的页面不会出现在表里这样playAt调用时只会影响实际可见的页面。第二onViewDetachedFromWindow里摘除并暂停这意味着滑走的那一页在脱离窗口的瞬间就已经暂停了不会出现滑过去半天后台还在出声的问题。第三pausePlayback里用的是playWhenReady false不是player.pause()。两者最终都会暂停播放但playWhenReady更干净它在系统自动暂停比如音频焦点丢失时不会把状态搞乱。pause()则会显式触发状态变更可能在快速切换时引入不必要的 seek 或缓冲重置。3.3 在 MainActivity 里挂接滚动回调与生命周期Adapter 只是提供了调度接口真正触发调度的是 ViewPager2 的页面回调。我一般在 MainActivity 里这样挂接。class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private val videoAdapter by lazy { VideoAdapter(loadVideoData()) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) binding.vpVideos.apply { orientation ViewPager2.ORIENTATION_VERTICAL offscreenPageLimit 2 adapter videoAdapter registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { videoAdapter.playAt(position) } }) } lifecycle.addObserver(object : LifecycleEventObserver { override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { when (event) { Lifecycle.Event.ON_PAUSE - videoAdapter.pauseAll() Lifecycle.Event.ON_RESUME - { binding.vpVideos.post { videoAdapter.playAt(binding.vpVideos.currentItem) } } Lifecycle.Event.ON_DESTROY - videoAdapter.releaseAll() else - Unit } } }) } }onPageSelected这个回调只会在滑动最终落位后触发一次所以在这里面调playAt不会出现滚动过程中反复切换播放器的问题。offscreenPageLimit 2决定了预创建的页数这个值后面会详细说先记住它和预加载直接相关。ON_RESUME分支里用binding.vpVideos.post {}包裹是因为 Activity 从后台回来时 surface 可能还没重建完直接 play 会出现声音先出画面后到的现象。post 到下一次 UI 消息循环等布局重新测量、渲染管线恢复后再去播放可以绕过大部分「回前台黑屏」问题。4. 预加载与首帧把上下滑的延迟感打掉4.1 offscreenPageLimit默认值不办事ViewPager2 有个容易被忽略的参数offscreenPageLimit。它默认值是 -1实际生效值相当于 1意思是除了当前页ViewPager2 最多预创建左右各一页的 ViewHolder。对普通图片轮播够用对视频 Feed 远远不够。想想这个场景用户连续快速下滑三页第二、三页的 ViewHolder 在滑到之前可能根本没创建等滑到时才去 inflate 布局、初始化播放器、加载视频这个「临时创建」的过程会直接体现在滑动卡顿和首帧黑屏上。我的做法是显式设置 2 页预创建也就是当前页之外上下各保留两页的 ViewHolder 和播放器实例。这样用户滑到第二页、第三页时页面和播放器已经存在于内存里只差playWhenReady true这一步延迟感会小很多。但要提醒一句这个参数不是越大越好。每多一页预创建就多占一份 PlayerView 的 TextureView 内存这些 surface 在部分低端机上单页就要占用几十 MB 显存。设置成 2 已经是平衡点。ViewPager2.OFFSCREEN_PAGE_LIMIT_DEFAULT 这种常量不要用在视频场景里它没有预创建概念完全依赖 RecyclerView 的回收机制表现会回到第一个问题。4.2 绑定即 prepare把网络时间从滑动时提前到 bind 时预创建 ViewHolder 只是第一步真正省时间的是「在 bind 的时候就让播放器 prepare」。这也是我在 Adapter 代码里让每一页创建播放器后就立刻setMediaItem加prepare()的原因。prepare()会触发视频源的网络请求、解封装、初始化解码器但不会开始播放。也就是说用户还在看第一页的时候第二页和第三页的视频数据已经在下载、解码管线已经在初始化了。等用户滑过去播放器内部已经有了可用的解码上下文甚至已经出了第一帧这时候执行playWhenReady true几乎没有等待。这里要区分两个状态prepare()完成不等于首帧可见。Media3 的回调onPlaybackStateChanged变成STATE_READY只代表播放器做好了准备不代表画面已经渲染到屏幕上。解码器的输出走到 surface 还需要一个完整帧的渲染周期这段延迟通常在几十到几百毫秒网络差或解码器慢时会更长。如果在这里处理不当就会看到视频进度已经在走但画面还是黑的。下一节会讲怎么处理封面和首帧的关系。4.3 封面与第一帧封面消失的时机不能跟着 STATE_READY 走封面的作用不只是美观它是首帧黑屏的兜底。列表滑动过程中新页面露出到首帧上屏之间有一段「空窗」播放器的 surface 是黑的如果封面这个时候已经隐藏了用户看到的就是一个黑页。所以封面隐藏的时机必须绑定「第一帧已经渲染完成」这个事件而不是绑定 STATE_READY。我在 Adapter 里用的是onRenderedFirstFrame()回调这是 Media3 专门用来通知「视频第一帧已经呈现到 surface」的接口。在这个回调里把封面设成 GONE能保证用户在任何情况下都先看到封面或视频画面不会看到黑屏。代码里用ivCover.tag data.videoUrl做了一次 Identity 比对是因为列表复用场景下封面 ImageView 可能已经被滑动复用来承载下一个视频的封面了。如果不做比对前一个视频的onRenderedFirstFrame回调晚到就可能把新视频的封面提前关掉导致黑屏。再补一个细节封面加载本身也有可能慢于视频首帧。如果 Glide 加载封面比首帧渲染还慢封面 ImageView 在首帧出现时还是透明的这时把封面设成 GONE 没有损失因为播放器的画面已经在下面了。所以封面图不应该设置背景色保持透明让播放器画面能透出来。反过来如果视频加载失败封面会一直挂着这时候要结合onPlayerError回调在封面上盖一层「播放失败点按重试」这个我一般在业务层处理不在 Adapter 里写死。5. 常见问题与避坑五条真实翻车记录这一章节是我自己在一个仿抖音滑动视频项目里踩过的坑按踩中的概率排序整理成五条每条都是现象、原因、解决的完整链路。这里不多讲理论直接给结论。5.1 封面消失过早导致黑屏闪烁现象上下滑动后新页面露出的一瞬间经常出现一个黑帧闪一下再出画面尤其在网络一般的时候特别明显。有人说「黑屏是玄学」其实不是。原因我一开始把封面的隐藏逻辑放在onPlaybackStateChanged的STATE_READY分支里认为播放器准备好了画面就出来了。实际上STATE_READY只代表缓冲和解封装完成渲染管线还差一步第一帧真正上屏发生在onRenderedFirstFrame回调里。这两个事件之间隔着几十到几百毫秒封面在这个时间窗口里被隐藏露出来的自然就是播放器底层的黑面。解决隐藏封面的时机无条件改用onRenderedFirstFrame并且加上 tag 比对防止列表复用导致旧回调关掉新封面。这个改完黑屏闪烁基本绝迹。5.2 快速滑动后播放错位声音和画面不是同一个视频现象连续快速下滑三四页后停住当前页面播放的声音是上一个视频的画面却是当前页的过一两秒才切换回正确内容。用户体感很割裂。原因早期代码里onPageSelected用findViewHolderForAdapterPosition(position)去取 holder快速滑动时 RecyclerView 的 ViewHolder 复用机制导致这个接口返回的 holder 已经被回收并重绑到其他 position 上。拿到 holder 后调play/pause作用对象和理想位置对不上播放调度就乱了。解决换用「aliveHolders 登记表」方案。onViewAttachedToWindow登记、onViewDetachedFromWindow摘除调度时遍历登记表而不是临时查 holder。这张表的 key 是当前 positionvalue 是 holder每次操作都是对真实挂在窗口上的页面生效不存在错位窗口。5.3 回到前台时声音先走、画面后到现象App 切到后台再回来第一秒只听到视频声音屏幕还是上一帧的定格画面然后才恢复播放画面。原因ON_RESUME里直接调用playWhenReady true时Activity 的 surface 可能还在重建过程中播放器这时候已经开始输出声音但解码帧无处可渲染。这个问题在部分低端机上更容易出现因为 surface 重建耗时更长。解决ON_RESUME分支用binding.vpVideos.post {}把播放动作推迟到下一个 UI 消息循环等布局和渲染管线恢复后再播放。保守一点的做法是固定延迟 100 到 150 毫秒再 play能在绝大多数机器上躲开这个窗口但 post 已经够用延迟值反而不好调。5.4 滑到三十几个视频后 OOM现象不做操作光滑列表滑到大约三十个视频时内存暴涨最终 OOM。看内存堆栈全是 Surface 或解码器 buffer 相关。原因每个 ViewHolder 都持有自己的 ExoPlayer滑出屏幕的 holder 只调用了pausePlayback没有释放播放器。被暂停的播放器依然持有解码器、Surface buffer、音频播放器等资源几十个暂停的播放器堆在一起内存占用远超预期。解决两条线同时做。第一条是onViewRecycled里必须release()这是兜底第二条是主动回收策略在playAt(position)里把非当前页且距离超过两格的播放器全部 release只保留当前页上下各两页的播放器实例。这样任何时刻内存里最多存在 5 个播放器内存曲线趋于平稳。5.5 触摸被吞松手后页面不吸附或点击无响应现象部分机型上松手后页面停在两页中间不吸附或者底部信息栏的点击偶尔失效。复现频率不高但一旦出现就非常影响体验。原因item 布局里如果存在可点击的 View 覆盖在 ViewPager2 的滑动区域上RecyclerView 的事件分发会和子 View 的点击判定互相竞争。当点击区域较大、且父容器没有对事件做明确分派时ViewPager2 的 SnapHelper 可能拿不到稳定的 ACTION_UP 事件导致吸附逻辑没有正确触发。解决对纯展示用的覆盖层标题、封面、渐变层统一设置android:clickablefalse和android:focusablefalse让它们不参与事件竞争真正需要点击的区域用独立的 View 承载并保持足够小的点击热区。这个调整之后吸附失效和点击无响应都没有再出现过。6. 进阶技巧手势细节、播放器回收与验收清单到这里列表已经能稳定跑起来但离「抖音效果」还有一点差距。差距不在功能在细节手感。这一章讲三个我常用的进阶处理。手势细节上ViewPager2 自带的滑动和松手吸附已经很接近抖音的体感但抖音的滑动是带阻尼的滑动距离越大页面位移的阻力越明显。这个效果不需要自己接管触摸事件可以在onPageScrollStateChanged里监听状态在SCROLL_STATE_DRAGGING时暂停比当前页远的页面的视频加载减少滑动过程中的解码压力从而变相提升跟手度。另外如果页面里有横向操作比如拖动视频调进度需要给那个 View 设置requestDisallowInterceptTouchEvent(true)告诉父容器不要拦截横向手势否则横滑会被 ViewPager2 当成纵向滑动的一部分处理。播放器回收方面我最终使用的策略是「保留半径 2」每次playAt之后把离当前页超过两个位置的页面播放器全部释放。这段逻辑独立成一个方法放在 Adapter 里方便以后调整参数。fun trimToRange(center: Int, radius: Int 2) { val toRemove aliveHolders.keys.filter { abs(it - center) radius } toRemove.forEach { position - aliveHolders.remove(position)?.release() } }center是当前停留页的 positionradius2意味着保留当前页上下各两页的播放器。调用时机放在playAt的最后一行保证先完成播放调度再做回收。这个参数不是死的如果机器内存紧张可以把 radius 降到 1代价是滑到第三页时可能遇到首帧延迟如果视频分辨率高也可以保持 2 但把offscreenPageLimit降到 1让页面预创建变少播放器保留变多这是一个需要结合实际机型微调的取舍。最后放一张验收清单我每次改完视频滑动列表都会按这四条过一遍省得回归的时候漏掉状态检查项操作方法通过标准首帧与黑屏弱网下快速上下滑动任何时刻看不到黑屏封面或画面至少有一个存在播放错位快速连滑 5 页后停留画面内容与音频内容一致1 秒内不自动跳转生命周期播放中按 Home 键再回前台回来后有画面有声音无先响后黑内存回收连续滑 50 个视频后看内存曲线播放器实例数始终不超过 7 个这三个进阶点做完这个仿抖音上下滑动切换视频的列表就从一个「能跑」的 demo 变成一个「敢上线」的模块了。最后说句真心话视频列表的很多问题不是代码写错了而是状态机没想清楚。从那以后我每次做视频 Feed不管需求多急都会强制自己先把「谁在播、谁在暂停、谁被释放」这三件事画清楚再动手写代码。这条习惯救了我很多次希望帮到你。本文还有配套的精品资源点击获取
返回列表