← 所有文章

五个Apple平台,三个共享文件:Return如何真正实现跨平台SwiftUI交付

Return是我开发的冥想计时器,运行在五个Apple平台上:iPhone、iPad、Mac、Apple Watch和Apple TV。1代码库共有40个Swift文件(不含测试)。其中只有三个文件在全部五个平台之间共享。其余文件被分散到不同的Xcode目标中,刻意复制了TimerManagerAudioManagerContentView等概念,而非通过#if os(...)条件编译来共享。

共享率约为7.5%,这是有意为之。

本文要谈的是:在2026年,跨平台SwiftUI应用的真实交付形态、为什么激进的代码共享被高估了,以及那三个确实共享的文件有什么共同点。

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所瞄准的五个平台,以Apple在developer.apple.com上呈现的方式展示。每一个在Xcode中都是独立的平台目标,而非运行时分支。

核心要点

  • Return:主目标(iOS + iPadOS + macOS)18个Swift文件,tvOS目标10个,watchOS目标7个,小组件(Live Activities)2个,真正的跨平台文件位于Return/Shared/下,共3个。合计40个。
  • 这三个共享文件都是与持久化相关的:MeditationSessionSessionStoreSessionHistoryView。它们承载的是通过iCloud流转的状态,而非需要适配各平台的UI。
  • tvOS和watchOS是独立的Xcode目标,而非主目标里的#if os(tvOS)分支。两者的控制模型差异太大,无法塞进一个ContentView。
  • 即便在主iOS/iPadOS/macOS目标内部,#if os代码块也大量存在:ContentView.swift中有10处,LiveActivityManager.swift中8处,VideoBackgroundView.swift中8处,AudioManager.swift中6处。
  • 老实说:在五个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

五个平台,三个共享文件,两个独立的平台目标外加一个小组件目标,再加上主目标内大量的条件编译。共享比例约为7.5%。大多数”多平台SwiftUI”教程主张的恰恰相反:写一个ContentView,通过@Environment(\.horizontalSizeClass)#if os(...)适配每一个平台。2这种方式对两个平台(iPhone + iPad)行得通,到了五个平台就会瓦解。

这三个共享文件的共同点

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.同一个源文件被三个Xcode目标引用,既不是符号链接,也不是内嵌于Swift package。Apple的构建系统会愉快地把一个文件编译进三个二进制中。

SessionStore.swift是持久化层:对NSUbiquitousKeyValueStore(Apple的iCloud键值存储)的一层薄封装,负责读写MeditationSession数组。这个选择很关键:KV-store同步让Return无需配置CloudKit容器就能跨设备共享会话历史,代价是整个存储上限为1 MB。12对于平均几百字节的冥想会话列表,这个上限绰绰有余。SessionHistoryView.swift是渲染会话的SwiftUI列表。这两个文件被iPhone、iPad、Mac、Watch和TV目标以完全相同的方式使用。

这三个文件的共同之处是:它们描述的是状态,而非交互。MeditationSession在每台设备上都是同一个概念。历史会话列表在每台设备上的读取方式也一致。它们都不涉及控制层、窗口管理、音频路由决策、焦点引擎或数字表冠。一旦一个文件需要知道自己运行在哪个平台上,它就不再适合共享。

其余部分为什么没有共享

TimerManager为例。iOS/iPadOS/macOS版本使用Timer.publish(every: 1, ...)并通过UserNotifications派发通知。tvOS版本(TVTimerManager)要处理用户通过Siri Remote暂停后屏保启动的情况。watchOS版本(WatchTimerManager)将工作委托给WKExtendedRuntimeSession(经由WatchSessionManager),让系统在屏幕变暗时仍保持应用响应,并通过数字表冠而非触摸接收输入。三个平台,三种截然不同的计时器行为。

理论上可以把它们统一为class TimerManager { #if os(watchOS) ... #elif os(tvOS) ... }。结果会是一个有三种模式的类,每种都是四十行被#if包裹的代码,改动iOS路径时随时可能搞坏watchOS路径。这是维护噩梦。

三个独立的类、三个独立的文件名,在硬盘上的代码更多,但在脑子里的代码更少。能读懂的重复胜过读不懂的抽象。

同样的逻辑适用于:

  • ContentView vs TVContentView vs WatchContentView:导航模型不同(iPhone上基于push、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合在了一起。这三个平台共享的代码远多于不共享的部分。SwiftUI的NavigationStack在三者上都能用。.glassEffect()也都能用。窗口管理的差异确实存在,但在一个目标内是可控的。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上焦点驱动的导航。视图结构本质上就是不同的:tvOS上的ContentView是一张可获焦行的图,iOS上的ContentView是一摞轻点即响应的栈。

时长选择器同理。在iPhone上,它从底部上滑并接受轻点。在Apple TV上,它是一行可获焦的水平单元格,用户用遥控器导航。TVDurationPicker.swift独立成文件,因为基于单元格的焦点设计在iPhone上没有对应物。把它们硬塞进同一个文件,只会得到两套用#if os(tvOS)粘合在一起、毫无关联的UI。

watchOS的情况:扩展运行时会话、HealthKit和更小的表面

watchOS引入了另外两个其他平台没有的结构性约束:

  1. WKExtendedRuntimeSession用于在表盘变暗期间维持应用响应。8如果不用它,watchOS会在每次秒级跳动之间激进地挂起应用,导致计时漂移。Return在watchOS目标的Info.plist中声明了WKBackgroundModes: mindfulness,以便系统识别该使用场景并分配运行时预算;运行时会话本身则通过默认的WKExtendedRuntimeSession()初始化器创建。
  2. iCloud同步通过NSUbiquitousKeyValueStore完成,而非WatchConnectivity。Return的会话历史同步走的是iPhone、iPad和Mac目标共用的同一个键值存储,所以在手表上记录的一次冥想能直接出现在iPhone的历史视图中,无需任何手表-手机的直接消息传递。WatchConnectivity也许将来可以用于实时状态同步,但Return选了更简单的模型:每台设备都写入同一个iCloud KV-store,任意设备的下一次读取都能看到合并后的结果。

WatchTimerManager.swift是手表侧的计时器;它把扩展运行时相关的工作委托给WatchSessionManager——后者定义在ReturnWatchApp.swift中:final class WatchSessionManager: NSObject, WKExtendedRuntimeSessionDelegate。iOS的TimerManager没有对应物,因为iOS应用在前台无需显式的运行时会话即可保持响应。把手表逻辑通过#if os(watchOS)塞进iOS的TimerManager,意味着iOS路径会引入它从不使用的WatchKit符号,而watchOS路径又需要iOS路径不需要的初始化流程。

WatchHealthKitManager.swift是主HealthKitManager的小型变体。它以同样的方式记录正念分钟数,但授权弹窗的UX有差异(手表无法展示HealthKitPermissionSheet)。Watch类的体量大约只有主类的一半。

主iOS/iPadOS/macOS目标内部发生了什么

即便在主目标内,共享也并非自动完成。ContentView.swift有十处#if os(macOS)#if !os(macOS)代码块;LiveActivityManager.swift有八处;VideoBackgroundView.swift有八处;AudioManager.swift有六处。Live Activities是iPhone独有的功能,所以整个LiveActivityManager都被#if os(iOS)包裹。iPhone上的时长选择器与iPad和Mac上的布局不同,所以ContentView里有平行的布局分支。

行之有效的模式是:对小的平台差异(键盘行为不同、内边距不同、缺少API)用#if os(...),对大的结构差异(焦点 vs 触摸、健身会话 vs 计时器)用独立目标。我最终采用的阈值是”分支超过约10行”。低于这个量,条件编译没问题。超过则意味着该文件同时承担了两份工作,而第二份工作应当属于另一个目标。

何时不要在全部五个平台上交付

诚实的评估如下。

如果你的应用信息密度高,跳过Apple Watch。46毫米的屏幕容不下30项列表、时长选择器和设置页。Return能在watchOS上活下来,是因为核心交互只有一个按钮(开始/停止计时器)。一个生产力应用、金融应用或媒体密集型应用做不到。

如果你的应用以交互为主,跳过Apple TV。电视适合环境式体验(隔着房间在屏幕上看一个计时器、播放音乐)。任何需要用户频繁输入的功能都是在跟平台对抗。Return之所以登陆tvOS,是因为”设个20分钟计时器,然后看屏幕里的火焰”恰好是合适的环境式场景。一个笔记应用会非常痛苦。

如果你的应用是手机优先的界面,跳过Mac。SwiftUI在Mac上能跑,但NavigationStack的push模型与真正的Mac侧栏相比像玩具。如果应用在Mac上会显得未完工,要么交付Catalyst(直接转译iPad应用),要么干脆跳过Mac,等到能做出真正的原生Mac UI再说。

如果你没做尺寸类适配,跳过iPad。把iPhone应用拉伸到iPad尺寸看起来很廉价。iPad至少需要带侧栏的NavigationSplitView;理想情况是真正的双栏布局。Return在iPad上用split view,在iPhone上用栈式布局。代码在同一个目标中,但UI是真正不同的。

我画下的规则是:当应用的核心交互能映射到该平台的输入模型时,才在那个平台上交付。冥想计时器适合上Apple Watch(一点开始)。冥想计时器适合上Apple TV(设了就看)。看板应用两者都不适合。

哪些东西毫不费力地通用

Return中真正在五个平台之间共享的三件事:

  1. 数据模型(MeditationSession)。该结构体在每个平台上完全一致,通过NSUbiquitousKeyValueStore同步,任意平台都能读取其他平台写入的内容。
  2. 会话历史视图(SessionHistoryView)。一个List渲染过往会话,在iPhone、iPad、Mac、Apple Watch和Apple TV上的表现完全一致。SwiftUI的List是少数能在五种形态因子上都干净适配的原语之一。
  3. 持久化封装(SessionStore)。读写与平台无关;底层存储(NSUbiquitousKeyValueStore)在哪里都是同一个API。

三个概念。状态、列表渲染和持久化。任何有状态、纯展示、不涉及硬件特定输入模型的东西都是可共享的。任何接触输入、焦点、音频路由、屏幕尺寸或后台执行的东西则不是。

这种模式在《iOS Agent开发指南》里也出现过——我用不同的措辞表达了同样的观点:agent能写的iOS应用部分,与人类编写的部分共享了大多数代码;需要人类判断的部分(签名、视觉打磨、性能),恰恰也是跨平台共享得最差的部分。9两条边界相互重合。两者都关乎领域知识何时开始变得关键。

多平台的代价

ROI是不对称的。在iPhone应用之上加iPad,大概多20%的代码(尺寸类分支、某些地方的split view)。在同一个目标里加Mac,再多15-20%(#if os(macOS)分支、菜单栏、窗口管理)。每个主要目标对小型应用而言会增加约10个文件。

Apple Watch和Apple TV才是昂贵的部分。给Return加watchOS需要在独立目标中新增11个文件,包括专门的音频、计时器和HealthKit管理器。加tvOS需要在另一个独立目标中新增10个文件,包括焦点管理和定制时长选择器。两者合起来,几乎让Swift表面积翻了一倍——而从用户功能层面看,它仍是同一款应用。

选择在五个平台都交付,并不是”我们想为多平台而多平台”。它是一连串独立的决定:Apple Watch,因为冥想计时器确实属于腕上;Apple TV,因为环境屏幕适合在房间里进行长时段冥想;Mac,因为有些用户在会议间隙在桌前冥想。每个平台都通过真实的使用场景挣得了自己的目标。

如果某个特性挣不到自己的目标,更划算的做法是跳过那个平台,把精力加倍投入到应用足够出色的平台上。

这对你的应用意味着什么

三个要点。

  1. 默认按主要平台分组各设一个目标。iOS + iPadOS + macOS放一个目标里行得通,因为核心交互(触摸 + 光标)相近。tvOS独立目标。watchOS独立目标。每个独立目标大约要付10个文件的代价,但能让你免于一个#if分支无限增长的”上帝类”。
  2. 激进地共享状态,而非交互。Codable模型结构体、持久化封装和List渲染几乎是免费通用的。计时器管理器、音频管理器、内容视图则不是。
  3. 让每个平台挣得自己的位置。不要因为能做就交付到watchOS。当应用的核心交互映射到该平台的输入模型时再交付。其余跳过。

这种模式与我为同一类应用写过的另外三个层面相辅相成:为Apple Intelligence设计的强类型App Intents、面向跨LLM agent的MCP服务器、面向设备前用户的Liquid Glass。同一栈的最外层是平台:应用究竟跑在哪些屏幕上。请像挑选AI层一样,审慎地挑选这一层。

常见问题

为什么不用Swift package来共享代码?

我考虑过。对三个文件而言,Swift package带来的仪式感比节省的成本还多。Apple的Xcode 26构建系统在你勾选目标成员关系框时,乐于把一个源文件编译进多个目标。一个package会引入独立的Package.swift、独立的测试目标,以及每次重构都得绕开的间接层。对于小型共享内核,简单的方案胜出。10

SwiftData在watchOS和tvOS上能用吗?

SwiftData在iOS 17+、macOS 14+、watchOS 10+和tvOS 17+上可用,覆盖Return瞄准的全部平台。11MeditationSession结构体是普通的Codable,而非@Model,因为Return采用NSUbiquitousKeyValueStore同步会话历史,而非SwiftData容器。对于@Model类型,模式同理:模型文件共享,持久化容器在必要时各平台分别处理。

该用Mac Catalyst还是原生Mac目标?

当iPad应用足够好,以至于Catalyst重建出来的Mac版本读起来像原生时,Catalyst就是合适的工具。Return的主目标是真正的多平台目标(并非Catalyst),用SwiftUI在一个二进制中构建iOS、iPadOS和macOS。Mac UI通过#if os(macOS)渲染得与iPad不同:用侧栏代替sheet、按钮带上键盘等价键,等等。Catalyst本来会更简单,但Mac UI会看起来像Mac上的iPad应用——这正是Catalyst最为人诟病的失败模式。

给一个小应用上Apple TV值得吗?

大概率不值。Apple TV应用有非常具体的使用场景(环境、媒体、休闲游戏)。如果你的应用不属于其中之一,那个平台的受众小到不足以撑起每个应用大约10个Swift文件的成本。Return之所以专门面向tvOS,是因为隔着房间在屏幕上进行长时段冥想,是少数几个适合该平台的、与生产力沾边的场景之一。

在五个平台上交付要多久?

很难给出精确数字;要看应用本身。Return从第一天就是多平台交付,而非逐步增加平台,这比事后改造要快。一个粗略的经验法则:仅iPhone的MVP,加上iPad支持、再加上Mac支持,大约是仅iPhone时间的1.5倍。再加Apple Watch约0.5倍。再加Apple TV又约0.5倍。所以五平台首发大约是仅iPhone工作量的2.5倍——但要注意,这是一个agent辅助的构建过程,大量重复代码是由Claude Code批量编辑的,并非手敲。

参考资料


  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。定义了两个ButtonStyle类型(TVCapsuleButtonStyleTVCircleButtonStyle),围绕@Environment(\.isFocused)在获得焦点时反转颜色与半透明效果,并应用缩放和阴影。 

  7. Apple Developer,《WatchConnectivity》。配对iPhone与Watch通信的框架;Return并未将其用于会话同步,而是依赖iCloud键值存储。 

  8. Apple Developer,《WKExtendedRuntimeSession》以及Info.plist中的《WKBackgroundModes》键。mindfulness值的官方说明是:”Enables extended runtime sessions for silent meditation”——正好契合冥想计时器。Return创建了默认的WKExtendedRuntimeSession(),并在watchOS目标的Info.plist中声明了WKBackgroundModes: mindfulness。生产代码:Return/ReturnWatch Watch App/ReturnWatchApp.swift定义了WatchSessionManager: NSObject, WKExtendedRuntimeSessionDelegate;WatchTimerManager.swift将扩展运行时工作委托给它。 

  9. 作者的分析见《Building iOS Apps with AI Agents》,一份基于8款生产应用、面向agent辅助iOS开发的实践指南。 

  10. Apple Developer,《Configuring a Multi-Platform App》。目标成员关系机制可让一个源文件编译进多个目标,无需Swift package。对小型共享内核而言,这是合适的工具。 

  11. Apple Developer,《SwiftData》platform availability。在iOS 17+、iPadOS 17+、macOS 14+、watchOS 10+、tvOS 17+、visionOS 1+上可用,覆盖全部五大Apple平台家族。 

  12. Apple Developer,《NSUbiquitousKeyValueStore》。Apple的iCloud键值存储,用于在用户设备间同步少量状态。按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。六个平台,六份责任。选择Apple目标平台首先是产品决策,其次才是工程决策。

1 分钟阅读

iOS 26 上的 HealthKit + SwiftUI:来自两款已上架应用的授权流程、样本类型与跨平台模式

来自 Water(饮水追踪,HKQuantitySample)和 Return(正念会话,HKCategorySample)的真实生产模式。权限 UX、async 封装、watchOS 变体,以及需要避开的陷阱。

5 分钟阅读

循环工程:在验证成本低廉处,循环才能取胜

循环工程,对照 Boris Cherny 的完整访谈记录来检验:他点名的每一个循环,验证成本都很低。正是这一约束决定了什么值得自动化。

4 分钟阅读