티스토리 뷰

반응형

[ 인트로 ]

SwiftUI는 state-driven, data-driven 방식으로 state 가 변하면 view 를 다시 그립니다 (body를 호출하여)
하지만 body를 재호출한다고 모든 것을 다시 그리는 것이 아니라 변경이 필요한 부분만 다시 렌더링 합니다.
 
그렇다면.. re-rendering 또는 re-draw를 결정하는 SwiftUI의 diff 판단 로직은 어떻게 될까요?

(= body가 호출될 때 무엇을 다시 그리고, 무엇을 다시 안그릴 지 어떻게 결정할까요?) 

 

WWDC21 - Demystify SwiftUI 를 보면 identity, life time, dependencies 이 세가지 요인이 메인요소인 것 같습니다. 

저는 이 중에서 identity 위주로 저의 re-rendering 추측을 전개 (?) 해보려고 합니다.  

 

 

이 글 의 실험이 너무 좋아서 저도 따라해보았어요! 🤍

랜덤 백그라운드를 줘서 다시 그려짐을 확인하는 방법 (gist) 인데, SwiftUI Instrument 사용하는 것보다 더 간단하고 명확한 것 같아요!

 

 

[ identity ]

SwiftUI의 모든 view는 identity 를 가집니다.

view가 처음 만들어질 때 SwiftUI는 identity 를 assign 합니다.

 

WWDC21 - Demystify SwiftUI

 

WWDC21 - Demystify SwiftUI  에서는 

- explicit identity

- structual identity 

를 나눠서 말하는데 저는 구분안하고 사용하겠습니다. 

 

 

 

[ 추측 ]

1) SwiftUI는 extract Subview  여부 /  dynamic property 의 종류 를 기준으로 삼아 뷰에 identity 를 부여한다. 

 

- body 안의 서브뷰들에게 같은 identity 를 부여한다.

하지만 Extract Subview를 한 경우, 다른 identity를 부여한다. // Part 1 실험 

 

- State의 경우, dependency 와 직접 관련있는 뷰에 별도의 identity 를 부여한다.

하지만 ObservableObject의 경우, 구체적인 dependency가 아니라 이 객체 자체와 관련있는 뷰라면 같은 identity를 부여한다 // Part 2 실험 

 

 

2) state의 변화가 생기면 관련 identity를 갱신해서 뷰를 업데이트 한다. 

 

 


 

Part 1.  Dependency 가 하나일 때

 

[ 예제 1 ]

아래 예제의 경우, body 안에서 isOn을 사용하는데요, 

body의 서브뷰들에 모두 같은 identity를 부여하는 것 같습니다.

그래서 state가 변하면 state와 상관없는 Text도 다시 그려집니다. 

struct ContentView: View {
    @State private var isOn = true
    
    private var lightStack: some View {
        VStack(spacing: 10) {
            Text(isOn ? "ON" : "OFF")
                .background(.random)
            
            Button {
                isOn.toggle()
            } label: {
                Text("🔦")
            }.background(.random)
        }
    }
    
    var body: some View {
        lightStack
        
        Spacer()
            .frame(height: 50)
        
        Text("NO MATTER!")
            .background(.random)
    }
}

 

 

 

[ 예제 2 ]

NoMatterText 를 extract subview 해보겠습니다.

이때 SwiftUI는 body안의 서브뷰들과 달리 NoMatterText에 다른 identity 를 부여합니다.

그래서 state가 변화해서 ContentView의 body를 재호출해도 NoMatterText는 다시 그리지 않습니다.

(명확한 실험을 위해 Spacer에도 컬러를 줘보겠습니다)

struct ContentView: View {
    @State private var isOn = true
    
    private var lightStack: some View {
        VStack(spacing: 10) {
            Text(isOn ? "ON" : "OFF")
                .background(.random)
            
            Button {
                isOn.toggle()
            } label: {
                Text("🔦")
            }.background(.random)
        }
    }
    
    var body: some View {
        lightStack
        
        Spacer()
            .frame(width: 100, height: 50)
            .background(.random)

        NoMatterText()
    }
}

struct NoMatterText: View {
    
    var body: some View {
        Text("NO MATTER!")
            .background(.random)
    }
}

 

 

 

[ 예제 2.1 ]

반면 body가 재호출될 때마다 NoMatterText에 다른 id를 부여하면

텍스트도 같이 다시 그려지는 것을 확인할 수 있습니다.

저는 이것을 보고 identity가 갱신되면 뷰를 다시 그린다고 생각했어요
(id modifier 문서에 id 값을 주면 identity가 reset 된다고 나와있음)

struct ContentView: View {
    @State private var isOn = true
    
    private var lightStack: some View {
        VStack(spacing: 10) {
            Text(isOn ? "ON" : "OFF")
                .background(.random)
            
            Button {
                isOn.toggle()
            } label: {
                Text("🔦")
            }.background(.random)
        }
    }
    
    var body: some View {
        lightStack
        
        Spacer()
            .frame(height: 50)

        ✅ NoMatterText().id(UUID())
    }
}

 

 

 

[ 예제  3 ]

반대로  lightStack 을 extract subview 해봅시다. 

이때 버튼을 누르면, ContentView의 body는 재호출되지 않고 LightStack의 body만 재호출됩니다. 
(State의 경우, dependency 와 직접 관련있는 뷰에만 별도의 identity 를 부여하기 때문)

 

그래서 당연히 state가 변해도 text 는 다시 그려지지 않습니다

struct ContentView: View {
    @State private var isOn = true
    
    var body: some View {
        LightStack(isOn: $isOn)
        
        Spacer()
            .frame(height: 50)
        
        Text("NO MATTER!")
            .background(.random)
    }
}

struct LightStack: View {
    @Binding var isOn: Bool
    
    var body: some View {
        VStack(spacing: 10) {
            Text(isOn ? "ON" : "OFF")
                .background(.random)
            
            Button {
                isOn.toggle()
            } label: {
                Text("🔦")
            }.background(.random)
        }
    }
}

 

[ 예제 3.1 ]

그럼 더 나아가.. 

LightStack의 Button Label도 extract subview 해보겠습니다. 

이 경우도 ButtonText에는 다른 identity를 부여합니다.

그래서 Button과 달리 ButtonText는 다시 그려지지 않습니다.

struct LightStack: View {
    @Binding var isOn: Bool
    
    struct ButtonText: View {
        var body: some View {
            Text("🔦").background(.random)
        }
    }
    
    var body: some View {
        VStack(spacing: 10) {
            Text(isOn ? "ON" : "OFF")
                .background(.random)
            
            Button {
                isOn.toggle()
            } label: {
                ButtonText()
            }.background(.random)
        }
    }
}

 

 

[ 예제 4 ]

state 의 경우, 전등 버튼을 누르면 ContentView의 body는 안불리고
LightStack의 body만 불렸습니다. (예제 3)

반면 StateObject, ObservedObject 의 경우, 버튼을 누르면 ContentView의 body가 불린 후, LightStack의 body가 불립니다. 

(ObservableObject의 경우, 구체적인 dependency가 아니라 이 객체 자체와 관련있는 뷰라면 같은 identity를 부여하기 때문.)

 

하지만 위에서 계속 봤듯이 ContentView의 body가 다시 불려도 

extract subview를 한 NoMatterText의 identity는 갱신되지 않기 때문에 텍스트는 다시 그려지지 않습니다. 

(반면 extract subview를 안한 경우 텍스트를 다시 그림) 

class LightState: ObservableObject {
    @Published var isOn = true
}

struct ContentView: View {
    @StateObject private var state = LightState()
    
    var body: some View {
        LightStack(isOn: $state.isOn)
        
        Spacer()
            .frame(height: 50)

        NoMatterText()
    }
}

 

Part 2. dependency가 여러 개인 경우

 

[ 예제 5 ]

State의 경우, 구체적인 dependency 를 보고 identity를 부여합니다.

그래서 dependency와 직접 관련이 있는 뷰들만 다시 그려집니다.

손전등을 토글하면 LightStack의 body만 재호출.

텍스트를 클릭하면 NoMatterText 의 body만 재호출.

struct ContentView: View {
    @State var isOn = true
    @State var noMatterText = "NO MATTER!"
    
    var body: some View {
        LightStack(isOn: $isOn)
        
        Spacer()
            .frame(height: 50)
        
        NoMatterText(text: $noMatterText)
    }
}


struct LightStack: View {
    @Binding var isOn: Bool
    
    var body: some View {
        VStack(spacing: 10) {
            Text(isOn ? "ON" : "OFF")
                .background(.random)
            
            Button {
                isOn.toggle()
            } label: {
                Text("🔦")
            }.background(.random)
        }
    }
}

struct NoMatterText: View {
    
    @Binding var text: String
    
    var body: some View {
        Text(text)
            .background(.random)
            .onTapGesture {
                text += "!"
            }
    }
}

 

 

 

[ 예제 6 ]

ObservableObject 의 경우,

구체적인 dependency가 아니라 이 객체 자체와 관련있는 뷰 에 동일한 identity 를 할당하고 갱신하는 것 같습니다. 

ContentView의 body가 재호출되고 

참조하고 있는 @Published 는 다르지만 LightStack, NoMatterView가 같이 다시 그려지고 있기 때문입니다. 

class SomeState: ObservableObject {
    @Published var isOn = true
    @Published var noMatterText = "NO MATTER!"
}

struct ContentView: View {
    @StateObject var state = SomeState()
    
    var body: some View {
        LightStack(isOn: $state.isOn)
        
        Spacer()
            .frame(height: 50)
        
        NoMatterText(text: $state.noMatterText)
    }
}

 

 

[ 예제 7 ]

그럼 NoMatterText에 dependency를 제거하면 어떻게 될까요?

NoMatterText는 ObservableObject 와 관련이 없는 서브뷰이기 때문에 다른 identity를 부여받고

state 변화에도 다시 그려지지 않습니다. 

class SomeState: ObservableObject {
    @Published var isOn = true
    @Published var noMatterText = "NO MATTER!"
}

struct ContentView: View {
    @StateObject var state = SomeState()
    
    var body: some View {
        LightStack(isOn: $state.isOn)
        
        Spacer()
            .frame(height: 50)
        
        NoMatterText()
    }
}

 

 

[ 예제 8 ]

하지만 Part 1의 교훈을 잊으면 안됩니다.

extract subview를 안한 경우, 별도의 identity를 부여받지 않으므로 text는 다시 그려집니다.

class SomeState: ObservableObject {
    @Published var isOn = true
    @Published var noMatterText = "NO MATTER!"
}

struct ContentView: View {
    @StateObject var state = SomeState()
    
    var body: some View {
        LightStack(isOn: $state.isOn)
        
        Spacer()
            .frame(height: 50)
        
        Text("NO MATTER!")
            .background(.random)
    }
}

 

 

 

 

 

[ 참고 ]

다시 그려지는 방식을 내가 원하는대로 바꿀 수도 있습니다. 

[SwiftUI] re-rendering, re-draw 커스터마이징 (?) 을 참고해주세요!

 

 

 

 

 

반응형
댓글