故事開始
最近我在開發一個 iOS 影片下載 App「Otter」,用的是最新的 Swift 6 和 iOS 26 SDK。既然都用 Swift 6 了,自然要用 @Observable 宏來管理狀態,畢竟這已經是 Apple 推薦的標準做法。
一切看起來都很美好,直到我發現——下載進度條完全不會動。
問題現象
我的 DownloadManager 是一個單例,負責管理所有下載任務:
@Observable
@MainActor
final class DownloadManager {
static let shared = DownloadManager()
private(set) var tasks: [DownloadTask] = []
}
在 DownloadView 裡,我用 @Environment 注入它:
struct DownloadView: View {
@Environment(DownloadManager.self) private var downloadManager
var body: some View {
List {
ForEach(downloadManager.tasks) { task in
DownloadTaskRow(task: task)
}
}
}
}
看起來很標準對吧?但當下載開始後,進度條永遠停在 0%,狀態永遠顯示「等待中」。
我打開 Debug Log,發現狀態確實有在更新:
[DownloadManager] State updated · progress=0.15
[DownloadManager] State updated · progress=0.32
[DownloadManager] State updated · progress=0.58
[DownloadManager] State updated · progress=1.0
[DownloadManager] State updated · state=completed
資料明明更新了,UI 卻紋風不動。這是什麼巫術?
除錯之路
第一個假設:@Observable 壞了
我先寫了一個測試視圖,把所有東西都內聯進去:
struct ObservableTestView: View {
@Environment(DownloadManager.self) private var downloadManager
var body: some View {
ForEach(Array(downloadManager.tasks.enumerated()), id: \.element.id) { index, _ in
let task = downloadManager.tasks[index]
Text("\(task.state.displayText) - \(Int(task.progress * 100))%")
}
}
}
結果:測試視圖可以正常更新!
好,所以 @Observable 沒壞。那問題在哪?
第二個假設:陣列元素修改沒被偵測
我原本更新狀態的方式是:
tasks[index].state = .running(progress: 0.5, progressText: "50%")
我懷疑 @Observable 無法偵測陣列元素的屬性變化,於是改成:
var updatedTask = tasks[index]
updatedTask.state = .running(progress: 0.5, progressText: "50%")
tasks[index] = updatedTask
結果:測試視圖還是正常,DownloadView 還是不動。
第三個假設(正確答案):子視圖快取
我仔細比較了測試視圖和 DownloadView 的差異:
測試視圖(可以更新):
ForEach(...) { index, _ in
let task = downloadManager.tasks[index]
Text(task.state.displayText) // 直接內聯
}
DownloadView(無法更新):
ForEach(downloadManager.tasks) { task in
DownloadTaskRow(task: task) // 傳給子視圖
}
我終於懂了!
當我把 task(一個 struct)傳給 DownloadTaskRow 時,SwiftUI 會根據 id 來決定是否重新渲染這個 row。由於 task.id 沒變,SwiftUI 認為:「喔,這個 row 不需要更新。」
但實際上 task.state 已經變了!SwiftUI 不知道,因為它只看 id。
解決方案
方案一:完全內聯(我最終用的)
把所有 UI 都寫在 ForEach 裡面:
ForEach(Array(downloadManager.tasks.enumerated()), id: \.element.id) { index, _ in
let task = downloadManager.tasks[index]
HStack {
Text(task.viewState.title)
Spacer()
if let progress = task.state.progress {
ProgressView(value: Double(progress))
Text("\(Int(progress * 100))%")
}
}
}
關鍵是每次都從 downloadManager.tasks[index] 重新取值,這樣 SwiftUI 才會追蹤到變化。
方案二:讓子視圖自己存取 Observable
如果你堅持要用子視圖,讓它自己去拿資料:
struct DownloadTaskRow: View {
let taskId: String
@Environment(DownloadManager.self) private var downloadManager
private var task: DownloadTask? {
downloadManager.tasks.first { $0.id == taskId }
}
var body: some View {
if let task = task {
// ...
}
}
}
這樣子視圖會直接觀察 downloadManager,當 tasks 變化時就會重新渲染。
所以到底學到什麼
@Observable 的觀察是基於「存取」的。只有在 view body 中直接存取 observable 的屬性,SwiftUI 才會追蹤變化。
傳遞值類型給子視圖會切斷觀察鏈。當你把 struct 從 observable 取出來傳給子視圖,那個子視圖就不再觀察原始的 observable 了。
SwiftUI 用 id 來判斷是否需要重新渲染。如果 id 沒變,SwiftUI 可能會跳過整個 view 的更新。
內聯或許不夠優雅,但它能動。
這個 bug 讓我抓狂了一整天。換成 ObservableObject 可以,但都用 Swift 6 了還回去用舊的?強制替換陣列元素沒用,重新賦值整個陣列也沒用。
最後發現問題根本不在 @Observable,而是在 SwiftUI 的子視圖快取機制。
反正就記住:@Observable UI 不更新的話,先檢查你是不是把值類型傳給了子視圖。
寫於 2025 年 12 月,在 Linux 上用 xtool 交叉編譯 iOS App 的某個深夜。是的,我沒有 Mac。