列表中item是视频,怎么优化它的打开速度

75 阅读4分钟

0. 结论概览(按收益排序)

  1. 预加载下一条/上一条(先 prepare() 后换 Surface 渲染)。
  2. 共享 OkHttpClient + ExoPlayer 缓存(SimpleCache) ,复用连接 & 切片。
  3. 播放器池(2–3 个)或单例 + 预热,避免频繁建/释解码器。
  4. 合理的缓冲阈值(降低 bufferForPlaybackMs,提高 minBufferMs)。
  5. 首帧占位图 & 无缝切换(缩放方式一致,收到首帧回调再隐藏)。
  6. 选择合适封装(MP4 需 moov 在前 / HLS 首分片小、GOP 短)。
  7. 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),调度更激进、更稳(可选)。


参考执行顺序(落地模板)

  1. App 级别初始化 OkHttp + SimpleCache + MediaSourceFactory

  2. 列表页面创建 PlayerPool(2–3 个)。

  3. 首次进入:

    • 取中心位 i,playerA.prepare(url[i]) 并绑定 Surface 播放;
    • 并行 playerB.preload(url[i+1])、playerC.preload(url[i-1])。
  4. 滚动停下:

    • 找新中心 j:如果已有预热播放器,直接换 Surface 并 play()
    • 旧播放器回收进池,去预热 j±1。
  5. 占位图:与视频缩放策略一致,收到 onRenderedFirstFrame 隐藏。