播放准备阶段的 ANR 分析

说到播放器 ANR,我个人的经验里,准备阶段 是最容易翻车的地方。为什么呢?因为 ExoPlayer 在准备阶段干的活特别杂——要拉 manifest、要建 DataSource、要初始化解码器,随便哪个环节卡住五秒以上,系统就给你弹个“无响应”对话框。

我记得有一次线上反馈,用户点开视频就卡死,复现率接近 40%。最后查到是主线程里直接调了 prepare(),而那个视频源是一个响应很慢的 HLS 流。你看,这坑早在准备阶段就埋下了。

核心观点:播放准备阶段的 ANR 多数源于 主线程被 IO 操作或同步初始化阻塞。排查时需要盯住 prepare 到 onPrepared 之间的时间线。

1. 准备阶段的完整流程

ExoPlayer 从 player.prepare() 到回调 Listener.onPrepared(),中间会依次执行这些事情:

  • 创建 MediaSource(例如 HlsMediaSource、DashMediaSource)
  • 内部创建 ChunkSource 并初始化 DataSource.Factory
  • 开始加载 Manifest(HLS 的 .m3u8、DASH 的 .mpd)
  • 解析 Manifest,构建轨道数组
  • (如果是本地文件)直接读取元数据
  • 初始化解码器(或等待解码器预热)
  • 回调 onPrepared

这里面任何一步放在主线程里做,一旦网络超时或磁盘阻塞,ANR 就来了。

💡 我的习惯:拿到一个播放 ANR 日志,先看 main 线程堆栈里有没有 ExoPlayerImpl 或 MediaSource 相关的帧。如果有,十有八九是准备阶段被卡住。

2. 常见的 ANR 场景

场景根因典型堆栈片段
网络 Manifest 加载卡死 主线程调用 prepare(),内部同步网络请求超时
DefaultHttpDataSource.open()
    at … DataSourceInputStream.read()
解码器初始化慢 同步创建 MediaCodec,厂商定制层耗时过长
MediaCodec.configure()
    at ExoPlayerImpl$1.handleMessage()
MediaSource 构建过重 在主线程中创建带大量预加载的 MediaSource
HlsMediaSource.()
    at MainActivity.play()

我有个亲身经历:某直播 App 在 Android 9 上频繁 ANR,堆栈都落在 MediaCodec.configure() 上。排查后发现是某款芯片的解码器初始化锁冲突,而 ExoPlayer 默认在主线程同步等待解码器就绪。后来我们在 prepare() 之前提前 warm up 解码器实例,才把 ANR 压下去。

3. 排查工具与方法

准备阶段的 ANR 排查,我一般按这个顺序来:

  1. 抓取 ANR trace 文件:从 /data/anr/traces.txt 或 BugReport 里找到主线程堆栈。
  2. 对比时间线:利用 Looper.getMainLooper().getThread().getStackTrace() 加上 log 输出,或者直接上 Systrace。
  3. 看 ExoPlayer 内部日志:开启 ExoPlayer.setLoggingLevel(Log.INFO),留意 Prepare 和 onPrepared 的时间戳。
  4. 检查 DataSource:确认是否使用了 DefaultHttpDataSource 且没有设置 connect timeout 或 read timeout。
⚠️ 曾经踩过的坑:有的同学把 prepare() 放到子线程里,以为能规避 ANR。但 ExoPlayer 内部仍然会在子线程回调某些同步操作(比如解码器配置),如果那个子线程是 HandlerThread 且消息队列被阻塞,回调照样卡住。我建议统一使用 prepare(Looper) 并确保子线程 Looper 不处理耗时任务。

4. 核心流程图:准备阶段 ANR 关键路径

下图展示了播放准备阶段容易产生 ANR 的节点。灰色框是主线程必经历程,红色框是高危卡点。

播放准备阶段 ANR 关键路径 player.prepare() 创建 MediaSource 加载 Manifest(网络/文件) 解析 Manifest 初始化解码器(同步) onPrepared() 回调 🔴 红色节点: 容易引发 ANR 的高危点 (网络/解码器阻塞)
看图要点:红线框出的两个节点——网络 Manifest 加载和解码器初始化,是最容易在主线程上阻塞的操作。排查时重点看这两个阶段的时间戳。

5. 快速定位代码示例

当你在 ANR trace 里看到类似下面的调用栈时:

"main" prio=5 tid=1 Blocked
  at com.google.android.exoplayer2.source.hls.HlsMediaSource.prepareSourceInternal(HlsMediaSource.java:123)
  at com.google.android.exoplayer2.ExoPlayerImpl.prepare(ExoPlayerImpl.java:456)
  ...

这就说明 prepareSourceInternal 里做了同步网络请求。我通常会在 DataSource.Factory 的创建处加两个关键参数:

DefaultHttpDataSource.Factory factory = new DefaultHttpDataSource.Factory()
    .setConnectTimeoutMs(3000)   // 3 秒连接超时
    .setReadTimeoutMs(3000);     // 3 秒读取超时

同时确保 player.prepare() 是在后台线程执行:

// 正确的做法
HandlerThread playThread = new HandlerThread("exo:play");
playThread.start();
Player player = new ExoPlayer.Builder(context)
    .setLooper(playThread.getLooper())
    .build();
player.prepare(mediaSource);

注意:即使 prepare 在后台线程,如果 Looper 的消息队列里后续有耗时操作,依然可能延迟回调。所以我习惯单独开一个专用于播放的 HandlerThread,不和 UI 线程混用。

🔧 小技巧:在 onPrepared 回调中打印 currentPosition 和 SystemClock.elapsedRealtime(),对比 prepare() 调用时间,就能算出精确的准备耗时。如果超过 3 秒,就要怀疑网络或解码器问题了。

6. 总结排查 Checklist

  • ✅ 确认 prepare() 是否在主线程执行(建议统一使用 setLooper 到后台线程)
  • ✅ DataSource 超时设置是否合理(建议 connect + read 合计 ≤ 6 秒)
  • ✅ MediaSource 是否在 UI 线程创建(高耗时操作如创建 HlsMediaSource 应后移至后台线程)
  • ✅ 解码器初始化是否同步阻塞(可尝试提前 warm up 解码器或使用 MediaCodecList 预查询)
  • ✅ 抓取完整 ANR trace,对比主线程堆栈是否存在 ExoPlayer 内部类的锁等待
⚠️ 特别提醒:不要为了赶进度而盲目将 prepare() 丢到异步线程后就撒手不管。记住,ExoPlayer 的内部状态机对线程安全要求较高,随意切换 Looper 可能导致状态不同步。建议始终使用同一个 Looper 执行所有播放操作。

准备阶段的 ANR 其实是最容易复现和修复的——只要你把 IO 操作从主线程剥离,并严格控制超时。我当年踩过的坑,基本都离不开“主线程等网络”或“主线程等解码器”这两个老朋友。希望今天的内容能帮你少走弯路。