← 所有文章

Live Activities 是狀態機,不是徽章

Return 中的 Live Activity 看起來像是鎖定畫面與動態島上的倒數數字。12但它不是一個數字。它是一台擁有五種生命週期狀態、三條外部解除路徑,以及一條必須自我防禦的可重入啟動路徑的狀態機。下方所述的模式是在生產環境中存活下來的那些。文末殘酷誠實的註腳會說明我尚未弄清楚的部分。

我曾推出一個 v1 版本,把 Live Activity 當成徽章來處理。「目前剩餘時間」是資料;其餘都是裝飾。那個版本有三個我在 TestFlight 抓到的錯誤,以及一個在生產環境才抓到的錯誤:

  1. 在啟動還在傳輸中時又點選啟動,會建立第二個活動,並讓第一個變成孤兒。
  2. 倒數計時在動態島上顯示正確,但鎖定畫面檢視在計時器暫停時會踩到 endTime <= Date(),於是顯示 0:00 直到使用者恢復計時為止。
  3. 使用者重設計時器後,Live Activity 仍長時間停留在畫面上,因為解除政策是 .default,而 Apple 會讓它顯示一段時間,最長可達四小時。
  4. (生產環境。)在從右至左語言區域(阿拉伯文、希伯來文)下,動態島緊湊尾端區域中的數字以反向呈現。拉丁數字搭配 RTL 版面。修正只需一行。

每一項都是狀態機的錯誤。倒數的數字本身沒問題。數字並不是產品。狀態才是產品。

下方的狀態機是從那些錯誤中存活下來的版本。

TL;DR

  • 上線版本的 LiveActivityManager 提供 5 個轉換方法(startActivityupdateActivityshowCycleCompleteshowFinalCompletionendActivity),加上 1 個讀取方法(hasActiveActivity)。這 224 行生產程式碼,在 startActivity 內部專門守住一個特定危險:並發的啟動呼叫,以及在該方法每個 await 邊界進行的取消檢查。3
  • ContentState 攜帶 6 個欄位:endTimecurrentCycletotalCyclesisPausedisCompletedremainingSeconds。前五個是狀態機的標籤。第六個(remainingSeconds)是 ActivityKit 的即時 timerInterval 無法服務時的靜態顯示備援。
  • 解除政策的決定才是真正的產品決策。使用者重設時用 .immediate,完成時用 .after(Date().addingTimeInterval(3)),絕不要用系統預設值。
  • 動態島緊湊尾端區域需要在計時器文字上加上 .environment(\.layoutDirection, .leftToRight),才能在 RTL 系統地區下保持拉丁數字 LTR。

狀態機

上線版本的 Live Activity 有一個閒置狀態、三個使用者可觀察的執行中狀態、一個終結狀態,以及一個開發者必須注意的可重入閘門:

┌──────────────────────────────────────────────────────────────────┐
│                  Lifecycle states                                 │
├──────────────────────────────────────────────────────────────────┤
│  IDLE          currentActivity == nil; no Live Activity present   │
│  RUNNING       isPaused=false, endTime > Date()                   │
│  PAUSED        isPaused=true, remainingSeconds=N                  │
│  CYCLE_END     isPaused=false, endTime <= Date(), isCompleted=false│
│  COMPLETE      isCompleted=true (terminal; transitions to IDLE)   │
└──────────────────────────────────────────────────────────────────┘
              │
              ↓
┌──────────────────────────────────────────────────────────────────┐
│             Dismissal policies (Apple)                            │
├──────────────────────────────────────────────────────────────────┤
│  .immediate            user reset                                  │
│  .after(now + 3s)      completion display window                   │
│  .default              system decides; can stay up to 4 hours      │
└──────────────────────────────────────────────────────────────────┘

Reentrancy gate inside startActivity():
  isStartingActivity flag + cancellable startActivityTask
  prevents two concurrent startActivity() calls from creating
  two Live Activities for one timer. Cancellation checks across
  each await keep the in-flight task safe to abort.

繪製路徑會先檢查 isPaused;正是這個順序讓暫停中的計時器在牆鐘時間越過 endTime 時也不會被繪製成 CYCLE_END7

這些狀態名稱不是套在數字上的標籤。狀態名稱是 LiveActivityManager(應用端,我的 SwiftUI 檢視所在處)與 ReturnLiveActivity(Widget 擴充功能,由 Apple 程序負責繪製介面)之間的契約。

這份契約是 TimerActivityAttributes.ContentState,總共 6 個欄位:3

public struct ContentState: Codable, Hashable {
    var endTime: Date
    var currentCycle: Int
    var totalCycles: Int?
    var isPaused: Bool
    var isCompleted: Bool = false
    var remainingSeconds: Int = 0
}

每一次狀態轉換都會改寫這個結構,並要求 ActivityKit 將其跨程序邊界傳遞給 Widget 擴充功能。Widget 接著重新繪製。沒有共享記憶體,沒有回呼。有的是一個 Codable 結構,會在每次轉換時跨越程序邊界。

這個事實排除了我可能想用閉包、view model、observable object 或計算屬性來做的任何事情。狀態必須以可序列化的資料表示。如果無法編碼,就不能轉換。

可重入啟動

Live Activities 對並發活動有硬上限,對在傳輸途中重複呼叫 Activity.request 會發生什麼事則有軟上限。硬上限有完整文件記載。4軟上限是「第二次呼叫可能成功並建立一個孤兒。」這個孤兒就是不再與您的 manager 中 currentActivity 關聯的那個 Live Activity。它會繼續存在,沒有任何路徑能重新回到您的程式碼中。它最終會自行依過時計時器解除。在那之前,使用者會看到一個重複的計時器。

這個孤兒就是 Return 上線時的 v1 錯誤。修正方式是在 LiveActivityManager.swift 中加入可重入閘門加可取消的 Task:3

private var isStartingActivity = false
private var startActivityTask: Task<Void, Never>?

func startActivity(...) {
    #if os(iOS)
    guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
    guard !isStartingActivity else { return }
    isStartingActivity = true

    startActivityTask?.cancel()

    startActivityTask = Task {
        defer {
            isStartingActivity = false
            startActivityTask = nil
        }
        guard !Task.isCancelled else { return }

        await endActivity()  // explicit cleanup of any prior state

        guard !Task.isCancelled else { return }

        // ... build attributes + contentState ...

        do {
            let activity = try Activity.request(...)
            guard !Task.isCancelled else { return }
            currentActivity = activity
        } catch {
            // log; flag clears via defer
        }
    }
    #endif
}

關於這個模式有三件事文件中沒有特別點出:

isStartingActivity 旗標才是真正的主動防護;startActivityTask?.cancel() 是防禦性清理。這個旗標會在第一次呼叫還在傳輸中時短路任何第二次的 startActivity 呼叫,因此公開路徑上不會真正發生競爭。先取消再替換的舞步仍然重要,因為傳輸中的 Task 是非同步的,可能比短命的呼叫者活得更久;取消可以避免一個過時的 Task 在呼叫者已離開之後繼續執行。

每個 await 邊界都要做 guard !Task.isCancelled 檢查。Swift 的取消是合作式的。即使 cancel 已被呼叫,Task 仍會繼續執行,直到它顯式檢查為止。每個 await 都是一次檢查的機會。少了 await 後的檢查,被取消的 Task 會繼續構建活動狀態、呼叫 Activity.request,並在成功時悄悄建立一個孤兒。

defer 會在 Task 主體完成之前清除旗標。沒有 defer 的話,提早 return(來自取消檢查)會永久將 isStartingActivity = true 卡住,活動就再也不會啟動,直到應用重啟。這個旗標是一把鎖;鎖必須在每條離開路徑上釋放。

pushType: nil 引數。Return 不使用 APNs 推送的 Live Activity 更新。應用程式透過 activity.update 在本機更新活動。如果您需要由推送驅動的更新(送貨追蹤、運動賽事比分、即時資料),那麼類型為 pushType: .token,契約也會複雜得多。5本機更新較為簡單,並足以涵蓋任何計時器/計數器/單一應用程式的工作流程。

暫停問題

ActivityKit 提供了一個漂亮的 Text(timerInterval: Date()...endTime, countsDown: true) 檢視,能繪製即時倒數而不需要應用程式做任何更新。6您設定結束時間,系統就會繪製即時計時器。不需要 Timer.publish、不需要 widget 重新整理、不耗電。

當計時器在執行時,這非常棒。當計時器被暫停時,這就錯了。

無論狀態中是否有「暫停」訊號,timerInterval 文字都會持續朝 endTime 倒數。Apple 的 API 中沒有「凍結在 10:23」的模式。如果您傳入 endTime = Date().addingTimeInterval(623),使用者在 10:23 處暫停,計時器文字仍會在 widget 中持續倒數到零。狀態欄位顯示已暫停,widget 卻顯示在執行。

修正方式是從同一個狀態繪製兩種不同的檢視:7

if context.state.isPaused {
    // static text
    Text(formatTime(context.state.remainingSeconds))
        .monospacedDigit()
} else if context.state.endTime > Date() {
    // live countdown
    Text(timerInterval: Date()...context.state.endTime, countsDown: true)
        .monospacedDigit()
} else {
    // post-end static
    Text("0:00")
        .monospacedDigit()
}

雙軌繪製就是 ContentStateremainingSeconds 當作獨立欄位攜帶的原因。當計時器在執行時,它是冗餘的(系統會從 endTime 計算)。當計時器被暫停時,它是唯一的真實來源。這個結構的兩半服務於兩種不同的繪製模式;isPaused 布林值在兩者間做選擇。

解除政策

activity.end(_:dismissalPolicy:) 接受三種 ActivityUIDismissalPolicy 值之一,選錯就是讓我的 v1 在使用者重設後,仍然在鎖定畫面上停留得彷彿天荒地老的原因:13

政策 何時使用 您會得到什麼
.immediate 使用者重設、發生錯誤、應用程式進入背景且無活動需要追蹤 活動立即消失。沒有寬限期
.after(date) 完成顯示:「您的冥想已完成」需要被閱讀片刻。日期必須在 Apple 允許的四小時窗口內 活動顯示最終狀態,然後在 date 解除
.default 當您真心想讓 Apple 的啟發式邏輯來決定時 系統會讓它「顯示一段時間」(Apple 用語),在 end 被呼叫後最長可達四小時

Return 在自然完成路徑上使用 .after(Date().addingTimeInterval(3))3

await activity.end(
    .init(state: contentState, staleDate: nil),
    dismissalPolicy: .after(Date().addingTimeInterval(3))
)

三秒是使用者瞄一眼鎖定畫面、意識到計時器結束、感受到打勾符號帶來的滿足感所需要的時間。少於三秒會顯得跳動。多於三秒會讓人感覺活動不知道自己已經結束了。

對於使用者觸發的重設,呼叫是 dismissalPolicy: .immediate。沒有窗口。使用者已經知道了。

v1 的錯誤選擇是 .default。對於完成的冥想計時器,系統會把活動保留可見足夠久,使用者會以為應用程式根本沒有偵測到完成。Apple 文件指出 .default 會讓已結束的活動「顯示一段時間」,最長可達四小時;13計時器的正確做法是讓解除明確化。

動態島緊湊區域

動態島有三種繪製模式,即使是簡單的計時器,這三種您都需要:2

  • 緊湊(動態島預設形狀):前端圖示加上尾端計時器
  • 最小(當另一個 Live Activity 競爭同一個動態島時):僅顯示前端圖示
  • 展開(長按):四個具名區域(leadingtrailingcenterbottom

在 Return 中贏得一席之地的模式是讓展開檢視幾乎與緊湊檢視相同:8

DynamicIsland {
    DynamicIslandExpandedRegion(.leading) {
        Image("AppIconSmall")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(width: 16, height: 16)
            .clipShape(RoundedRectangle(cornerRadius: 4))
    }
    DynamicIslandExpandedRegion(.trailing) {
        TimerText(...)
    }
    DynamicIslandExpandedRegion(.center) { EmptyView() }
    DynamicIslandExpandedRegion(.bottom) { EmptyView() }
} compactLeading: {
    Image("AppIconSmall")...
} compactTrailing: {
    TimerText(...)
} minimal: {
    Image("AppIconSmall")...
}

大多數 Live Activity 教學會把展開檢視當作「真正的」設計,並在 bottom 區域放豐富內容。對於冥想計時器來說,展開是無謂的累贅。使用者透過長按打開展開檢視,而長按本身已經提供了「有事發生」的觸覺回饋。加入內容反而會讓展開說出使用者沒要求的話。展開模式中的空區域並非設計失敗,它們本身就是設計。

RTL 錯誤

那個生產環境的錯誤。iOS 上的阿拉伯文與希伯來文使用者回報,動態島緊湊尾端的計時器數字以反向呈現。拉丁數字字串 5:23 被繪製成 32:5,因為緊湊尾端的版面方向繼承了系統地區的 RTL 設定。

SwiftUI 在 widget 程序內會繼承系統的版面方向,因此當使用者的手機設定為阿拉伯文或希伯來文時,動態島的計時器文字也會跟著套用 RTL。即使在 RTL 介面中,拉丁數字也應該以 LTR 繪製。修正方式是在數字文字檢視上鎖定版面方向:7

.environment(\.layoutDirection, .leftToRight)

這個覆寫應加在 TimerText 內的數字 Text 檢視(動態島緊湊/展開)以及鎖定畫面檢視內,而不是整個檢視。拉丁數字無論使用者的系統地區為何,都應該由左至右閱讀;像「Cycle 2 of 3」這類的循環標籤則保持本地化,因此會跟隨系統的版面方向。

這個錯誤在本國地區的 TestFlight 中不會浮現。它會在真實的 RTL 使用者打開計時器的那一刻浮現。教訓:在任何可能在 RTL 地區執行的 Live Activity 中,每一個拉丁數字文字檢視都要部署 LTR 鎖定的環境覆寫。

本地化的故事

TimerActivityAttributes 會攜帶一個 languageCode: String 欄位,由應用程式在建立活動時設定:9

let attributes = TimerActivityAttributes(
    timerDuration: duration,
    languageCode: settings.appLanguage  // app's selected language, not system's
)

Widget 擴充功能會讀取這個欄位來繪製本地化字串:

private var locale: Locale {
    let code = context.attributes.languageCode
    return code.isEmpty ? .current : Locale(identifier: code)
}

private func localized(_ key: String.LocalizationValue) -> String {
    String(localized: key, locale: locale)
}

為什麼應用程式要傳入自己的語言代碼,而不是讓 widget 直接讀取 Locale.current:因為 widget 擴充功能執行於自己的程序中。它的 Locale.current 是系統地區,不是應用程式選擇的地區。如果使用者把 Return 設為韓文,但 iPhone 是英文,沒有這個覆寫的話,widget 就會說英文。應用程式的語言偏好會透過活動屬性一同傳遞;widget 會予以尊重。

Localizable.xcstrings 與應用程式並存於 widget 目標中,但它們是獨立的檔案。即使同一個字串已存在於 Return/Localizable.xcstrings,widget 中使用的字串仍必須存在於 ReturnWidgets/Localizable.xcstrings。忘記這點意味著當應用程式說韓文時,widget 會回退到開發語言。

我會如何重新打造

ContentState 變小。六個欄位太多了。endTimeremainingSeconds 之間的冗餘,是繞過 timerInterval 沒有暫停模式所付出的代價。如果重新開始,我會攜帶單一的 displayMode 列舉(runningpaused(remainingSeconds: Int)cycleEndcomplete),讓繪製程式碼依 case 分派。六個欄位要在五個轉換方法之間維持正確改寫,比四個 case 困難。

加入互動式 Live Activity 按鈕(iOS 17+)。Return 目前並未在動態島中提供暫停/恢復控制項。使用者必須打開應用程式才能暫停。iOS 17 為 Live Activities 內的 App Intents 加入了 Button(intent:)10互動式暫停控制是顯而易見的擴充,也是我接下來會為 Return 推出的下一項。

用推送更新的 Live Activities 做跨裝置計時器同步。Return 透過 NSUbiquitousKeyValueStore 在 iPhone、iPad、Watch 與 Apple TV 之間同步工作階段(在 Five Apple Platforms, Three Shared Files 中詳述)。今天活動是從 iPhone 或 iPad 應用程式本機啟動並本機更新。理想情況下,使用者在 Apple Watch 上啟動計時器時,能在 iPhone 上即時看到 Live Activity 反映出該狀態。透過 APNs 推送至 Live Activity 就是路徑。5我還沒做。

何時不要使用 Live Activities

一次性的暫態狀態。「已儲存!」這類的 Toast 不值得用 Live Activity。系統已經有橫幅。請用它。

沒有時間維度的頻繁變動資料。Live Activities 最適合具有清晰時間錨點的事物(計時器、送貨 ETA、比賽計時、通話時間)。股價跑馬燈與運動賽事比分能用,是因為它們有一個工作階段窗口。通用的儀表板則不行。

沒有鎖定畫面/待機使用情境的應用程式。Live Activities 需要實實在在的工程投入(目標設定、ContentState 設計、解除政策決定、RTL 處理、本地化管線)。使用者直接打開應用、使用過程中從未查看過鎖定畫面的應用,並不是合適的形狀。相片編輯器不需要。健身追蹤器需要。

在非 iOS 介面上,但有附帶條件。Return 的 LiveActivityManager 將實作放在 #if os(iOS) 之後,因為計時器是從 iPhone 或 iPad 應用啟動的。ActivityKit 本身將鎖定畫面橫幅、動態島、Apple Watch Smart Stack、Mac 與 CarPlay 描述為呈現介面;iOS 26 擴充了其中數個。4watchOS 仍有自己的 complications API 用於全螢幕繪製。macOS 有選單列應用。iPadOS 自 iPadOS 17 起支援 Live Activities,但沒有動態島區域。Return 的 manager 在一個 224 行的檔案中有 8 個 #if os(iOS) 守衛。

對於在 iOS 26+ 上推出的應用,這個模式意味著什麼

兩個重點。

  1. 把 Live Activity 當作狀態機,不是數字。狀態機有清楚的狀態、清楚的轉換與清楚的解除規則。畫面上的數字只是某個狀態的一種繪製。先把狀態做對。

  2. 可重入守衛是您還沒踩到的那個錯誤。我在實際環境中看過的每一個沒有實作 isStartingActivity 加可取消 Task 的 Live Activity manager,至少都出過一次孤兒活動的錯誤。這個守衛只有 6 行。寫一次就好。

請將這篇與我為同一系列應用所寫的文章一起閱讀:給 Apple Intelligence 用的型別化 App Intents;用於跨 LLM 代理的 MCP 伺服器;視覺層的 Liquid Glass 模式;跨裝置觸及的多平台推出。Live Activities 是同一個堆疊中的 iOS 鎖定畫面與動態島層。完整的系列在 Apple Ecosystem Series 中樞。要了解更廣泛的 iOS 加 AI 代理脈絡,請參閱 iOS Agent Development 指南

FAQ

Live Activities 與 WidgetKit widget 有何不同?

WidgetKit widget 是依 TimelineProvider 定義的間隔繪製;系統決定何時重新整理,widget 從靜態時間軸重新繪製。11Live Activities 則是回應特定應用程式驅動的 activity.update(...) 呼叫而繪製,並在底層活動的整個期間(計時器、送貨、健身)持續存在。兩者都打包在 widget 擴充功能目標中;差別在觸發模型。

Live Activities 在 iPad 上能用嗎?

可以,從 iPadOS 17+ 起。鎖定畫面橫幅是主要的繪製介面;iPad 沒有動態島。同樣的 ActivityConfiguration 程式碼可用;只要預期動態島區域永遠不會在 iPad 上繪製即可。

Live Activity 能在我的應用程式程序之外存活嗎?

可以。一旦 Activity.request 成功,活動就由 ActivityKit 擁有。應用程式程序可被系統終止;活動會繼續在鎖定畫面與動態島上繪製,直到您明確結束它(或直到系統的過時規則將其解除)。明確的 endActivity() 呼叫之所以重要正是這個原因;若應用程式重設時沒有明確結束,活動就會比計時器活得更久。

為什麼這篇文章沒有涵蓋推送更新的 Live Activities?

我尚未在 Return 中推出推送更新的 Live Activities。依本系列的類型規則:上線程式碼類文章只記錄生產程式碼實際做的事。推送更新已列在「我會如何重新打造」中;等我推出之後,會有一篇文章專門涵蓋。

SwiftUI 應用中 Live Activities 的實際檔案配置是什麼?

三個部分:3712

  • 在主應用目標中:LiveActivityManager.swift(管理活動生命週期)、TimerActivityAttributes.swift(與 widget 共享的 ActivityAttributes 結構;兩個目標都會編譯這個檔案)。
  • 在 widget 擴充功能目標中:ReturnLiveActivity.swift(具備 ActivityConfiguration 主體的 Widget 一致性)、ReturnWidgetsBundle.swift@main WidgetBundle)。
  • 設定:在應用目標中加入 Info.plist,並設定 NSSupportsLiveActivities = YES

Widget 擴充功能目標需要 ActivityKit 與 WidgetKit 匯入。TimerActivityAttributes 是兩個目標之間唯一共享的檔案;其餘都是目標隔離的。


Live Activity 不是鎖定畫面上的一個數字。它是一台每次轉換都會跨越程序邊界的狀態機。把狀態做對、守住可重入、刻意選擇解除政策、釘住版面方向。數字會自己照顧好自己。

References


  1. Author’s Return, a SwiftUI meditation timer published on the App Store on April 21, 2026, available for iPhone, iPad, Mac, Apple Watch, and Apple TV. Live Activities ship on the iOS target only. 

  2. Apple Developer, “ActivityKit framework”. Lock Screen banner, Dynamic Island compact / minimal / expanded modes, activity lifecycle. Available iOS 16.1+; Dynamic Island available iPhone 14 Pro and later. 

  3. Production code in Return/Return/LiveActivityManager.swift (224 lines, 8 #if os(iOS) blocks) and Return/Return/TimerActivityAttributes.swift (43 lines). Shared between the app target and the widget extension target via target membership. 

  4. Apple Developer, “Displaying live data with Live Activities”. Concurrency limits, supported platforms (iOS 16.1+, iPadOS 17+), NSSupportsLiveActivities Info.plist key. 

  5. Apple Developer, “Updating and ending your Live Activity with ActivityKit push notifications”. The pushType: .token path requires a separate APNs auth key, server-side push token registration, and a different update protocol from local activity.update(...) calls. 

  6. Apple Developer, “Text(timerInterval:pauseTime:countsDown:showsHours:)”. Live system-rendered countdown timer; renders without app updates while the activity is running. 

  7. Production code in Return/ReturnWidgets/ReturnLiveActivity.swift (232 lines). The widget extension’s Widget conformance with ActivityConfiguration<TimerActivityAttributes> body. The TimerText view at lines 61-102 handles the paused / running / post-end three-state rendering. 

  8. Apple Developer, “DynamicIsland”. The four named expanded regions (leading, trailing, center, bottom) plus three compact-mode views (compactLeading, compactTrailing, minimal). 

  9. The widget extension runs in its own process and inherits the system locale, not the app’s selected locale. Apps that support in-app language switching (Return supports 27 languages) must pass the language code through ActivityAttributes so the widget can render in the user’s chosen language. Pattern: Locale(identifier: context.attributes.languageCode) rather than Locale.current

  10. Apple Developer, “Button(intent:)”. Available in widget and Live Activity views from iOS 17+. Bridges App Intents into Lock Screen / Dynamic Island controls without requiring app foregrounding. 

  11. Apple Developer, “TimelineProvider”. The widget refresh model that predates Live Activities; pre-computed entries with system-managed reload windows. 

  12. Production code in Return/ReturnWidgets/ReturnWidgetsBundle.swift (16 lines). The @main WidgetBundle that registers ReturnLiveActivity as the widget extension’s only widget. Required pattern for widget extensions; the bundle is what the system loads. 

  13. Apple Developer, “ActivityUIDismissalPolicy”. Three cases: .default, .immediate, .after(_:). Apple states .default keeps an ended Live Activity visible “for some time” up to four hours, and .after(_:) accepts a date within the same four-hour window. 

相關文章

iOS 26 的小工具介面:一個 App Intent,多處呈現

iOS 26 的小工具、控制中心控制項與即時動態,全都是 App Intents 的呈現面。一個 intent 即可驅動按鈕、控制項與即時動態的操作。

2 分鐘閱讀

SwiftUI 中的 Liquid Glass:在 iOS 26 上推出 Return 學到的三種模式

Apple 的 Liquid Glass 是一行 SwiftUI API。Return 的三種模式超越了 .glassEffect():透過 Core Text 字形路徑將玻璃套用於文字、鏡面反射,以及 HUD 疊加層。

7 分鐘閱讀

迴圈工程:在驗證成本低廉之處,迴圈才能取勝

以 Boris Cherny 的完整逐字稿驗證迴圈工程:他點名的每一個迴圈,驗證成本都很低廉。這項限制決定了什麼適合自動化。

4 分鐘閱讀