Return...

一款跨越五块屏幕的禅意冥想与专注计时器:iPhone、iPad、Apple Watch、Apple TV 与 Mac。

于 2026 年 4 月 21 日发布。一套代码。二十七种语言,包括阿拉伯语和希伯来语。四款主题、三种铃声、零数据分析。接下来讲述的是它如何诞生的过程:技术选择、设计取舍,以及从数百个 AI 生成的水滴中一点点剪辑到只剩一个的漫长静默过程。

全平台

一套代码,五块屏幕。

Return 是我发布过的第一款从单个 Xcode 项目运行在 Apple 每一类屏幕上的 App:iPhone、iPad、Apple Watch、Apple TV 与 Mac。五十七个 Swift 文件,约 12,700 行代码,零外部依赖。纯粹的 SwiftUI、AVFoundation、HealthKit、ActivityKit 与 WidgetKit。

朴素的做法是写一个通用的 TimerManager,然后用 #if 为每种平台差异分支。我没有这么做。Return 发布了三个计时器类(iOS 与 macOS 上的 TimerManager、tvOS 上的 TVTimerManager、watchOS 上的 WatchTimerManager),它们共享状态语义,但尊重各平台真正擅长的事。Live Activities 仅在 iOS。HealthKit 仅在 API 存在之处。延长运行时会话仅在 Watch。每个管理器都比单一多态类更简短、更诚实。

Return 运行于 iPhone 17 Pro Max,火主题
iPhone
Return 运行于 macOS,火主题
Mac
Return 运行于 Apple Watch Series 11,火主题
Watch
Return 运行于 Apple TV,火主题
Apple TV
Return 运行于 iPad Pro 13 英寸,火主题
iPad

该共享之处方才共享。

一个 Shared/ 目录承载所有目标必须达成共识的部分:MeditationSession 数据模型、SessionStore iCloud 封装,以及 SessionHistoryView。设置通过 App Group(group.com.941apps.Return)在 Watch 与手机之间同步。其余部分刻意按平台区分。

最清晰的例子是那一行决定某次会话是否已写入 HealthKit 的代码。iPhone 直接写入,所以会话结束的瞬间,"已同步"为 true。Mac 与 TV 根本无法写入 HealthKit,所以"已同步"为 false,直到 iPhone 稍后接手待处理的会话。同一意图,相反的布尔值,一个 #if:

Swift · TimerManager.swift:120-138
/// Save session to SessionStore for cross-device sync and HealthKit syncing
private func saveSessionToStore(startTime: Date, endTime: Date) {
    // On iOS: if healthKitEnabled, we save directly to HealthKit, so mark as synced
    // On Mac: if healthKitEnabled, we want to sync to iPhone, so mark as NOT synced
    #if os(iOS)
    let alreadySynced = settings.healthKitEnabled
    #else
    let alreadySynced = !settings.healthKitEnabled
    #endif

    let session = MeditationSession(
        startDate: startTime,
        endDate: endTime,
        sourceDevice: .current,
        syncedToHealthKit: alreadySynced
    )

    SessionStore.shared.addSession(session)
}

我始终回到这个模式:能让意图清晰可读的最少行数。当同一个布尔值在不同平台意味着不同的事,就把它写成不同的布尔值。#if 就成了文档的一部分。

本地化

二十七种语言,以及从右至左的支持。

Return 是我发布过的第一款覆盖所有我在意语言的 Apple App。二十七种语言都经过了完整的审校流程,包括阿拉伯语和希伯来语。这一切都存放在一个 Localizable.xcstrings 文件里,这听起来不像听上去那么壮烈。只要你愿意停止手动拼接字符串,Xcode 会替你完成大部分工作。

Return 主页,水主题,英语
English主页 · 水
Return 主页,火主题,日语
日本語主页 · 火
Return 主页,森林主题,简体中文
简体中文主页 · 森林
Return 设置页,德语
Deutsch设置
Return HealthKit 权限页,韩语
한국어HealthKit

只要你不与之对抗,RTL 就是白送的胜利。

SwiftUI 把 .leading.trailing 当作语义方向,而非像 .left.right 那样固定不变的方向。按语义方向排版一次,同一个页面就会在阿拉伯语、希伯来语、波斯语或乌尔都语中自动镜像,无需专用代码路径。设置标签翻转、返回箭头反向、开关位置对调。主题图标(水滴、火焰、叶片)原地不动。我没有为此写过一行 RTL 代码。

Return 主页,森林主题,英语,从左至右布局
英语 · LTR
Return 主页,森林主题,阿拉伯语,从右至左布局
阿拉伯语 · RTL
Return 主页,森林主题,希伯来语,从右至左布局
希伯来语 · RTL

有一个例外是我在发布前抓到的:SwiftUI 会把布局方向同样应用到 Text 视图,这意味着阿拉伯语和希伯来语截图的第一版里,计时器显示的是 "00:02" 而不是 "20:00"——拉丁数字被从右至左排列了。在所有承载时间或数字内容的 Text 视图上加一个 .environment(\.layoutDirection, .leftToRight) 修饰符就解决了。上面的截图来自已经包含该修饰符的发布版本。

截图集由 fastlane 生成,它以不同的 -AppleLanguages 参数运行同一套 UI 测试。App 自身的 effectiveLocale 模式读取该标志,重建视图层级,并捕获结果。一个辅助函数,二十七种语言,四种设备类别,全部在一个通宵任务中完成。

Swift · ReturnWatchApp.swift:92-111
/// The locale to use for the app - either user-selected or system default
/// In snapshot mode, always use system language (set by -AppleLanguages)
/// to allow screenshot generation for different locales
private var effectiveLocale: Locale {
    if isSnapshotMode || appLanguage.isEmpty {
        if let preferredLanguage = Locale.preferredLanguages.first {
            return Locale(identifier: preferredLanguage)
        }
        return .current
    }
    return Locale(identifier: appLanguage)
}

var body: some Scene {
    WindowGroup {
        WatchContentView()
            .preferredColorScheme(.dark)
            .environment(\.locale, effectiveLocale)
            .id(appLanguage) // Force rebuild when locale changes
    }
}

.id(appLanguage) 是那个物超所值的细节。没有它,SwiftUI 会缓存旧的视图层级,运行时切换语言后字符串不会刷新。有了它,整棵树都会被丢弃并重建,所有内容都会自动重新读取本地化字符串。一行代码,一整类 bug 被消除。

HealthKit

正念分钟,终于来了。

Apple 原生的 Watch 正念 App 把内置的 Reflect 与 Breathe 会话上限设为五分钟。HealthKit API 本身并没有这个上限。它会欣然接受任何结束时间晚于开始时间的 HKCategorySample。限制存在于 UI,而不是系统。Return 在每台设备上都放了一个 5 到 60 分钟的选择器,并如实写入你真正静坐的时长。

Swift · HealthKitManager.swift:92-103
/// Save a mindful session with the given start and end time
func saveMindfulSession(start: Date, end: Date) async -> Bool {
    guard isAvailable else { return false }

    // Don't save if end is before or equal to start
    guard end > start else { return false }

    let sample = HKCategorySample(
        type: mindfulType,
        value: HKCategoryValue.notApplicable.rawValue,
        start: start,
        end: end
    )
    ...
}

唯一的校验是 end > start。这也是 HealthKit 自身全部的校验。Apple 的 API 一直都乐于记录一次四十五分钟的冥想。只是请求这件事的按钮一直缺席。

其中三台设备没有 HealthKit,也能跨设备工作。

Mac 与 Apple TV 根本没有 HealthKit。显而易见的回应是"那就别在那上面记录会话了"。不那么显而易见、但正确的回应是:照样记录,写入 iCloud Key-Value Store,等 iPhone 下次唤醒时由它接手。Return 的 SessionStore 是共享存储,MeditationSession.syncedToHealthKit 是待处理标志,HealthKitManager.syncPendingSessions() 在 iOS App 每次回到前台时都会运行。

SessionStore
iCloud Key-Value Store
待处理会话
iPhone 写入 HealthKit ♥
Apple 健康正念分钟柱状图,显示一个月内平均 20 分钟
Apple 健康正念分钟,柱状视图。Apple 自家的正念 App 最多只能进行五分钟的 Reflect 会话。底层的数据存储并不在意你往里写什么。
Apple 健康正念分钟日历视图,显示过去 4 周内 18 天有练习
同一批数据,日历视图:过去 4 周有 18 天,每一次会话都由 Return 记录。
Return 会话历史页面,展示一系列 20 分钟冥想会话
Return 自身的会话历史。每台设备都做出贡献,每一次会话都带有来源标记。

这是一块我认为 Apple 自己该做的事:一个真正的跨平台正念分钟写入器,不需要你在 Mac 上想冥想时还得保持手机处于活跃状态。在 Apple 做之前,Return 先做了。

生成式

水从何而来。

四款主题。四段环境音循环。三种铃声。所有这些都是生成的,而大多数都被丢弃了。视频来自 Midjourney,音频来自 ElevenLabs,真正重要的工作不是提示词。是剪辑。看着两百张水滴的排列网格,挑出那一个能够无缝循环、没有可见接缝的。听着四十种寺庙钟声的变体,直到有一种拥有正确的起音、正确的衰减,并且不会听起来像手机通知。

Midjourney 样片:数百张水滴变体,几张标有心形与播放三角形
水 · 显示 128 张
Midjourney 样片:几十张火的变体
火 · 显示 96 张
Midjourney 样片:树冠与叶片的变体
森林 · 显示 60 张
Midjourney 样片:云与天空的探索,并未采用
未发布的探索 · 显示 128 张

每一块图格都是一次生成。心形是通过第一轮筛选的。播放三角形是我进一步做成了视频的。最终发布了四款主题。其余全部留在网格中,而这正是整个过程的意义所在:比例才是关键。

铃声在音频上经历了相同的弧线。提示、聆听、精修、再提示。我留下了三种:Singing Bowl、Temple Bell、Soft Chime。每一种都反复迭代,直到它不再听起来合成。

我不会假装去数总共生成了多少次。每款主题数百次是诚实的说法。纪律不在于提示词。它在于丢弃一切仅仅不错的,只留下那些能在计时器背后静默陪伴二十分钟、却从不让你注意到它们的。

修行

为什么是计时器,而不是老师。

这一段是私人的。我做 Return 是因为我早已有一套冥想修行,却找不到一款懂得让路的计时器。我真正在坐的,是日本禅宗的武家一脉:Takuan、Yagyu、Musashi、Dogen、Hakuin。不是那些大型 App 所售卖的疗愈式正念。不同的意图,不同的质地。

某一周里轮换的内容:

  • Susokukan(数息)。随呼吸从一数到十,每当数到一半遗失便回到一。根基。先建立专注,joriki
  • Shikantaza(只管打坐)。无对境。不数数、不参话头、不观想。不执著的心。Dogen 的核心坐禅形式,也是我真正想要的那种状态最接近的正式形态。
  • Koan(公案)。主要是 Joshu 的 Mu。一个无法被思考解决的问题,被持守至思考放弃。
  • Maranasati(死观)。Hagakure 的视角。慎用。求生会紧缩心念;这一法切穿它。
  • Isshin(一心)。Takuan 与 Yagyu 的领域:放松而不松懈,沉稳而灵动。从坐垫到接下来一切之间的桥梁。
  • 整合日。感恩、慈悲、传承。JihiKatsujinken:活人剑,而非杀人剑。通常在周六。
  • Sakki(对敌意的觉察)。每次会话末尾附加五分钟的开放聆听。让 shikantaza 走下坐垫,在日常环境中接受压力测试。

轮换并不僵硬。需要稳定时用数息。需要突破时用公案。需要在开放中休息时用只管打坐。需要澄清生死赌注时用死观。多样性本身就属于训练。

Return 是一款计时器,因为我不需要手机里有老师。我需要的,是某样替我看住钟、用我尊重的铃声标记起始与结束、在其间让出路的东西。如果你已经有修行,那大概也是你想要的。如果你是新手,去找一位坐在房间里的老师。然后再回来。

克制

Return 里没有什么。

Return 不是 Calm。也不是 Headspace。这里没有带着英式口音的解说员把你引入身体扫描。没有庆祝你连续坚持的卡通头像。没有解锁新引导课程的订阅。Return 是一款计时器。其理念是:如果你已经有修行,你在 App 里就不需要老师。你需要的是一个替你看管时间、然后让出路的工具。

  • 没有引导语音或旁白
  • 没有连续天数、积分或游戏化
  • 没有订阅或 App 内购买
  • 永远没有广告
  • 没有分析统计;App 什么也不追踪
  • 没有社交登录或分享
  • 没有骚扰弹窗,没有冷启动弹窗
  • 内购流程里没有阴暗套路,因为根本没有内购流程

Return 有的,刻意保持精简:四种重复模式(一次、直到停止、直到指定时间、重复 N 次)、每个循环之间两秒的呼吸停顿、每次切换时一至三声铃响、三种铃声可选、四款主题、HealthKit 授权选项,以及一个语言选择器。这就是整个产品。

如此严格的代价,体现在设置模型上。每一项面向用户的偏好,都由属性本身钳制到合法范围,而不是由 UI 校验完成。只要一不留神,UI 校验也会成为一种阴暗套路。bellRepeatCount 的 getter 不可能返回 1、2、3 之外的值。向底层的 @AppStorage 写入 0 或 47 会被静默钳制回允许的范围。

Swift · Settings.swift:74-81
@ObservationIgnored
@AppStorage("bellRepeatCount") private var _bellRepeatCount = 1

/// Validated bell repeat count (1-3)
var bellRepeatCount: Int {
    get { max(1, min(3, _bellRepeatCount)) }
    set { _bellRepeatCount = max(1, min(3, newValue)) }
}

Return 售价 $2.99。一次购买,永久拥有。没有需要维护的服务器成本、没有需要续订的订阅、没有盯着你在做什么的分析管线。产品就是产品。如果你想了解我为何坚持以这种方式做 App 的更长版本,去读 Minimum Worthy ProductThe Steve Test。短版本就在这一节里。

Return.

现已在 App Store 上架,适用于 iPhone、iPad、Apple Watch、Apple TV 与 Mac。