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 的平台。延伸執行時段(extended runtime sessions)僅限 Watch。每個管理器都比單一的多型類別更精簡、更誠實。

Return 在 iPhone 17 Pro Max 上執行,套用 Fire 主題
iPhone
Return 在 macOS 上執行,套用 Fire 主題
Mac
Return 在 Apple Watch Series 11 上執行,套用 Fire 主題
Watch
Return 在 Apple TV 上執行,套用 Fire 主題
Apple TV
Return 在 iPad Pro 13 吋上執行,套用 Fire 主題
iPad

在該共享的地方共享。

單一的 Shared/ 資料夾存放所有 target 都必須共識的部分: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 把內建的「靜心」與「呼吸」時段限制在五分鐘以內。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,下次手機醒來時再由它接手處理。Return 的 SessionStore 是共享儲存,MeditationSession.syncedToHealthKit 是待處理旗標,HealthKitManager.syncPendingSessions() 會在 iOS App 每次回到前景時執行。

SessionStore
iCloud Key-Value Store
待同步時段
iPhone 寫入 HealthKit ♥
Apple「健康」正念分鐘長條圖,顯示一個月內平均每次 20 分鐘
Apple「健康」正念分鐘,長條檢視。Apple 自家的「正念」App 上限只有五分鐘的「靜心」時段。底層資料儲存根本不在乎你寫了什麼進去。
Apple「健康」正念分鐘日曆檢視,顯示過去 4 週有 18 天進行了冥想練習
相同資料,日曆檢視:過去 4 週有 18 天,每次時段都由 Return 寫入。
Return 的「時段歷史」畫面,顯示一系列 20 分鐘冥想時段的清單
Return 自己的時段歷史。每部裝置都會貢獻,每次時段都帶有來源標記。

這正是我認為 Apple 自己該做的:一個真正跨平台的正念分鐘寫入機制,不需要手機醒著你才能在 Mac 上冥想。在他們動手之前,Return 做了。

生成式

那些水是從哪裡來的。

四種主題。四段環境循環。三種鐘聲。全部由 AI 生成,大多數被扔掉。影片是 Midjourney,音訊是 ElevenLabs,真正重要的工作不是下 prompt,而是編輯。在兩百滴水珠的網格中盯著看,挑出一滴能乾淨循環、看不出接縫的。聽著四十個版本的寺廟鐘聲,直到其中一個擁有正確的起音與正確的衰減,且聽起來不像手機通知音。

Midjourney 聯絡表:數百個水珠變體,其中少數標示愛心與播放三角形
水 · 顯示 128 組
Midjourney 聯絡表:數十個火焰變體
火 · 顯示 96 組
Midjourney 聯絡表:樹冠與葉片變體
森林 · 顯示 60 組
Midjourney 聯絡表:未上線的雲與天空探索
未發行的探索 · 顯示 128 組

每一格都是一次生成。愛心是通過第一輪篩選的。播放三角形是我帶去做影片的。四種主題上線。其餘全部留在網格裡,這正是整個流程的重點:比例才是關鍵。

鐘聲在音訊上走的是同一條弧線。下 prompt、聆聽、修正、再下 prompt。我留下三個:Singing Bowl、Temple Bell、Soft Chime。每個都反覆迭代,直到不再聽起來合成感十足為止。

我不會假裝能數清總生成次數。每個主題數百組,這是老實話。紀律不在 prompt 本身,而在於捨棄所有「還不錯」的,只留下能夠在計時器背後安靜地陪你二十分鐘、卻不曾成為你注意到的那個東西的那幾組。

修行

為什麼是計時器,而非老師。

這段是私人的。我做 Return 是因為我已經有冥想修行,卻找不到一款肯退到一旁的計時器。我所靜坐的是日本禪的武道脈流:Takuan、Yagyu、Musashi、Dogen、Hakuin。不是大型 App 所販售的治療式正念。意圖不同,質地也不同。

一週之中輪替的內容:

  • Susokukan(數息)。隨呼吸從一數到十,數丟就回到一。基礎。先建立專注,joriki
  • Shikantaza(只管打坐)。無對象。不數息、不參話頭、不觀想。不執取的心。Dogen 禪修的核心形式,也是最貼近我真正想要的狀態的正式對應。
  • Koan。主要是 Joshu 的 Mu。一個無法以思考解決的問題,持續參究直到思考放棄為止。
  • Maranasati(念死)。以 Hagakure 的框架。審慎使用。面對存亡會收緊心;這個則直接切穿它。
  • Isshin(一心)。Takuan 與 Yagyu 的領域:放鬆卻堅定,安住卻能動。坐墊與接下來一切之間的橋樑。
  • 整合日。感恩、慈悲、傳承。JihiKatsujinken:活人劍,而非殺人劍。通常是週六。
  • Sakki(察覺敵意)。每次時段後附加五分鐘的開放式聆聽。把 shikantaza 帶離坐墊,在日常環境中實測。

輪替並不僵硬。需要穩定時數息。需要突破時參話頭。需要在開放中安歇時就 shikantaza。需要釐清什麼才重要時就念死。多樣性本身就屬於這套訓練。

Return 是一個計時器,因為我不需要手機裡的老師。我需要一個幫我守時鐘的東西,以我尊敬的鐘聲標記開始與結束,在中間完全退到一旁。如果你已經有修行,你大概也想要這樣。如果你是全新的初學者,請找一位真人老師,進到一間真正的禪房。然後再回來。

克制

Return 裡沒有什麼。

Return 不是 Calm。不是 Headspace。沒有英國腔旁白把你帶進身體掃描。沒有卡通角色慶祝你的連續天數。沒有訂閱方案來解鎖新的引導課程。Return 是一個計時器。想法是:如果你已經有修行,App 裡就不需要老師。你需要的是一個為你守住時間、然後退到一旁的工具。

  • 沒有引導旁白或配音
  • 沒有連續天數、分數或遊戲化機制
  • 沒有訂閱或 App 內購
  • 永遠沒有廣告
  • 沒有分析追蹤;App 什麼都不記錄
  • 沒有社群登入或分享
  • 沒有煩人提示畫面,沒有冷啟動彈窗
  • IAP 流程裡沒有暗模式,因為根本沒有 IAP 流程

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。