axiom-tvos

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

tvOS 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
axiom-liquid-glass
for implementation patterns.
tvOS与iOS共享UIKit和SwiftUI,但在一些关键方面存在差异,这些差异几乎会让每一位iOS开发者都踩坑。三个最危险的错误假设:(1) 本地文件会持久化,(2) 存在WebView,(3) 焦点机制与@FocusState的工作方式一致。
核心原则 tvOS并非「电视版iOS」。它拥有双焦点系统、无持久化本地存储、无WebView,并且有两代互不兼容的遥控器。请将其视为独立平台。
tvOS 26 采用Liquid Glass设计语言,搭配全新应用图标系统。可参考
axiom-liquid-glass
实现相关模式。

tvOS Porting Triage

tvOS 移植检查清单

Before shipping a tvOS port, verify these five areas — they account for 90% of tvOS-specific bugs:
AreaCheckSection
StorageNo persistent local files — iCloud required§3
FocusDual system working, focus guides for gaps§1
WebViewReplaced with JavaScriptCore or native rendering§4
Text inputShadow input or fullscreen keyboard handled§6
AVPlayerAudio 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

双焦点系统

SystemControlsAPI
UIKit Focus EngineHardware remote navigation, directional scanningUIFocusEnvironment, UIFocusSystem, UIFocusGuide
SwiftUI FocusProgrammatic 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:
  • .focused(_:equals:)
    — Bind focus to a value
  • .focusable()
    — Make custom views focusable
  • .focusSection()
    — Group related items for directional navigation
  • .defaultFocus(_:_:)
    — Set where focus starts in a scope
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
canBecomeFocused
(UIKit) or
.focusable()
(SwiftUI). The top-left item receives initial focus at launch.
UIButton、UITextField、UITableViewCell和UICollectionViewCell默认可获取焦点。自定义视图需要设置
canBecomeFocused
(UIKit)或
.focusable()
(SwiftUI)。应用启动时,左上角的元素会获得初始焦点。

Common Focus Gotchas

常见焦点陷阱

GotchaSymptomFix
Non-focusable containerSwipe skips your viewAdd
.focusable()
or override
canBecomeFocused
Focus guide missingCan't navigate to isolated viewAdd UIFocusGuide to bridge the gap
@FocusState ignoredProgrammatic focus doesn't workCheck preferredFocusEnvironments chain
Focus update not requestedFocus stays stale after layout changeCall setNeedsFocusUpdate() + updateFocusIfNeeded()
Items not in grid layoutFocus jumps unpredictablyArrange focusable items in a grid or use focus guides
UIHostingConfiguration focusFocus corruption in mixed UIKit/SwiftUIKnown issue — test UIHostingConfiguration cells carefully

陷阱症状修复方案
容器不可聚焦滑动遥控器时跳过你的视图添加
.focusable()
或重写
canBecomeFocused
缺少焦点引导无法导航到孤立视图添加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

代际差异

FeatureGen 1 (2015-2021)Gen 2 (2021+)
Top surfaceTouchpad (full swipe)Clickpad + outer touch ring
Swipe gesturesFull areaRing edge only
Click navigationCenter pressD-pad style
AccelerometerYesYes
特性第一代(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
,
.pageDown

Low-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、pressesCancelled

Game 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的存储目录

DirectoryExists?Persistent?
DocumentsNoN/A
Application SupportYesNo — system can delete when app is not running
CachesYesNo — system deletes under storage pressure
tmpYesNo
目录是否存在?是否持久化?
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

数据库推荐

SolutiontvOS ViabilityNotes
SQLiteData + CloudKit SyncEngineRecommendediCloud is persistent; local is just cache
SwiftData + CloudKitWorks, but fragileNo 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 + CloudKitDangerousSpace inflation from CloudKit metadata
Local-only GRDB/SQLiteUnreliableSystem deletes the database file
NSUbiquitousKeyValueStoreGood for small data1 MB limit, key-value only
On-demand resourcesGood for read-only assetsOS manages download/purge lifecycle
See
axiom-sqlitedata
for CloudKit SyncEngine patterns,
axiom-storage
for full storage decision tree.

解决方案tvOS适用性说明
SQLiteData + CloudKit SyncEngine推荐iCloud持久化;本地仅作缓存
SwiftData + CloudKit可用,但不稳定无持久化本地存储;ModelContainer必须从一开始就配置CloudKit——后续添加同步需要迁移;系统删除数据库会触发下次启动时的全量同步
CoreData + CloudKit不推荐CloudKit元数据会导致空间膨胀
仅本地的GRDB/SQLite不可靠系统会删除数据库文件
NSUbiquitousKeyValueStore适用于小型数据1 MB限制,仅支持键值对
按需资源适用于只读资源系统管理下载/清除生命周期
参考
axiom-sqlitedata
获取CloudKit SyncEngine模式,
axiom-storage
获取完整的存储决策树。

4. 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

替代方案

NeedSolution
Parse HTML/JSONUse JavaScriptCore (JSContext, JSValue — no DOM)
Display web contentRender natively from parsed data
HLS streaming from m3u8Local HTTP server pattern (see below)
OAuth loginDevice 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 = true

TVLockupView

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 focus
TVPosterView的基类——灵活的容器,管理带焦点行为的内容:
swift
let lockup = TVLockupView()
lockup.contentView.addSubview(customView)
lockup.headerView = headerFooter   // TVLockupHeaderFooterView
lockup.footerView = footerFooter
// showsOnlyWhenAncestorFocused:焦点状态下页眉/页脚的可见性

Other TVUIKit Components

其他TVUIKit组件

ComponentPurpose
TVCardViewSimple container with customizable background
TVCaptionButtonViewButton with image + text + directional parallax
TVMonogramViewUser initials/image with PersonNameComponents
TVCollectionViewFullScreenLayoutImmersive full-screen collection with parallax + masking
TVMediaItemContentViewContent 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

三种实现方式

ApproachBest ForKeyboard Style
UIAlertControllerQuick, simple inputModal with text field
UITextFieldMulti-field formsFullscreen keyboard with Next/Previous
UISearchControllerSearchInline single-line keyboard
方式最佳适用场景键盘样式
UIAlertController快速、简单的输入带文本框的模态弹窗
UITextField多字段表单带下一步/上一步的全屏键盘
UISearchController搜索内联单行键盘

UITextField (Fullscreen Keyboard)

UITextField(全屏键盘)

The primary text input method. Calling
becomeFirstResponder()
presents a fullscreen keyboard:
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()

SwiftUI
.searchable()

SwiftUI's
.searchable()
modifier works on tvOS and presents the system search keyboard. Use it for standard search patterns:
swift
NavigationStack {
    List(filteredItems) { item in
        Text(item.title)
    }
    .searchable(text: $searchText, prompt: "Search movies")
}
For custom search UI beyond what
.searchable()
offers, fall back to the shadow input pattern above.

SwiftUI的
.searchable()
修饰符可在tvOS上使用,会弹出系统搜索键盘。适用于标准搜索模式:
swift
NavigationStack {
    List(filteredItems) { item in
        Text(item.title)
    }
    .searchable(text: $searchText, prompt: "搜索电影")
}
如需超出
.searchable()
能力的自定义搜索UI,可使用上述影子输入模式。

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 play
swift
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)
// 用户点击播放时切换为.playback

Custom 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

设备性能差异

DeviceChipRAMNotes
Apple TV HD (4th gen)A82 GBStill supported; much slower
Apple TV 4K (1st gen)A10X3 GBCapable
Apple TV 4K (2nd gen)A124 GBGood
Apple TV 4K (3rd gen)A154 GBExcellent
Test on older hardware. The Apple TV HD is still in use and dramatically slower than 4K models.

设备芯片内存说明
Apple TV HD(第4代)A82 GB仍受支持;性能慢很多
Apple TV 4K(第1代)A10X3 GB性能尚可
Apple TV 4K(第2代)A124 GB性能良好
Apple TV 4K(第3代)A154 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
        }
    }
}
#endif

View Inspection Helper

视图检查工具

swift
#if DEBUG
extension View {
    func debugBorder() -> some View {
        border(.red, width: 1)
    }
}
#endif
swift
#if DEBUG
extension View {
    func debugBorder() -> some View {
        border(.red, width: 1)
    }
}
#endif

Simulator 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

反合理化借口

ThoughtReality
"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