axiom-tvos について
tvOS アプリの開発や移植に取り組む開発者向けのリファレンススキルです。フォーカス、Siri Remote の入力、ストレージ、ウェブコンテンツなどの特有の挙動と実行時の注意点を解説します。
主な機能
- 01UIKit と SwiftUI のフォーカス機構
- 02Siri Remote の入力処理
- 03tvOS のストレージ制約
- 04ウェブコンテンツの代替手段
- 05テキスト入力と AVPlayer の注意点
活用シーン
- 01iOS アプリを tvOS に移植
- 02フォーカス移動の問題を調査
- 03アプリデータの永続化を設計
- 04WebView ベースのコンテンツを置き換え
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-tvosインストール後、Claude Code で/axiom-tvosを入力して読み込まれたか確認します。
詳細
tvOS 開発
概要
tvOS は iOS と UIKit および SwiftUI を共有していますが、すべての iOS 開発者がつまずくような重要な違いがあります。特に危険な思い込みは、(1)ローカルファイルは保持される、(2)WebView が存在する、(3)フォーカスは @FocusState のように機能する、という3つです。
基本原則 tvOS は「テレビ上の iOS」ではありません。デュアルフォーカスシステムがあり、永続的なローカルストレージはなく、WebView もありません。また、互換性のない2世代のリモコンがあります。独自のプラットフォームとして扱ってください。
tvOS 26 では Liquid Glass のデザイン言語と、新しいアプリアイコンシステムが採用されています。実装パターンについては axiom-liquid-glass を参照してください。
tvOS 移植時のトリアージ
tvOS への移植をリリースする前に、次の5つの領域を確認してください。tvOS 固有のバグの90%は、これらに起因します。
| 領域 | 確認事項 | セクション |
|---|---|---|
| ストレージ | 永続的なローカルファイルは不可。iCloud が必要 | §3 |
| フォーカス | デュアルシステムが動作していること。隙間はフォーカスガイドで補完 | §1 |
| WebView | JavaScriptCore またはネイティブ描画に置き換えられていること | §4 |
| テキスト入力 | シャドウ入力またはフルスクリーンキーボードが処理されていること | §6 |
| AVPlayer | オーディオセッション、バッファー、Menu ボタンのステートマシン | §7、§8 |
「tvOS でコンパイルできる」ことには何の意味もありません。この5つの領域はコンパイルに成功しても、実行時に失敗します。
このスキルを使う場面
- 新しい tvOS アプリを開発する、または tvOS ターゲットを追加する
- iOS アプリを tvOS に移植する
- tvOS のフォーカス、リモート入力、ストレージに関する問題をデバッグする
- tvOS で AVPlayer、TVUIKit、テキスト入力を扱う
プロンプトの例
開発者が実際に尋ねる質問と、このスキルが答える内容を紹介します。
1. 「iOS アプリを tvOS に移植していますが、フォーカスナビゲーションが動作しません」
-> このスキルでは、デュアルフォーカスシステム(UIKit Focus Engine と @FocusState)とよくある落とし穴について説明します
2. 「tvOS アプリを起動するたびに、すべてのデータが失われます」
-> このスキルでは、永続的なローカルストレージがないことを説明し、iCloud を優先するパターンを紹介します
3. 「tvOS の SwiftUI で Siri Remote の入力を処理するにはどうすればよいですか?」
-> このスキルでは、両世代のリモコンと3つの入力レイヤー(SwiftUI、UIKit ジェスチャー、GameController)を扱います
4. 「tvOS で WebView が動作しません。Web コンテンツを表示するにはどうすればよいですか?」
-> このスキルでは、解析に JavaScriptCore を使う方法と、ネイティブ描画の代替手段を紹介します
危険な兆候
次のいずれかが見つかった場合は、作業を中止してください。
- 「iOS と同じストレージコードを使えばいい」 — tvOS には Document ディレクトリがありません
- 「これなら WebView が使える」 — tvOS に WebView は一切ありません(Apple HIG:「tvOS ではサポートされていません」)
- 「@FocusState でフォーカスを扱える」 — tvOS にはデュアルフォーカスシステムがあり、@FocusState だけでは不十分です
- 「Application Support に保存すればいい」 — ここはキャッシュ専用です。アプリが実行されていない間、システムによってファイルが削除されます
- 「標準の UITextField で動作する」 — tvOS のテキスト入力ではフルスクリーンキーボードが起動します。シャドウ入力パターンを検討してください
- 「iOS と同じ AVPlayer コードを使えばいい」 — tvOS では起動時に .ambient オーディオセッションが必要で、Menu ボタンのカスタム処理とバッファーの調整も必要です。iOS の標準的な AVPlayer 設定では、オーディオセッションの競合や戻る操作の不具合が発生します。
1. Focus Engine と @FocusState
tvOS には、共存させる必要がある2つのフォーカスシステムがあります。これは iOS 開発者が最も混乱する原因です。
デュアルシステム
| システム | 制御対象 | API |
|---|---|---|
| UIKit Focus Engine | ハードウェアリモコンによるナビゲーション、方向スキャン | UIFocusEnvironment、UIFocusSystem、UIFocusGuide |
| SwiftUI フォーカス | プログラムによるフォーカスのバインディング、フォーカスセクション | @FocusState、.focused()、.focusable()、.focusSection() |
それぞれが適用される場面
User swipes on remote → UIKit Focus Engine handles it (always)
Code sets @FocusState → SwiftUI handles it (sometimes overridden by Focus Engine)
落とし穴:@FocusState ではプログラムからフォーカスを設定できますが、最終的な決定権は UIKit Focus Engine にあります。Focus Engine がビューをフォーカス不可と判断した場合、@FocusState による割り当ては何の通知もなく無視されます。
UIKit Focus Engine API
UIFocusEnvironment プロトコル(UIView、UIViewController、UIWindow が実装)は、次の機能を提供します。
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
}
}
UIFocusGuide — ナビゲーションの隙間をつなぐ
フォーカス可能なビューが直接的なグリッドレイアウトに配置されていない場合、Focus Engine は方向スキャンでそのビューを見つけられません。UIFocusGuide は、実際のビューへ誘導する、見えないフォーカス可能領域を作成します。
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]
SwiftUI Focus API
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
}
}
tvOS 向けの主な SwiftUI フォーカス修飾子:
.focused(_:equals:)— フォーカスを値にバインドします.focusable()— カスタムビューをフォーカス可能にします.focusSection()— 関連する項目をまとめて、方向キーで移動できるようにします.defaultFocus(_:_:)— スコープ内でフォーカスを開始する位置を設定します
デフォルトでフォーカス可能な要素
UIButton、UITextField、UITableViewCell、UICollectionViewCell はデフォルトでフォーカス可能です。カスタムビューには canBecomeFocused(UIKit)または .focusable()(SwiftUI)が必要です。起動時は左上の項目に初期フォーカスが設定されます。
フォーカスに関するよくある落とし穴
| 落とし穴 | 症状 | 対処法 |
|---|---|---|
| フォーカス不可能なコンテナー | スワイプしてもビューがスキップされる | .focusable() を追加するか、canBecomeFocused をオーバーライドします |
| フォーカスガイドがない | 独立したビューに移動できない | UIFocusGuide を追加して間をつなぎます |
| @FocusState が無視される | プログラムからフォーカスを設定できない | preferredFocusEnvironments のチェーンを確認します |
| フォーカス更新が要求されていない | レイアウト変更後もフォーカスが古いままになる | setNeedsFocusUpdate() + updateFocusIfNeeded() を呼び出します |
| 項目がグリッドレイアウトになっていない | フォーカスが予測不能に移動する | フォーカス可能な項目をグリッドに配置するか、フォーカスガイドを使用します |
| UIHostingConfiguration のフォーカス | UIKit と SwiftUI が混在するとフォーカスが破損する | 既知の問題です。UIHostingConfiguration のセルを慎重にテストしてください |
2. Siri Remote の入力
ハードウェアが異なる 2 世代があるため、コードで両方に対応する必要があります。
世代ごとの違い
| 機能 | 第 1 世代(2015-2021) | 第 2 世代(2021 以降) |
|---|---|---|
| 上面 | タッチパッド(全面スワイプ) | クリックパッド + 外周のタッチリング |
| スワイプジェスチャ | 全面 | リングの縁のみ |
| クリック操作 | 中央を押す | 十字キー形式 |
| 加速度計 | あり | あり |
標準 SwiftUI 修飾子(推奨)
ほとんどの UI では、SwiftUI がフォーカスシステムを通じてリモート入力を自動的に処理します。
Button("Play") { startPlayback() }
.focused($isFocused) // Automatically responds to remote navigation
List(items) { item in
Text(item.title)
}
// List navigation works automatically with remote
// Note: First item receives focus by default on tvOS — use .defaultFocus() to override
ジェスチャレコグナイザー(UIKit)
UIKit のレコグナイザーを使って、特定のボタン押下やジェスチャを検出します。
// 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)
利用可能な UIPress.PressType の値:.menu、.playPause、.select、.upArrow、.downArrow、.leftArrow、.rightArrow、.pageUp、.pageDown
低レベルの押下処理
細かく制御するには、UIResponder の押下メソッドをオーバーライドします。
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
Game Controller フレームワーク(生入力)
カスタム操作(シーク、ゲーム)では、Siri Remote に GCMicroGamepad としてアクセスします。
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 }
}
プログレスバーのスクラブ操作
滑らかにシークするには、仮想ダンピングを使用した UIPanGestureRecognizer を使います。
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
}
}
3. ストレージの制約
これは tvOS で最も危険な iOS の思い込みです。 tvOS に Documents ディレクトリはありません。ローカルストレージはすべてキャッシュであり、システムによっていつでも削除される可能性があります。iCloud との連携を省くと、実機でアプリの起動間にのみ発生する「データが消える」という断続的な不具合のデバッグに 2~3 週間かかることがあります。
Apple の App Programming Guide for tvOS より:「新しい Apple TV 向けに開発されるすべてのアプリは、iCloud にデータを保存し、優れたユーザー体験を提供する方法でデータを取得できなければなりません。」
tvOS にあるもの
| ディレクトリ | 存在するか | 永続性 |
|---|---|---|
| Documents | いいえ | 該当なし |
| Application Support | はい | いいえ — アプリが実行されていない間にシステムが削除する可能性があります |
| Caches | はい | いいえ — ストレージ不足時にシステムが削除します |
| tmp | はい | いいえ |
サイズ制限
- アプリバンドル:最大 4 GB
- NSUserDefaults / UserDefaults:最大 500 KB(Apple のドキュメントによる。iOS の 4 MB ではありません)。利用可能ですが、システムによる消去の対象となるため、セッション間で永続する保証はありません
- オンデマンドリソース:OS が管理する読み取り専用アセットに利用できます
- ローカルキャッシュ:サイズの保証はありません。アプリが実行されていない間にシステムが消去する可能性があります
これが意味すること
- ローカルファイルはすべて、アプリの起動間に消える可能性があります
- ローカルに保存された SQLite データベースは削除されます
- アプリはローカルデータが一切なくても動作しなければなりません
- ダウンロードしたデータはアプリの実行中には削除されず、セッション間でのみ削除されます
推奨パターン
// ✅ 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 []
}
データベースの推奨事項
| ソリューション | tvOS での適性 | 備考 |
|---|---|---|
| SQLiteData + CloudKit SyncEngine | 推奨 | iCloud が永続データを保持し、ローカルデータはキャッシュにすぎません |
| SwiftData + CloudKit | 動作しますが、不安定です | ローカルのみの永続ストレージはありません。ModelContainer は最初から CloudKit 用に構成する必要があり、後から同期を追加するには移行が必要です。システムによってデータベースが削除されると、次回起動時に完全な再同期が行われます |
| CoreData + CloudKit | 危険です | CloudKit のメタデータによって容量が膨らみます |
| ローカルのみの GRDB/SQLite | 信頼性に欠けます | システムによってデータベースファイルが削除されます |
| NSUbiquitousKeyValueStore | 小規模なデータに適しています | 1 MB の上限があり、キーと値の形式のみです |
| オンデマンドリソース | 読み取り専用のアセットに適しています | ダウンロードと削除のライフサイクルは OS が管理します |
参照先:CloudKit SyncEngine のパターンについては axiom-sqlitedata、ストレージの選択肢の全体については axiom-storage をご覧ください。
4. WebView は使用できません
tvOS には WKWebView、SFSafariViewController、WebView がありません。Apple の HIG では、Web ビューは「tvOS ではサポートされていません」と明記されています。
できること
| 必要なこと | ソリューション |
|---|---|
| HTML/JSON の解析 | JavaScriptCore(JSContext、JSValue — DOM はありません)を使用します |
| Web コンテンツの表示 | 解析したデータからネイティブに描画します |
| m3u8 からの HLS ストリーミング | ローカル HTTP サーバーのパターン(以下を参照) |
| OAuth ログイン | デバイスコードフロー(RFC 8628)またはコンパニオンデバイス |
解析に JavaScriptCore を使用する
JavaScriptCore は、DOM や Web 描画機能を持たない JavaScript 実行エンジンを提供します。tvOS で利用できます。
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] ?? []
主なクラス:JSVirtualMachine(実行環境)、JSContext(スクリプト評価)、JSValue(型の橋渡し)
制限事項:DOM、Web 描画、fetch/XMLHttpRequest はありません。純粋な JavaScript の実行のみです。
HLS 用のローカル HTTP サーバー
AVPlayer に変更済みの m3u8 プレイリストを提供する必要がある場合:
// 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)
5. TVUIKit コンポーネント
tvOS 専用の UIKit コンポーネントです。UIViewRepresentable を使って SwiftUI と連携します。
TVPosterView
フォーカス時の拡大表示と視差効果を内蔵したメディアコンテンツ表示機能です:
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
TVLockupView
TVPosterView の基底クラスで、フォーカス時の動作を伴うコンテンツを管理する柔軟なコンテナーです:
let lockup = TVLockupView()
lockup.contentView.addSubview(customView)
lockup.headerView = headerFooter // TVLockupHeaderFooterView
lockup.footerView = footerFooter
// showsOnlyWhenAncestorFocused: header/footer visibility on focus
その他の TVUIKit コンポーネント
| コンポーネント | 目的 |
|---|---|
| TVCardView | カスタマイズ可能な背景を備えたシンプルなコンテナー |
| TVCaptionButtonView | 画像、テキスト、方向性のある視差効果を備えたボタン |
| TVMonogramView | PersonNameComponents を使ったユーザーのイニシャルまたは画像 |
| TVCollectionViewFullScreenLayout | 視差効果とマスキングを備えた没入型の全画面コレクション |
| TVMediaItemContentView | バッジや再生進行状況を含むコンテンツ構成 |
TVDigitEntryViewController
システムが提供するパスコード/PIN 入力機能(tvOS 12 以降):
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)
6. tvOS でのテキスト入力
tvOS のテキスト入力は iOS とは根本的に異なります。Apple は UI でのテキスト入力を最小限に抑えることを推奨しています。
3 つの方法
| 方法 | 最適な用途 | キーボードのスタイル |
|---|---|---|
| UIAlertController | 手早く簡単な入力 | テキストフィールド付きモーダル |
| UITextField | 複数フィールドのフォーム | 「次へ」/「前へ」付きの全画面キーボード |
| UISearchController | 検索 | インラインの 1 行キーボード |
UITextField(全画面キーボード)
主要なテキスト入力方法です。becomeFirstResponder() を呼び出すと、全画面キーボードが表示されます:
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
シャドウ入力パターン(SwiftUI)
SwiftUI で独自のスタイルを適用した入力トリガーを使う場合:
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)
}
}
}
UISearchController(インラインキーボード)
検索インターフェース向けです。入力はすべて 1 行に収まりますが、カスタマイズできる範囲は非常に限られています:
let searchController = UISearchController(searchResultsController: resultsVC)
searchController.searchResultsUpdater = self
// Cannot customize text traits or add input accessories
SwiftUI の .searchable()
SwiftUI の .searchable() 修飾子は tvOS で動作し、システム検索キーボードを表示します。標準的な検索パターンにはこちらを使用します:
NavigationStack {
List(filteredItems) { item in
Text(item.title)
}
.searchable(text: $searchText, prompt: "Search movies")
}
.searchable() で実現できない独自の検索 UI が必要な場合は、上記のシャドウ入力パターンを使用してください。
7. AVPlayer の調整
tvOS のメディアアプリで快適な UX を実現するには、AVPlayer を特定の方法で構成する必要があります。
必須の設定
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
カスタムの閉じる処理
デフォルトでは、下方向へのスワイプでプレーヤーが閉じます。メディアアプリでは、これをオーバーライドします。
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)
}
}
}
8. メニューボタンの状態マシン
Siri Remote のメニューボタンは、「戻る」と「閉じる」の両方の役割を持ちます。メディアアプリで正しく処理するには、状態マシンが必要です。
問題点
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)
パターン
enum PlayerState {
case loading // Buffering / loading content
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
case .loading:
cancelLoading()
dismiss(animated: true)
return .loading
}
}
9. ネットワークの違い
IPv6 の優先度
Apple TV は IPv6 を強く優先します。App Store のすべてのアプリは、IPv6 のみのネットワーク(DNS64/NAT64)をサポートする必要があります。バックエンドが IPv4 のみに対応している場合、一部のネットワークでは接続が遅くなったり、失敗したりすることがあります。
デバイスのパフォーマンス差
| デバイス | チップ | RAM | 備考 |
|---|---|---|---|
| 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. 開発者エクスペリエンス
デバッグ専用の入力マクロ
シミュレータでキーボードショートカットを使い、Siri Remote がなくてもテストできます。
#if DEBUG
extension View {
func debugOnlyModifier() -> some View {
self.onKeyPress(.space) {
print("Space pressed — simulating select")
return .handled
}
}
}
#endif
ビュー検査ヘルパー
#if DEBUG
extension View {
func debugBorder() -> some View {
border(.red, width: 1)
}
}
#endif
シミュレータの制限事項
- シミュレータでは、フォーカスエンジンの動作が正確に再現されません
- フォーカス移動は、必ず実機の Apple TV でテストしてください
- シミュレータのキーボード入力と Siri Remote の入力は異なります
- パフォーマンスのプロファイリングは実機で行う必要があります(特に Apple TV HD)
不合理な思い込みへの対処
| 思い込み | 実際のところ |
|---|---|
| 「iOS と同じコードを使えばいい」 | tvOS はストレージ、フォーカス、入力、Web ビューの点で iOS と異なります。問題に直面することになります。 |
| 「フォーカスの動作は iOS と同じ」 | tvOS には、UIKit フォーカスエンジンと SwiftUI の @FocusState という、2つのフォーカスシステムがあります。@FocusState だけでは不十分です。 |
| 「とりあえずローカルストレージで問題ない」 | tvOS には永続的なローカルストレージがありません。Apple は iCloud 機能を必須としています。 |
| 「WebView は使える」 | Apple の HIG では、Web ビューは「tvOS ではサポートされていません」。DOM はなく、JavaScriptCore のみです。 |
| 「TextField でテキスト入力を処理すればいい」 | UITextField を使うと、フルスクリーンキーボードが表示されます。UX を向上させるには、シャドー入力パターンや UISearchController の使用を検討してください。 |
| 「シミュレータでテストするだけでいい」 | フォーカスエンジンとパフォーマンスの検証には、実機でのテストが必要です。 |
リソース
出典:「Surviving tvOS」(Ronnie Wong、2026年)— Syncnext メディアプレーヤーの tvOS エンジニアリングログ
Apple のドキュメント:/tvuikit、/uikit/uifocusenvironment、/uikit/uifocusguide、/swiftui/focus、/gamecontroller/gcmicrogamepad、/avfoundation/avplayer、/javascriptcore
Apple のガイド:App Programming Guide for tvOS(ストレージ、入力、ジェスチャ)、HIG Web Views(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