播放器实例的创建与复用优化

播放器实例的创建,说白了就是 new 一个 ExoPlayer 对象出来。很多人觉得这步没什么,不就是 new 一下吗?但我告诉你,这个操作背后有不少隐藏成本。我自己在优化一个短视频应用时,发现仅仅因为频繁创建和销毁播放器,启动延迟就多出来 300ms 以上。

为什么创建播放器这么“贵”?

ExoPlayer 的创建不是简单的 new 对象。它会做以下几件事:

  • 初始化内部的渲染器、解码器、音轨选择器
  • 创建 HandlerThread 和 Looper 用于内部消息循环
  • 加载默认的 LoadControl、TrackSelector 等组件
  • 绑定到 Surface/TextureView(如果有)
  • 预分配缓冲区(取决于 LoadControl 配置)

嗯,这些操作加起来,在低端机上可能超过 100ms。你想想看,如果每次进入页面都重新创建一次,用户得等多久?

关键指标: 创建一个 ExoPlayer 实例(无 Surface)的平均耗时约 50~120ms,具体取决于设备和配置。复用实例可直接消除这部分开销。

复用策略:单例 vs 对象池

复用播放器实例是启动优化的老药方。我个人习惯分两种情况来用:

1. 页面级复用(推荐)

如果应用里只有少数几个页面需要播放视频(比如 Feed 流 + 详情页),直接在 Application 里持有一个全局播放器实例,谁用谁拿。缺点是不能同时播放多个视频,但 90% 的场景够用了。

// 简单单例实现
public class PlayerManager {
    private static ExoPlayer player;
    
    public static ExoPlayer getInstance(Context context) {
        if (player == null) {
            player = new ExoPlayer.Builder(context).build();
        }
        return player;
    }
}
注意: 单例模式下切换播放源时必须调用 player.stop() 并重置 MediaItem,否则会残留上一曲的状态。我曾经遇到过因为没清理,导致切换时闪一下黑屏的问题。

2. 对象池复用(多实例场景)

如果应用需要同时播放多个视频(比如画中画、多视角直播),单例就不够用了。这时可以用一个固定大小的对象池,比如最多 3 个播放器实例。

public class PlayerPool {
    private final ArrayDeque<ExoPlayer> pool = new ArrayDeque<>(3);
    
    public synchronized ExoPlayer acquire(Context context) {
        ExoPlayer player = pool.poll();
        if (player == null) {
            player = new ExoPlayer.Builder(context).build();
        }
        return player;
    }
    
    public synchronized void release(ExoPlayer player) {
        player.stop();
        player.clearMediaItems();
        pool.offer(player);
    }
}

这样既控制了内存占用,又避免了每次都 new。注意释放时要重置内部状态,防止泄漏。

配置优化:别在 Builder 里堆太多东西

很多人在创建播放器时往 Builder 里加一堆自定义组件:

new ExoPlayer.Builder(context)
    .setRenderersFactory(...)
    .setMediaSourceFactory(...)
    .setLoadControl(...)
    .setTrackSelector(...)
    .build();

这会让创建时间成倍增加。我的建议是:只在 Builder 里设置你真正需要覆盖的组件,其余的用默认实现。默认的 DefaultRenderersFactory 和 DefaultLoadControl 已经经过了大量优化,没必要自己重写。

配置项是否必须改?影响
RenderersFactory通常不改改可能导致解码器选择逻辑改变,增加初始化耗时
LoadControl按需改比如设置缓冲区大小,但默认的 DefaultLoadControl 已经够用
TrackSelector按需改比如强制使用某种音轨,改后影响很小
MediaSourceFactory很少改除非你要自定义 DASH/HLS 解析逻辑
经验之谈: 我以前在一个直播项目中,为了个性化缓冲策略改了 LoadControl,结果发现创建时间多了 40ms,而且缓冲效果几乎没变。最后乖乖用回默认。说白了,默认的往往是最好的。

避坑指南:复用时的状态清理

复用播放器最大的坑就是状态残留。还记得我前面说的那个黑屏问题吗?具体做法:

  • 复用前调用 player.stop()
  • 调用 player.clearMediaItems() 清空播放列表
  • 重置 player.seekToDefaultPosition()
  • 如果之前设置了 Volume,记得恢复默认值

另外,如果你使用了 Player.Listener,复用时一定要解绑旧的监听器,否则会收到上个页面的回调,造成奇怪的 bug。我曾经因为没解绑,导致播放器切回来时自动暂停,用户反馈“点播放没反应”。排查了半天才发现是旧监听器里调了 player.pause()。

一张图理解创建与复用的本质

下面这张图展示了播放器实例从创建到复用的完整逻辑。核心就是:能复用就别创建,能轻量就别堆配置。

播放器实例创建与复用策略 触发播放请求 是否有可用实例? (池中是否有空闲) 是 从池中取出 复用实例 否 创建新实例 new ExoPlayer 配置 Builder 默认/轻量配置 清理状态 stop/clearMediaItems 准备播放 开始播放 复用可节省 50~120ms 创建耗时 清理状态是关键,否则容易出 bug

总结一下

播放器实例的创建与复用优化,核心就两件事:

  1. 能用复用就别新建 —— 单例或对象池,根据并发需求选
  2. 能轻量就别堆配置 —— 保留默认组件,只覆盖必要项

这两个动作加起来,能让你的启动速度提升 100~200ms。在短视频、直播这类对首帧时间敏感的场景下,这 100ms 可能就是用户体验的天壤之别。嗯,今天就先聊到这儿,你可以在自己的项目里试试看。