五个Apple平台,三个共享文件:Return如何真正实现跨平台SwiftUI交付
Return是我开发的冥想计时器,运行在五个Apple平台上:iPhone、iPad、Mac、Apple Watch和Apple TV。1代码库共有40个Swift文件(不含测试)。其中只有三个文件在全部五个平台之间共享。其余文件被分散到不同的Xcode目标中,刻意复制了TimerManager、AudioManager和ContentView等概念,而非通过#if os(...)条件编译来共享。
共享率约为7.5%,这是有意为之。
本文要谈的是:在2026年,跨平台SwiftUI应用的真实交付形态、为什么激进的代码共享被高估了,以及那三个确实共享的文件有什么共同点。
Return所瞄准的五个平台,以Apple在developer.apple.com上呈现的方式展示。每一个在Xcode中都是独立的平台目标,而非运行时分支。
核心要点
- Return:主目标(iOS + iPadOS + macOS)18个Swift文件,tvOS目标10个,watchOS目标7个,小组件(Live Activities)2个,真正的跨平台文件位于
Return/Shared/下,共3个。合计40个。 - 这三个共享文件都是与持久化相关的:
MeditationSession、SessionStore、SessionHistoryView。它们承载的是通过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路径。这是维护噩梦。
三个独立的类、三个独立的文件名,在硬盘上的代码更多,但在脑子里的代码更少。能读懂的重复胜过读不懂的抽象。
同样的逻辑适用于:
ContentViewvsTVContentViewvsWatchContentView:导航模型不同(iPhone上基于push、TV上基于焦点、Watch上基于列表)。AudioManagervsTVAudioManagervsWatchAudioManager:音频会话类别不同,watchOS的后台音频规则更严格,tvOS对AirPlay的路由也不同。VideoBackgroundView在主目标里有8处#if os(iOS)分支(还有一处#elseif os(macOS)配套),覆盖了不同的视频资源(fire_phone.mp4vsfire_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引入了另外两个其他平台没有的结构性约束:
WKExtendedRuntimeSession用于在表盘变暗期间维持应用响应。8如果不用它,watchOS会在每次秒级跳动之间激进地挂起应用,导致计时漂移。Return在watchOS目标的Info.plist中声明了WKBackgroundModes: mindfulness,以便系统识别该使用场景并分配运行时预算;运行时会话本身则通过默认的WKExtendedRuntimeSession()初始化器创建。- 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中真正在五个平台之间共享的三件事:
- 数据模型(
MeditationSession)。该结构体在每个平台上完全一致,通过NSUbiquitousKeyValueStore同步,任意平台都能读取其他平台写入的内容。 - 会话历史视图(
SessionHistoryView)。一个List渲染过往会话,在iPhone、iPad、Mac、Apple Watch和Apple TV上的表现完全一致。SwiftUI的List是少数能在五种形态因子上都干净适配的原语之一。 - 持久化封装(
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,因为有些用户在会议间隙在桌前冥想。每个平台都通过真实的使用场景挣得了自己的目标。
如果某个特性挣不到自己的目标,更划算的做法是跳过那个平台,把精力加倍投入到应用足够出色的平台上。
这对你的应用意味着什么
三个要点。
- 默认按主要平台分组各设一个目标。iOS + iPadOS + macOS放一个目标里行得通,因为核心交互(触摸 + 光标)相近。tvOS独立目标。watchOS独立目标。每个独立目标大约要付10个文件的代价,但能让你免于一个
#if分支无限增长的”上帝类”。 - 激进地共享状态,而非交互。Codable模型结构体、持久化封装和
List渲染几乎是免费通用的。计时器管理器、音频管理器、内容视图则不是。 - 让每个平台挣得自己的位置。不要因为能做就交付到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批量编辑的,并非手敲。
参考资料
-
作者的Return,一款冥想计时器应用,2026年4月21日在App Store上线。原生目标:iOS 26+、iPadOS 26+、macOS 26+、watchOS 26+、tvOS 26+。全程SwiftUI。跨设备会话历史使用
NSUbiquitousKeyValueStore。 ↩ -
Apple Developer,《Configuring a Multi-Platform App》以及WWDC 2024上的《SwiftUI essentials》主题。Apple的默认指引倾向于”单一目标 + 环境驱动的适配”;本文采用的多目标路线是有意的偏离。 ↩
-
生产代码位于
Return/Return/Shared/MeditationSession.swift、SessionStore.swift、SessionHistoryView.swift。MeditationSession.swift的头部注释为:“Add this file to: Return, ReturnTV, ReturnWatch Watch App targets.” ↩ -
生产代码位于
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>统计。 ↩ -
Apple Developer,《Focus interactions》Human Interface Guidelines。tvOS焦点引擎与iOS的触摸、Mac的指针,在导航模型上是根本性的不同。 ↩
-
生产代码位于
Return/ReturnTV/TVFocusModifier.swift。定义了两个ButtonStyle类型(TVCapsuleButtonStyle和TVCircleButtonStyle),围绕@Environment(\.isFocused)在获得焦点时反转颜色与半透明效果,并应用缩放和阴影。 ↩ -
Apple Developer,《WatchConnectivity》。配对iPhone与Watch通信的框架;Return并未将其用于会话同步,而是依赖iCloud键值存储。 ↩
-
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将扩展运行时工作委托给它。 ↩ -
作者的分析见《Building iOS Apps with AI Agents》,一份基于8款生产应用、面向agent辅助iOS开发的实践指南。 ↩
-
Apple Developer,《Configuring a Multi-Platform App》。目标成员关系机制可让一个源文件编译进多个目标,无需Swift package。对小型共享内核而言,这是合适的工具。 ↩
-
Apple Developer,《SwiftData》platform availability。在iOS 17+、iPadOS 17+、macOS 14+、watchOS 10+、tvOS 17+、visionOS 1+上可用,覆盖全部五大Apple平台家族。 ↩
-
Apple Developer,《NSUbiquitousKeyValueStore》。Apple的iCloud键值存储,用于在用户设备间同步少量状态。按Apple公开的限制,所有键合计的总存储大小上限为1 MB。生产代码:
Return/Return/Shared/SessionStore.swift。 ↩ -
Apple Developer,
EnvironmentValues.isFocused。在iOS 14+、iPadOS 14+、macOS 11+、tvOS 14+、watchOS 7+上可用。API本身是跨平台的;不同的是,焦点是否是用户的主要导航操作方式。 ↩