
评论区陆续加入了图片、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/onViewDetachedFromWindowView 被 attach 或 detach 到 Window可以理解为“滑入了可视区”和“滑出了可视区”。onViewRecycledViewHolder 被回收到 RecycledViewPool表示它的 View 可以复用但未必销毁。直连控制最大的问题就是把这两个回调当成了“暂停按钮”和“销毁按钮”。实际上onViewRecycled并不意味着这个 ViewHolder 永远不再出现它随时可能被onBindViewHolder重新拉出来用。真正的“销毁”其实是页面关闭或者池满被清理这俩根本不是一回事。2.2 四个最容易翻车的细节把上面那几行简化代码放到真实评论场景里至少有四个细节会翻车第一个release()用得太早。ViewHolder 被回收入池后release()直接销毁了播放器内部资源。紧接着用户再滑回来RecyclerView 从池里取出同一个 ViewHolderonBindViewHolder里又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切到PLAYINGexit()里要确保 Surface 已经 ready从PLAYING切到PAUSEDexit()里要记录播放位置方便下次seekTo。这里有一个容易被忽略的点切换状态时必须记录触发原因。是用户手动点击播放还是滚动停止后自动播放是被页面主生命周期打断还是被 Dispatcher 抢占原因不同恢复策略也不同。手动播放被暂停后用户回来后应该自动恢复自动播放被暂停后用户回来后是否恢复要看可见性策略。如果不记录原因后面排查“为什么视频没有自动恢复”会非常痛苦。3.3 事件分发Adapter 只做转发代理只做决策有了状态机还需要一个事件分发中心。我把它叫做 Dispatcher作用是把 RecyclerView 的事件广播给所有代理。Adapter 的职责被压缩成三个字做转发。onViewAttachedToWindow就把事件转发给 DispatcheronViewDetachedFromWindow也转发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 mutableSetOfWeakReferenceMediaProxy() 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 里执行加载库 clearGIF 动图多导致内存上涨Glide 播放动图内存占用高改静态首帧 点击播放或提升可见性阈值两个视频同时出声自动播放判定各自为政Dispatcher 统一申请出声权音频滑出后播放断断续续立即暂停导致体验割裂增加 300ms 容忍窗口空状态内容闪烁detach 事件太频繁导致暂停恢复反复状态稳定窗口 100~200ms6. 再往前一步可见性判定、预算控制与预加载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 的事件流再慢慢把图片和音频接进来这个过程会比想象中顺利很多。