SwiftUI 列列反覆初始化,並不能證明列列曾消失或遺失狀態。若要診斷 Mac 上的捲動介面,請分別追蹤建構、保留的狀態、顯示回呼,以及視窗範圍內的可見性。

本指南適用於正在調查自己 macOS App 中列列記錄反覆出現的開發者。具體目標是判斷更新究竟只是建立了另一個檢視值、重設了列列狀態,還是改變了捲動視窗範圍中顯示的列列。範例使用合成標籤和一般按鈕。

每個生命週期訊號實際代表什麼?

SwiftUI 列列通常使用結構,也就是 Swift 的值型別。建立另一個結構值,和建立持續存在的參考物件不同。Swift 文件在Swift 中的值型別與參考型別說明了這項差異。

SwiftUI 也有自己的識別模型。Apple 說明,暫時性的檢視值可以在多次更新中代表相同的識別,而檢視的生命週期取決於該識別。因此,初始化器記錄無法證明先前列列的生命週期已結束。請參閱深入解析 SwiftUI。

使用以下訊號回答不同問題:

訊號 回答的問題 判讀限制
列列初始化器 是否建構了另一個列列值? 無法證明列列已顯示或狀態已被取代。
body 評估 是否評估了這個檢視描述? 不會計算顯示的影格數。
保留的列列狀態 這個診斷值是否在更新後仍存在? 描述該狀態是否延續,不代表視窗範圍內是否有列列。
onAppear 和 onDisappear SwiftUI 是否回報了顯示狀態轉換? 時機取決於檢視型別。
onScrollVisibilityChange 列列是否跨越指定的視窗可見性門檻? 測量文件所述的捲動可見條件。

Apple 說明,onAppear 會在第一個影格繪製前完成,確切時機取決於檢視型別。onDisappear 會在消失後執行,時機也因檢視而異。這兩個方法都沒有說明狀態何時解除配置。請將這些回呼事件與儲存空間生命週期的結論分開看待。請參閱 onAppear(perform:) 和 onDisappear(perform:)。

若要判斷視窗範圍內的可見性,請明確選擇門檻。在 macOS 15 及後續版本中,onScrollVisibilityChange(threshold:_:) 會回報檢視是否跨越指定的可見性門檻;預設值為 0.5。Apple 也說明,當檢視出現時若已達到門檻,系統會呼叫一次回呼。請參閱 onScrollVisibilityChange(threshold:_:)。

本文所說的「視窗範圍內可見」,是指符合該捲動門檻;這不代表有人看過該列列。

建立能區分各種訊號的小型實驗

使用包含 macOS 15 API 的 SDK 建立 macOS SwiftUI App,並將部署目標設為 macOS 15 或更新版本。以以下完整程式取代產生的 App 和內容檢視宣告。只保留一個 @main 進入點。

範例使用 ScrollView 和 LazyVStack,讓視窗範圍行為一目了然。Apple 將 LazyVStack 描述為按需建立項目。本實驗不假設 List 的載入或保留行為相同。請參閱 LazyVStack。

import SwiftUI
import Foundation

@main
struct RowLifetimeLabApp: App {
    var body: some Scene {
        WindowGroup("Row Lifetime Lab") {
            ProbeView()
        }
    }
}

struct ProbeView: View {
    @State private var revision = 0
    @State private var generation = 0

    var body: some View {
        VStack(spacing: 12) {
            HStack {
                Button("Update rows") {
                    revision += 1
                }
                Button("Reset identity") {
                    generation += 1
                }
                Text("Revision \(revision)")
            }

            ScrollView {
                LazyVStack(spacing: 8) {
                    ForEach(1...60, id: \.self) { rowID in
                        ProbeRow(rowID: rowID, revision: revision)
                    }
                }
                .padding()
            }
            .id(generation)
        }
        .padding()
        .frame(minWidth: 560, minHeight: 360)
    }
}

struct ProbeRow: View {
    let rowID: Int
    let revision: Int
    private let constructionID: UUID

    @State private var appearanceToken: UUID? = nil
    @State private var count = 0

    init(rowID: Int, revision: Int) {
        self.rowID = rowID
        self.revision = revision
        self.constructionID = UUID()
        print("init row=\(rowID) value=\(constructionID)")
    }

    var body: some View {
        let _ = print("body row=\(rowID) value=\(constructionID)")

        HStack {
            VStack(alignment: .leading) {
                Text("Sample row \(rowID)")
                Text("Revision \(revision)")
                Text(appearanceToken?.uuidString ?? "Awaiting appearance")
                    .font(.caption.monospaced())
            }
            Spacer()
            Button("Count \(count)") {
                count += 1
            }
        }
        .padding(12)
        .frame(height: 80)
        .background(Color.gray.opacity(0.12))
        .onAppear {
            if appearanceToken == nil {
                appearanceToken = UUID()
            }
            print("appear row=\(rowID) token=\(tokenDescription)")
        }
        .onDisappear {
            print("disappear row=\(rowID) token=\(tokenDescription)")
        }
        .onScrollVisibilityChange(threshold: 0.5) { visible in
            print("viewport row=\(rowID) visible=\(visible)")
        }
    }

    private var tokenDescription: String {
        appearanceToken?.uuidString ?? "unassigned"
    }
}

這兩個 UUID 用途不同。constructionID 用來標示每次初始化器呼叫。appearanceToken 一開始為空,並在保留的列列狀態首次觀察到顯示時設定。它不會記錄狀態配置的時間。計數則提供另一個可檢視連續性的值。

Apple 說明,SwiftUI 會管理 State 儲存空間,並在其值改變時更新相依的介面內容。因此,這項診斷會將標記和計數保存在私有狀態中,並將建構資訊留作一般儲存屬性。請參閱 State。

本文已依照文件檢視相關 API,但因無法使用 Swift 工具鏈,未在此編譯或執行。下方的記錄範例說明如何解讀你執行後的結果,並非實際擷取的結果。

執行三項受控檢查並解讀結果

1. 更新列列輸入,但不改變識別

在 Xcode 中執行 App,並開啟除錯主控台。按兩次某個可見列列的 Count 按鈕,記下它的標記,然後按 Update rows。傳給列列的版本號會改變,而固定的整數識別碼維持不變。

如果初始化器訊息再次出現,但所選列列仍保有原本的標記和計數,就表示值反覆建構時,診斷狀態仍獲保留。不要把這些初始化器訊息當作獨立的螢幕顯示生命週期。顯示的版本號可確認列列收到了新輸入。

顯示標記是透過狀態設定的,而這本身可能觸發另一次更新。這是刻意設計的診斷行為。不要預期 body 或初始化器訊息會出現固定次數,並在調查完成後移除這些探針。

2. 將列列捲出視窗範圍,再捲回來

緩慢捲動,直到所選列列跨越 50% 門檻。比較 viewport 訊息與顯示訊息,然後捲回該列列。再次檢查其標記和計數。

返回後標記仍保留,表示這項診斷狀態在這次捲動離開期間仍持續存在。若標記不同,表示先前的標記未獲保留。若要解釋實際 App 中的重設,請檢查識別變更、有條件的移除,以及容器的保留行為,不要只根據捲動推斷原因。

Apple 目前的指引說明,延遲堆疊可能會在顯示前預先取得檢視主體並進行版面配置。如果捲動方向反轉,已預先取得的檢視可能永遠不會收到 onAppear。Apple 也說明,延遲堆疊中位於螢幕外的狀態最終可能遭到丟棄,並建議將必須在捲動後保留的資料放在模型或外層檢視中。請參閱深入了解 SwiftUI 的延遲堆疊與捲動。

因此,範例中的列列本機計數適合作為探針。對於實際的編輯介面,應將必須在列列移除後保留的變更,存放在較長生命週期的擁有者中,並以項目的穩定識別碼作為索引。

3. 刻意重設識別

按下 Reset identity。程式會變更套用到整個捲動檢視的識別,讓你能與一般版本更新進行明確比較。Apple 明確說明,變更傳給 id(_:) 的值會重設檢視的識別,包括其狀態。請參閱 id(_:)。

檢查新出現列列的標記是否為新值,以及計數是否回到初始值。由於重設套用至捲動檢視子樹,請勿將此用作保留捲動位置的受控測試。

檢視自己的追蹤記錄時,可使用以下判斷輔助表:

觀察結果 可支持的結論 下一步檢查
出現新的初始化器訊息;標記和計數仍保留 發生了建構,而診斷狀態仍持續存在。 若更新緩慢,檢查初始化器的成本。
顯示回呼重複出現;標記仍保留 重複顯示沒有取代該標記。 若行為取決於門檻,請使用視窗可見性回呼。
一般更新後標記和計數重設 列列狀態未如預期延續。 檢查資料 ID、變動的 id 修飾子,以及條件分支。
列列已初始化,但從未回報顯示 僅有建構不足以證明列列已顯示。 考慮延遲準備和後續捲動變化。
視窗可見性變為 false 已不符合所選的捲動門檻。 判斷該門檻是否符合預期行為。

對於動態資料,請在更新期間讓識別碼持續附著於底層項目。每次讀取都產生新的識別碼,會讓狀態無法延續。也要檢查祖先識別:若你刻意取代某個子樹的識別,穩定的列列 ID 也無法保留其中的列列。Apple 在深入解析 SwiftUI中討論了穩定的資料識別碼和結構識別。

較早版本的 macOS 有哪些可行方式?

範例中的捲動可見性修飾子需要 macOS 15。若要降低部署目標,必須使用可用性分支,並提供另一套實作;onAppear 並非等效的門檻測量方式。

若你的 App 已使用自行管理的 AppKit 捲動介面,NSScrollView.documentVisibleRect 會提供文件檢視中透過內容檢視可見的部分,座標以文件檢視為準。另一種以幾何資訊為基礎的方式,是在相同座標系統中比較該矩形與你自己的列列矩形,並在捲動和版面配置變更後重新計算。請參閱 documentVisibleRect。

例如,你可以明確訂定一項規則:若列列矩形至少有一半面積與文件可見矩形相交,就將列列歸類為可見。這是建議採用的應用程式規則,並非宣稱它重現了 SwiftUI 的實作。請明確處理空矩形和座標轉換。

SwiftUI 提供 NSViewRepresentable,可整合由 App 建立和管理的 AppKit 檢視。其文件所述的建立、更新、協調和拆除方法提供了受支援的橋接方式。請遵循 SwiftUI 對所代表檢視版面配置的控制;Apple 警告不要直接變更該檢視的影格或界限。請參閱 NSViewRepresentable。

這項替代方式適用於你自行管理的 AppKit 文件檢視,不必假設 SwiftUI List 使用了哪些私有底層檢視。

TextKit 版面配置能證明文字位於螢幕上嗎?

TextKit 同樣需要區分準備工作與視窗幾何資訊。如果你自己的列列中包含 AppKit 文字檢視,而你想確認哪些文字片段與可見區域相交,這點就很重要。

TextKit 2 的 NSTextViewportLayoutController 適用於 macOS 12 及後續版本,會管理一個作用中的版面配置區域,通常包含可見區域和額外的過度捲動區域。其 viewportBounds 包含超繪範圍,而選用的 viewportRange 描述目前的視窗版面配置。兩者都不應重新解釋為精確的可見字元集合。請參閱 NSTextViewportLayoutController。

若要實作 TextKit 2,請使用文字版面幾何資訊,而非配置事件。NSTextLayoutManager 可為指定文字範圍列舉文字片段。將所得的片段矩形與實際裁切矩形轉換至相同座標系統後再比較。請考慮文字檢視的 textContainerOrigin;不要假設文字容器座標等同文件檢視座標。請參閱 enumerateTextSegments(in:type:options:using:) 和 textContainerOrigin。

若現有實作使用 TextKit 1,NSLayoutManager.glyphRange(forBoundingRect:in:) 接受文字容器座標中的矩形。Apple 說明,傳回的連續範圍可能包含位於該矩形外的字形,因此它是供進一步檢查的候選範圍,而非精確的可見清單。請參閱 glyphRange(forBoundingRect:in:)。

診斷時請保留文字引擎。Apple 說明,在 macOS 12 及後續版本中,存取 NSTextView 或文字容器的 layoutManager 屬性會造成系統切換至使用 NSLayoutManager 的相容模式。若採用 TextKit 2,請改用 textLayoutManager 及其 API。請參閱 NSTextView。

請選擇能回答介面問題的訊號:初始化器記錄用於確認建構,保留狀態用於確認連續性,顯示回呼用於確認顯示狀態轉換,而視窗幾何資訊用於確認可見性。使用穩定 ID 的實驗,再搭配一次刻意的識別重設,就能觀察這些差異,無須依據主控台輸出量猜測。

來源