随着智能手机和平板电脑的爆炸式增长,电视成为了下一个联网“智能”设备。对于智能电视而言,现在占有率最广的就是 Android 系统了。
Android TV 是谷歌开发的互动电视平台,于 2014 年在其 I/O 大会上发布。
而 Compose for TV 是谷歌为构建 Android TV 应用设计的新 UI 框架,Compose for TV 融合了适用于 TV 应用的 Android Jetpack Compose 的所有优势,可让您更轻松地为应用构建功能强大且美观出众的界面。
前言
前面章节学习了TV Leanback 库,今天我们来了解和学习下基于Jetpack库的Compose for TV如何开发TV。
兼容性
Compose for TV 适用于搭载 Android 5.0(API 级别 21)或更高版本的 Android TV。 若要使用 1.0 版 Compose for TV,您需要使用 1.3.0 版 androidx.compose 库和 Kotlin 1.7.10。
设置
在 Android TV 上使用 Jetpack Compose 与将 Jetpack Compose 用于任何其他 Android 项目相似。主要区别在于,Compose for TV 添加了一些库,这些库提供针对 TV 进行了优化的组件,可让您更轻松地创建为 TV 量身定制的界面。
模块分层
Compose模块 | 说明 |
---|---|
Material | 此模块位于最上层,基于 Material Design 系统实现的各种 Composable,同时提供了基于 Material Design 的 主题,图标等。 |
Foundation | 此模块为UI提供了一些基础的 Composable,例如 Row, Column, LazyColumn等布局类UI,以及特定手势识别等,这些基础 Composable 在很多平台都可以通用。 |
UI | UI层的功能众多,包含了多个模块(ui-text, ui-graphics, ui-tooling等),这些模块构筑了上层 Composable运行的基础,例如 Composable 的测量,布局,绘制,事件处理以及 Modifier管理等。 |
Runtime | Compose 通过UI树的diff驱动界面刷新,此模块提供了基本的对UI树的管理能力。此模块 还 提供了 Compose 运行时的基本组件,例如 remember、mutableStateOf、@Composable 注释 和 SideEffect。 |
使用 Compose for TV 的一些具体优势包括:
- 灵活性:Compose 可用于创建任何类型的界面,从简单的布局到复杂的动画。组件是可直接使用的,但也可以进行自定义和样式设置,以满足您的应用的需求。
- 简化开发流程并加快开发速度:Compose 与现有代码兼容,让开发者可以使用更少的代码更高效地构建应用。
- 直观:Compose 使用声明式语法,让您能够更改界面、调试、理解和审核代码。
Compose for TV
-
添加依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.02.00")
implementation(composeBom)
// General compose dependencies.
implementation("androidx.activity:activity-compose:1.10.0")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
//Compose for TV 库提供了专为大屏幕设计的专用组件
// Compose for TV dependencies.
implementation("androidx.tv:tv-material:1.0.0")
implementation("androidx.tv:tv-foundation:1.0.0")
} -
清单配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45<manifest>
/*
* 通过声明 android.software.leanback 功能,声明您的应用是专为 Android TV 打造的。
* 如果您的应用同时在移动设备和 TV 上运行,请将 required 属性值设置为 false。
* 如果您将 required 属性值设为 true,Google Play 只会将您的应用提供给 Android TV OS。
*/
<uses-feature android:name="android.software.leanback"
android:required="false" />
//声明触摸屏并非必需,此设置会将您的应用标识为能够在 TV 设备上运行,否则您的应用不会出现在 TV 设备上的 Google Play 中。
<uses-feature android:name="android.hardware.touchscreen"
android:required="false" />
//android:banner横幅属性,请配置使用尺寸为 320 x 180 像素的 xhdpi 资源。文字必须包含在图片中。
<application
android:banner="@drawable/banner" >
...
<activity
android:name="com.example.android.MainActivity"
android:label="@string/app_name" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name="com.example.android.TvActivity"
android:label="@string/app_name"
android:banner="@drawable/banner"
android:theme="@style/Theme.Leanback">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
/*
* 如果您不在应用中添加 LEANBACK_LAUNCHER intent 过滤器,那么在 TV 设备上运行 Google Play 的用户将看不到您的应用。
* 此外,如果您的应用没有此过滤器,当您使用开发者工具将其加载到 TV 设备上时,该应用不会出现在 TV 界面中。
*/
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
<manifest>
在 Android 12 及更高版本中,Android TV 应用不支持使用 SplashScreen 平台 API 构建的自定义启动画面动画。
主要类
- VerticalGrid/HorizontalGrid:替代传统 RecyclerView,实现纵向/横向滚动列表,支持焦点自动对齐与遥控器导航优化。
- TvNavHostController:管理 TV 应用导航栈,支持跨页面跳转与返回逻辑(如从主页到详情页)。
- 焦点处理(Focus):
- FocusRequester/FocusManager-显式控制组件焦点获取(如页面初始焦点定位到首个按钮)。
- Modifier.focusable-功能:标记组件为可聚焦状态,适配遥控器操作。
- Modifier.onKeyEvent-拦截遥控器按键事件,实现自定义交互逻辑。
- TvTheme:TV 专用主题,优化大屏显示效果(如字体大小、间距等)。
- TvSurface:用于背景容器,支持动态主题切换与阴影效果。
- TvCard:标准化内容卡片布局(如电影海报+标题展示)。
- TvPager/Carousel:实现横向分页滚动(如推荐内容轮播)。
- TvLazyRow/TvLazyColumn/ImmersiveList:基于 LazyRow/LazyColumn 扩展,替代传统 RecyclerView,优化 TV 场景下的滚动性能与焦点记忆。
- TvNavigationMenu:侧边导航栏专用组件,支持图标与文字混合排版。
- LaunchedEffect 和 SideEffect:用于执行副作用,如网络请求或数据库操作,LaunchedEffect 在 Composition 中执行,而 SideEffect 在布局计算后执行。
开发问题
-
适配问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14val displayMetrics = LocalContext.current.resources.displayMetrics
val fontScale = LocalDensity.current.fontScale
val density = displayMetrics.density
val widthPixels = displayMetrics.widthPixels
val widthDp = widthPixels / density
val display = "density: $density\nwidthPixels: $widthPixels\nwidthDp: $widthDp"
CompositionLocalProvider(
LocalDensity provides Density(
density = widthPixels / 1920f,
fontScale = fontScale
)
) {
... ...
} -
取消点击波纹
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31//自定义LocalIndication
object NoRippleIndication : Indication {
private object NoIndicationInstance : IndicationInstance {
override fun ContentDrawScope.drawIndication() {
drawContent()
}
}
override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance {
return NoIndicationInstance
}
}
//使用
fun MaterialTheme(
...
) {
...
CompositionLocalProvider(
LocalColors provides rememberedColors,
LocalContentAlpha provides ContentAlpha.high,
LocalIndication provides NoRippleIndication,
LocalRippleTheme provides MaterialRippleTheme,
LocalShapes provides shapes,
LocalTextSelectionColors provides selectionColors,
LocalTypography provides typography
) {
...
}
} -
焦点问题
- FocusRequester/FocusManager
- Modifier.focusable
-
使用Android View
- 如何嵌入 Android View
- WebView wrapper库使用 WebView wrapper
-
Service悬浮窗问题
popupWindow
WindowManager.addView1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16fun AbstractComposeView.addToLifecycle() {
val viewModelStoreOwner = ComposeViewModelStoreOwner()
val lifecycleOwner = ComposeViewLifecycleOwner()
lifecycleOwner.performRestore(null)
lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
setViewTreeLifecycleOwner(lifecycleOwner)
setViewTreeViewModelStoreOwner(viewModelStoreOwner)
setViewTreeSavedStateRegistryOwner(lifecycleOwner)
val coroutineContext = AndroidUiDispatcher.CurrentThread
val runRecomposeScope = CoroutineScope(coroutineContext)
val reComposer = Recomposer(coroutineContext)
this.compositionContext = reComposer
runRecomposeScope.launch {
reComposer.runRecomposeAndApplyChanges()
}
} -
注入小组件
使用ksp生成hilt代码来注入Composable组件。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119//定义注解和接口
annotation class CollectCompose(
val qualifier: KClass<out Any>
)
interface CollectComposeOwner<in T> {
fun Show(scope: T)
}
fun <T> T.Show(owners: Collection<CollectComposeOwner<T>>) {
owners.forEach { owner -> owner.Show(this) }
}
//定义注解处理器
class CollectComposeProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return CollectComposeProcessor(environment)
}
}
class CollectComposeProcessor(environment: SymbolProcessorEnvironment) : SymbolProcessor {
private companion object {
const val COLLECT_COMPOSE = "com.seiko.tv.anime.ui.composer.collector.CollectCompose"
}
private val coderGenerator = environment.codeGenerator
private val logger = environment.logger
override fun process(resolver: Resolver): List<KSAnnotated> {
logger.info("collecting compose...")
resolver.getSymbolsWithAnnotation(COLLECT_COMPOSE)
.asSequence()
.filterIsInstance<KSFunctionDeclaration>()
.forEach { it.accept(BuilderVisitor(), Unit) }
return emptyList()
}
inner class BuilderVisitor : KSVisitorVoid() {
override fun visitFunctionDeclaration(function: KSFunctionDeclaration, data: Unit) {
logger.info("find func ${function.simpleName.asString()}")
val packageName = function.packageName.asString()
val fileName = function.simpleName.asString() + "Module"
val qualifier = function.annotations
.find { it.shortName.asString() == "CollectCompose" }!!.arguments
.find { it.name!!.asString() == "qualifier" }!!.value as KSType
coderGenerator.createNewFile(
dependencies = Dependencies(aggregating = true, function.containingFile!!),
packageName = packageName,
fileName = fileName
).use { output ->
val str = """
|package $packageName
|
|import androidx.compose.foundation.layout.BoxScope
|import androidx.compose.runtime.Composable
|import dagger.Module
|import dagger.Provides
|import dagger.hilt.InstallIn
|import dagger.hilt.android.components.ActivityComponent
|import dagger.multibindings.IntoSet
|import ${qualifier.declaration.qualifiedName!!.asString()}
|
|@InstallIn(ActivityComponent::class)
|@Module
|object ${function.simpleName.asString()}Module {
| @Provides
| @IntoSet
| @${qualifier.declaration.simpleName.asString()}
| fun provide${function.simpleName.asString()}() = object : CollectComposeOwner<BoxScope> {
| @Composable
| override fun Show(scope: BoxScope) {
| scope.${function.simpleName.asString()}()
| }
| }
|}
|
""".trimMargin()
output.write(str.toByteArray())
}
}
}
}
//使用
//定义扩展函数
fun BoxScope.FpsScreenComponent() {
...
}
class AnimeTvActivity : ComponentActivity() {
lateinit var collectScreenComponents: Set< CollectComposeOwner<BoxScope>>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Box() {
AppScreen()
Show(collectScreenComponents)
}
}
}
}…
总结
TV开发一开始是 RecycleView,要去解决焦点,优化等问题,后来是 Leanback ,到现在的 Compose TV 简单明了,开发速度提升了很多强烈推荐Compose for TV开发电视。