ExoPlayer 的线程模型与主线程交互

做 ExoPlayer ANR 排查,绕不开一个核心问题:线程模型。这玩意儿要是不搞明白,你连 ANR 是怎么触发的基本都理不清。

我记得刚接手项目时,线上报了个颇诡异的 ANR,日志指向 Thread-6 里有个 Looper.loop() 卡住了。排查半天才发现——那根本不是主线程,而是 ExoPlayer 内部的一个渲染线程因为外部回调阻塞了主线程的消息队列。嗯,今天我们就来把这层关系彻底捋清。

ExoPlayer 的线程家族

ExoPlayer 自己维护了一套内部线程池,不直接跑在主线程上。这么做的好处是不阻塞 UI,但坏处是——如果你不懂它内部怎么跟主线程通信,分分钟踩坑。

ExoPlayer 线程模型与主线程交互 主线程(UI) Looper.loop() 消息队列 Handler 分发 Player.Listener UI 更新 ExoPlayer 内部线程池 播放器线程 时序调度、状态机 读取数据请求 渲染线程 音视频解码 帧渲染输出 数据加载线程 网络/本地 I/O 缓存管理 音频处理线程 AudioTrack 写 音效处理 回调通过 Handler post 到主线程队列
嗯,这里要注意:ExoPlayer 内部线程的个数不是固定的。比如你用了多个 MediaSource,加载线程可能会创建多个。但无论如何,它们都通过 Handler 跟主线程沟通。

主线程到底在等什么?

先看看官方文档怎么说——ExoPlayer 的 Player.Listener 和 AnalyticsListener 默认都在你创建 player 的那个线程里回调。绝大多数情况下就是主线程。也就是说,你在回调里写的任何代码,都在主线程的 Looper.loop() 里跑。

那 ANR 是怎么来的?说白了就是:主线程的消息队列被堵住了。ExoPlayer 内部线程把回调事件 post 到主线程,但主线程正在干别的事(比如你的 onPlayerStateChanged 里做了网络请求),队列里的消息一直没被处理,过 5 秒——ANR。

线程角色 职责 与主线程交互方式 ANR 风险点
播放器线程 媒体时序、状态转换 通过 Handler 通知状态改变 回调中做耗时 I/O
渲染线程 解码、帧输出 渲染完成回调 post onRenderedFirstFrame 执行阻塞
数据加载线程 下载/缓存 加载状态回调 onLoadCompleted 里做同步锁
音频处理线程 AudioTrack 写入 音频延迟/错误回调 onAudioSessionId 中初始化第三方库

这些回调,就是 ANR 的引爆点

我见过最典型的两个坑:

  1. 在 Player.Listener 回调里直接调 Thread.sleep() 或网络请求——这都不用多说,主线程只要 sleep 超过 5 秒必 ANR。
  2. 在回调里用 Future.get(timeout) 等待内部线程结果——如果内部线程也在等主线程(死锁),那直接炸。
  3. 把 Looper.myLooper() 当主线程 Looper 用,但 player 创建在后台线程——这时候回调跑在后台线程,但你自己在回调里又 post 了消息到“主线程”,结果混乱。

关键原则:ExoPlayer 的所有回调都默认在创建 player 的线程执行。如果你在主线程创建 player,那所有回调都在主线程。任何阻塞主线程的操作,都是 ANR 的种子。

避坑指南:我曾经踩过的雷

我曾经接手过一个视频直播项目,线上 ANR 率一直 0.3% 降不下来。抓了个堆栈,怎么看都是 Handler.dispatchMessage 卡住,但主线程代码里明明没耗时操作。后来我用 systrace 一拉,发现 ExoPlayerImplInternal.handleMessage 里调了 onPlayerError,而我恰好在 onPlayerError 回调里做了数据库写操作——用的是主线程的 Room 同步写,直接卡住消息队列。

从那以后我养成了一个习惯:所有 ExoPlayer 回调里,只做状态记录和轻量 UI 刷新,如果需要做网络或数据库,一律切到后台线程。代码就像下面这样:

player.addListener(
  object : Player.Listener {
    override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
      // 只更新 UI 状态(轻量)
      binding.playPauseButton.isVisible = playbackState == Player.STATE_READY
      // 需要写数据库,切到后台
      lifecycleScope.launch(Dispatchers.IO) {
        repo.savePlaybackState(playbackState)
      }
    }
  }
)

警告:即使你切到了 Dispatchers.IO,也要注意 lifecycleScope 的生命周期。如果回调发生在 Activity 已销毁但协程还在跑,可能引发内存泄漏。我个人建议用 viewModelScope 配合 lifecycle.repeatOnLifecycle。

怎么判断线程模型是否影响了 ANR?

拿到 ANR 日志后,先看主线程堆栈。如果 android.os.Handler.dispatchMessage 上面有 com.google.android.exoplayer2.Player.Listener 相关的调用,基本就是这个问题。

我自己习惯再跑一份 Thread.getAllStackTraces() 的 dump,确认内部线程是否卡在等待主线程的某个资源上。比如加载线程调了 Future.get() 等主线程的缓存结果,那双向等待就出现了。

说白了,ExoPlayer 的线程模型本身没毛病,用好了相当高效。但一旦你忘记“回调在主线程”这个事实,ANR 就离你不远了。记住:回调里只做轻量操作,耗时任务全切到后台线程——这句话能帮你解决 80% 的 ExoPlayer ANR 问题。