SwiftUI 렌더링
SwiftUI는 코드에서 세 가지를 봅니다: identity(이전과 동일한 요소인가?), lifetime(해당 identity가 얼마나 지속되는지, 그리고 그에 따른 @State), 그리고 dependencies(변경될 때 body를 다시 실행하게 만드는 입력). "왜 다시 렌더링됐지/상태를 잃었지/애니메이션이 이상해졌지/끊기지?"라는 거의 모든 질문은 이 세 가지 중 하나입니다.
모델
- 뷰 값은 일시적입니다. identity는 그렇지 않습니다. 뷰 구조체가 생성되고, 이전 값과 비교된 후 폐기됩니다. 지속되는 것은 identity이며,
@State/@StateObject저장소는 해당 identity에 연결됩니다: identity가 변경되면 상태가 교체되고onAppear/task가 다시 실행되며 이미지가 다시 로드됩니다. - 두 종류의 identity. 명시적:
ForEachID,Identifiable,.id(_:). 구조적: 뷰의 타입과 계층 내 위치 —if/switch의 각 분기는 서로 다른 identity입니다. - 의존성 그래프. 뷰의 의존성은 저장 프로퍼티(부모로부터의 입력),
@State,@Environment,@Binding, 그리고body가 읽는@Observable프로퍼티입니다. 하나가 변경되면, 그에 의존하는 뷰만 새로운body를 얻습니다. 그런 다음 SwiftUI는 새 자식 값을 이전 값과 비교하고 변경된 것이 없으면 중단합니다. - 업데이트 순서 무효화된 뷰의 경우: 새 값 → 동적 프로퍼티 업데이트 →
body실행 → 자식 비교 → 재귀.
규칙
Identity
- 인덱스로 식별하지 마십시오 (
ForEach(Array(x.enumerated()), id: \.offset), 동적 데이터에 대한0..<count). 위에 삽입된 모든 항목은 이후의 모든 인덱스를 이동시킵니다: 상태가 재설정되고, 이미지가 다시 로드되며, 애니메이션이 잘못된 위치로 이동합니다. 안정적이고 고유한 ID를 사용하세요. - ID는 안정적이고 고유해야 합니다. 계산된
id에UUID()를 사용하지 마십시오(렌더링할 때마다 새로운 identity). 반복될 수 있는 이름(두 개의 "개 비스킷" 간식)을 사용하지 마십시오. 데이터가 보장할 수 없는 경우 반복되는 것을 고유하게 만드십시오(key,key#2) —ForEach에서 중복된 ID는 잘못 동작합니다. - 생성되거나 스트리밍된 콘텐츠의 경우, 이동하지 않는 구조에서 identity를 파생하십시오: 소스 텍스트에서 요소가 시작되는 위치(스트리밍은 추가만 함), 또는 프로그램 출력에 대한 템플릿 위치 + 반복당 키(React 모델:
template:key). 키가 없는 항목(느슨한 텍스트, 문단)은 그 앞에 있는 키가 있는 요소에 카운터를 더한 값(e812+2)으로 이름을 지정하십시오. 그러면 끝에 새 콘텐츠가 추가되어도 이전 항목의 이름이 변경되지 않습니다. - 분기보다는 수식어를 변경하는 단일 뷰를 선호하십시오.
if expired { v.opacity(0.3) } else { v }는 두 개의 identity입니다;v.opacity(expired ? 0.3 : 1)는 하나입니다. "비활성" 값(투명도 1, 패딩 0, 크기가 없는frame,layoutPriority(0),transformEnvironment)은 비용이 거의 들지 않습니다. 진정으로 다른 뷰에 대해서만 분기를 사용하십시오. AnyLayout으로 레이아웃을 전환하고if wrap { FlowLayout { … } } else { HStack { … } }을 사용하지 마십시오 — 자식은 identity와 상태를 유지합니다.AnyView를 피하십시오. SwiftUI에서 타입 구조를 숨깁니다(더 나쁜 diffing, 그리고List/Table에서는 모든 행을 세기 위해 빌드하도록 강제).@ViewBuilder함수,switch, 제네릭을 사용하십시오.- 요소 종류를 열거형으로 모델링하고 디스패칭 뷰에서
default없이switch하십시오: 새로운 케이스가 조용히 처리되지 않을 수 있습니다.
의존성
- 뷰에 사용하는 것만 전달하십시오. 전체 모델을 사용하는 뷰는 어떤 필드가 변경되어도
body를 다시 실행합니다; 개가 아니라 이미지를 전달하십시오. 의존성 범위를 좁히기 위해 하위 뷰를 추출하십시오 — 모든 줄에 대해 판단적으로 사용하지 마십시오. @Observable은body가 읽는 것을 추적합니다. 액션 클로저에서 프로퍼티를 읽는 것은 의존성을 생성하지 않습니다;body에서 읽는 것은 생성합니다. 뷰가 표시하는 상태를 모델에 두고 필요한 곳에서만body에서 읽으십시오.- 렌더링당 새로운 클로저(또는 다른 비교 불가능한 값)를 환경에 절대 넣지 마십시오 — 예:
body에서.environment(\.actions, Actions { … }). 비교할 수 없으므로 모든 독자는 모든 부모 업데이트에서 무효화됩니다. 대신 안정적인 객체를 환경에 넣고(@Observable과 함께.environment(model),@Environment(Model.self) var model: Model?로 읽음), 액션에서 호출하십시오. - 변경된 것이 없을 때 알리지 마십시오.
@Observable프로퍼티는 모든 설정에서 알립니다. 보호:if new != value { value = new }— 모든 청크나 틱에서 파생 상태를 재구성할 때 중요합니다. - 변경되지 않은 하위 트리를 건너뛰려면
Equatable뷰를 사용하십시오. 뷰를 준수시키고(프로퍼티 래퍼가 없을 때만 가능) 부모가 대부분 변경되지 않은 자식으로 자주 다시 렌더링되는 곳(콘텐츠가 스트리밍되는 블록 목록)에.equatable()을 적용하십시오. 뷰가 표시하는 것만 비교하는 사용자 정의==를 작성하십시오. ⚠️.equatable()은 컨테이너에서 자식의layoutPriority를 숨깁니다.HStack/행 내부에서layoutPriority(1)자식이EquatableView로 래핑되면 여유 공간을 확보하지 못하고 행이 다르게 레이아웃됩니다. 우선 순위에 의존하는 행/스택의 자식이 아닌, 블록의 세로 목록에.equatable()을 사용하십시오.
저렴한 body
body에서 구문 분석, 디코딩, 필터링, 정렬 또는 할당량이 많은 작업을 수행하지 마십시오. 모든 무효화에서 실행됩니다. 모델에서 미리 계산하거나, 캐시하거나(입력으로 키가 지정된NSCache), 실제로 입력이 변경되는 뷰로 끌어올리십시오.- 비용이 많이 드는 입력을 빠르게 변경되는 상태와 분리하십시오. 뷰가 데이터를 디코딩 하고 자주 변경되는
@State(드래그, 스크럽, 호버)를 보유하는 경우, 디코딩은 모든 변경 시 다시 실행됩니다. 부모에서 디코딩하고, 결과를@State를 소유하는 자식에게 전달하십시오. - 느린 작업은
init또는body가 아닌.task에 넣으십시오.async로 만드십시오. 동적 프로퍼티의 초기 값에서 절대 차단하지 마십시오. - 비용이 많이 드는 형식화를 캐시하십시오 —
AttributedString(markdown:), 핫 경로의 문자열 보간, 번들 조회.
Lists
List/Table은 모든 ID를 미리 수집합니다. ID 생성을 저렴하게 유지하고, 각ForEach요소에 일정한 수의 뷰를 제공하십시오 — 행 클로저 내에if필터나AnyView를 넣지 마십시오(데이터를 모델에서 필터링하고body에서 인라인으로 필터링하지 마십시오). 중첩된ForEach는 섹션에 적합합니다.
UIKit 셀의 SwiftUI (UIHostingConfiguration)
- 셀은 재사용되며 SwiftUI 뷰 상태가 해당 셀에 표시되는 다음 항목으로 전달될 수 있습니다. 루트에
.id(item.id)를 지정하여 다른 항목이 새 트리를 얻도록 하고, 사용자에게 표시되는 상태(선택됨, 확장됨, 선택)를@State가 아닌 안정적인 ID로 키가 지정된 모델에 유지하십시오. 그렇지 않으면 재사용 및 스크롤 시 손실/잘못 적용됩니다. - 셀 재구성은 루트 뷰 값을 다시 빌드합니다. 콘텐츠가 관찰된 모델에 의해 구동되는 경우, 루트 뷰를 표시하는 내용에 대해
Equatable로 만들고(모델에만 공급되는 원시 입력이 아님).equatable()을 적용하여 업데이트당 재구성이 전체 트리를 다시 실행하지 않도록 하십시오. 실제 업데이트는 관찰이 수행합니다. - 자체 크기 조정은 열린 높이로 측정됩니다. 이상적인 크기가 없는 뷰는
Canvas, 모양,GeometryReader에서 0으로 축소됩니다..aspectRatio(r, contentMode: .fit)만으로는 충분하지 않습니다 — 작은 사용자 정의Layout으로 크기를 지정하십시오(제안의 너비, 높이 = 너비 / 비율, 프로빙 높이 무시). .aspectRatio(nil, contentMode:)는 뷰를 콘텐츠의 이상적인 크기에 고정합니다 — 카드가 행을 채우지 못합니다. 비율이 있을 때만.aspectRatio를 적용하십시오.
검토 체크리스트
- 모든
ForEach에 안정적이고 고유한 ID가 있습니다 —\.offset없음, 계산된UUID()없음. -
AnyView없음; 분기는 진정으로 다른 뷰에만 사용; 레이아웃 전환에는AnyLayout사용. - 뷰는 렌더링하는 데이터만 받습니다.
- 환경에서 렌더링당 비교 불가능한 항목이 생성되지 않습니다.
-
body는 구문 분석/디코딩/필터링을 수행하지 않습니다. 비용이 많이 드는 입력은 빠른@State와 분리됩니다. - Observable 프로퍼티가 동일한 값으로 다시 설정되지 않습니다.
-
.equatable()은 작업을 절약하는 곳에 사용되며layoutPriority를 사용하는 자식에는 사용되지 않습니다. - 호스팅된 셀: 루트
.id(item.id), 표시 상태는 모델에, Equatable 루트. - 이상적인 크기가 없는 뷰는 자체 크기 조정 셀에 들어가기 전에 크기가 지정됩니다.
검증
- **
let _ = Self._printChanges()**를body상단에 추가하면 실행 이유(@self,@identity또는 변경된 프로퍼티)가 기록됩니다. 디버그 전용 — 출시 전에 제거하십시오. - Instruments → SwiftUI 템플릿 body 수 및 느린 업데이트 확인; Hangs/Hitches로 효과 확인.
- Identity 테스트: 스트리밍 콘텐츠의 모든 접두사를 렌더링하고 완료된 모든 항목의 ID가 최종 렌더링에서 변경되지 않고 나타나는지 확인; 상호 작용 후, 살아남은 요소가 ID를 유지하는지 확인; 중복된 형제 ID가 없는지 확인.
- 렌더링 리팩토링 전후 픽셀 Diff: macOS에서
ImageRenderer로 뷰를 오프스크린 렌더링(동일한 코드,.frame(width:), nil 높이의proposedSize)하고 PNG를 비교합니다.EquatableView/layoutPriority문제와 같은 레이아웃 회귀를 잡아냅니다. (Map,ScrollView및 원격 이미지는 오프스크린 렌더링되지 않습니다; 다른 모든 것은 비교하십시오.) 변경된 파일을 한 번에 하나씩 토글하여 차이점을 이분합니다.
참고 자료
references/case-study.md— AddToChatMenu 응답 렌더러(스트리밍, 모델 생성 마크업, 컬렉션 뷰 셀): 이러한 규칙에 대해 발견된 각 문제와 수정 사항.- WWDC21 Demystify SwiftUI (세션 10022); WWDC23 Demystify SwiftUI performance (세션 10160).