Post

SwiftUI Preview and Archive Must Compile Together

SwiftUI Preview and Archive Must Compile Together

SwiftUI Preview 與 Archive 使用不同的建置情境,但兩者仍會編譯同一批來源檔。 如果 Preview 專用 helper 只存在於 Debug,而 #Preview 在 Release 仍引用它, 就會發生「Preview 修好了,Archive 卻失敗」;反過來,若直接把整個 Preview 排除,又可能掩蓋 Preview 原本的 Core Data 問題。

正確目標不是讓其中一邊暫時通過,而是讓 Preview 的宣告與相依程式碼在所有 組態都能通過編譯,只把真正不應進入正式版本的假資料 seed 隔離。

問題寫法:Preview helper 只存在於 Debug

假設 Preview trait 在所有組態都會被編譯:

1
2
3
4
5
6
7
8
struct SampleDataPreviewModifier: PreviewModifier {
    static func makeSharedContext() async throws -> Context {
        let container = await MainActor.run {
            PersistenceController.makePreviewContainer()
        }
        return Context(container: container)
    }
}

但是 container helper 被整個包在 #if DEBUG

1
2
3
4
5
#if DEBUG
static func makePreviewContainer() -> NSPersistentContainer {
    // 建立 Preview container
}
#endif

Debug Preview 可以找到方法,Archive 使用 Release 時卻看不到它,最後出現:

1
Type 'PersistenceController' has no member 'makePreviewContainer'

錯誤在 Xcode Issue Navigator 有時只短暫出現,點擊後又消失,但 Archive 程序已經失敗。這不是簽章或 Organizer 問題,而是 Debug 與 Release 的 符號可見範圍不一致。

不完整的補法:把所有 Preview 包進 #if DEBUG

最直接的 workaround 是:

1
2
3
4
5
#if DEBUG
#Preview(traits: .sampleData) {
    ChartSummaryView()
}
#endif

這通常可以解除 Archive 的編譯錯誤,但它不是 Apple #PreviewPreviewModifier 範例採用的結構。更重要的是,它只讓 Release 看不到問題, 沒有處理 Preview 本身的資料環境是否正確。

如果 Preview 原本還有 Core Data fetch request 缺少 entity,這個補法不會 修正它,只會把兩條建置路徑切得更開。

正確寫法:基礎設施皆可編譯,只有 seed 限定 Debug

Apple 建議使用 PreviewModifier 建立共享 context,再透過 trait 注入 Preview。 Preview container helper 應在 Debug 與 Release 都保持可編譯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static func makePreviewContainer() -> NSPersistentContainer {
    let modelURL = Bundle(for: FoodItem.self)
        .url(forResource: "MeowCare", withExtension: "momd")!
    let model = NSManagedObjectModel(contentsOf: modelURL)!

    let container = NSPersistentContainer(
        name: "MeowCare",
        managedObjectModel: model
    )

    let description = NSPersistentStoreDescription()
    description.type = NSInMemoryStoreType
    description.configuration = "Private"
    container.persistentStoreDescriptions = [description]
    container.loadPersistentStores { _, error in
        precondition(error == nil, "Preview store 載入失敗:\(error!)")
    }
    return container
}

PreviewModifier 本身也保持所有組態可編譯,但假資料只在 Debug 建立:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct SampleDataPreviewModifier: PreviewModifier {
    struct Context: @unchecked Sendable {
        let container: NSPersistentContainer
    }

    static func makeSharedContext() async throws -> Context {
        let container = await MainActor.run {
            PersistenceController.makePreviewContainer()
        }

        #if DEBUG
        await MainActor.run {
            CareEventSeeder.seedForPreview(
                into: container.viewContext
            )
        }
        #endif

        return Context(container: container)
    }

    func body(content: Content, context: Context) -> some View {
        content.environment(
            \.managedObjectContext,
            context.container.viewContext
        )
    }
}

Preview 則維持 Apple 範例的正常形式:

1
2
3
#Preview(traits: .sampleData) {
    ChartSummaryView()
}

這樣 Release 能解析 Preview trait 與 helper,Debug Preview 則額外取得假資料。 兩條路徑共用同一套宣告,不會因為 #if DEBUG 把必要方法切掉。

Core Data Preview 必須明確指定 entity

即使 Preview container 正確,Xcode Preview 的 JIT/動態替換仍可能無法從 FetchedResults<CareEvent> 推導 Core Data entity。

簡寫:

1
2
3
4
5
6
7
8
9
@FetchRequest(
    sortDescriptors: [
        NSSortDescriptor(
            keyPath: \CareEvent.occurredAt,
            ascending: true
        )
    ]
)
private var events: FetchedResults<CareEvent>

在一般 app 執行環境通常正常,但 Preview 可能崩潰:

1
2
executeFetchRequest:error:
A fetch request must have an entity.

改用 Apple 支援的完整 request initializer,明確指定 entity name:

1
2
3
4
5
6
7
8
9
10
11
12
13
@FetchRequest(fetchRequest: {
    let request = NSFetchRequest<CareEvent>(
        entityName: "CareEvent"
    )
    request.sortDescriptors = [
        NSSortDescriptor(
            keyPath: \CareEvent.occurredAt,
            ascending: true
        )
    ]
    return request
}())
private var events: FetchedResults<CareEvent>

這不是只針對某個 Preview 的特例。只要同一個 Core Data model 的 View 仍使用 簡寫,就可能成為下一個崩潰點。因此遇到 entity 相關 Preview crash 時,應搜尋 整個專案的 @FetchRequest,一次統一處理,而不是只補目前出錯的畫面。

驗證順序

修改 Preview/Core Data 基礎設施後,至少依序確認:

  1. Debug 模擬器建置成功。
  2. 所有受影響的 Preview Canvas 能建立並顯示。
  3. Release 使用真正的 archive action 成功。
  4. 既有單元測試通過。
  5. git diff --check 沒有格式問題。

一般 Release Build 成功不等於 Archive 已驗證;同樣地,Preview 編譯成功也 不代表 Canvas 執行時不會因 fetch request 或 managed object entity 問題崩潰。

完成條件必須同時包含 Preview runtime 與 Archive。

看到錯誤時怎麼判斷

如果 Archive 出現:

1
Type 'PersistenceController' has no member 'makePreviewContainer'

先檢查 Preview 呼叫端與 helper 是否位於不同的條件編譯範圍。

如果 Preview 出現:

1
must have a valid NSEntityDescription

或:

1
A fetch request must have an entity

則檢查:

  • Preview container 是否載入正確的 .momd
  • seeder 是否從同一個 context 的 model 取得 entity。
  • 所有 @FetchRequest 是否明確使用 NSFetchRequest(entityName:)

不要先把問題歸因於 DerivedData、Clean Build 或使用者操作。這些錯誤訊息已經 指出編譯條件或 Core Data entity 配置不完整,應直接檢查程式碼。

Reference

This post is licensed under CC BY 4.0 by the author.