将 TVML 应用迁移到 SwiftUI
Migrate your TVML app to SwiftUI
2024年6月10日
一句话判断
TVMLKit 在 tvOS 18 正式被标记为 deprecated,SwiftUI 已经完全具备了构建媒体目录和流媒体 app 的能力——如果你还在维护 TVML 项目,这场 Session 就是你的迁移路线图。
这场 Session 讲了什么
2015 年 Apple 发布 tvOS 时,流媒体 app 大多是网页实现的,开发者的资源和技术栈集中在 Web 端。TVMLKit 就是 Apple 为这些 Web 团队提供的过渡方案,让他们用类似 HTML 的模板语言就能创建 Apple TV app。9 年过去了,流媒体的生态彻底变了——用户在 iPhone、iPad、Apple TV、Mac、Vision Pro 上通过原生 app 消费内容,SwiftUI 成了跨平台构建原生 UI 的统一工具。
tvOS 18 中,SwiftUI 已经能覆盖 TVMLKit 的所有能力,而且提供了更大的灵活性和每年递增的新功能。你的代码用 Swift 编写,这是 iOS、iPadOS、macOS、watchOS、visionOS 通用的语言。同一套组件、同一套技术,构建任何 Apple 平台的 app 都适用。
Session 详细演示了如何用 SwiftUI 构建一个典型的 tvOS 媒体 app:内容锁屏(lockup)的圆角图片+标题+聚焦抬起效果、水平滚动的货架(shelf)、首页的促销内容布局、以及搜索功能。每一部分都对比了 TVML 的做法和 SwiftUI 的等价实现。
值得深挖的点
containerRelativeFrame 是 tvOS 布局的秘密武器
在 tvOS 上,内容货架需要精确对齐安全区域边界,同时在屏幕边缘露出后续内容的预览来暗示”还有更多”。TVML 用声明式模板自动处理这些,SwiftUI 的解决方案是 containerRelativeFrame modifier。
这个 modifier 告诉 SwiftUI:这个 item 的 frame 应该相对于最近祖先容器视图来计算。你可以指定”在容器宽度内放 6 个 item”,它就会自动计算每个 item 的大小,确保对齐安全区域并在两侧留出预览空间。关键是要让 containerRelativeFrame 的 spacing 参数和 LazyHStack 的 spacing 保持一致——不匹配的话布局会出现肉眼可见的偏移。
更妙的是,这个 API 是跨平台的。同一个组件在 tvOS 上产生 6 个 item 的横排布局,在 iPad 上可以改成 4 个,只需要调整参数。这比 TVML 的平台限定模板灵活太多。
borderless buttonStyle 精准还原了 tvOS 的聚焦交互
tvOS 上的内容锁屏有一套精细的聚焦效果:圆角图片、聚焦时抬起并倾斜、高光随遥控器触摸移动、周围文字自动避让抬起区域。SwiftUI 的 borderless buttonStyle 直接提供了这套行为。
如果你用默认的 bordered 样式,会得到一个带背景托盘的按钮——这不是媒体内容的典型外观。切换到 borderless 后,按钮就只剩图片和文字,聚焦时自动获得抬起、倾斜、高光效果。对于搜索结果等需要信息密度的场景,用 .card buttonStyle 可以得到带圆角背景托盘的锁屏——和 Apple TV app 的搜索结果完全一致。
代码片段
构建 tvOS 风格的内容锁屏
// 基础锁屏:图片 + 标题,聚焦时抬起并高光
struct ContentLockup: View {
let title: String
let imageURL: URL
var body: some View {
// borderless 样式在 tvOS 上提供抬起+倾斜+高光效果
Button(action: { playContent() }) {
VStack(alignment: .leading) {
AsyncImage(url: imageURL)
.aspectRatio(2/3, contentMode: .fill) // 海报比例
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 12))
Text(title)
.font(.caption)
.lineLimit(1)
}
}
.buttonStyle(.borderless) // 关键:不用默认的 bordered
}
}
场景:媒体 app 中的内容卡片。坑点:不用 borderless 的话,每个卡片都会有一个灰色的背景托盘,完全不像 tvOS 原生的内容展示。
水平滚动货架布局
// 水平滚动的货架,对齐安全区域,边缘预览
struct ContentShelf: View {
let items: [MediaItem]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 32) { // 记住这个 spacing 值
ForEach(items) { item in
ContentLockup(title: item.title, imageURL: item.artworkURL)
// 关键:相对于容器计算 frame
.containerRelativeFrame(
.horizontal,
count: 6, // 一屏放 6 个
span: 1,
spacing: 32 // 必须和 LazyHStack 的 spacing 一致
)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.scrollClipDisabled() // 聚焦抬起效果不会被裁剪
}
}
场景:媒体 app 首页的内容行。坑点:scrollClipDisabled() 必须加上,否则聚焦时按钮的抬起阴影和溢出部分会被 ScrollView 裁掉。
搜索功能的快速实现
// tvOS 搜索页,几行代码就能实现
struct SearchView: View {
@State private var searchText = ""
let allContent: [MediaItem]
var body: some View {
NavigationStack {
List {
ForEach(filteredContent) { item in
// 使用 card 样式提供信息密度更高的搜索结果
Button(action: { openItem(item) }) {
HStack {
AsyncImage(url: item.artworkURL)
.frame(width: 80, height: 120)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading) {
Text(item.title).font(.headline)
Text(item.subtitle).font(.subheadline)
}
}
}
.buttonStyle(.card) // 搜索结果用 card 样式
}
}
.searchable(text: $searchText, prompt: "搜索电影、节目...")
}
}
var filteredContent: [MediaItem] {
if searchText.isEmpty { return allContent }
return allContent.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
}
}
场景:app 的搜索标签页。坑点:tvOS 的 searchable modifier 会自动处理键盘和 Siri Dictation 的集成,不需要自己实现输入界面。
最佳实践
- 立即开始规划 TVML 迁移:TVMLKit 虽然 still available,但不会再获得新功能。越早迁移到 SwiftUI,越能利用每年平台的新能力。
- 复用跨平台代码:你的 SwiftUI 视图在 iOS、iPadOS、tvOS 上都能运行。构建组件时考虑不同平台的布局参数差异,但共享核心逻辑。
- 理解 tvOS 的两种 buttonStyle:媒体内容用
.borderless(聚焦抬起效果),搜索结果等需要信息密度的场景用.card(带背景托盘)。 - spacing 一致性是布局正确的前提:
containerRelativeFrame的 spacing 必须和容器的 spacing 匹配,否则会出现对齐问题。 - 禁用 scroll clipping:tvOS 的聚焦效果会让按钮超出自身 bounds,必须用
.scrollClipDisabled()防止被裁剪。
还有什么值得关注
- Hero carousel(大尺寸促销横幅)只需在锁屏组件上去掉文字标题并调整
containerRelativeFrame的 count 即可实现。 - 专辑封面等正方形内容只需要改
aspectRatio和每屏数量。 - TVML 的 JavaScript 逻辑层可以迁移为 Swift 的 ViewModel,利用
@Observable或 Combine 管理状态。