0. 结论概览(按收益排序)
- 预加载下一条/上一条(先 prepare() 后换 Surface 渲染)。
- 共享 OkHttpClient + ExoPlayer 缓存(SimpleCache) ,复用连接 & 切片。
- 播放器池(2–3 个)或单例 + 预热,避免频繁建/释解码器。
- 合理的缓冲阈值(降低 bufferForPlaybackMs,提高 minBufferMs)。
- 首帧占位图 & 无缝切换(缩放方式一致,收到首帧回调再隐藏)。
- 选择合适封装(MP4 需 moov 在前 / HLS 首分片小、GOP 短)。
- Surface 优化(优先 SurfaceView,可提前绑定 Surface)。
1. 媒体层:缓存 + 共享网络栈
// 1) 全局唯一 OkHttpClient(复用 TCP/TLS)
val okHttp = OkHttpClient.Builder()
.retryOnConnectionFailure(true)
.build()
// 2) ExoPlayer 媒体缓存(磁盘 LRU 1GB 示例)
val cacheDir = File(context.cacheDir, "media_cache")
val cache = SimpleCache(cacheDir, LeastRecentlyUsedCacheEvictor(1L shl 30))
// 3) DataSource: 先读缓存再走网络
val httpFactory = DefaultHttpDataSource.Factory()
.setConnectTimeoutMs(5000)
.setReadTimeoutMs(15000)
.setAllowCrossProtocolRedirects(true)
// 或者 OkHttpDataSource.Factory(okHttp)
val cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(httpFactory)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
// 4) MediaSourceFactory
val mediaSourceFactory = DefaultMediaSourceFactory(cacheDataSourceFactory)
作用:连接复用减少 TLS 握手、本地缓存命中减少首帧等待、进出列表二次播放更快。
2. 播放器池(或单例)与预加载
方案 A:小型播放器池(推荐 2–3 个)
object PlayerPool {
private const val MAX = 3
private val players = ArrayDeque<ExoPlayer>()
fun obtain(ctx: Context, mediaSourceFactory: MediaSourceFactory): ExoPlayer {
return players.removeFirstOrNull() ?: ExoPlayer.Builder(ctx)
.setMediaSourceFactory(mediaSourceFactory)
.setLoadControl(
DefaultLoadControl.Builder()
.setBufferDurationsMs(
/*minBuffer*/ 12_000,
/*maxBuffer*/ 50_000,
/*bufferForPlayback*/ 250, // 降低首帧门槛
/*afterRebuffer*/ 500
).build()
).build()
}
fun recycle(player: ExoPlayer) {
if (players.size < MAX) {
player.clearVideoSurface()
player.stop() // 保留已加载的连接池/解码器缓存
players.addLast(player)
} else {
player.release()
}
}
}
方案 B:单例 + 预热
也可单一 ExoPlayer,附近条目走 prepare() 预加载,滚动时只切 Surface 和 playWhenReady。但遇到频繁切流时,双/三实例更稳(当前播放、下一个预缓冲、上一个待复用)。
3. 预加载下一条 / 上一条(关键)
在用户看到第 i 条时,预加载 i±1:
/** 预加载(不渲染) */
fun preload(player: ExoPlayer, url: String) {
val item = MediaItem.fromUri(url)
player.setMediaItem(item, /*resetPosition=*/true)
player.playWhenReady = false
player.prepare() // 拉元数据 + 缓冲前几百毫秒
// 不绑定 Surface,等真正可见时再绑定即可
}
列表滚动监听里:
- 停下后找到靠近中心的 position 作为目标;
- 当前播放 playerA,提前让 playerB prepare(nextUrl);
- 当用户滑到下一条:把可见 View 的 Surface 绑定到已准备好的 playerB 并 play() ;同时把 playerA 回收到池子&去预热下一条。
4. 绑定/解绑 Surface(避免重新建解码器)
// ViewHolder 里
override fun onViewAttachedToWindow(holder: VH) {
val surface = holder.textureView?.let { Surface(it.surfaceTexture) }
val player = acquirePreparedPlayerFor(position) // 你自己的映射/管理
player.setVideoSurface(surface) // 不重复 prepare
player.playWhenReady = true
player.addListener(object : Player.Listener {
override fun onRenderedFirstFrame() {
holder.placeholder.isVisible = false // 首帧到达后隐藏占位
}
})
}
override fun onViewDetachedFromWindow(holder: VH) {
val player = playerOf(holder.adapterPosition) ?: return
player.pause()
player.clearVideoSurface() // 不 release,回收到池,快速复用
recycle(player) // PlayerPool.recycle(...)
holder.placeholder.isVisible = true // 下次复用时又能平滑
}
SurfaceView 优先(更省功耗/少拷贝);如 UI 叠加复杂可用 TextureView。
5. 首帧“无缝”显示:占位图 + 缩放一致
- 列表里先显示首帧缩略图(服务端下发/本地提取),与视频同等比例/裁剪策略。
- 收到 onRenderedFirstFrame() 再隐藏占位,做到无闪烁。
holder.thumb.scaleType = ImageView.ScaleType.CENTER_CROP
player.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
// Compose 则保证 Image 与视频渲染视图 contentScale 一致
6. 媒体文件与 CDN 友好性(超高收益)
- MP4:确保 moov 原子在文件头(FFmpeg:-movflags +faststart),否则必须下载完尾部索引才能播,极慢。
- HLS/DASH:首分片尽量小(~1s),关键帧间隔 GOP 短(1s 左右),初始自适应码率偏低一点更快起播。
- Range 支持:服务器支持 Range 请求,客户端可先拉首段迅速出图。
7. 滚动层优化(RecyclerView / Compose)
- SnapHelper + OnScrollListener:停住后只让中心项播放,其他全部暂停/解绑。
- 预取:LinearLayoutManager.setInitialPrefetchItemCount(n);Compose 用 LazyListState + 你自己的预加载调度。
- “只在可见”加载:利用 onViewAttached/Detached / DisposableEffect 管理绑定/解绑,防止越界播放。
8. 其他细节参数
- LoadControl(媒体3):
val loadControl = DefaultLoadControl.Builder()
.setBufferDurationsMs(12_000, 50_000, 250, 500)
.build()
-
- bufferForPlaybackMs=250:降低首帧需要的缓冲量 → 更快起播。
- minBufferMs=12s+:滑动后减少二次 rebuffer。
- SeekParameters:如果你的策略会 seekTo(0) 以对齐关键帧,可用:
player.seekParameters = SeekParameters.CLOSEST_SYNC
-
前台模式(服务场景) :player.setForegroundMode(true),调度更激进、更稳(可选)。
参考执行顺序(落地模板)
-
App 级别初始化 OkHttp + SimpleCache + MediaSourceFactory。
-
列表页面创建 PlayerPool(2–3 个)。
-
首次进入:
- 取中心位 i,playerA.prepare(url[i]) 并绑定 Surface 播放;
- 并行 playerB.preload(url[i+1])、playerC.preload(url[i-1])。
-
滚动停下:
- 找新中心 j:如果已有预热播放器,直接换 Surface 并 play() ;
- 旧播放器回收进池,去预热 j±1。
-
占位图:与视频缩放策略一致,收到 onRenderedFirstFrame 隐藏。