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()
三点说明:
setAllowCrossProtocolRedirects(true)不开的话,跨协议 302 会直接失败,日志里只看到一句很模糊的Source error。Referer走setDefaultRequestProperties而不是逐个塞,这样 302 跳转到 CDN 时会自动带上。- 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 的开机自启动与桌面横幅适配。