Повторная инициализация строки SwiftUI не доказывает, что строка исчезла или потеряла своё состояние. Чтобы диагностировать прокручиваемый интерфейс Mac, отслеживайте отдельно создание, сохранение состояния, вызовы обратного вызова появления и видимость в области прокрутки.
Это руководство предназначено разработчику, который выясняет причины повторяющихся сообщений о строках в собственном приложении для macOS. Задача — определить, создаёт ли обновление лишь ещё одно значение представления, сбрасывает состояние строки или меняет набор строк в области прокрутки. В примере используются условные метки и обычные кнопки.
Что именно показывает каждый сигнал жизненного цикла?
Строка 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:_:).
В этой статье «видимость в области прокрутки» означает выполнение условия заданного порога прокрутки. Это не измерение того, смотрел ли кто-нибудь на строку.
Создайте небольшой эксперимент, который разделяет сигналы
Создайте приложение SwiftUI для macOS с SDK, включающим API macOS 15, и задайте целевую версию macOS 15 или новее. Замените сгенерированные объявления приложения и представления содержимого полной программой ниже. Оставьте только одну точку входа @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 и оставьте открытой консоль отладки. Дважды нажмите кнопку Count у видимой строки, запишите её токен, затем нажмите Update rows. Передаваемая строкам версия изменится, а фиксированные целочисленные идентификаторы останутся прежними.
Если сообщения инициализатора появляются снова, но выбранная строка сохраняет токен и счётчик, это свидетельствует о повторном создании значений при сохранении диагностического состояния. Не считайте эти сообщения об инициализации отдельными периодами пребывания на экране. Отображаемая версия подтверждает, что строка получила новые входные данные.
Токен появления присваивается через состояние, что само может вызвать ещё одно обновление. Это сделано намеренно для диагностики. Не ожидайте точного количества сообщений инициализатора или body; после завершения расследования удалите эти диагностические проверки.
2. Прокрутите строку за пределы области прокрутки и обратно
Медленно прокрутите, пока выбранная строка не пересечёт порог 50%. Сравните сообщения viewport с сообщениями о появлении, затем вернитесь к строке. Снова проверьте её токен и счётчик.
Если после возвращения токен сохранился, значит диагностическое состояние оставалось непрерывным во время этой прокрутки. Другой токен означает, что прежний токен не сохранился. Чтобы объяснить сброс в настоящем приложении, изучите изменения идентичности, условное удаление и особенности сохранения состояния контейнером, а не делайте вывод о причине только на основании прокрутки.
В актуальных рекомендациях Apple сказано, что ленивые стеки могут заранее вычислять тело представления и его компоновку до появления. Если направление прокрутки меняется, предварительно подготовленное представление может так и не получить onAppear. Apple также поясняет, что состояние находящихся за пределами области прокрутки элементов ленивого стека со временем может быть отброшено, и рекомендует хранить данные, которые должны переживать прокрутку, в модели или внешнем представлении. См. Погружение в ленивые стеки и прокрутку в SwiftUI.
Поэтому локальный счётчик строки в примере полезен как диагностическая проверка. В настоящем интерфейсе редактирования сохраняйте изменения, которые должны пережить удаление строки, в объекте с более длительным временем жизни, используя стабильный идентификатор элемента как ключ.
3. Намеренно сбросьте идентичность
Нажмите Reset identity. Программа меняет идентичность всего представления прокрутки, что даёт возможность сравнить этот случай с обычным обновлением версии. Apple прямо указывает, что изменение значения, переданного в id(_:), сбрасывает идентичность представления, включая его состояние. См. id(_:).
Проверьте, появились ли у новых строк новые токены и начальные значения счётчиков. Поскольку сброс применяется ко всему поддереву представления прокрутки, не используйте его как контролируемую проверку сохранения позиции прокрутки.
Используйте эту таблицу для анализа собственного журнала:
| Наблюдение | Обоснованный вывод | Следующая проверка |
|---|---|---|
| Новые сообщения инициализатора; токен и счётчик сохраняются | Создание происходило при сохранении диагностического состояния. | Если обновления медленные, проверьте стоимость инициализатора. |
| Повторяются вызовы обратного вызова появления; токен сохраняется | Повторное появление не заменило этот токен. | Для логики, зависящей от порога, используйте обратные вызовы видимости в области прокрутки. |
| Токен и счётчик сбрасываются после обычного обновления | Состояние строки не сохранилось ожидаемым образом. | Проверьте идентификаторы данных, изменяющиеся модификаторы id и условные ветви. |
| Строка инициализирована, но сообщения о появлении нет | Одного создания недостаточно, чтобы подтвердить появление. | Учтите ленивую подготовку и последующие изменения прокрутки. |
| Видимость в области прокрутки становится ложной | Выбранный порог прокрутки больше не выполняется. | Решите, соответствует ли этот порог требуемому поведению. |
Для динамических данных сохраняйте идентификаторы, привязанные к исходным элементам, при обновлениях. Новый идентификатор при каждом чтении мешает сохранению непрерывности. Также проверьте идентичность родительских представлений: стабильные идентификаторы строк не сохранят строку в поддереве, идентичность которого вы намеренно заменили. Apple рассказывает о стабильных идентификаторах данных и структурной идентичности в Разбираемся в SwiftUI.
Какой способ поддерживается в более ранних версиях macOS?
Модификатор видимости при прокрутке в примере требует macOS 15. Чтобы снизить целевую версию, нужна ветка проверки доступности с отдельной реализацией; onAppear не является эквивалентным измерением порога.
Если приложение уже использует интерфейс прокрутки AppKit, принадлежащий вашему приложению, NSScrollView.documentVisibleRect предоставляет часть представления документа, видимую через представление содержимого, в координатах представления документа. Альтернативный подход на основе геометрии — сравнивать этот прямоугольник с собственными прямоугольниками строк в той же системе координат и выполнять перерасчёт после прокрутки и изменений компоновки. См. documentVisibleRect.
Например, можно явно задать правило: считать строку видимой, если с прямоугольником видимой части документа пересекается не менее половины её площади. Это предлагаемое правило приложения, а не утверждение, что оно воспроизводит внутреннюю реализацию SwiftUI. Явно обрабатывайте пустые прямоугольники и преобразование координат.
SwiftUI предоставляет NSViewRepresentable для интеграции представления AppKit, которое создаёт и контролирует ваше приложение. Его задокументированные методы создания, обновления, координации и завершения работы обеспечивают поддерживаемый мост. Соблюдайте управление компоновкой представленного представления со стороны SwiftUI: Apple предупреждает, что нельзя напрямую менять его рамку или границы. См. NSViewRepresentable.
Этот запасной подход касается представления документа AppKit, которым вы управляете. Он не требует предположений о внутренних представлениях SwiftUI List.
Доказывает ли компоновка TextKit, что текст находится на экране?
В TextKit необходимо так же разделять подготовку и геометрию области прокрутки. Это важно, если строка вашего приложения содержит текстовое представление AppKit и нужно определить, какие фрагменты текста пересекаются с видимой областью.
NSTextViewportLayoutController из TextKit 2, доступный в 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 указывает, что обращение к свойству layoutManager объекта NSTextView или текстового контейнера в macOS 12 и новее приводит к переходу в режим совместимости с использованием NSLayoutManager. Для пути TextKit 2 используйте textLayoutManager и его API. См. NSTextView.
Выбирайте сигнал, отвечающий на вопрос об интерфейсе: сообщения инициализатора — для создания, сохранённое состояние — для непрерывности, обратные вызовы появления — для переходов появления, геометрия области прокрутки — для видимости. Эксперимент со стабильными идентификаторами и одним намеренным сбросом идентичности позволяет увидеть разницу, не делая выводов по количеству сообщений в консоли.
Источники
- Типы-значения и ссылочные типы в Swift — Swift.org
- Разбираемся в SwiftUI — Apple
onAppear(perform:)— AppleonDisappear(perform:)— AppleonScrollVisibilityChange(threshold:_:)— AppleLazyVStack— AppleState— Apple- Погружение в ленивые стеки и прокрутку в SwiftUI — Apple
id(_:)— AppledocumentVisibleRect— AppleNSViewRepresentable— AppleNSTextViewportLayoutController— AppleenumerateTextSegments(in:type:options:using:)— AppletextContainerOrigin— AppleglyphRange(forBoundingRect:in:)— AppleNSTextView— Apple