前言
写过大屏 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。但很多国产盒子用蓝牙私有遥控,需要单独适配:
语音接入最简单的方式——直接用系统 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 输出跟仿真器完全是两个栈。
必备测试矩阵
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 与焦点系统白皮书