评论区陆续加入了图片、GIF、视频和音频之后,真正的难题从来不是“怎么显示”,而是“怎么管生命周期”。一条带视频的评论滑出屏幕,播放器是暂停还是释放?回收到 RecyclerView 缓存池里的 ViewHolder 还能不能再用?快速滑动时评论区声音会不会乱成一锅粥?最开始,我选择把控制逻辑直接写在 Adapter 里,也就是标题里说的直连控制。写起来确实痛快,上线之后却每天都在和黑屏、串音、内存上涨作斗争。后来下决心重构,把“视图状态变化”翻译成“媒体状态变化”的职责从业务代码里单独抽出来,做成生命周期代理,评论区才算真正稳定下来。这篇文章把这段踩坑和重构过程完整拆一遍,适合正在接手评论或信息流富媒体、又不得不在 RecyclerView 里手动管理播放器的 Android 开发同学。
1. 评论富媒体,为什么偏偏卡在生命周期上
1.1 评论区的媒体场景,和普通列表有什么不一样
先说结论:评论区的富媒体比普通图片流难管,不是难在加载,而是难在“混合”和“复用”两个词上。
普通的信息流列表,媒体形态相对统一,要么是九宫格图片,要么是视频流。图片可以交给加载库自动处理,视频流有成熟的 RecycleView + 播放器实践。但评论区不一样,一条评论可能同时带一张缩略图、一个 GIF 动图、一段 30 秒的视频预览,偶尔还有语音评论。这些媒体挤在一个高度复用的列表里,滚动速度往往比信息流还快——用户刷评论区很多时候是在“找自己关心的那条”,中途还会进进出出详情页。
更关键的是,RecyclerView 的 ViewHolder 是高频复用的。一个 ViewHolder 滑出屏幕后不会被立刻销毁,而是带着旧的 View 状态进入缓存池。下一次 bind 时,它可能被同一媒体类型复用,也可能被另一个位置的媒体复用。这就产生了一个很核心的问题:如果你在 ViewHolder 上直接操作播放器,你根本不知道这个播放器此刻属于谁、应该处于什么状态。
生命周期管理这个词听起来抽象,落到评论场景其实就是四个问题:
- 什么时候加载媒体资源(图片、视频首帧、音频 URL)
- 什么时候开始播放(视频、GIF、音频)
- 什么时候暂停(滑出可视区、页面进入后台)
- 什么时候真正释放(ViewHolder 被回收、页面被销毁)
任何一个问题答错了,就会复现我们后来排查到的各种怪现象:视频滑出去再滑回来黑屏、两条评论的声音同时响、快速滑动时图片闪旧图、内存里残留了一堆播放器实例。
1.2 直连控制的由来:它凭什么流行过
直连控制并不是一个设计出来的方案,它几乎是每个接手评论富媒体的人的第一反应。
需求很简单:评论列表里有视频,用户滑到这条评论时自动播放,滑出去就暂停。于是你自然会写:在onBindViewHolder里拿到这条评论的播放器,调用play();在onViewDetachedFromWindow里调用pause();在onViewRecycled里调用release()。这就是直连控制——业务代码和具体 View/播放器直接耦合,Adapter 亲自管理每一个媒体实例的生命周期。
这套写法的特点是简单、直观、需求能立刻满足,但问题出在“媒体类型一多就失控”。今天加一个视频播放器,你在 Adapter 里加一段逻辑;明天加一个音频播放,你又加一段。很快 Adapter 就变成了一个大杂烩:图片的清理逻辑、视频的暂停逻辑、音频的释放逻辑互相穿插。到了后面,改一处可能导致另一个媒体类型出问题,而且因为每个媒体都是独立处理的,状态完全没有统一管理,出问题只能靠打断点一步一步排查。
从演进的角度看,直连控制是必经之路,但它只适合媒体类型极少、列表不复杂的场景。一旦评论富媒体成为产品的核心模块,它必然会被重构。
1.3 失控的三个典型事故
我在第一版直连控制上线后,接到了三个典型事故,每一个几乎都可以复现。
第一个是“滑出去再滑回来,视频黑屏”。用户刷到一条视频评论,等它播了两秒后又往上滑走,再滑回来,视频区域黑屏,点击也没有反应。排查后发现,onViewDetachedFromWindow里执行了暂停,但onViewRecycled被触发时播放器被release()了,而 ViewHolder 又没有被销毁,它只是进入缓存池。被复用后,播放器是需要重新初始化才能用的。
第二个是“两条评论的声音混在一起”。当时的实现里,每个 item 都维护自己的播放器实例,滚动时各自判断是否可见、是否播放。结果快速滚动后,两个播放器同时处于播放状态,声音叠在一起,用户非常容易察觉到。
第三个是内存上涨。评论里的 GIF 动图比较多,Glide 加载动图本身没问题,但一旦 ViewHolder 一直复用,旧动图的引用没有及时清理,加上每个离开屏幕的 item 都保留了一个播放器实例,内存水位线肉眼可见地上涨。虽然用 Android Profiler 可以看到堆在涨,但定位到具体是哪一行代码就非常费劲,因为直连控制下每个媒体都有自己的一堆逻辑。
这三个事故让我意识到,问题不在某个具体实现不严谨,而在于“生命周期管理”这件事根本没有被当成一个独立模块来设计。于是我开始考虑引入生命周期代理。
2. 直连控制:看起来能用的写法,藏了哪些坑
2.1 直连控制的代码轮廓
先还原一下直连控制的真实代码长什么样。简化版大致如下:
override fun onBindViewHolder(holder: CommentViewHolder, position: Int) { val comment = comments[position] if (comment.mediaType == MediaType.VIDEO) { val player = playerPool.acquire() holder.playerView.player = player player.playWhenReady = true } } override fun onViewDetachedFromWindow(holder: CommentViewHolder) { super.onViewDetachedFromWindow(holder) holder.playerView.player?.pause() } override fun onViewRecycled(holder: CommentViewHolder) { super.onViewRecycled(holder) holder.playerView.player?.release() }如果你在真实项目里这样写过,你应该已经踩过坑了。onViewDetachedFromWindow和onViewRecycled是 RecyclerView 提供的回调,但它们不代表同一个语义:
onViewAttachedToWindow/onViewDetachedFromWindow:View 被 attach 或 detach 到 Window,可以理解为“滑入了可视区”和“滑出了可视区”。onViewRecycled:ViewHolder 被回收到 RecycledViewPool,表示它的 View 可以复用,但未必销毁。
直连控制最大的问题,就是把这两个回调当成了“暂停按钮”和“销毁按钮”。实际上,onViewRecycled并不意味着这个 ViewHolder 永远不再出现,它随时可能被onBindViewHolder重新拉出来用。真正的“销毁”其实是页面关闭或者池满被清理,这俩根本不是一回事。
2.2 四个最容易翻车的细节
把上面那几行简化代码放到真实评论场景里,至少有四个细节会翻车:
第一个,release()用得太早。ViewHolder 被回收入池后,release()直接销毁了播放器内部资源。紧接着用户再滑回来,RecyclerView 从池里取出同一个 ViewHolder,onBindViewHolder里又playerPool.acquire()拿了一个新播放器。如果你的播放器池没有做完善的复用校验,很容易出现“旧播放器已经被 release 但引用还在”的情况,调用它的play()就会抛异常,或者干脆黑屏。
第二个,onViewDetachedFromWindow过于频繁。RecyclerView 的 detached 不一定代表完全不可见,它可能只是 item 被移出窗口边缘一两个像素。在这个回调里无脑pause(),会导致用户滚动列表时视频一会儿播一会儿停,体验极差。你需要的是“真实可见性判断”,而不是一个简单的 detach 回调。
第三个,SurfaceView 的 flash 问题。用 SurfaceView 播放视频时,它的 Surface 是独立窗口,ViewHolder 复用时 Surface 可能重建,视频就会出现“黑屏闪烁”。直连控制下你很难把握SurfaceHolder的surfaceDestroyed和surfaceCreated时机,因为播放器实例和 ViewHolder 的生命周期已经错开了。
第四个,异步回调悬空。加载视频首帧、初始化播放器都是异步的,直连控制下你发起一个异步任务,用户却已经滑走了,这个任务完成后回调直接打在已经被复用的 ViewHolder 上,更新了错误的位置。直连控制没有一个“归属权”机制,异步结果不知道该交给谁。
2.3 什么时候用直连,什么时候必须换
我不是说直连控制一无是处。如果评论列表里只有静态图片,直连完全够用,甚至图片加载库自己就能处理大部分生命周期问题。但一旦出现以下三个信号,就该换方案:
- 评论里开始出现视频或音频,且数量占比超过 10%
- 已经有超过两种媒体类型需要手动管理
- 出现“滑出去再滑回来状态不正确”的线上反馈
这三个信号出现任意一个,直连控制的维护成本就会指数级上升。因为你需要在一个 Adapter 里维护多套相互独立的状态机,每一套状态机的时序都不同,一旦媒体多了,代码里全是if (mediaType == ...)这样的分支,加新类型就要把所有回调再改一遍。
3. 生命周期代理:把状态转移从业务里捞出来
3.1 代理模式的本质:视图状态到媒体状态的翻译器
生命周期代理这个名字听起来很唬人,它的本质就是一个翻译器:把 RecyclerView 产生的视图事件(attach、detach、recycled、scroll)翻译成媒体能理解的状态变化(加载、播放、暂停、释放)。
为什么要加这一层,而不是直接在业务里判断?因为视图事件和媒体行为之间并非一一对应。比如,ViewHolder detach 之后,一个处于“待播放”状态的视频应该暂停,但一个只加载了首帧的静态预览图却什么都不用做。再比如,ViewHolder 回收入池时,视频播放器应该被暂停并解除 Surface 绑定,但图片的 Bitmap 却可以保留。同一个事件,对不同媒体要执行不同的动作,这本身就是一组规则。把规则散落在 Adapter 里,就是直连控制;把规则收拢到一个代理对象里,就是生命周期代理。
你甚至可以这样理解:代理就像干洗店的前台,负责接收来自 RecyclerView 的各种“通知”(衣服送来了、顾客取走了、柜子满了),然后根据衣物的材质(图片、视频、音频)决定送去干洗还是水洗。Adapter 不再管衣物怎么洗,它只管把通知送到前台就行。
这个模式的另一个好处是:RecyclerView 不关心代理内部怎么实现,代理也不关心 RecyclerView 是列表还是网格,它们之间只通过几个简单的方法通信。耦合度降下来之后,新增一种媒体类型,只需要新增一个代理子类,不用再改动 Adapter 的一大堆回调。
3.2 媒体状态机怎么设计
一个可靠的评论富媒体代理,核心是一套状态机。我重构时把状态机设计成了这样:
IDLE -> LOADING -> READY -> PLAYING -> PAUSED -> IDLE | └--------> RELEASEDIDLE:初始状态,代理刚创建,还没有数据。LOADING:开始加载媒体资源,例如请求视频首帧、解析动图第一帧、准备播放器。READY:资源加载完成,但还没开始播放。比如视频首帧显示出来了,等待用户点击播放或者列表滚动停止后再自动播放。PLAYING:正在播放,视频播放中、动图动起来了、音频出声了。PAUSED:因为不可见、页面后台等原因暂停,但资源还在内存里。RELEASED:资源已释放,代理不可再用。
这六个状态之间的切换,不是随便切的。我在实现时给每个状态定义了两个核心方法:enter()和exit()。进入一个状态时清理掉上一个状态留下的东西,退出时打日志记录原因。比如从READY切到PLAYING,exit()里要确保 Surface 已经 ready;从PLAYING切到PAUSED,exit()里要记录播放位置,方便下次seekTo。
这里有一个容易被忽略的点:切换状态时必须记录触发原因。是用户手动点击播放,还是滚动停止后自动播放?是被页面主生命周期打断,还是被 Dispatcher 抢占?原因不同,恢复策略也不同。手动播放被暂停后,用户回来后应该自动恢复;自动播放被暂停后,用户回来后是否恢复要看可见性策略。如果不记录原因,后面排查“为什么视频没有自动恢复”会非常痛苦。
3.3 事件分发:Adapter 只做转发,代理只做决策
有了状态机,还需要一个事件分发中心。我把它叫做 Dispatcher,作用是把 RecyclerView 的事件广播给所有代理。
Adapter 的职责被压缩成三个字:做转发。onViewAttachedToWindow就把事件转发给 Dispatcher,onViewDetachedFromWindow也转发,onViewRecycled同样转发。但 Adapter 本身不处理任何业务,它甚至不需要知道当前 mediaType 是什么。
override fun onViewAttachedToWindow(holder: CommentViewHolder) { super.onViewAttachedToWindow(holder) dispatcher.onAttach(holder.mediaProxy) } override fun onViewDetachedFromWindow(holder: CommentViewHolder) { super.onViewDetachedFromWindow(holder) dispatcher.onDetach(holder.mediaProxy) } override fun onViewRecycled(holder: CommentViewHolder) { super.onViewRecycled(holder) dispatcher.onRecycle(holder.mediaProxy) dispatcher.unregister(holder.mediaProxy) }Dispatcher 内部维护一个代理集合,收到onAttach时就调用对应代理的onAttach()方法,同时更新这个代理在集合中的注册状态。收到onDetach也一样。这样每个代理的决策逻辑成为唯一入口,你在一个代理类里就能看清它全部的生命周期规则,而不用再到 Adapter 里翻来翻去。
Dispatcher 还有两个额外的职责:一是监听 RecyclerView 滚动状态,当滚动停止时触发一次可见性检查,让真正可见的自动播放类代理恢复播放;二是当整个页面进入后台时,统一把所有代理暂停。这两个职责如果压在 Adapter 上,Adapter 会膨胀到没法看。
3.4 和图片库自带绑定能力的配合
说到这里,一定会有人想到:图片加载库不是已经处理了生命周期吗?
确实如此。Glide 的with(view)会把 View 的生命周期绑定到请求上,View detach 时自动暂停加载,View attach 时自动恢复。Coil 也是类似机制,它甚至支持更细的 lifecycle 感知。所以对静态图片、GIF 动图这类资源,我不建议自己写一套代理,直接用加载库自带的生命周期绑定就足够。
问题在于:视频播放器和音频播放器没有这种开箱即用的绑定能力。以 ExoPlayer 为例,它内部有自己的状态机,但没有任何机制告诉它“我的 Surface 暂时不可见了”或者“这个 ViewHolder 被回收了”。这些东西必须由使用者自己处理。
所以我的实现里,代理有两种存在形式:
- 图片类代理:内部只是简单包装了 Glide 的加载逻辑,事件来了直接把关联的
RequestManager暂停或恢复。 - 视频/音频类代理:内部才真正实现复杂的状态管理和
onAttach/onDetach/onRecycle逻辑。
这种“混合”也没什么不好。代理模式的价值在于对外提供统一接口,对内允许不同实现。你甚至可以给图片和视频各写一套代理子类,但都实现同一个接口。这样 Adapter、Dispatcher 只和接口打交道,底层用什么库、怎么处理,完全由代理自己决定。
顺便说一句:如果你评论里的 GIF 动图占比很高,建议不要直接依赖 Glide 的自动暂停恢复机制。Glide 自动暂停是“加载层”的暂停,但动图占用的内存并不会立刻释放。评论场景里动图数量往往很大,我最终是把动图改成了“静态首帧 + 点击播放”的策略,成本一下子降下来了。这个细节在第 4 节里再展开。
4. 实操改造:从直连切到代理的落地过程
4.1 改造前的四件事
重构不是直接把代码从一套换成另一套就行,我建议在动手前先做四件事。
第一,盘点媒体类型。把你评论列表里出现的媒体类型列成一个表:静态图片、GIF、视频、音频、自定义 View 绘制的表情动画等等,标出各自的数据来源、加载方式、是否自带生命周期管理。这一步会暴露出很多你平时没注意到的“隐藏媒体类型”。
第二,明确滚动时的行为预期。产品对“自动播放”的预期是什么?是滑动到视频区域就自动播,还是需要停在屏幕上一段时间再自动播?是滑动停止后立即恢复播放,还是延迟 300ms?这些预期直接决定代理里状态机的分支逻辑。我当时的预期是:视频在 item 超过半屏可见且滚动停止 200ms 后自动播放;音频只有点击才播放,但播放中滑出屏幕自动暂停;GIF 默认展示首帧,点击后全屏播放。
第三,划定内存预算。评论列表媒体的内存预算不能是无限的。我给当时项目的建议是:图片 + GIF 首帧缓存占用不超过应用可用内存的 1/4,视频播放器实例不超过 2~3 个,音频实例不超过 2 个。这个预算在代理内统一执行,而不是每个 item 各自为战。
第四,建立日志开关。代理模式的好处之一是每个状态切换都集中在一起,非常适合加日志。改造前我就给代理状态机加了动态开关,所有的状态切换、事件来源、耗时都输出到日志。后面验证和排查问题,全靠这些日志。
4.2 代理中心与 Proxy 的实现
我用 Kotlin 重写了这套机制,核心接口长这样:
interface MediaProxy { fun bind(data: CommentData) fun onAttach() fun onDetach() fun onScrollStateChanged(state: Int) fun onHostPause() fun onHostResume() fun onRecycle() fun release() }bind(data)负责把评论数据绑定到代理上,内部这个时候只是把数据暂存,并不做真正的加载,真正的加载放到onAttach()里。这样设计是为了避免用户在快速滚动时,一个 item 刚滑入就立刻触发加载,大量资源请求堆积在 UI 线程。
Dispatcher 的实现里要处理好“注册”和“注销”的边界:
class CommentMediaDispatcher(private val hostLifecycle: Lifecycle) { private val proxies = mutableSetOf<WeakReference<MediaProxy>>() fun register(proxy: MediaProxy) { proxies.add(WeakReference(proxy)) } fun unregister(proxy: MediaProxy) { proxies.remove(WeakReference(proxy)) } fun onAttach(proxy: MediaProxy) { proxy.onAttach() } fun onDetach(proxy: MediaProxy) { proxy.onDetach() } fun onRecycle(proxy: MediaProxy) { proxy.onRecycle() unregister(proxy) } fun onScrollStateChanged(state: Int) { val snapshot = proxies.mapNotNull { it.get() } snapshot.forEach { it.onScrollStateChanged(state) } } fun releaseAll() { val snapshot = proxies.mapNotNull { it.get() } snapshot.forEach { it.release() } proxies.clear() } }这里有几个关键点:
- 弱引用集合:代理和 ViewHolder 的生命周期绑定在一起,如果 Dispatcher 强引用代理,一旦代理没被正确注销,就会造成内存泄漏。用
WeakReference至少能保证 ViewHolder 销毁时弱引用自动失效。 - 快照遍历:遍历代理集合时可能会触发某些代理的内部操作,这些操作又可能修改集合(比如
unregister),所以遍历前先拍一个快照,避免ConcurrentModificationException。 - 页面级而不是全局级:Dispatcher 强烈建议做成页面级别的实例,一个 Activity/Fragment 一个,而不是跨页面全局单例。全局单例的问题在于,多个列表同时存在时,事件会串台。
视频代理是实现里最复杂的。它的核心逻辑是在onAttach时判断当前状态:如果之前是PAUSED,说明只是滑出可视区导致暂停,应该尝试恢复;如果之前是RELEASED,说明资源已经释放,需要重新初始化。而onDetach时会先把播放位置记录到内存,再暂停播放器。onRecycle时解除 Surface 的绑定,并把播放器归还给播放器池。
4.3 图片、视频、音频三类媒体的代理细则
先说图片类。静态图片其实不太需要写代理,但评论里的图片,尤其是九宫格图片,需要统一处理onRecycle时的清理。我见过不少问题,滑动的图片在复用后短暂显示旧图,就是因为ImageView复用时没有清除旧的 drawable。正确做法是在代理的onRecycle()里调用加载库的清除方法,比如 Glide 的Glide.with(itemView).clear(),而不是手动调用bitmap.recycle()——后者在复用场景下极容易崩。
GIF 动图的处理是一个重要决策。直接用 Glide 播放动图,内存占用大概是同等尺寸静态图的好几倍,评论区动图一多,内存非常容易顶不住。我当时改成了“静态首帧 + 点击播放”的方案:评论列表里只显示静态首帧,用户点击后进入全屏播放动图。这个方案的代价是用户需要多点一次,但换来的是列表滑动性能的显著改善。如果你的产品确实需要动图在列表里自动动起来,请至少对 GIF 做一个“只播放当前可见且滚动静止时”的策略,文中第 6 节会提到可见性判定。
视频类代理是生命周期管理中投入最多的部分。几个关键决策:
- 不要每个 item 创建新播放器,用播放器池。我当时维护了 3 个 ExoPlayer 实例,同一个播放器可以顺序绑定到不同的 item。这样无论列表里有几百条视频评论,真正存在于内存中的播放器只有 3 个。
- 用 TextureView 还是 SurfaceView?快速滚动的评论列表里,SurfaceView 的 Surface 复用容易闪黑屏,我第一次重构用的就是 SurfaceView,踩了这个坑后切到 TextureView,体验稳定很多。如果你最终必须用 SurfaceView,要确保在
SurfaceHolder的surfaceDestroyed回调里先解除播放器绑定,而不是等到onViewRecycled才处理。 - 视频首帧加载用 Thumbnail 而不是直接从视频流解码。ExoPlayer 的
MediaItem虽然能拉流播放,但首帧交给 Glide 处理往往更快更省电。等到真正需要播放时,再让播放器接管。
音频类代理最容易被遗忘,但它有一个特殊问题:声音的“侵入性”比画面强得多。画面可以在不可见时直接暂停,音频如果也这样,用户会觉得声音断断续续。所以音频代理的暂停策略是:当 item 滑出可视区时,不立即暂停,而是给一个 300ms 的容忍窗口,如果在窗口内又滑回可视区,就继续播放;否则暂停。同时,音频代理和所有“会出声”的媒体共享一个互斥规则:同一时刻只有一个代理可以处于PLAYING状态,我是在 Dispatcher 里通过一个activeSpeaker字段来实现的。
4.4 改造后怎么验证效果
改造完成后,我总结了一套验证手段,不只是“跑起来不崩”。
第一,滚动静止测试。写一个自动化遍历脚本,反复上下滚动评论列表,滑 20 屏后再回到第一屏,检查所有 item 的显示是否正常、视频是否还能播、有没有黑屏或串音。滚动速度分三档:缓慢滑动、正常滑动、快速 fling。
第二,回收入池测试。把 RecyclerView 的缓存池大小调大或调小,观察同一个 ViewHolder 被反复 bind 时的表现。重点看onViewRecycled之后再次onBindViewHolder时,视频是否还能续播上次的进度,图片是否出现旧图闪烁。
第三,内存和卡顿检测。用 Profiler 观察每 5 秒一次的内存快照,对比重构前后的内存水位。同时开启dumpsys gfxinfo,看列表滑动时的帧耗时。一个合格线是:评论列表快速滑动时丢帧率不超过 10%,内存增长在一分钟内回落到预算线以下。
第四,异步竞态模拟。在代理里加入一个“延迟回调”的调试开关,比如视频首帧加载故意延迟 500ms,观察代理被 recycle 后再回调时是否正确丢弃了这个结果。这一步能提前堵住很多真实网络下的时序问题。
5. 常见问题与排查技巧实录
5.1 已经被回收的代理还在回调
现象:视频首帧加载完成回调时,播放器已经被 release,调用play()直接崩。
原因:异步任务没有检查代理当前状态。视频加载是异步的,绑定后用户很快滑走,代理进入RELEASED,但回调仍然执行。
解决方案:在代理里维护一个自增的 generation 计数。每次bind()时 +1,异步回调回来时如果发现 generation 已经变了,就说明这个回调已经过期,直接丢弃。这个技巧在快速滚动的评论列表里几乎是必需品,因为一个 ViewHolder 一秒内可能被 bind 三次。
5.2 快速滑动时的状态风暴
现象:快速 fling 时,一个 item 从被 attach 到 detach 可能只间隔几十毫秒,状态机在LOADING、PLAYING、PAUSED之间来回跳,卡顿明显。
原因:事件频率太高,代理每次收到事件都立刻做状态切换,导致做了一堆无用功。
解决方案:引入“状态稳定窗口”。当收到 detach 事件时,不立即暂停,而是延迟 100ms 再执行;如果窗口内重新 attach,就取消暂停。同理,滚动停止后也不立即恢复播放,等待 200ms 确认滚动真的停了再切回PLAYING。这个阈值要根据实际列表滚动速度调,我当时用 200ms,效果稳定。
5.3 声音打架问题
现象:多条视频评论同时播放,声音混在一起;或者一条评论刚滑出去,音频已经停了,但另一条评论的视频又开始播,听感像“声音乱跳”。
原因:没有统一的“唯一出声”规则,或者只对视频做了互斥,忘了音频。
解决方案:在 Dispatcher 增加互斥注册表。任何代理进入PLAYING之前都要先申请“出声权”,申请成功的唯一代理才能继续;其他代理要么暂停,要么被降级成静音播放。具体实现时,我是在onScrollStateChanged里先让所有代理的自播规则失效,再由滚动停止后的可见性检查统一决定谁有资格出声。
5.4 内存回收与缓存池边界
现象:onViewRecycled时如果真的release()了视频播放器,那么该 item 再次滑入时需要重新初始化,表现为“视频每次重新进入都要等加载”。如果不 release,把播放器保留在 ViewHolder 上,内存里又会积累大量播放器实例。
原因:没有区分“回收入池”和“真释放”两种场景。
解决方案:明确两者的边界。ViewHolder 回收入池,只是 View 进入缓存池,媒体资源可以保留一部分(比如视频暂停在当前位置,图片保留 bitmap),但播放器要归还到播放器池而不是直接销毁。只有两种情况下才真正释放:一是代理所在的 ViewHolder 被销毁(RecyclerView 池满清理或页面销毁),二是 Dispatcher 的整体releaseAll()被调用。为了确定这个边界,我在MediaProxy.release()里加了一个日志字段,记录被释放的原因,这样排查内存问题时一眼就能看出哪些播放器没有正确归还。
我把遇到过的典型问题和排查思路整理成了速查表:
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 滑出去再滑回来视频黑屏 | ViewHolder 回收入池时播放器被释放 | 改为暂停并归还播放器池,滑入时重新绑定 Surface |
| 快速滚动时图片闪旧图 | ImageView 复用时未清除旧 drawable | 代理 onRecycle 里执行加载库 clear |
| GIF 动图多导致内存上涨 | Glide 播放动图内存占用高 | 改静态首帧 + 点击播放,或提升可见性阈值 |
| 两个视频同时出声 | 自动播放判定各自为政 | Dispatcher 统一申请出声权 |
| 音频滑出后播放断断续续 | 立即暂停导致体验割裂 | 增加 300ms 容忍窗口 |
| 空状态内容闪烁 | detach 事件太频繁导致暂停恢复反复 | 状态稳定窗口 100~200ms |
6. 再往前一步:可见性判定、预算控制与预加载
6.1 用真实可见面积而不是 item position 判断
很多直连控制的实现里,判断一个 item 是否可播,用的是LayoutManager的findFirstVisibleItemPosition()和findLastVisibleItemPosition(),只要 position 在范围内就认为可播。这其实不够准确。
一个 item 可能只露出 5% 的边缘,另一个 item 被遮挡了一大半,但 position 都在“可见范围”内。如果都自动播放,用户还没看清内容就白白加载了。我的做法是计算真实可见面积:
fun isHalfVisible(holder: RecyclerView.ViewHolder): Boolean { val rect = Rect() if (!holder.itemView.getGlobalVisibleRect(rect)) { return false } val visibleArea = rect.width().toLong() * rect.height() val totalArea = holder.itemView.width.toLong() * holder.itemView.height return visibleArea * 10 >= totalArea * 5 }getGlobalVisibleRect()会结合屏幕坐标和 item 的可见部分,自动处理遮挡。这里用“超过 50% 可见”作为自动播放的阈值,你可以根据产品需求调整,但不要低于 30%,否则边缘露一点就播,浪费流量又打断滚动。
这个函数要在滚动停止后调用,不能在滚动过程中频繁调用,否则每一帧都在计算 Rect,性能扛不住。
6.2 给评论区设一个媒体预算
评论列表的媒体资源预算意识,是我后来才补上的。
所谓预算,就是“在任意时刻允许存在多少个正在播放的媒体、多少个加载中的媒体、多少个已加载待播放的媒体”。没有预算,所有 item 都觉得自己应该加载资源,结果就是带宽、内存、CPU 全被占满。
我当时设了三层预算:
- 播放预算:同时最多 1 个出声,2 个视频播放器实例,1 个音频播放器实例。
- 加载预算:列表中同一时刻最多预加载 5 个 item 的媒体资源,超出部分排队等待。
- 内存预算:评论区域图片缓存总量上限设为应用可用内存的 1/4,超出后按 LRU 淘汰。
预算机制放在 Dispatcher 里,代理在进入LOADING状态前向 Dispatcher 申请额度,额度满了就保持IDLE,直到其他代理释放额度。这个机制看着简单,实际效果非常好——评论区内存水位从之前的频繁告警,变成了一条很平稳的线。
6.3 预加载的正确姿势
预加载和缓存池是两个容易混淆的概念。缓存池是 ViewHolder 层面的复用,预加载则是资源层面的提前准备。
正确姿势是:在滚动方向的前方 3~5 个 item 处,提前触发资源的加载。比如用户往下滑,当前第 10 条评论显示到一半,我就让第 11、12、13 条评论的图片首帧开始预加载。RecyclerView 本身有setItemPrefetchEnabled()控制布局预取,但媒体资源的预加载要靠代理里的preload(targetPosition)方法完成。
预加载要遵守两条纪律:
- 只在滚动方向确定后再预加载,不要在滚动方向不确定的路口提前加载。滚动停止时不做预加载,因为此时用户可能向任意方向滑。
- 预加载的资源一定是最低成本版本。图片用缩略图尺寸,视频只加载首帧,音频不预加载。这样即使预判错误,浪费的流量也有限。
踩过最深的坑是在评论列表刚进入页面时把所有可见 item 的完整视频资源全部预加载,结果用户在 WiFi 下没问题,切到移动网络就被用户投诉流量消耗大。后来改成:移动网络下只预加载首帧,点击播放时才拉取视频流。
我做完这一版重构后,最大的感受是:评论富媒体的生命周期管理,本质上不是在管理“媒体”,而是在管理“规则”——什么事件触发什么行为、什么时候该沉默、什么时候该出声、资源什么时候该退场。生命周期代理不是银弹,但它把散落的规则收敛到了可维护的边界里。你如果正在改一块类似的评论列表,建议先从最简单的视频代理写起,跑通一个代理、理顺 Dispatcher 的事件流,再慢慢把图片和音频接进来,这个过程会比想象中顺利很多。