← すべての記事

5つのApple プラットフォーム、共有ファイルはわずか3つ:Returnのクロスプラットフォームの実態

私の瞑想タイマーアプリ Return は、iPhone、iPad、Mac、Apple Watch、Apple TV という5つの Apple プラットフォームで動作しています。1 コードベースには(テストを除いて)40の Swift ファイルがあります。そのうち5つのプラットフォームすべてで共有されているのは3ファイルだけです。 残りは、#if os(...) による条件付きコンパイルで共有するのではなく、TimerManagerAudioManagerContentView といった概念を重複させる形で、別々の Xcode ターゲットに分割されています。

共有率は約7.5%、これは意図的なものです。

この記事は、2026年において、クロスプラットフォームの SwiftUI アプリを実際に出荷するとはどういうことか、なぜ積極的なコード共有が過大評価されているのか、そして実際に共有された3つのファイルに共通するものは何かを論じます。

iOS 26 platform tile from Apple Developer iPadOS 26 platform tile from Apple Developer macOS 26 platform tile from Apple Developer watchOS 26 platform tile from Apple Developer tvOS 26 platform tile from Apple Developer

Return が対象とする5つのプラットフォーム。Apple が developer.apple.com で提示している通りです。それぞれが Xcode 上で別個のプラットフォームターゲットであり、ランタイム分岐ではありません。

TL;DR

  • Return:メインターゲット(iOS + iPadOS + macOS)の Swift ファイルが18、tvOS ターゲットが10、watchOS ターゲットが7、ウィジェットファイル(Live Activities)が2、そして真にクロスプラットフォームなファイルが Return/Shared/ に3つ。合計40。
  • 共有されている3ファイルは永続化に関わるもの:MeditationSessionSessionStoreSessionHistoryView。これらは iCloud 経由で行き来する状態であり、プラットフォームに合わせて変化する UI ではありません。
  • tvOS と watchOS はメインターゲット内の #if os(tvOS) 分岐ではなく、別々の Xcode ターゲットです。コントロールモデルがあまりにも違いすぎて、ひとつの ContentView には収まりません。
  • メインの iOS/iPadOS/macOS ターゲット内ですら、#if os ブロックが繁茂します。ContentView.swift で10個、LiveActivityManager.swift で8個、VideoBackgroundView.swift で8個、AudioManager.swift で6個。
  • 率直な見立て:5つの Apple プラットフォームをまたいだ積極的な共有は、保守上の負債です。小さな共有コア(永続化レイヤー)と、プラットフォーム固有の UI を分離する構成のほうが、ひとつの巨大な #if まみれのファイルよりも速く出荷でき、壊れにくいのです。

プラットフォーム別の補完記事については、Apple プラットフォームマトリクスwatchOS ランタイム契約Liquid Glass SwiftUI パターン集をご覧ください。

数字で見る

テストと UI テストを除いた、Swift ファイル数によるコードベースの形状はこうです。

Return/                            18 files   (iPhone + iPad + Mac, single target)
├── Shared/                         3 files     cross-platform truth   ├── MeditationSession.swift   ├── SessionStore.swift   └── SessionHistoryView.swift
├── ContentView.swift              (10 #if os branches)
├── TimerManager.swift             (2 #if os branches)
├── AudioManager.swift             (6 #if os branches)
├── HealthKitManager.swift
├── LiveActivityManager.swift      (8 #if os branches, iOS-only)
├── ThemeManager.swift
├── VideoBackgroundView.swift      (8 #if os branches)
├── GlassTextShape.swift           (Liquid Glass, see prior post)
├── GlassTimerText.swift
└──  (settings, theme, audio assets, etc.)

ReturnTV/                          10 files   (tvOS, separate target)
├── TVContentView.swift
├── TVTimerManager.swift            duplicates main TimerManager
├── TVAudioManager.swift            duplicates main AudioManager
├── TVDurationPicker.swift
├── TVFocusModifier.swift           tvOS button styles for focus
├── TVSettingsView.swift
└── ReturnWatch Watch App/              7 files   (watchOS, separate target)
├── WatchContentView.swift
├── WatchTimerManager.swift         duplicates main TimerManager
├── WatchAudioManager.swift         duplicates main AudioManager
├── WatchHealthKitManager.swift     duplicates main HealthKitManager (mostly)
├── WatchSettingsView.swift
└── ReturnWidgets/                      2 files   (Live Activity + bundle)
├── ReturnLiveActivity.swift
└── ReturnWidgetsBundle.swift

5つのプラットフォーム、3つの共有ファイル、プラットフォームごとに分けた2つのターゲットに加えてウィジェットターゲット、さらにメインターゲット内には大量の条件付きコンパイル。共有率はおよそ7.5%です。たいていの「マルチプラットフォーム SwiftUI」チュートリアルでは逆を勧めています。@Environment(\.horizontalSizeClass)#if os(...) ですべてのプラットフォームに適応するひとつの ContentView を書け、というものです。2 それは2つのプラットフォーム(iPhone + iPad)なら通用します。5つになると破綻します。

共有された3ファイルに共通するもの

Return/Shared/MeditationSession.swift は SwiftData に隣接する値型を定義します。3

struct MeditationSession: Codable, Identifiable, Equatable {
    let id: UUID
    let startDate: Date
    let endDate: Date
    let durationSeconds: Int
    let sourceDevice: DeviceType
    var syncedToHealthKit: Bool

    enum DeviceType: String, Codable, CaseIterable {
        case iPhone, iPad, mac, appleTV, appleWatch
    }
}

このファイルのヘッダーコメントは決定的に重要です。// Add this file to: Return, ReturnTV, ReturnWatch Watch App targets. 同じソースファイルが3つの Xcode ターゲットから参照されており、シンボリックリンクでもなく、Swift パッケージに埋め込まれているわけでもありません。Apple のビルドシステムは、ひとつのファイルを3つのバイナリへと喜んでコンパイルします。

SessionStore.swift は永続化レイヤーで、NSUbiquitousKeyValueStore(Apple の iCloud Key-Value Store)の薄いラッパーとして MeditationSession の配列を読み書きします。この選択には意味があります。KV ストアによる同期は、CloudKit コンテナをプロビジョニングしなくてもデバイス間でセッション履歴を共有できる代わりに、ストア全体が合計1 MB に上限が定められているというトレードオフを伴います。12 1件あたり数百バイト程度の瞑想セッションのリストにとっては、上限は十分すぎるほどです。SessionHistoryView.swift はセッションを描画する SwiftUI のリストです。両者とも、iPhone、iPad、Mac、Watch、TV のターゲットから同じように使われています。

この3ファイルに共通すること:いずれも状態を記述するもので、相互作用を記述するものではない、という点です。MeditationSession という概念はどのデバイスでも同じです。過去のセッション一覧の読み方も、どのデバイスでも同じです。どちらも操作面、ウィンドウマネージャ、オーディオルーティングの判断、フォーカスエンジン、Digital Crown のいずれも関与しません。ファイルが「自分はどのプラットフォームで動いているのか」を知る必要が出た瞬間に、共有可能ではなくなるのです。

なぜ残りは共有しなかったのか

TimerManager を例にとります。iOS/iPadOS/macOS 版は Timer.publish(every: 1, ...) を使い、通知は UserNotifications 経由で配信します。tvOS 版(TVTimerManager)は、ユーザーが Siri Remote で一時停止し、スクリーンセーバーが起動するケースを扱います。watchOS 版(WatchTimerManager)は、画面が暗くなっても OS がアプリを応答可能な状態に保てるよう(WatchSessionManager 経由で)WKExtendedRuntimeSession に処理を委譲し、入力はタッチではなく Digital Crown を経由してルーティングします。3つのプラットフォーム、3つの大きく異なるタイマーの振る舞いです。

これらを class TimerManager { #if os(watchOS) ... #elif os(tvOS) ... } として統合することはできます。結果として得られるのは、3つのモードを持ち、それぞれ40行の #if で囲まれたコードを抱え、iOS のパスに触れると watchOS のパスを壊しかねないクラスです。それは保守の悪夢です。

3つの異なるクラスを3つのファイル名で持つほうが、ディスク上のコード量は多くなりますが、頭の中のコード量は少なくなります。読める重複は、読めない抽象化に勝ります。

同じ論理は次にも当てはまります。

  • ContentView vs TVContentView vs WatchContentView:ナビゲーションモデルが違います(iPhone はプッシュベース、TV はフォーカスベース、Watch はリストベース)。
  • AudioManager vs TVAudioManager vs WatchAudioManager:オーディオセッションのカテゴリが違い、watchOS にはバックグラウンドオーディオに関するより厳しいルールがあり、tvOS は AirPlay へのルーティングが異なります。
  • VideoBackgroundView はメインターゲットに8つの #if os(iOS) 分岐を持ち(さらに対になる #elseif os(macOS) がひとつ)、異なる動画アセット(fire_phone.mp4 vs fire_mac.mp4)、異なるレイヤータイプ、異なるアスペクト比をカバーしています。4

ひとつ補足しておくと、メインの Return/ ターゲットは確かに iOS、iPadOS、macOS をひとまとめにしています。これら3つのプラットフォームは、共有しないコードよりも共有するコードのほうが多いのです。SwiftUI の NavigationStack は3つすべてで動作します。.glassEffect() も3つすべてで動作します。ウィンドウ管理の差異は実在しますが、ひとつのターゲット内で扱える範囲です。tvOS と watchOS が、私が別ターゲットの線を引いた箇所でした。

tvOS のケース:フォーカスエンジンが別ターゲットを強いた理由

Apple TV のナビゲーションはフォーカスエンジンを中心に組まれています。5 ユーザーが操作できるすべての UI 要素は、自身がフォーカス可能であることを宣言します。Siri Remote の方向ボタンはフォーカスを要素間で移動させ、選択を押すとフォーカスされた要素がアクティブ化されます。tvOS 上の SwiftUI は .focusable().focusEffect、そして @Environment(\.isFocused) に応答するカスタム ButtonStyle 型を通じて、これを公開しています。Apple のファーストパーティアプリで使われているパララックス・チルト効果のためのものです。TVFocusModifier.swift の実プロダクトコードはこうです。6

struct TVCapsuleButtonStyle: ButtonStyle {
    var accentColor: Color = .white
    @Environment(\.isFocused) private var isFocused

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .colorMultiply(isFocused ? focusedTextColor : accentColor)
            .background(
                Capsule().fill(isFocused
                    ? AnyShapeStyle(accentColor)
                    : AnyShapeStyle(.ultraThinMaterial))
            )
            .clipShape(Capsule())
            .scaleEffect(isFocused ? 1.1 : 1.0)
            .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
            .shadow(color: .black.opacity(isFocused ? 0.3 : 0.1),
                    radius: isFocused ? 20 : 5, y: isFocused ? 10 : 2)
            .animation(.easeInOut(duration: 0.2), value: isFocused)
    }
}

同じファイルでは、四角形・円形コントロール用の TVCircleButtonStyle も定義しています。両方のスタイルとも、フォーカス時に色と透過度を反転させます。フォーカスされていないボタンは .ultraThinMaterial の上に乗り、フォーカスされたボタンはアクセントカラーで塗りつぶされ、スケールとシャドウが強調されます。このパターンは、本アプリにおいては構造的に tvOS 固有のものです。@Environment(\.isFocused) は iOS、iPadOS、macOS、watchOS、tvOS のすべてで利用可能ですが、13 フォーカス駆動のナビゲーションが主要な相互作用モデルとなるのは tvOS だけで、Siri Remote はポインタイベントもタッチイベントも生成しません。iPhone や iPad では同等のコントロールはタップでヒットテストされ、Mac ではホバーかクリックです。TVFocusModifier.swift のボタンスタイルは、フォーカスがユーザーの主要なアフォーダンスであることを前提に、視覚的な反応全体をその周辺に設計しています。iOS のタッチ、Mac のホバー、tvOS のフォーカス駆動ナビゲーションをひとつの場所で扱う ContentView を書く良い方法はありません。ビュー構造そのものが本質的に違うのです。tvOS の ContentView はフォーカス可能な行のグラフであり、iOS の ContentView はタップして実行するスタックです。

期間ピッカーについても同じことが言えます。iPhone では下からスライドアップしてタップを受け付けます。Apple TV ではフォーカス可能なセルの横一列で、ユーザーがリモコンで操作します。TVDurationPicker.swift がそれ自身のファイルになっているのは、セルベースのフォーカス設計が iPhone には類例を持たないからです。それらをひとつのファイルに押し込めることは、#if os(tvOS) でつなぎ合わせた、互いに無関係な2つの UI を持つことを意味します。

watchOS のケース:拡張ランタイムセッション、HealthKit、そして小さな表面積

watchOS には、他のプラットフォームにはない構造的な制約が2つあります。

  1. WKExtendedRuntimeSession:watch の画面が暗くなっている間もアプリを応答可能な状態に保つためのものです。8 これがないと、watchOS は1秒のティックごとにアプリを積極的にサスペンドし、タイマーがズレていきます。Return は watchOS ターゲットの Info.plistWKBackgroundModes: mindfulness を宣言することで、OS にこの用途を認識させてランタイム予算を獲得します。ランタイムセッション自体はデフォルトの WKExtendedRuntimeSession() イニシャライザで作成されます。
  2. NSUbiquitousKeyValueStore 経由の iCloud 同期:WatchConnectivity ではありません。Return のセッション履歴の同期は、iPhone、iPad、Mac のターゲットが使うのと同じ Key-Value Store に乗っています。そのため、watch で記録された瞑想は、watch から phone への直接的なメッセージングなしに iPhone の履歴ビューに現れます。WatchConnectivity はライブの状態同期のために将来選択肢になり得ますが、Return はよりシンプルなモデルを選びました。各デバイスが同じ iCloud KV ストアに書き込み、いずれかのデバイスでの次回読み込み時にその和集合を見るというものです。

WatchTimerManager.swift は watch 側のタイマーで、拡張ランタイム関連の処理を WatchSessionManager に委譲します。これは ReturnWatchApp.swiftfinal class WatchSessionManager: NSObject, WKExtendedRuntimeSessionDelegate として定義されています。iOS の TimerManager には類似物がありません。iOS のアプリは、明示的なランタイムセッションなしでフォアグラウンドで応答可能なままだからです。watch のロジックを #if os(watchOS) で iOS の TimerManager に詰め込むことは、iOS のコードパスが使いもしない WatchKit のシンボルをインポートし、さらに watchOS のコードパスは iOS のパスにはない初期化経路を必要とすることを意味します。

WatchHealthKitManager.swift はメインの HealthKitManager のより小さな派生形です。マインドフルな分数を同じ方法で記録しますが、認証プロンプトの UX が異なります(watch は HealthKitPermissionSheet を表示できません)。watch のクラスはメインのおよそ半分のサイズです。

メインの iOS/iPadOS/macOS ターゲット内で起きていること

メインターゲット内ですら、共有は自動ではありません。ContentView.swift には10個の #if os(macOS) または #if !os(macOS) ブロックがあり、LiveActivityManager.swift には8個、VideoBackgroundView.swift には8個、AudioManager.swift には6個があります。Live Activities は iPhone 専用機能なので、LiveActivityManager 全体が #if os(iOS) で囲まれています。iPhone での期間ピッカーは iPad や Mac の期間ピッカーとは異なるレイアウトを使うので、ContentView には並行するレイアウト分岐があります。

うまく機能してきたパターンはこうです。小さなプラットフォーム差分(キーボード挙動の違い、パディングの違い、欠けている API)には #if os(...) を、構造的に大きな差分(フォーカス vs. タッチ、ワークアウトセッション vs. タイマー)には別ターゲットを。 私が結局採用してきた閾値は「分岐コードが10行を超えるかどうか」です。それ以下なら、条件付きコンパイルで問題ありません。それ以上なら、そのファイルは2つの仕事を一度にやっており、2つ目の仕事は別のターゲットに属するのです。

5つすべてのプラットフォームで出荷すべきでないとき

率直な見立てです。

情報密度の高いアプリなら、Apple Watch はスキップしましょう。 46mm の画面には、30件のリストと期間ピッカーと設定ページを置く余地はありません。Return が watchOS で生き残れるのは、コアな相互作用がボタン1つ(タイマーの開始/停止)だからです。生産性アプリ、ファイナンスアプリ、メディアリッチなアプリでは、そうはいきません。

操作主体のアプリなら、Apple TV はスキップしましょう。 TV はアンビエントな体験のためのもの(部屋の向こう側の画面でタイマーが動く、音楽再生)です。ユーザーから頻繁に入力を必要とするものは、すべてプラットフォームと戦うことになります。Return が tvOS にあるのは、「20分のタイマーをセットして画面の炎を眺める」がまさに正しいアンビエント・ケースだからです。メモアプリだったら惨めなことになるでしょう。

phone-first なインターフェースなら、Mac はスキップしましょう。 Mac 上の SwiftUI は動作はしますが、NavigationStack のプッシュモデルは、本物の Mac サイドバーと比べるとおもちゃのように映ります。Mac で未完成に感じるアプリなら、Catalyst(iPad アプリを変換するもの)で出荷するか、Mac ネイティブの UI を作れるまで Mac は完全にスキップしましょう。

サイズクラスへの対応をしていないなら、iPad はスキップしましょう。 iPad を満たすために引き伸ばされた iPhone アプリは安っぽく映ります。iPad には最低でもサイドバーを備えた NavigationSplitView が必要で、理想的には本物の2ペインレイアウトです。Return は iPad ではスプリットビュー、iPhone ではスタックを使っています。コードは同じターゲット内ですが、UI は本質的に違います。

私が引いた基準はこうです。アプリのコアな相互作用がそのプラットフォームの入力モデルにマッピングされるとき、そのプラットフォームで出荷するのです。瞑想タイマーを Apple Watch で出荷する(タップひとつで開始)。瞑想タイマーを Apple TV で出荷する(セットして放っておく)。かんばんボードはどちらでも出荷しないことです。

努力なしに移植できるもの

Return で5つのプラットフォームすべてにわたって共有された3つは、こうです。

  1. データモデルMeditationSession)。構造体はどのプラットフォームでも同じで、NSUbiquitousKeyValueStore 経由で同期され、どのプラットフォームも他のいずれかが書き込んだものを読めます。
  2. セッション履歴ビューSessionHistoryView)。過去のセッションの List は、iPhone、iPad、Mac、Apple Watch、Apple TV のいずれでも同じように描画されます。SwiftUI の List は、5つすべてのフォームファクタにわたって綺麗に適応する数少ないプリミティブのひとつです。
  3. 永続化ラッパーSessionStore)。読み書きはプラットフォーム非依存で、基盤となるストレージ(NSUbiquitousKeyValueStore)はどこでも同じ API です。

3つの概念。状態、リスト描画、永続化。ハードウェア固有の入力モデルを伴わない、状態を持つ表示寄りのものはすべて共有可能です。 入力、フォーカス、オーディオルーティング、画面サイズ、バックグラウンド実行のいずれかに触れるものは、共有不可能です。

このパターンは「iOS Agent Development」ガイドにも登場します。そこで私は同じことを別の言葉で論じました。iOS アプリのうちエージェントが書ける部分は、人間が書く部分とほとんどのコードを共有していて、人間の判断を要する部分(署名、ビジュアルの仕上げ、パフォーマンス)は、まさにプラットフォーム間でうまく共有できない部分でもある、と。9 2つの境界線は重なり合っています。どちらも、ドメイン知識が物を言い始める場所についての話なのです。

マルチプラットフォームのコスト

コストは非対称です。iPhone アプリに iPad を加えるのは、せいぜい20%多いコードで済みます(サイズクラスの分岐、いくつかの場所でのスプリットビュー)。同じターゲットに Mac を加えるとさらに15-20%が加わります(#if os(macOS) 分岐、メニューバー、ウィンドウ管理)。各メジャーターゲットは、小さなアプリでも約10ファイル増えます。

Apple Watch と Apple TV は高くつきます。Return に watchOS を加えるには、別ターゲットに11個の新規ファイルが必要でした。専用のオーディオ、タイマー、HealthKit マネージャを含みます。tvOS を加えるには、もうひとつの別ターゲットに10個の新規ファイルが必要でした。フォーカス管理とカスタム期間ピッカーを含みます。両者を合わせると、ユーザー機能のレベルでは同じアプリでありながら、Swift の表面積はほぼ倍になりました。

5つすべてで出荷するという選択は、「マルチプラットフォーム自体が目的」だったわけではありません。一連の独立した判断の積み重ねでした。Apple Watch は瞑想タイマーが本当に手首に属するから、Apple TV はアンビエントな画面フォーマットが部屋での長いセッションに合うから、Mac は会議の合間にデスクで瞑想するユーザーがいるから。各プラットフォームは、現実のユースケースを持つことで自身のターゲットを獲得しました。

ある機能がそのターゲットを獲得しないなら、安価な選択肢は、そのプラットフォームをスキップして、アプリが秀でているプラットフォームに二倍コミットすることです。

あなたのアプリにとっての意味

3つの要点です。

  1. メジャーなプラットフォームグループあたり1ターゲットをデフォルトに。 iOS + iPadOS + macOS をひとつのターゲットにまとめるのは、コアな相互作用(タッチ + カーソル)が似ているから機能します。tvOS は別ターゲット。watchOS は別ターゲット。各別ターゲットはおよそ10ファイルのコストですが、#if 分岐が際限なく増殖していく神クラスから救ってくれます。
  2. 状態は積極的に共有し、相互作用は共有しない。 Codable なモデル構造体、永続化ラッパー、List の描画は、ほとんどタダで移植できます。タイマーマネージャ、オーディオマネージャ、コンテンツビューはそうではありません。
  3. 各プラットフォームを獲得しなさい。 できるからといって watchOS で出荷してはいけません。アプリのコアな相互作用がそのプラットフォームの入力モデルにマッピングされるときに出荷しなさい。残りはスキップ。

このパターンは、同じ系列のアプリについて私が書いてきた他の3つの面と並んで機能します。Apple Intelligence のための型付き App Intents、クロスツールエージェントのための MCP サーバー、デバイスを操作する人間のための Liquid Glass。同じスタックの一番外側のレイヤーがプラットフォームです。アプリがそもそもどの画面で動くのか。これを AI の面と同じくらい意図的に選びなさい。

FAQ

共有コードに Swift パッケージを使わないのはなぜですか?

検討はしました。3ファイルのために、Swift パッケージは節約してくれる以上の儀式を増やします。Apple の Xcode 26 のビルドシステムは、Target Membership のチェックボックスをオンにすれば、ひとつのソースファイルを複数のターゲットへ喜んでコンパイルしてくれます。パッケージは別個の Package.swift を、別個のテストターゲットを、そしてリファクタのたびに辿らねばならない間接層を1段増やします。小さな共有コアには、シンプルな答えが勝つのです。10

SwiftData は watchOS と tvOS で動きますか?

SwiftData は iOS 17+、macOS 14+、watchOS 10+、tvOS 17+ で利用可能で、Return が対象とするすべてのプラットフォームをカバーしています。11 MeditationSession 構造体は @Model ではなく素の Codable です。Return がセッション履歴の同期に SwiftData コンテナではなく NSUbiquitousKeyValueStore を使っているからです。@Model 型でも同じパターンが機能します。モデルファイルは共有し、必要であれば永続化コンテナはプラットフォームごとに変えればよいのです。

Mac Catalyst を使うべきか、ネイティブ Mac ターゲットを作るべきか?

Catalyst は、iPad アプリが十分に良くて、Catalyst で再ビルドした Mac 版がネイティブとして読めるときに正解の道具です。Return のメインターゲットは(Catalyst ではなく)真のマルチプラットフォームターゲットで、iOS、iPadOS、macOS 向けに SwiftUI でひとつのバイナリにビルドされています。Mac の UI は #if os(macOS) を使って iPad とは異なる描画をします。シートの代わりにサイドバー、ボタン上のキーボードショートカット、などです。Catalyst のほうがシンプルだったでしょうが、Mac の UI は Mac 上で iPad アプリのように見えていたでしょう。それが Catalyst が最も知られている失敗モードです。

Apple TV は小さなアプリでも出荷する価値がありますか?

おそらくありません。Apple TV のアプリには非常に特定のユースケースがあります(アンビエント、メディア、カジュアルゲーム)。あなたのアプリがそのいずれにも合わないなら、プラットフォームの観客はアプリあたり10個の Swift ファイルを正当化するには小さすぎます。Return が tvOS を狙っているのは、部屋の向こう側の画面での長い瞑想セッションが、プラットフォームに合致する数少ない生産性に隣接したユースケースの1つだからです。

5つすべてのプラットフォームで出荷するのにどれくらいかかりますか?

正確な数字は出しにくく、アプリによります。Return はプラットフォームを段階的に追加していくのではなく、初日からマルチプラットフォームで出荷しました。これは後から追加するよりも速いのです。大まかな目安としては、iPhone 専用のアプリ、プラス iPad サポート、プラス Mac サポートで、iPhone 専用にかかる時間のおよそ1.5倍です。Apple Watch を加えるとさらに0.5倍。Apple TV を加えるとさらに0.5倍。つまり5プラットフォームの初回リリースは、iPhone 専用の労力のおよそ2.5倍です。ただし、これは重複コードのほとんどを手で打つのではなく Claude が一括編集したエージェント支援のビルドだったという但し書きが付きます。

参考文献


  1. 著者の Return、2026年4月21日に App Store で公開された瞑想タイマーアプリ。ネイティブターゲット:iOS 26+、iPadOS 26+、macOS 26+、watchOS 26+、tvOS 26+。全体を通して SwiftUI。デバイス間のセッション履歴には NSUbiquitousKeyValueStore。 

  2. Apple Developer、“Configuring a Multi-Platform App” と WWDC 2024 の “SwiftUI essentials” セッション。Apple のデフォルトの指針は、環境駆動の適応を伴うひとつのターゲットへ傾いています。本記事が取るマルチターゲットの道は、意図的な逸脱です。 

  3. 実プロダクトコード:Return/Return/Shared/MeditationSession.swiftSessionStore.swiftSessionHistoryView.swiftMeditationSession.swift のヘッダーコメントには次のように書かれています。“Add this file to: Return, ReturnTV, ReturnWatch Watch App targets.” 

  4. 実プロダクトコード:Return/Return/VideoBackgroundView.swift(8つの #if os(iOS) 分岐に加え、ひとつの #elseif os(macOS) 分岐)、Return/Return/ContentView.swift(10の #if os 分岐)、Return/Return/AudioManager.swift(6つの #if os 分岐)、Return/Return/LiveActivityManager.swift(8つの #if os 分岐、ファイル全体が iOS 専用)。分岐数は grep -Ec '^\s*#if os\\(' <file> の実行結果に基づきます。 

  5. Apple Developer、“Focus interactions” Human Interface Guidelines。tvOS のフォーカスエンジンは、iOS のタッチや Mac のポインタとは根本的に異なるナビゲーションモデルです。 

  6. 実プロダクトコード:Return/ReturnTV/TVFocusModifier.swift。フォーカス時に色と透過度を反転させ、スケール + シャドウを適用するために @Environment(\.isFocused) をラップする2つの ButtonStyle 型(TVCapsuleButtonStyleTVCircleButtonStyle)を定義しています。 

  7. Apple Developer、“WatchConnectivity”。ペアリングされた iPhone と Watch の通信のためのフレームワーク。Return はセッション同期にこれを使わず、代わりに iCloud Key-Value Store に依存しています。 

  8. Apple Developer、“WKExtendedRuntimeSession”“WKBackgroundModes” Info.plist キー。mindfulness の値は次のように文書化されています。”Enables extended runtime sessions for silent meditation” — 瞑想タイマーにぴったりの選択です。Return はデフォルトの WKExtendedRuntimeSession() を作成し、watchOS ターゲットの Info.plistWKBackgroundModes: mindfulness を宣言しています。実プロダクトコード:Return/ReturnWatch Watch App/ReturnWatchApp.swiftWatchSessionManager: NSObject, WKExtendedRuntimeSessionDelegate を定義し、WatchTimerManager.swift が拡張ランタイム関連の処理を委譲しています。 

  9. 著者の分析 Building iOS Apps with AI Agents、本番運用中の8つのアプリにわたるエージェント支援 iOS 開発の実践者向けガイド。 

  10. Apple Developer、“Configuring a Multi-Platform App”。Target Membership により、Swift パッケージなしにひとつのソースファイルを複数のターゲットへコンパイルできます。小さな共有コアには適した道具です。 

  11. Apple Developer、“SwiftData” platform availability。iOS 17+、iPadOS 17+、macOS 14+、watchOS 10+、tvOS 17+、visionOS 1+ で利用可能で、5つすべての Apple プラットフォームファミリをカバーします。 

  12. Apple Developer、“NSUbiquitousKeyValueStore”。ユーザーのデバイス間で少量の状態を同期するための Apple の iCloud Key-Value Store。Apple が公表する制限により、ストア合計サイズは全キーで合計1 MB に制限されます。実プロダクトコード:Return/Return/Shared/SessionStore.swift。 

  13. Apple Developer、EnvironmentValues.isFocused。iOS 14+、iPadOS 14+、macOS 11+、tvOS 14+、watchOS 7+ で利用可能。API はクロスプラットフォームですが、フォーカスがユーザーの主要なナビゲーションのアフォーダンスかどうかは異なります。 

関連記事

Apple プラットフォームマトリクス:どのターゲットにどのアプリがふさわしいか

iOS、iPad、Mac、Watch、Vision、TV。6つのプラットフォーム、6つの責務。Apple ターゲットの選定は、エンジニアリング判断である前にプロダクト判断です。

4 分で読める

iOS 26のHealthKit + SwiftUI:認可、サンプルタイプ、そして2つの実アプリから得たクロスプラットフォームパターン

Water(水分摂取トラッキング、HKQuantitySample)とReturn(マインドフルセッション、HKCategorySample)から得た本番運用パターン。許可UX、async ラッパー、watchOSバリアント、そして避けるべ…

4 分で読める

ループ・エンジニアリング――検証が安いところでループは勝つ

ループ・エンジニアリングを、Boris Cherny の全文書き起こしと照合する。彼が名前を挙げるループはどれも検証が安い。その制約こそが、何を自動化すべきかを決める。

4 分で読める