ExoPlayer 库的加载与初始化

说到 ExoPlayer 的初始化,很多人上来就是 new ExoPlayer.Builder(context).build(),然后就开始怼播放逻辑。嗯,这么做项目是能跑,但线上卡顿、首帧慢、甚至内存暴涨,往往就埋在这第一步里。

我优化过好几个播放器启动的场景,说白了,初始化阶段的核心就两件事:库的加载 和 Player 实例的创建。这两步做不好,后面再好的解码策略也白搭。

库加载——被忽略的启动瓶颈

ExoPlayer 的库加载主要分两个层面:

  • Gradle 依赖声明:你引入的模块越多,ProGuard 或 R8 在打包时合并的 class 就越多,APK 体积自然大,但这不是重点。
  • 运行时首次加载:当应用冷启动、第一次调用 ExoPlayer.Builder 时,ClassLoader 需要把相关的 Renderer、DataSource、MediaSource 等类全部加载到内存。

关键指标:我监控过一个项目,纯粹因为依赖了用不到的扩展(比如 RTMP、HLS 的某些解析器),导致 Player 创建耗时从 8ms 飙升到 120ms。后者发生在冷启首帧时,用户直接感觉「点播放后黑屏了 1 秒」。

为什么会有这么大的差距?因为 ExoPlayer 的类加载是按需懒加载的,但 Player.Builder 在 build() 时会触发一次内部的 DefaultRenderersFactory 初始化,里面会根据你注册的扩展,去加载对应 Renderer 的 Class。如果你不小心引入了非必要的依赖,这些类就会被加载进来,即使你根本不使用它们。

个人建议:严格使用 implementation 而不是 api,并且只引入你真正需要的 module。例如只播放 HLS 就只加 exoplayer-hls,别顺带把 exoplayer-rtmp 也拖进来。

初始化流程——逐阶段解析

我们来看标准的 ExoPlayer 初始化流程,我把它拆成 5 个阶段:

  1. Builder 创建:配置带宽、缓存、渲染器工厂等。
  2. build() 调用:实例化内部组件(TrackSelector、LoadControl、MediaSource.Factory 等)。
  3. setMediaSource():解析媒体来源,准备底层数据源。
  4. prepare():启动播放器内部状态机,开始缓冲区准备。
  5. setPlayWhenReady(true):真正开始渲染。

其中 build() 和 prepare() 是最容易踩坑的地方。我曾经在团队里看到有人把 setMediaSource 和 prepare 写在 UI 线程里,导致丢帧掉得飞起。

// 错误示范:主线程直接阻塞
ExoPlayer player = new ExoPlayer.Builder(context).build();
MediaSource source = new ProgressiveMediaSource.Factory(dataSourceFactory)
        .createMediaSource(MediaItem.fromUri(url));
player.setMediaSource(source);
player.prepare();  // 可能触发IO或网络
player.setPlayWhenReady(true);

我曾经接手过一个项目,上述代码跑在 Activity.onCreate() 里,用户首页加载慢得像 PPT。定位后发现 prepare() 内部会同步创建 DefaultTrackSelector 的默认音视频选择逻辑,耗时约 30ms。解决方案是:把 prepare() 放到子线程或者使用异步 prepare(ExoPlayer 从 2.12 开始支持 prepare() 异步调用)。

优化手段:懒加载与预加载的平衡

初始化优化的核心思路其实就四个字:按需启动。别一股脑把所有东西都塞进 App 启动阶段。

优化策略适用场景延迟收益
延迟创建 Player用户点击播放才初始化冷启阶段完全无负担
预创建 Player 但延迟 prepare列表页提前创建实例减少点击后创建耗时
使用 PlayerView 的自动管理只需布局占位精简代码但牺牲控制力

我个人的习惯是:只在用户真正进入播放页时创建 Player。如果你用的是首页有视频、但用户可能滑动切换的场景,可以提前 500ms 在后台线程 build() 出一个 Player 实例,缓存起来。但注意:千万别提前调用 prepare(),因为不知道用户究竟要点哪个视频。

小技巧:如果需要预创建 Player,可以这样:

// 后台线程预构建
new Thread(() -> {
    ExoPlayer player = new ExoPlayer.Builder(context)
            .setRenderersFactory(new DefaultRenderersFactory(context))
            .setLoadControl(new DefaultLoadControl())
            .build();
    // 存入弱引用池,待用户点击时取出使用
    playerPool.put(player);
}).start();

注意 DefaultRenderersFactory 的构造函数允许传入 extensionRendererMode 参数,设置成 EXTENSION_RENDERER_MODE_OFF 可以跳过扩展加载,进一步提速。

初始化阶段的整体结构

下面这张图把整个初始化链路的节点和时序整理了一下,你可以直观看到哪些环节可以并行或延迟。

ExoPlayer 初始化流程框架图 App 调用 Builder 配置 build() 创建 Player setMediaSource() prepare() setPlayWhenReady 渲染 & 缓冲 💡 说明: build() 与 prepare() 是耗时大头, 可考虑后台线程执行或延迟执行。 🔧 优化点: 1. 按需引入 module 2. 预创建 Player 但不 prepare 3. 使用 DefaultRenderersFactory 关闭扩展

避坑指南

  • 内存泄漏:我曾经在一个模块里把 ExoPlayer 实例作为 Activity 的成员变量,但没在 onDestroy 里 release,导致整个 Activity 无法被回收。一定要注意生命周期绑定。
  • 重复初始化:如果用户在列表页快速滑动了十几次,每次滑出都创建和销毁 Player,那初始化开销会被放大。建议使用 ViewHolder 复用时复用 Player 实例,或者使用对象池。
  • 线程阻塞:new DefaultHttpDataSource.Factory() 默认不设置 connectTimeout,某些弱网环境下 DNS 解析会卡住主线程。我习惯在 Factory 里显式设置超时: .setConnectTimeoutMs(5000).setReadTimeoutMs(8000)。

总结一句话:初始化阶段的性能调优,就是「能懒则懒,能异步则异步,能复用则复用」。你想想看,用户点播放按钮的那一瞬间,系统要加载几十个类、创建一堆对象,如果我们能让这些工作在后台悄悄完成,用户感知到的就是瞬间响应。