Administrator
发布于 2026-08-26 / 33 阅读
0
0

系列文章《Android 工程师的 100 篇进阶笔记》第 94 篇:Android TV 与大屏适配:焦点系统、10ft UI 与 D-pad 工程

前言

写过大屏 App 的工程师最常吐槽:明明模拟器跑通,到客厅一打开就焦点飘、文字被切、遥控器按半天没反应。

跟手机 App 最大的区别:你不能相信用户手指会点在某个像素上。他用 1.2GHz 处理器的盒子 + 红外遥控器,跟你交互的工具就叫"上下左右确认返回"。

这一篇只讲工程实践,不讲消费电子行业趋势。

一、先理解 10ft UI:屏幕不是 5 寸

10-foot UI(10 英尺用户界面)指的是用户坐在沙发上离屏幕 10 英尺(约 3 米)。从这个距离:

  • 最小可读字号 24sp(标题 32sp+);

  • 焦点框(focus ring)至少 3dp 宽,且对比度 ≥ 3:1;

  • 任何焦点区域必须在 D-pad 单次按键可达的距离内;

  • 滚动操作必须有"页"的概念,不能"滚一行"。

工程上意味着字号、间距、可滚动元素数量、点击热区全部要重新调。

二、Leanback 还是 Compose for TV

2025 年 Leanback 已经 deprecated,但很多存量项目还在用。新项目建议直接用 Compose for TV:

dependencies {
  implementation("androidx.tv:tv-foundation:1.0.0")
  implementation("androidx.tv:tv-material:1.0.0")
}

@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun TvApp() {
  TvTheme {
    ImmersiveList(
      background = { BackdropImage() },
      list = { featuredList }
    ) { item ->
      CarouselCard(item)
    }
  }
}

TvTheme 自动将焦点态颜色、按下态 inset、音效 haptics 全面集成。不必像 Leanback 那样手写 OnFocusChangeListener。

三、焦点系统:D-pad 才是真主

手机焦点是被动概念——点哪儿亮哪儿。TV 焦点是主动概念——用户按方向键,焦点跳到你预设的下一个元素。

关键:不要破坏默认 focusOrder

@Composable
fun TvGrid() {
  val listState = rememberLazyListState()
  LazyColumn(state = listState) {
    items(items) { Row { Cell(it) } }
  }
  // 默认会按"上->上->上"遍历列表项;不要拦截 D-pad Key.DirectionUp
}

如果某行用自定义 ViewGroup 实现焦点遍历,要重写:

override fun focusSearch(focused: View?, direction: Int): View? {
  // 1. 找到当前焦点行
  // 2. 根据 direction 找下一行同一列的 View
  // 3. 跨行找上一行的"对应列"元素(焦点记忆)
}

Compose for TV 里这逻辑改用 Modifier.focusRestorer():

LazyRow(
  modifier = Modifier.focusRestorer { firstItem }
) {
  items(...) { Card(it) }
}

focusRestorer 会让"焦点跳出 LazyRow 重新回来"时落到上次进入的位置,避免焦点丢失到默认首项。

四、横屏一切:UImode 与 Configuration

TV 应用必须横屏。在 AndroidManifest 里:

<activity
  android:screenOrientation="sensorLandscape"
  android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" />

横屏要重新设计布局:左导航 + 右主区是经典的"10-foot UI"结构。不要直接把手机竖屏的 LinearLayout 倒过来填满屏幕。

五、必备:Leanback Launcher 集成

要让 App 在 TV 主屏显示 banner、出现在推荐行里,需要 TvActivity(继承 FragmentActivity)并声明:

<meta-data
  android:name="android.app.banner"
  android:resource="@drawable/tv_banner" />
<meta-data
  android:name="androidx.leanback.PREVIEW_KEY_URI"
  android:value="content://..." />

同时把启动器配置进 <intent-filter>:android.intent.category.LEANBACK_LAUNCHER。

六、远控协议:HDMI CEC、蓝牙遥控器、语音

多数电视盒支持 HDMI CEC,能让电视遥控器控 App。但很多国产盒子用蓝牙私有遥控,需要单独适配:

方案

触发

代码

HDMI CEC

物理按键事件,统一映射

onKeyDown(KEYCODE_BUTTON_SELECT)

蓝牙遥控器

厂商定制键码

处理 KeyEvent.KEYCODE_* 数字键

语音

SearchManager.ACTION_SEARCH 或 MediaSession

Activity.startActivityForResult

手机 App

配套 App

LocalSocket + 自定义协议

语音接入最简单的方式——直接用系统 Search:

val intent = Intent(Intent.ACTION_SEARCH).apply {
  putExtra(SearchManager.QUERY, userUtterance)
  startActivityForResult(intent, REQ_VOICE_SEARCH)
}

七、性能:盒子性能是手机 1/3

实测:小米盒子 S 4K 的 CPU 跑 Geekbench 单核 1100 分,比 Pixel 8 的 2000+ 差一半以上。这意味着:

  • 绝不在主线程跑 IO;用 Dispatchers.IO 默认是后台线程,盒子 SDK 上是分开的。

  • 不让列表渲染阻塞——Crossfade 动画用 200ms,别用 spring(spring 在动画执行期间会一直 invalidate)。

  • 预加载——MainPage 的数据在 onCreate 第一次进入就拉,不要等焦点到了再拉。

  • 图片加载——Coil 在盒子端默认 cache 100MB 不合理,改 30MB,因为盒子存储普遍 8/16GB。

八、测试:投屏 + 真盒子 + 仿真器

仿真器跑的是 Android TV (1080p) 镜像,几乎所有焦点逻辑都能模拟。但音频/视频播放必须真盒子——解码器、DRM、HDMI 输出跟仿真器完全是两个栈。

必备测试矩阵

设备

系统

关注

NVIDIA Shield TV

Android TV 14

性能基准

小米盒子 S

MIUI for TV 14

蓝牙遥控

Apple TV 4K(如果上 tvOS)

tvOS 18

对比工程

仿真器

Android TV 14

焦点调试

ADB 调试盒子:adb connect <盒子IP>:5552 后用 adb shell input keyevent KEYCODE_DPAD_DOWN 直接模拟遥控器按键,不需要手动按。

九、避坑清单

  • 不要使用 setOnClickListener 给 ImageView,必须 setOnFocusChangeListener + 自定义焦点框;

  • 不要在 onResume 重新加载数据,会让焦点跳回顶部;

  • 不要把播放/暂停按钮设为默认焦点,远控用户进入页面时焦点应落在第一项;

  • 横屏申请了 windowFullscreen,但不要 hide status bar,TV 上 status bar 用来显示时间、电量。

小结

TV App 是工程上"显式比隐式好"的典型——焦点要主动给、可达性要主动算、资源要主动释放。和折叠屏的"被动响应"思路完全不同。下一篇文章我们换个话题:架构师软技能——技术决策、跨团队影响力与团队工程文化。

参考阅读

  • Compose for TV 官方指南(developer.android.com/tv)

  • 10-foot UI 设计规范

  • D-pad 与焦点系统白皮书


评论