Android TV 直播播放器开发实录:ExoPlayer 播 FLV 直播流 + 遥控器交互的 8 个坑

家里换了台电视,想在电视上用遥控器自由选台看直播,但市面上的应用交互都别扭,于是干脆自己写了一个。前后迭代了几十个版本,踩了不少坑,这篇文章把其中最有价值的 8 个坑和几段可复用的代码整理出来。

技术栈很简单:Kotlin + Android Media3 (ExoPlayer),没有任何第三方 SDK,整包不到 7MB。

本文讨论的都是播放器开发与 Android TV 交互层面的技术,示例地址统一使用 example.com。


一、播放链路:把一条 FLV 直播流跑起来

直播源拿到手是一个 HTTP 链接,直接跳转到真正的 FLV 直播流地址。配置起来不难,关键是几个容易忽略的参数:

player = ExoPlayer.Builder(context)
    .setMediaSourceFactory(
        DefaultMediaSourceFactory(
            DefaultDataSource.Factory(
                context,
                DefaultHttpDataSource.Factory()
                    .setUserAgent(MOBILE_UA)
                    .setDefaultRequestProperties(mapOf(
                        "Referer" to PAGE_URL,   // 服务端校验来源
                        "Origin" to PAGE_ORIGIN
                    ))
                    .setAllowCrossProtocolRedirects(true)   // http → https 跳转必须开
                    .setConnectTimeoutMs(8000)
                    .setReadTimeoutMs(12000)
            )
        )
    )
    .build()

三点说明:

  1. setAllowCrossProtocolRedirects(true) 不开的话,跨协议 302 会直接失败,日志里只看到一句很模糊的 Source error。
  2. Referer 走 setDefaultRequestProperties 而不是逐个塞,这样 302 跳转到 CDN 时会自动带上。
  3. MIME 类型显式指定,否则 ExoPlayer 要先嗅探几个字节才能判断容器,直播场景下这是纯浪费的起播时间:
val isFlv = url.contains("type=.flv") || url.endsWith(".flv")
val item = MediaItem.Builder()
    .setUri(url)
    .setMimeType(if (isFlv) MimeTypes.VIDEO_FLV else MimeTypes.APPLICATION_M3U8)
    .build()

Media3 1.3.1 的 DefaultExtractorsFactory 里 FlvExtractor 是默认包含且无条件创建的,不需要额外依赖(我用 javap 反编译确认过)。


二、起播慢?调小 LoadControl 的缓冲

ExoPlayer 默认按「点播」来配缓冲,直播场景完全用不上:默认要攒够几秒缓冲才出画面,体感上就是"按了半天才出图"。

直播直播,出画优先,卡顿可以靠自动换线解决:

val loadControl = DefaultLoadControl.Builder()
    .setBufferDurationsMs(
        1500,   // minBufferMs
        12000,  // maxBufferMs
        500,    // bufferForPlaybackMs          ← 出画面最快多少
        1000    // bufferForPlaybackAfterRebufferMs
    ).build()

player = ExoPlayer.Builder(context).setLoadControl(loadControl).build()

bufferForPlaybackMs 从默认的 2500 调到 500,起播体感差异非常明显。


三、遥控器交互:别指望系统焦点,自己画高亮

这是整个项目里最耗时间的一块,也是最容易被低估的一块。

最初我用了标准的 ListView + 系统焦点,结果在真机上方向键按了没反应、选中的行也没有任何背景色。后来我放弃了焦点模型,改成完全由自己控制的选中状态:

private inner class RowAdapter(private var items: List<String>) : BaseAdapter() {
    var selected = 0
        set(value) { field = value; notifyDataSetChanged() }   // 赋值即刷新

    override fun getCount() = items.size
    override fun getItem(position: Int) = items[position]
    override fun getItemId(position: Int) = position.toLong()

    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
        val tv = (convertView as? TextView) ?: TextView(this@Act).apply {
            textSize = 17f
            setPadding(36, 22, 36, 22)
            setTextColor(Color.WHITE)
        }
        tv.text = items[position]
        // 选中行自己画蓝底,不依赖任何 focus/selector
        tv.setBackgroundColor(if (position == selected) SELECTED_BG else TRANSPARENT)
        return tv
    }
}

按键全部在 Activity 层统一处理:

when (keyCode) {
    KEYCODE_DPAD_UP, KEYCODE_DPAD_DOWN -> {
        val delta = if (keyCode == KEYCODE_DPAD_UP) -1 else 1
        val np = (adapter.selected + delta).coerceIn(0, adapter.count - 1)
        adapter.selected = np
        listView.setSelection(np)
        return true   // 吞掉,避免透传给别的组件
    }
    KEYCODE_DPAD_CENTER, KEYCODE_ENTER -> {
        onConfirm(adapter.selected)
        return true
    }
}

收益:高亮是自己 draw 的,任何盒子、任何主题下都一定看得见;按键完全确定,不存在"焦点跑到别处"的诡异问题。代价:要自己维护左右两个列表之间的焦点切换。

同样的思路用在设置页:不要给按钮写死 setBackgroundColor()——那会把系统的焦点态直接覆盖掉,遥控器移过去毫无反馈。改成 StateListDrawable:

fun focusBg(normal: Int, focused: Int) = StateListDrawable().apply {
    addState(intArrayOf(android.R.attr.state_focused), ColorDrawable(focused))
    addState(intArrayOf(), ColorDrawable(normal))
}

四、切台卡:三招把响应时间压下来

直播应用的交互爽感,几乎全在"换台快不快"上。我的经验是三件事:

1. 不要预探测

我最早写成"先请求每条线路测一次能不能通,再开始播",结果最慢的一条要等 4 秒,10 条线就是灾难。改成直接开播、失败再换下一条,由 onPlayerError 驱动:

override fun onPlayerError(err: PlaybackException) { autoSwitchLine() }

2. 后台预取当前高亮的那一行

列表滚动停止 1.2 秒后,后台悄悄把这一行的播放地址先取好。等用户真按下 OK,缓存已就位,几乎秒开:

private val prefetchTask = Runnable {
    val ch = target ?: return@Runnable
    if (resolver.hasCache(ch.tid, ch.id)) return@Runnable   // 已有缓存就别再请求
    Thread { resolver.resolve(ch.tid, ch.id) }.start()
}
private fun schedulePrefetch(ch: Channel) {
    target = ch
    handler.removeCallbacks(prefetchTask)
    handler.postDelayed(prefetchTask, 1200)   // 去抖动
}

3. 缓存用「每频道独立锁」

这里踩过一次很典型的坑:我原来把解析方法整段 synchronized(this),后台预取和用户切台共用一把锁。于是出现的现象是——预取正在跑,用户按方向键切台就"卡住不动了",看起来像 bug,实际是在排队等锁。

// ❌ 错误:全局锁,预取会阻塞用户的切台请求
synchronized(this) { ... }

// ✅ 正确:每频道一把锁,互不干扰
private val locks = ConcurrentHashMap<String, Any>()

fun resolveLines(tid: String, id: String): List<String>? {
    val key = "$tid-$id"
    val lock = locks.getOrPut(key) { Any() }
    synchronized(lock) {
        cache[key]?.let { if (!it.expired) return it.lines }
        ... // 请求并解析
    }
}

另外把缓存 TTL 从 8 分钟放到 30 分钟,已经看过的频道切回去是瞬时的,完全不发网络请求。


五、稳定性:三个"画面不动了"的处理

电视应用的用户不会看日志,画面不动就是 bug,所以恢复逻辑必须做得又快又自动。

1. 从后台返回后画面定格

直播流暂停后调用 play() 往往是没用的——isPlaying 返回 true,但画面仍停在旧帧。正确做法是重新建立一路 HTTP 连接:

override fun onResume() {
    super.onResume()
    if (wasStopped) {
        wasStopped = false
        val url = lastPlayedUrl ?: return
        beginLoading()
        playUrl(url, currentChannel)   // 用记住的地址直接重连,不用重新解析
    }
}

因为地址是上次拿到的,重连不需要再重新请求源站,几百毫秒就恢复了。屏幕上同时保留一个「▶ 继续播放」按钮作为兜底。

2. 加载提示一闪就没

切台时旧频道还在播放,它一有缓冲波动就触发 onIsPlayingChanged(true),把刚弹出的加载遮罩直接关掉了——用户看到的就是"没有加载提示"。

加一个标记位,只有这次要播的新频道真正起播才收起:

private var awaitingStart = false

fun beginLoading() { awaitingStart = false; mask.visibility = VISIBLE }
fun playUrl(...)    { awaitingStart = true; player.prepare() }

override fun onIsPlayingChanged(p: Boolean) {
    if (p && awaitingStart) { awaitingStart = false; hideLoading() }
}

3. 断了自动回来

播放器报错 → 换下一条线路 → 全部试完 → 强制重新解析(可能是缓存里地址过期了)→ 还不行才提示用户。同时请求自带 3 次重试,单次网络抖动不会让用户看到失败页。


六、踩坑实录(按血量排序)

① URL 里的 + 号被服务端当成空格

临时播放凭证里带 +,如果在传输前用 URLDecoder 解码过一次,+ 会变成空格,服务端校验必失败,返回 404。解析得到的地址要原样传递,不要再多做一次 URLDecode。 这个坑从日志上看就是"Source error / code=2004",完全查不出原因,我对着地址逐字节比对才定位。

② setLineSpacing(6f, 0f) 把所有文字叠在一起

第二个参数是行高倍数,传 0 意味着行高为 0,多行文字全部糊成一团乱码。正确写法:

textView.setLineSpacing(0f, 1.15f)   // add, multiplier

③ 全屏主题下 AlertDialog 创建直接抛异常

Activity 用的是 android:Theme.Material.NoActionBar.Fullscreen,AlertDialog.Builder(...).show() 会失败;更糟的是我用 try/catch 兜底成了"异常就直接退出",表现就是点退出没有任何提示就闪退了。

电视端更稳妥的做法是自绘浮层:一个半透明 FrameLayout + 卡片 + 两个可聚焦按钮,完全不依赖系统对话框主题。

④ 按钮写死背景色会吞掉焦点态

setBackgroundColor() 会覆盖整个 background drawable,焦点高亮随之消失。用 StateListDrawable 按 state_focused 切换。

⑤ 播放器回调的时序要看清楚

onPlaybackStateChanged(STATE_READY)、onIsPlayingChanged 这几个回调,在换媒体源时会按你意想不到的顺序来。别在任意一个回调里都去"关掉 loading",用 awaitingStart 这样的业务标记去收敛。

⑥ 网络请求失败要区分"没网"和"服务端返错误码"

PlaybackException.errorCode 里 2001 是网络连不上,2004 是拿到了 HTTP 错误状态码。这两个原因的排查方向完全相反,我一开始没做区分,浪费了大量时间在"是不是网络问题"上。

⑦ 全局锁 / 异步并发会让"卡顿"看起来像 bug

见第四节第 3 点。任何后台任务都不要持有和用户交互路径相同的锁。

⑧ 后台任务的进度提示会干扰 UI

bringToFront() 确保加载遮罩在所有浮层之上;否则它会被后添加的面板挡住,用户以为没有加载状态。


七、写在最后

电视应用和手机应用的开发体感差别很大:

  • 没有触摸,所有交互都要用遥控器一遍遍试;
  • 焦点与高亮是最高优先级,看不清"选了什么"就等于不能用;
  • 容错要求高,用户不看日志,画面不动就是产品坏了,必须自动恢复;
  • 直播流是"续命型"数据源,任何一次断连都要有确定的重连路径。

把这两类逻辑做扎实,应用的体验会立刻上一个台阶。

源码已开源,欢迎交流: https://github.com/personal82555/gangao-tv

如果这篇文章对你有帮助,点个赞、收藏一下,下篇打算聊聊 Android TV 的开机自启动与桌面横幅适配。