axiom-tvos
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesetvOS Development
tvOS 开发
Overview
概述
tvOS shares UIKit and SwiftUI with iOS but diverges in critical ways that catch every iOS developer. The three most dangerous assumptions: (1) local files persist, (2) WebView exists, (3) focus works like @FocusState.
Core principle tvOS is not "iOS on TV." It has a dual focus system, no persistent local storage, no WebView, and a remote with two incompatible generations. Treat it as its own platform.
tvOS 26 Adopts Liquid Glass design language with new app icon system. See for implementation patterns.
axiom-liquid-glasstvOS与iOS共享UIKit和SwiftUI,但在一些关键方面存在差异,这些差异几乎会让每一位iOS开发者都踩坑。三个最危险的错误假设:(1) 本地文件会持久化,(2) 存在WebView,(3) 焦点机制与@FocusState的工作方式一致。
核心原则 tvOS并非「电视版iOS」。它拥有双焦点系统、无持久化本地存储、无WebView,并且有两代互不兼容的遥控器。请将其视为独立平台。
tvOS 26 采用Liquid Glass设计语言,搭配全新应用图标系统。可参考实现相关模式。
axiom-liquid-glasstvOS Porting Triage
tvOS 移植检查清单
Before shipping a tvOS port, verify these five areas — they account for 90% of tvOS-specific bugs:
| Area | Check | Section |
|---|---|---|
| Storage | No persistent local files — iCloud required | §3 |
| Focus | Dual system working, focus guides for gaps | §1 |
| WebView | Replaced with JavaScriptCore or native rendering | §4 |
| Text input | Shadow input or fullscreen keyboard handled | §6 |
| AVPlayer | Audio session, buffer, Menu button state machine | §7, §8 |
"It compiles on tvOS" means nothing. These five areas compile fine and fail at runtime.
在发布tvOS移植版本前,请验证以下五个领域——它们占了tvOS特有bug的90%:
| 领域 | 检查项 | 章节 |
|---|---|---|
| 存储 | 无持久化本地文件——必须使用iCloud | §3 |
| 焦点 | 双焦点系统正常工作,针对间隙添加焦点引导 | §1 |
| WebView | 使用JavaScriptCore或原生渲染替代 | §4 |
| 文本输入 | 处理影子输入或全屏键盘 | §6 |
| AVPlayer | 音频会话、缓存、菜单按钮状态机 | §7, §8 |
「能在tvOS上编译通过」毫无意义。这五个领域的代码编译正常,但运行时会出现故障。
When to Use This Skill
何时使用本技能
- Building a new tvOS app or adding tvOS target
- Porting an iOS app to tvOS
- Debugging focus, remote input, or storage issues on tvOS
- Working with AVPlayer, TVUIKit, or text input on tvOS
- 开发新tvOS应用或添加tvOS目标
- 将iOS应用移植到tvOS
- 调试tvOS上的焦点、遥控器输入或存储问题
- 在tvOS上使用AVPlayer、TVUIKit或文本输入功能
Example Prompts
示例提问
These are real questions developers ask that this skill answers:
以下是开发者实际会问到的、本技能可解答的问题:
1. "I'm porting my iOS app to tvOS and focus navigation doesn't work"
1. 「我正在把iOS应用移植到tvOS,但焦点导航无法正常工作」
-> The skill explains the dual focus system (UIKit Focus Engine vs @FocusState) and common traps
-> 本技能会解释双焦点系统(UIKit Focus Engine vs @FocusState)以及常见陷阱
2. "My tvOS app loses all data between launches"
2. 「我的tvOS应用每次启动后数据都会丢失」
-> The skill explains there is no persistent local storage and shows the iCloud-first pattern
-> 本技能会说明tvOS无持久化本地存储,并展示iCloud优先的实现模式
3. "How do I handle Siri Remote input in SwiftUI on tvOS?"
3. 「如何在tvOS的SwiftUI中处理Siri Remote输入?」
-> The skill covers both generations of remote and the three input layers (SwiftUI, UIKit gestures, GameController)
-> 本技能涵盖两代遥控器以及三层输入机制(SwiftUI、UIKit手势、GameController)
4. "WebView doesn't work on tvOS, how do I display web content?"
4. 「WebView在tvOS上无法使用,我该如何展示网页内容?」
-> The skill shows JavaScriptCore for parsing and native rendering alternatives
-> 本技能会展示使用JavaScriptCore解析内容,以及原生渲染的替代方案
Red Flags
危险信号
If ANY of these appear, STOP:
- "I'll just use the same storage code as iOS" — tvOS has no Document directory
- "WebView will work for this" — No WebView on tvOS at all (Apple HIG: "Not supported in tvOS")
- "@FocusState handles focus" — tvOS has a dual focus system; @FocusState alone is incomplete
- "I'll save to Application Support" — It's Cache-only; the system deletes files when app is not running
- "Standard UITextField will work" — tvOS text input triggers a fullscreen keyboard; consider the shadow input pattern
- "I'll just use the same AVPlayer code" — tvOS needs .ambient audio session on launch, custom Menu button handling, and buffer tuning. Default iOS AVPlayer setup causes audio session conflicts and broken back navigation.
如果出现以下任何一种情况,请立即停止:
- 「我直接复用iOS的存储代码就行」——tvOS没有Document目录
- 「WebView可以满足这个需求」——tvOS完全不支持WebView(苹果HIG明确说明:「tvOS不支持WebView」)
- 「@FocusState可以处理所有焦点问题」——tvOS拥有双焦点系统;仅靠@FocusState是不够的
- 「我把数据存在Application Support目录」——该目录仅作缓存用;系统会在应用未运行时删除其中的文件
- 「标准UITextField可以正常工作」——tvOS的文本输入会触发全屏键盘;请考虑影子输入模式
- 「我直接复用iOS的AVPlayer代码就行」——tvOS启动时需要.ambient音频会话、自定义菜单按钮处理,以及缓存调优。默认的iOS AVPlayer设置会导致音频会话冲突和返回导航故障。
1. Focus Engine vs @FocusState
1. Focus Engine vs @FocusState
tvOS has two focus systems that must coexist. This is the #1 source of confusion for iOS developers.
tvOS拥有两个必须共存的焦点系统。这是iOS开发者最容易混淆的点。
The Dual System
双焦点系统
| System | Controls | API |
|---|---|---|
| UIKit Focus Engine | Hardware remote navigation, directional scanning | UIFocusEnvironment, UIFocusSystem, UIFocusGuide |
| SwiftUI Focus | Programmatic focus binding, focus sections | @FocusState, .focused(), .focusable(), .focusSection() |
| 系统 | 控制对象 | API |
|---|---|---|
| UIKit Focus Engine | 硬件遥控器导航、方向扫描 | UIFocusEnvironment, UIFocusSystem, UIFocusGuide |
| SwiftUI 焦点 | 程序化焦点绑定、焦点分组 | @FocusState, .focused(), .focusable(), .focusSection() |
When Each Applies
适用场景
User swipes on remote → UIKit Focus Engine handles it (always)
Code sets @FocusState → SwiftUI handles it (sometimes overridden by Focus Engine)The trap: @FocusState can set focus programmatically, but the UIKit Focus Engine is the ultimate authority. If the Focus Engine considers a view unfocusable, @FocusState assignments are silently ignored.
用户滑动遥控器 → 始终由UIKit Focus Engine处理
代码设置@FocusState → 由SwiftUI处理(有时会被Focus Engine覆盖)陷阱:@FocusState可以程序化设置焦点,但UIKit Focus Engine拥有最终控制权。如果Focus Engine认为某个视图不可获取焦点,@FocusState的赋值会被静默忽略。
UIKit Focus Engine API
UIKit Focus Engine API
The UIFocusEnvironment protocol (implemented by UIView, UIViewController, UIWindow) provides:
swift
class MyViewController: UIViewController {
// Priority-ordered list of where focus should go
override var preferredFocusEnvironments: [UIFocusEnvironment] {
[preferredButton, fallbackButton]
}
// Validate proposed focus changes
override func shouldUpdateFocus(
in context: UIFocusUpdateContext
) -> Bool {
// Return false to block focus movement
return context.nextFocusedView != disabledButton
}
// Respond to completed focus changes
override func didUpdateFocus(
in context: UIFocusUpdateContext,
with coordinator: UIFocusAnimationCoordinator
) {
coordinator.addCoordinatedAnimations {
context.nextFocusedView?.transform = CGAffineTransform(scaleX: 1.1, y: 1.1)
context.previouslyFocusedView?.transform = .identity
}
}
// Request focus update (async)
func moveFocusToPreferred() {
setNeedsFocusUpdate() // Schedule update
updateFocusIfNeeded() // Execute immediately
}
}UIFocusEnvironment协议(由UIView、UIViewController、UIWindow实现)提供以下功能:
swift
class MyViewController: UIViewController {
// 按优先级排序的焦点目标列表
override var preferredFocusEnvironments: [UIFocusEnvironment] {
[preferredButton, fallbackButton]
}
// 验证拟进行的焦点变更
override func shouldUpdateFocus(
in context: UIFocusUpdateContext
) -> Bool {
// 返回false以阻止焦点移动
return context.nextFocusedView != disabledButton
}
// 响应已完成的焦点变更
override func didUpdateFocus(
in context: UIFocusUpdateContext,
with coordinator: UIFocusAnimationCoordinator
) {
coordinator.addCoordinatedAnimations {
context.nextFocusedView?.transform = CGAffineTransform(scaleX: 1.1, y: 1.1)
context.previouslyFocusedView?.transform = .identity
}
}
// 请求焦点更新(异步)
func moveFocusToPreferred() {
setNeedsFocusUpdate() // 调度更新
updateFocusIfNeeded() // 立即执行
}
}UIFocusGuide — Bridging Navigation Gaps
UIFocusGuide — 填补导航间隙
When focusable views aren't in a direct grid layout, the Focus Engine can't find them by scanning directionally. UIFocusGuide creates invisible focusable regions that redirect to real views:
swift
let focusGuide = UIFocusGuide()
view.addLayoutGuide(focusGuide)
// Position the guide between two non-adjacent views
NSLayoutConstraint.activate([
focusGuide.leadingAnchor.constraint(equalTo: leftButton.trailingAnchor),
focusGuide.trailingAnchor.constraint(equalTo: rightButton.leadingAnchor),
focusGuide.topAnchor.constraint(equalTo: leftButton.topAnchor),
focusGuide.heightAnchor.constraint(equalTo: leftButton.heightAnchor)
])
// When focus enters the guide, redirect to the target view
focusGuide.preferredFocusEnvironments = [rightButton]当可获取焦点的视图不在直接网格布局中时,Focus Engine无法通过方向扫描找到它们。UIFocusGuide可以创建不可见的可聚焦区域,将焦点重定向到真实视图:
swift
let focusGuide = UIFocusGuide()
view.addLayoutGuide(focusGuide)
// 将引导视图定位在两个不相邻的视图之间
NSLayoutConstraint.activate([
focusGuide.leadingAnchor.constraint(equalTo: leftButton.trailingAnchor),
focusGuide.trailingAnchor.constraint(equalTo: rightButton.leadingAnchor),
focusGuide.topAnchor.constraint(equalTo: leftButton.topAnchor),
focusGuide.heightAnchor.constraint(equalTo: leftButton.heightAnchor)
])
// 当焦点进入引导视图时,重定向到目标视图
focusGuide.preferredFocusEnvironments = [rightButton]SwiftUI Focus API
SwiftUI 焦点API
swift
struct ContentView: View {
@FocusState private var focusedItem: MenuItem?
var body: some View {
VStack {
ForEach(MenuItem.allCases) { item in
Button(item.title) { select(item) }
.focused($focusedItem, equals: item)
}
}
.focusSection() // Group focusable items for navigation
.defaultFocus($focusedItem, .home) // Set initial focus
}
}Key SwiftUI focus modifiers for tvOS:
- — Bind focus to a value
.focused(_:equals:) - — Make custom views focusable
.focusable() - — Group related items for directional navigation
.focusSection() - — Set where focus starts in a scope
.defaultFocus(_:_:)
swift
struct ContentView: View {
@FocusState private var focusedItem: MenuItem?
var body: some View {
VStack {
ForEach(MenuItem.allCases) { item in
Button(item.title) { select(item) }
.focused($focusedItem, equals: item)
}
}
.focusSection() // 分组可聚焦项以支持导航
.defaultFocus($focusedItem, .home) // 设置初始焦点
}
}tvOS专用的关键SwiftUI焦点修饰符:
- — 将焦点与值绑定
.focused(_:equals:) - — 使自定义视图可获取焦点
.focusable() - — 分组相关项以支持方向导航
.focusSection() - — 设置作用域内的初始焦点位置
.defaultFocus(_:_:)
Default Focusable Elements
默认可聚焦元素
UIButton, UITextField, UITableViewCell, and UICollectionViewCell are focusable by default. Custom views need (UIKit) or (SwiftUI). The top-left item receives initial focus at launch.
canBecomeFocused.focusable()UIButton、UITextField、UITableViewCell和UICollectionViewCell默认可获取焦点。自定义视图需要设置(UIKit)或(SwiftUI)。应用启动时,左上角的元素会获得初始焦点。
canBecomeFocused.focusable()Common Focus Gotchas
常见焦点陷阱
| Gotcha | Symptom | Fix |
|---|---|---|
| Non-focusable container | Swipe skips your view | Add |
| Focus guide missing | Can't navigate to isolated view | Add UIFocusGuide to bridge the gap |
| @FocusState ignored | Programmatic focus doesn't work | Check preferredFocusEnvironments chain |
| Focus update not requested | Focus stays stale after layout change | Call setNeedsFocusUpdate() + updateFocusIfNeeded() |
| Items not in grid layout | Focus jumps unpredictably | Arrange focusable items in a grid or use focus guides |
| UIHostingConfiguration focus | Focus corruption in mixed UIKit/SwiftUI | Known issue — test UIHostingConfiguration cells carefully |
| 陷阱 | 症状 | 修复方案 |
|---|---|---|
| 容器不可聚焦 | 滑动遥控器时跳过你的视图 | 添加 |
| 缺少焦点引导 | 无法导航到孤立视图 | 添加UIFocusGuide填补间隙 |
| @FocusState被忽略 | 程序化焦点设置无效 | 检查preferredFocusEnvironments链 |
| 未请求焦点更新 | 布局变更后焦点保持不变 | 调用setNeedsFocusUpdate() + updateFocusIfNeeded() |
| 元素未按网格布局 | 焦点跳转不可预测 | 将可聚焦元素排列成网格或使用焦点引导 |
| UIHostingConfiguration焦点问题 | 混合UIKit/SwiftUI时焦点混乱 | 已知问题——仔细测试UIHostingConfiguration单元格 |
2. Siri Remote Input
2. Siri Remote 输入
Two generations with different hardware — your code must handle both.
两代遥控器硬件不同——你的代码必须同时兼容两者。
Generation Differences
代际差异
| Feature | Gen 1 (2015-2021) | Gen 2 (2021+) |
|---|---|---|
| Top surface | Touchpad (full swipe) | Clickpad + outer touch ring |
| Swipe gestures | Full area | Ring edge only |
| Click navigation | Center press | D-pad style |
| Accelerometer | Yes | Yes |
| 特性 | 第一代(2015-2021) | 第二代(2021+) |
|---|---|---|
| 顶部表面 | 触控板(全区域滑动) | 点击板 + 外圈触控环 |
| 滑动手势 | 全区域支持 | 仅支持环边缘 |
| 点击导航 | 中心按压 | 方向键样式 |
| 加速度计 | 有 | 有 |
Standard SwiftUI Modifiers (Preferred)
标准SwiftUI修饰符(推荐)
For most UI, SwiftUI handles remote input automatically through the focus system:
swift
Button("Play") { startPlayback() }
.focused($isFocused) // Automatically responds to remote navigation
List(items) { item in
Text(item.title)
}
// List navigation works automatically with remote对于大多数UI,SwiftUI会通过焦点系统自动处理遥控器输入:
swift
Button("播放") { startPlayback() }
.focused($isFocused) // 自动响应遥控器导航
List(items) { item in
Text(item.title)
}
// 列表导航可通过遥控器自动操作Gesture Recognizers (UIKit)
手势识别器(UIKit)
Detect specific button presses and gestures via UIKit recognizers:
swift
// Detect Play/Pause button
let playPause = UITapGestureRecognizer(target: self, action: #selector(handlePlayPause))
playPause.allowedPressTypes = [NSNumber(value: UIPress.PressType.playPause.rawValue)]
view.addGestureRecognizer(playPause)
// Detect swipe on touchpad
let swipe = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe))
swipe.direction = .right
view.addGestureRecognizer(swipe)Available UIPress.PressType values: , , , , , , , ,
.menu.playPause.select.upArrow.downArrow.leftArrow.rightArrow.pageUp.pageDown通过UIKit识别器检测特定按钮按压和手势:
swift
// 检测播放/暂停按钮
let playPause = UITapGestureRecognizer(target: self, action: #selector(handlePlayPause))
playPause.allowedPressTypes = [NSNumber(value: UIPress.PressType.playPause.rawValue)]
view.addGestureRecognizer(playPause)
// 检测触控板滑动
let swipe = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe))
swipe.direction = .right
view.addGestureRecognizer(swipe)可用的UIPress.PressType值: , , , , , , , ,
.menu.playPause.select.upArrow.downArrow.leftArrow.rightArrow.pageUp.pageDownLow-Level Press Handling
底层按压处理
For fine-grained control, override UIResponder press methods:
swift
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
for press in presses {
if press.type == .select {
handleSelectDown()
}
}
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
for press in presses {
if press.type == .select {
handleSelectUp()
}
}
}
// Always implement all four: pressesBegan, pressesEnded, pressesChanged, pressesCancelled如需精细控制,重写UIResponder的按压方法:
swift
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
for press in presses {
if press.type == .select {
handleSelectDown()
}
}
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
for press in presses {
if press.type == .select {
handleSelectUp()
}
}
}
// 必须实现全部四个方法:pressesBegan、pressesEnded、pressesChanged、pressesCancelledGame Controller Framework (Raw Input)
Game Controller框架(原始输入)
For custom interactions (scrubbing, games), access the Siri Remote as a GCMicroGamepad:
swift
import GameController
NotificationCenter.default.addObserver(
forName: .GCControllerDidConnect, object: nil, queue: .main
) { notification in
guard let controller = notification.object as? GCController,
let micro = controller.microGamepad else { return }
// Touchpad as analog D-pad (-1.0 to 1.0)
micro.dpad.valueChangedHandler = { _, xValue, yValue in
handleRemoteInput(x: xValue, y: yValue)
}
// reportsAbsoluteDpadValues: true = absolute position, false = relative movement
micro.reportsAbsoluteDpadValues = false
// allowsRotation: true = values adjust when remote is rotated
micro.allowsRotation = false
// Face buttons
micro.buttonA.pressedChangedHandler = { _, _, pressed in }
micro.buttonX.pressedChangedHandler = { _, _, pressed in }
micro.buttonMenu.pressedChangedHandler = { _, _, pressed in }
}如需自定义交互(如 scrubbing、游戏),可将Siri Remote作为GCMicroGamepad访问:
swift
import GameController
NotificationCenter.default.addObserver(
forName: .GCControllerDidConnect, object: nil, queue: .main
) { notification in
guard let controller = notification.object as? GCController,
let micro = controller.microGamepad else { return }
// 触控板作为模拟方向键(-1.0 到 1.0)
micro.dpad.valueChangedHandler = { _, xValue, yValue in
handleRemoteInput(x: xValue, y: yValue)
}
// reportsAbsoluteDpadValues: true = 绝对位置,false = 相对移动
micro.reportsAbsoluteDpadValues = false
// allowsRotation: true = 遥控器旋转时数值调整
micro.allowsRotation = false
// 正面按钮
micro.buttonA.pressedChangedHandler = { _, _, pressed in }
micro.buttonX.pressedChangedHandler = { _, _, pressed in }
micro.buttonMenu.pressedChangedHandler = { _, _, pressed in }
}Progress Bar Scrubbing
进度条拖拽
UIPanGestureRecognizer with virtual damping for smooth seeking:
swift
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
let velocity = gesture.velocity(in: view)
let dampingFactor: CGFloat = 0.002 // Tune for feel
switch gesture.state {
case .changed:
let seekDelta = velocity.x * dampingFactor
player.seek(to: currentTime + seekDelta)
default:
break
}
}使用UIPanGestureRecognizer和虚拟阻尼实现流畅的进度跳转:
swift
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
let velocity = gesture.velocity(in: view)
let dampingFactor: CGFloat = 0.002 // 根据手感调整
switch gesture.state {
case .changed:
let seekDelta = velocity.x * dampingFactor
player.seek(to: currentTime + seekDelta)
default:
break
}
}3. Storage Constraints
3. 存储限制
This is the most dangerous iOS assumption on tvOS. tvOS has no Document directory. All local storage is Cache that the system can delete at any time. Skipping iCloud integration means 2-3 weeks debugging intermittent "data disappears" bugs that only happen on real devices between app launches.
From Apple's App Programming Guide for tvOS: "Every app developed for the new Apple TV must be able to store data in iCloud and retrieve it in a way that provides a great customer experience."
这是tvOS上最危险的iOS错误假设。 tvOS没有Document目录。所有本地存储都是缓存,系统可随时删除。如果跳过iCloud集成,你会花费2-3周调试间歇性的「数据消失」bug,这些bug只会在真实设备的应用重启后出现。
来自苹果tvOS应用编程指南:「为新款Apple TV开发的每一款应用必须能够将数据存储在iCloud中,并以良好的用户体验方式检索数据。」
What tvOS Has
tvOS的存储目录
| Directory | Exists? | Persistent? |
|---|---|---|
| Documents | No | N/A |
| Application Support | Yes | No — system can delete when app is not running |
| Caches | Yes | No — system deletes under storage pressure |
| tmp | Yes | No |
| 目录 | 是否存在? | 是否持久化? |
|---|---|---|
| Documents | 否 | 不适用 |
| Application Support | 是 | 否——系统会在应用未运行时删除其中文件 |
| Caches | 是 | 否——系统会在存储压力下删除其中文件 |
| tmp | 是 | 否 |
Size Limits
大小限制
- App bundle: 4 GB maximum
- NSUserDefaults: 500 KB maximum (Apple docs; not the 4 MB iOS gets)
- On-demand resources: Available for read-only assets the OS manages
- Local cache: No guaranteed size; system can purge while app is not running
- 应用包:最大4 GB
- NSUserDefaults:最大500 KB(苹果文档说明;不同于iOS的4 MB)
- 按需资源:适用于只读资源,由系统管理
- 本地缓存:无保证大小;系统可在应用未运行时清除
What This Means
这意味着什么
- Every local file can vanish between app launches
- SQLite databases stored locally will be deleted
- Your app must survive with zero local data
- Downloaded data is NOT deleted while the app is running — only between sessions
- 所有本地文件都可能在应用重启后消失
- 存储在本地的SQLite数据库会被删除
- 你的应用必须能够在无本地数据的情况下正常运行
- 下载的数据在应用运行时不会被删除——仅在会话之间可能被删除
Recommended Pattern
推荐实现模式
swift
// ✅ CORRECT: iCloud as primary, local as cache only
func loadData() async throws -> [Item] {
// 1. Try iCloud first (persistent)
if let cloudData = try? await fetchFromICloud() {
// Cache locally for offline use
try? cacheLocally(cloudData)
return cloudData
}
// 2. Fall back to local cache (may not exist)
if let cached = try? loadFromLocalCache() {
return cached
}
// 3. Start fresh — this is normal on tvOS
return []
}swift
// ✅ 正确方式:以iCloud为主,本地仅作缓存
func loadData() async throws -> [Item] {
// 1. 优先尝试从iCloud加载(持久化)
if let cloudData = try? await fetchFromICloud() {
// 缓存到本地以供离线使用
try? cacheLocally(cloudData)
return cloudData
}
// 2. 回退到本地缓存(可能不存在)
if let cached = try? loadFromLocalCache() {
return cached
}
// 3. 从头开始——这在tvOS上是正常情况
return []
}Database Recommendations
数据库推荐
| Solution | tvOS Viability | Notes |
|---|---|---|
| SQLiteData + CloudKit SyncEngine | Recommended | iCloud is persistent; local is just cache |
| SwiftData + CloudKit | Works, but fragile | No persistent local-only storage; ModelContainer must be configured for CloudKit from day one — adding sync later requires migration; system database deletion triggers full re-sync on next launch |
| CoreData + CloudKit | Dangerous | Space inflation from CloudKit metadata |
| Local-only GRDB/SQLite | Unreliable | System deletes the database file |
| NSUbiquitousKeyValueStore | Good for small data | 1 MB limit, key-value only |
| On-demand resources | Good for read-only assets | OS manages download/purge lifecycle |
See for CloudKit SyncEngine patterns, for full storage decision tree.
axiom-sqlitedataaxiom-storage| 解决方案 | tvOS适用性 | 说明 |
|---|---|---|
| SQLiteData + CloudKit SyncEngine | 推荐 | iCloud持久化;本地仅作缓存 |
| SwiftData + CloudKit | 可用,但不稳定 | 无持久化本地存储;ModelContainer必须从一开始就配置CloudKit——后续添加同步需要迁移;系统删除数据库会触发下次启动时的全量同步 |
| CoreData + CloudKit | 不推荐 | CloudKit元数据会导致空间膨胀 |
| 仅本地的GRDB/SQLite | 不可靠 | 系统会删除数据库文件 |
| NSUbiquitousKeyValueStore | 适用于小型数据 | 1 MB限制,仅支持键值对 |
| 按需资源 | 适用于只读资源 | 系统管理下载/清除生命周期 |
参考 获取CloudKit SyncEngine模式,获取完整的存储决策树。
axiom-sqlitedataaxiom-storage4. No WebView
4. 无WebView
tvOS has no WKWebView, no SFSafariViewController, no WebView. Apple HIG explicitly states: web views are "Not supported in tvOS."
tvOS没有WKWebView、SFSafariViewController或任何WebView。苹果HIG明确说明:WebView「在tvOS中不受支持」。
What You Can Do
替代方案
| Need | Solution |
|---|---|
| Parse HTML/JSON | Use JavaScriptCore (JSContext, JSValue — no DOM) |
| Display web content | Render natively from parsed data |
| HLS streaming from m3u8 | Local HTTP server pattern (see below) |
| OAuth login | Device code flow (RFC 8628) or companion device |
| 需求 | 解决方案 |
|---|---|
| 解析HTML/JSON | 使用JavaScriptCore(JSContext、JSValue——无DOM) |
| 展示网页内容 | 从解析后的数据进行原生渲染 |
| 从m3u8进行HLS流播放 | 本地HTTP服务器模式(见下文) |
| OAuth登录 | 设备码流程(RFC 8628)或配套设备 |
JavaScriptCore for Parsing
使用JavaScriptCore解析
JavaScriptCore provides a JavaScript execution engine without DOM or web rendering. Available on tvOS.
swift
import JavaScriptCore
let context = JSContext()!
// Evaluate scripts
context.evaluateScript("""
function parsePlaylist(m3u8Text) {
return m3u8Text.split('\\n')
.filter(line => !line.startsWith('#'))
.filter(line => line.trim().length > 0);
}
""")
// Pass data safely via setObject (avoids injection)
context.setObject(m3u8Content, forKeyedSubscript: "rawContent" as NSString)
let result = context.evaluateScript("parsePlaylist(rawContent)")
// Convert back to Swift types
let segments = result?.toArray() as? [String] ?? []Key classes: JSVirtualMachine (execution environment), JSContext (script evaluation), JSValue (type bridging)
Limitation: No DOM, no web rendering, no fetch/XMLHttpRequest. Pure JavaScript execution only.
JavaScriptCore提供JavaScript执行引擎,但无DOM或网页渲染功能。tvOS支持该框架。
swift
import JavaScriptCore
let context = JSContext()!
// 执行脚本
context.evaluateScript("""
function parsePlaylist(m3u8Text) {
return m3u8Text.split('\\n')
.filter(line => !line.startsWith('#'))
.filter(line => line.trim().length > 0);
}
""")
// 通过setObject安全传递数据(避免注入)
context.setObject(m3u8Content, forKeyedSubscript: "rawContent" as NSString)
let result = context.evaluateScript("parsePlaylist(rawContent)")
// 转换回Swift类型
let segments = result?.toArray() as? [String] ?? []关键类:JSVirtualMachine(执行环境)、JSContext(脚本执行)、JSValue(类型桥接)
限制:无DOM、无网页渲染、无fetch/XMLHttpRequest。仅支持纯JavaScript执行。
Local HTTP Server for HLS
用于HLS的本地HTTP服务器
When you need to serve modified m3u8 playlists to AVPlayer:
swift
// Use Swifter (httpswift/swifter) or GCDWebServer
// Serve rewritten m3u8 on localhost, point AVPlayer to it
let localURL = URL(string: "http://localhost:8080/playlist.m3u8")!
let playerItem = AVPlayerItem(url: localURL)如需向AVPlayer提供修改后的m3u8播放列表:
swift
// 使用Swifter(httpswift/swifter)或GCDWebServer
// 在本地主机上提供重写后的m3u8,将AVPlayer指向该地址
let localURL = URL(string: "http://localhost:8080/playlist.m3u8")!
let playerItem = AVPlayerItem(url: localURL)5. TVUIKit Components
5. TVUIKit组件
tvOS-exclusive UIKit components. Bridge to SwiftUI via UIViewRepresentable.
tvOS专属的UIKit组件。可通过UIViewRepresentable桥接到SwiftUI。
TVPosterView
TVPosterView
Media content display with built-in focus expansion and parallax:
swift
import TVUIKit
let poster = TVPosterView(image: UIImage(named: "moviePoster"))
poster.title = "Movie Title"
poster.subtitle = "2024"
// Focus expansion and parallax happen automatically
// Access the underlying image view:
poster.imageView.adjustsImageWhenAncestorFocused = true用于展示媒体内容,内置焦点放大和视差效果:
swift
import TVUIKit
let poster = TVPosterView(image: UIImage(named: "moviePoster"))
poster.title = "电影标题"
poster.subtitle = "2024"
// 焦点放大和视差效果自动生效
// 访问底层图片视图:
poster.imageView.adjustsImageWhenAncestorFocused = trueTVLockupView
TVLockupView
Base class for TVPosterView — a flexible container managing content with focus behavior:
swift
let lockup = TVLockupView()
lockup.contentView.addSubview(customView)
lockup.headerView = headerFooter // TVLockupHeaderFooterView
lockup.footerView = footerFooter
// showsOnlyWhenAncestorFocused: header/footer visibility on focusTVPosterView的基类——灵活的容器,管理带焦点行为的内容:
swift
let lockup = TVLockupView()
lockup.contentView.addSubview(customView)
lockup.headerView = headerFooter // TVLockupHeaderFooterView
lockup.footerView = footerFooter
// showsOnlyWhenAncestorFocused:焦点状态下页眉/页脚的可见性Other TVUIKit Components
其他TVUIKit组件
| Component | Purpose |
|---|---|
| TVCardView | Simple container with customizable background |
| TVCaptionButtonView | Button with image + text + directional parallax |
| TVMonogramView | User initials/image with PersonNameComponents |
| TVCollectionViewFullScreenLayout | Immersive full-screen collection with parallax + masking |
| TVMediaItemContentView | Content configuration with badges, playback progress |
| 组件 | 用途 |
|---|---|
| TVCardView | 简单容器,可自定义背景 |
| TVCaptionButtonView | 带图片+文字+方向视差的按钮 |
| TVMonogramView | 使用PersonNameComponents展示用户首字母/头像 |
| TVCollectionViewFullScreenLayout | 沉浸式全屏集合视图,带视差+遮罩 |
| TVMediaItemContentView | 带徽章、播放进度的内容配置 |
TVDigitEntryViewController
TVDigitEntryViewController
System-provided passcode/PIN entry (tvOS 12+):
swift
let digitEntry = TVDigitEntryViewController()
digitEntry.numberOfDigits = 4
digitEntry.titleText = "Enter PIN"
digitEntry.promptText = "Enter your parental control code"
digitEntry.isSecureDigitEntry = true
present(digitEntry, animated: true)
digitEntry.entryCompletionHandler = { pin in
guard let pin else { return } // User cancelled
authenticate(with: pin)
}
// Reset entry
digitEntry.clearEntry(animated: true)系统提供的密码/PIN输入界面(tvOS 12+):
swift
let digitEntry = TVDigitEntryViewController()
digitEntry.numberOfDigits = 4
digitEntry.titleText = "输入PIN码"
digitEntry.promptText = "输入你的家长控制码"
digitEntry.isSecureDigitEntry = true
present(digitEntry, animated: true)
digitEntry.entryCompletionHandler = { pin in
guard let pin else { return } // 用户取消
authenticate(with: pin)
}
// 重置输入
digitEntry.clearEntry(animated: true)6. Text Input on tvOS
6. tvOS上的文本输入
tvOS text input is fundamentally different from iOS. Apple recommends minimizing text input in your UI.
tvOS的文本输入与iOS有本质区别。苹果建议尽量减少UI中的文本输入操作。
Three Approaches
三种实现方式
| Approach | Best For | Keyboard Style |
|---|---|---|
| UIAlertController | Quick, simple input | Modal with text field |
| UITextField | Multi-field forms | Fullscreen keyboard with Next/Previous |
| UISearchController | Search | Inline single-line keyboard |
| 方式 | 最佳适用场景 | 键盘样式 |
|---|---|---|
| UIAlertController | 快速、简单的输入 | 带文本框的模态弹窗 |
| UITextField | 多字段表单 | 带下一步/上一步的全屏键盘 |
| UISearchController | 搜索 | 内联单行键盘 |
UITextField (Fullscreen Keyboard)
UITextField(全屏键盘)
The primary text input method. Calling presents a fullscreen keyboard:
becomeFirstResponder()swift
let textField = UITextField()
textField.placeholder = "Enter name"
textField.becomeFirstResponder() // Presents keyboard immediately
// Done button returns user to previous page
// Built-in Next/Previous buttons navigate between text fields主要的文本输入方式。调用会弹出全屏键盘:
becomeFirstResponder()swift
let textField = UITextField()
textField.placeholder = "输入名称"
textField.becomeFirstResponder() // 立即弹出键盘
// 完成按钮会返回上一页
// 内置的下一步/上一步按钮可在文本框间导航Shadow Input Pattern (SwiftUI)
影子输入模式(SwiftUI)
When you want a custom-styled input trigger in SwiftUI:
swift
struct TVTextInput: View {
@State private var text = ""
@State private var isEditing = false
var body: some View {
Button {
isEditing = true
} label: {
HStack {
Text(text.isEmpty ? "Search..." : text)
.foregroundStyle(text.isEmpty ? .secondary : .primary)
Spacer()
Image(systemName: "keyboard")
}
.padding()
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.sheet(isPresented: $isEditing) {
TVKeyboardSheet(text: $text)
}
}
}如需在SwiftUI中使用自定义样式的输入触发按钮:
swift
struct TVTextInput: View {
@State private var text = ""
@State private var isEditing = false
var body: some View {
Button {
isEditing = true
} label: {
HStack {
Text(text.isEmpty ? "搜索..." : text)
.foregroundStyle(text.isEmpty ? .secondary : .primary)
Spacer()
Image(systemName: "keyboard")
}
.padding()
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.sheet(isPresented: $isEditing) {
TVKeyboardSheet(text: $text)
}
}
}UISearchController (Inline Keyboard)
UISearchController(内联键盘)
For search interfaces — all input on a single line, but very limited customization:
swift
let searchController = UISearchController(searchResultsController: resultsVC)
searchController.searchResultsUpdater = self
// Cannot customize text traits or add input accessories适用于搜索界面——所有输入在单行完成,但自定义能力有限:
swift
let searchController = UISearchController(searchResultsController: resultsVC)
searchController.searchResultsUpdater = self
// 无法自定义文本属性或添加输入附件SwiftUI .searchable()
.searchable()SwiftUI .searchable()
.searchable()SwiftUI's modifier works on tvOS and presents the system search keyboard. Use it for standard search patterns:
.searchable()swift
NavigationStack {
List(filteredItems) { item in
Text(item.title)
}
.searchable(text: $searchText, prompt: "Search movies")
}For custom search UI beyond what offers, fall back to the shadow input pattern above.
.searchable()SwiftUI的修饰符可在tvOS上使用,会弹出系统搜索键盘。适用于标准搜索模式:
.searchable()swift
NavigationStack {
List(filteredItems) { item in
Text(item.title)
}
.searchable(text: $searchText, prompt: "搜索电影")
}如需超出能力的自定义搜索UI,可使用上述影子输入模式。
.searchable()7. AVPlayer Tuning
7. AVPlayer调优
tvOS media apps need specific AVPlayer configuration for good UX.
tvOS媒体应用需要特定的AVPlayer配置以获得良好的用户体验。
Essential Settings
必要设置
swift
let player = AVPlayer(url: streamURL)
// automaticallyWaitsToMinimizeStalling defaults to true (iOS 10+/tvOS 10+)
// Set false for immediate playback when synchronizing players
// or when you want playback to start ASAP from a non-empty buffer
player.automaticallyWaitsToMinimizeStalling = false
// Buffer hint — 0 means system chooses automatically
// Higher values reduce stalling risk but consume more memory
player.currentItem?.preferredForwardBufferDuration = 30
// Audio session — don't interrupt other apps' audio on launch
try AVAudioSession.sharedInstance().setCategory(.ambient)
// Switch to .playback when user presses playswift
let player = AVPlayer(url: streamURL)
// automaticallyWaitsToMinimizeStalling默认值为true(iOS 10+/tvOS 10+)
// 当同步多个播放器或希望从非空缓存立即启动播放时,设置为false
player.automaticallyWaitsToMinimizeStalling = false
// 缓存提示——0表示由系统自动选择
// 更高的值可降低卡顿风险,但会消耗更多内存
player.currentItem?.preferredForwardBufferDuration = 30
// 音频会话——启动时不要中断其他应用的音频
try AVAudioSession.sharedInstance().setCategory(.ambient)
// 用户点击播放时切换为.playbackCustom Dismiss Logic
自定义关闭逻辑
The default swipe-down gesture dismisses the player. Override for media apps:
swift
class PlayerViewController: AVPlayerViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Handle Menu button for custom back navigation
let menuPress = UITapGestureRecognizer(
target: self, action: #selector(handleMenu)
)
menuPress.allowedPressTypes = [
NSNumber(value: UIPress.PressType.menu.rawValue)
]
view.addGestureRecognizer(menuPress)
}
@objc func handleMenu() {
if isShowingControls {
hideControls()
} else {
dismiss(animated: true)
}
}
}默认的向下滑动手势会关闭播放器。媒体应用可重写该逻辑:
swift
class PlayerViewController: AVPlayerViewController {
override func viewDidLoad() {
super.viewDidLoad()
// 处理菜单按钮以实现自定义返回导航
let menuPress = UITapGestureRecognizer(
target: self, action: #selector(handleMenu)
)
menuPress.allowedPressTypes = [
NSNumber(value: UIPress.PressType.menu.rawValue)
]
view.addGestureRecognizer(menuPress)
}
@objc func handleMenu() {
if isShowingControls {
hideControls()
} else {
dismiss(animated: true)
}
}
}8. Menu Button State Machine
8. 菜单按钮状态机
The Siri Remote Menu button doubles as "back" and "dismiss." Media apps need a state machine to handle it correctly.
Siri Remote的菜单按钮兼具「返回」和「关闭」功能。媒体应用需要状态机来正确处理。
The Problem
问题场景
State: Playing with controls visible
Menu press → Hide controls (not dismiss)
State: Playing with controls hidden
Menu press → Show "are you sure?" or dismiss
State: In submenu/settings overlay
Menu press → Close overlay (not dismiss player)状态:播放中,控件可见
按下菜单按钮 → 隐藏控件(不关闭)
状态:播放中,控件隐藏
按下菜单按钮 → 显示「确定要退出吗?」提示或直接关闭
状态:在子菜单/设置浮层中
按下菜单按钮 → 关闭浮层(不关闭播放器)Pattern
实现模式
swift
enum PlayerState {
case playing // Controls hidden
case controlsShown // Controls visible
case submenu // Settings/subtitles overlay
}
func handleMenuPress(in state: PlayerState) -> PlayerState {
switch state {
case .submenu:
dismissSubmenu()
return .controlsShown
case .controlsShown:
hideControls()
return .playing
case .playing:
dismiss(animated: true)
return .playing
}
}swift
enum PlayerState {
case playing // 控件隐藏
case controlsShown // 控件可见
case submenu // 设置/字幕浮层
}
func handleMenuPress(in state: PlayerState) -> PlayerState {
switch state {
case .submenu:
dismissSubmenu()
return .controlsShown
case .controlsShown:
hideControls()
return .playing
case .playing:
dismiss(animated: true)
return .playing
}
}9. Network Differences
9. 网络差异
IPv6 Priority
IPv6优先级
Apple TV strongly prefers IPv6. All App Store apps must support IPv6-only networks (DNS64/NAT64). If your backend is IPv4-only, connections may be slower or fail on some networks.
Apple TV优先使用IPv6。所有App Store应用必须支持纯IPv6网络(DNS64/NAT64)。如果你的后端仅支持IPv4,在部分网络上连接可能会变慢或失败。
Device Performance Variance
设备性能差异
| Device | Chip | RAM | Notes |
|---|---|---|---|
| Apple TV HD (4th gen) | A8 | 2 GB | Still supported; much slower |
| Apple TV 4K (1st gen) | A10X | 3 GB | Capable |
| Apple TV 4K (2nd gen) | A12 | 4 GB | Good |
| Apple TV 4K (3rd gen) | A15 | 4 GB | Excellent |
Test on older hardware. The Apple TV HD is still in use and dramatically slower than 4K models.
| 设备 | 芯片 | 内存 | 说明 |
|---|---|---|---|
| Apple TV HD(第4代) | A8 | 2 GB | 仍受支持;性能慢很多 |
| Apple TV 4K(第1代) | A10X | 3 GB | 性能尚可 |
| Apple TV 4K(第2代) | A12 | 4 GB | 性能良好 |
| Apple TV 4K(第3代) | A15 | 4 GB | 性能极佳 |
务必在旧设备上测试。Apple TV HD仍在使用,且性能远低于4K机型。
10. Developer Experience
10. 开发者体验
Debug-Only Input Macros
仅调试用的输入宏
Test without Siri Remote in Simulator using keyboard shortcuts:
swift
#if DEBUG
extension View {
func debugOnlyModifier() -> some View {
self.onKeyPress(.space) {
print("Space pressed — simulating select")
return .handled
}
}
}
#endif在模拟器中无需Siri Remote,使用键盘快捷键测试:
swift
#if DEBUG
extension View {
func debugOnlyModifier() -> some View {
self.onKeyPress(.space) {
print("按下空格键——模拟选择操作")
return .handled
}
}
}
#endifView Inspection Helper
视图检查工具
swift
#if DEBUG
extension View {
func debugBorder() -> some View {
border(.red, width: 1)
}
}
#endifswift
#if DEBUG
extension View {
func debugBorder() -> some View {
border(.red, width: 1)
}
}
#endifSimulator Limitations
模拟器限制
- Simulator does not accurately simulate Focus Engine behavior
- Always test focus navigation on a real Apple TV device
- Simulator keyboard input != Siri Remote input
- Performance profiling must happen on device (especially Apple TV HD)
- 模拟器无法准确模拟Focus Engine的行为
- 务必在真实Apple TV设备上测试焦点导航
- 模拟器键盘输入 ≠ Siri Remote输入
- 性能分析必须在设备上进行(尤其是Apple TV HD)
Anti-Rationalization
反合理化借口
| Thought | Reality |
|---|---|
| "I'll just use the same code as iOS" | tvOS diverges in storage, focus, input, and web views. You will hit walls. |
| "Focus works like iOS" | tvOS has a dual focus system (UIKit Focus Engine + SwiftUI @FocusState). @FocusState alone is insufficient. |
| "Local storage is fine for now" | There is no persistent local storage on tvOS. Apple requires iCloud capability. |
| "WebView will work" | Apple HIG: web views are "Not supported in tvOS." JavaScriptCore only (no DOM). |
| "I'll handle text input with TextField" | UITextField triggers a fullscreen keyboard. Consider shadow input pattern or UISearchController for better UX. |
| "I only need to test on Simulator" | Focus Engine and performance require real device testing. |
| 想法 | 现实 |
|---|---|
| 「我直接复用iOS的代码就行」 | tvOS在存储、焦点、输入和WebView方面差异很大。你会遇到各种问题。 |
| 「焦点机制和iOS一样」 | tvOS拥有双焦点系统(UIKit Focus Engine + SwiftUI @FocusState)。仅靠@FocusState是不够的。 |
| 「本地存储暂时够用」 | tvOS无持久化本地存储。苹果要求应用具备iCloud能力。 |
| 「WebView可以正常使用」 | 苹果HIG明确说明:「tvOS不支持WebView」。仅可使用JavaScriptCore(无DOM)。 |
| 「我用TextField处理文本输入就行」 | UITextField会触发全屏键盘。请考虑影子输入模式或UISearchController以获得更好的用户体验。 |
| 「我只需要在模拟器上测试」 | 焦点机制和性能必须在真实设备上测试。 |
Resources
资源
Source: "Surviving tvOS" (Ronnie Wong, 2026) — tvOS engineering log for Syncnext media player
Apple Docs: /tvuikit, /uikit/uifocusenvironment, /uikit/uifocusguide, /swiftui/focus, /gamecontroller/gcmicrogamepad, /avfoundation/avplayer, /javascriptcore
Apple Guides: App Programming Guide for tvOS (storage, input, gestures), HIG Web Views (tvOS exclusion)
WWDC: 2016-215, 2017-224, 2021-10023, 2021-10081, 2021-10191, 2023-10162, 2025-219
Skills: axiom-storage, axiom-sqlitedata, axiom-avfoundation-ref, axiom-hig-ref, axiom-liquid-glass
来源:《Surviving tvOS》(Ronnie Wong,2026)——Syncnext媒体播放器的tvOS工程日志
苹果文档:/tvuikit, /uikit/uifocusenvironment, /uikit/uifocusguide, /swiftui/focus, /gamecontroller/gcmicrogamepad, /avfoundation/avplayer, /javascriptcore
苹果指南:tvOS应用编程指南(存储、输入、手势),HLS Web视图(tvOS排除说明)
WWDC:2016-215, 2017-224, 2021-10023, 2021-10081, 2021-10191, 2023-10162, 2025-219
相关技能:axiom-storage, axiom-sqlitedata, axiom-avfoundation-ref, axiom-hig-ref, axiom-liquid-glass