[SwiftUI] 렌더링 실험

    0. 서론

    렛어스 고에서 숲의 스마일님이 연사한 SwiftUI 렌더링 성능 개선 컨퍼런스 영상을 보고 너무 재밌어서 여기에 했던 실험 + 내가 그동안 궁금했던 것들까지 실험해 보았다.

    https://www.youtube.com/watch?v=N2Wq-MMx81c&list=PLfx4MMAj7YbHmfbcHSGIIH33yvpptIzT7&index=5

     

    그동안은 성능과 상관없이 내가 원하는 대로 실행되면 빨리 다음 고고 였는데, 진행한 프로젝트도 쌓였고

    이제는 다음 단계로 넘어가야할 차례라고 생각했다. 한번 가려웠던 부분을 긁어보자.

     

    1. @State & @Binding

    제일 간단한 State&Binding부터 살펴보자. 아래처럼 간단하게 뷰를 구성해보았다.

    struct StateBinding1View: View {
        @State private var count = 0
    
        var body: some View {
            VStack {
                Text("Count: \(count)")
                
                Button("증가") {
                    count += 1
                }
                
                targetView()
            }
            .font(.largeTitle)
        }
        
        @ViewBuilder
        private func targetView() -> some View {
            let _ = Self._printChanges()
            Text("렌더링 되기 싫어요")
        }
    }

     

    실행을 해보면 @State로 선언된 count의 값이 바뀌면서 targetView도 같이 업데이트 되는 것을 볼 수 있다.

     

     

    targetView는 업데이트 할 필요가 없는데,,,,,,, 그렇다면 어떻게 해야할까?

     

    하위 Struct로 쪼개기

    아래처럼 ChildView로 쪼개보자.

    struct StateBinding2View: View {
        @State private var count = 0
    
        var body: some View {
            VStack {
                Text("Count: \(count)")
                
                Button("증가") {
                    count += 1
                }
                
                ChildView()
            }
            .font(.largeTitle)
        }
    }
    
    private struct ChildView: View {
    
        var body: some View {
            let _ = Self._printChanges()
            Text("렌더링 되기 싫어요")
        }
    }

     

    실행을 해보면,, count 값이 바뀌어도 업데이트가 안되는 것을 볼 수 있다!

     

     

    @Binding?

    그러면 바인딩을 써보자. Binding한 값을 쓰지 않더라도 하위뷰는 업데이트 될까?

    struct StateBinding3View: View {
        @State private var count = 0
    
        var body: some View {
            VStack {
                Text("Count: \(count)")
                
                Button("증가") {
                    count += 1
                }
                
                ChildView(count: $count)
            }
            .font(.largeTitle)
        }
    }
    
    private struct ChildView: View {
        
        @Binding var count: Int
        
        var body: some View {
            let _ = Self._printChanges()
            Text("렌더링 되기 싫어요")
        }
    }

     

     

    실행을 해보니, @Binding만 들고 있더라도 하위뷰가 업데이트 되고 있음을 확인할 수 있었다.

     

     

    2. ObservableObject Protocol

    이전 프로젝트에서 가장 많이 썼던 패턴인 MVVM(ObservableObject&Published)을 한번 알아보자.

    제일 상위 뷰에서 @StateObject로 ViewModel을 초기화 해주고 하위뷰에서는 @ObservableObject로 받게 해주었다.

    final class ViewModel1: ObservableObject {
        @Published private(set) var count = 0
        
        init() {
            print("ViewModel 생성됨")
        }
        
        func increase() {
            count += 1
        }
        
        deinit {
            print("ViewModel 소멸됨")
        }
    }
    
    struct ObservableObject1View: View {
        @StateObject var viewModel = ViewModel1()
        
        var body: some View {
            VStack {
                Text("Count: \(viewModel.count)")
                
                Button("증가") {
                    viewModel.increase()
                }
                
                ChildView(viewModel: viewModel)
            }
            .font(.largeTitle)
        }
    }
    
    
    private struct ChildView: View {
        @ObservedObject var viewModel: ViewModel1
    
        var body: some View {
            let _ = Self._printChanges()
            Text("렌더링 되기 싫어요")
        }
    }

     

    @Binding과 마찬가지로 ChildView에서 ViewModel을 들고 있기만 해도 업데이트 되는 것을 볼 수 있다.

     

    그렇다면, 이런 현상을 방지하고 싶다면 어떻게 해야할까?

    iOS 17부터 지원하는 @Observable 매크로를 사용하면 이를 방지할 수 있다.

     

    3. @Observable

    아래 코드는 바로 위에 코드를 Observable 매크로로 마이그레이션 한 코드이다.

    @Observable
    final class ViewModel3 {
        private(set) var count = 0
        
        init() {
            print("ViewModel 생성됨")
        }
        
        func increase() {
            count += 1
        }
        
        deinit {
            print("ViewModel 소멸됨")
        }
    }
    
    struct Observable1View: View {
        @State var viewModel = ViewModel3()
        
        var body: some View {
            VStack {
                Text("Count: \(viewModel.count)")
                
                Button("증가") {
                    viewModel.increase()
                }
                
                ChildView(viewModel: viewModel)
            }
            .font(.largeTitle)
        }
    }
    
    
    private struct ChildView: View {
        let viewModel: ViewModel3
    
        var body: some View {
            let _ = Self._printChanges()
            Text("렌더링 되기 싫어요")
        }
    }

     

    바로 결과를 보자.

    ChildView에서 ViewModel을 들고 있어도 업데이트가 안되는 것을 볼 수 있다!!!!!

     

     

    그렇다면... 자식 뷰 내부에서 사용되는 속성이 변경되지 않더라도, 부모의 상태(count) 변경에 따라 자식 뷰가 업데이트될까?

    바로 코드를 작성해보았다.

    @Observable
    final class ViewModel5 {
        private(set) var count = 0
        private(set) var fixedNum = 0
        
        init() {
            print("ViewModel 생성됨")
        }
        
        func increase() {
            count += 1
        }
        
        deinit {
            print("ViewModel 소멸됨")
        }
    }
    
    struct Observable3View: View {
        @State var viewModel = ViewModel5()
        
        var body: some View {
            VStack {
                Text("Count: \(viewModel.count)")
                
                Button("증가") {
                    viewModel.increase()
                }
                
                ChildView(viewModel: viewModel)
            }
            .font(.largeTitle)
        }
    }
    
    
    private struct ChildView: View {
        let viewModel: ViewModel5
    
        var body: some View {
            VStack {
                let _ = Self._printChanges()
                Text("Fixed Value: \(viewModel.fixedNum)")
            }
        }
    }

     

     

    Fixed Value가 바뀌지 않으니 뷰가 업데이트 되지 않는다는 것을 볼 수 있다!

     

    그동안에 @Observable로 구현한 프로젝트에서
    뷰모델의 UI업데이트와 관련된 변수/내부 변수를 구분하기 위해서 하나의 구조체로 묶었던것이 생각나 이것도 실험 해보았다.

    코드는 다음과 같다.

    @Observable
    final class ViewModel6 {
        
        struct State {
            var count = 0
            var fixedNum = 0
        }
        
        private(set) var state: State = .init()
        
        init() {
            print("ViewModel 생성됨")
        }
        
        func increase() {
            state.count += 1
        }
        
        deinit {
            print("ViewModel 소멸됨")
        }
    }
    
    struct Observable4View: View {
        @State var viewModel = ViewModel6()
        
        var body: some View {
            VStack {
                Text("Count: \(viewModel.state.count)")
                
                Button("증가") {
                    viewModel.increase()
                }
                
                ChildView(viewModel: viewModel)
            }
            .font(.largeTitle)
        }
    }
    
    
    private struct ChildView: View {
        let viewModel: ViewModel6
    
        var body: some View {
            VStack {
                let _ = Self._printChanges()
                Text("Fixed Value: \(viewModel.state.fixedNum)")
            }
        }
    }

     

     

    ....는 viewModel.state가 변경이 되기 때문에 ChildView가 업데이트 되었다..

    그동안에 했던 프로젝트들에서 불필요한 하위뷰의 업데이트가 이루어졌다는 소리 ㅜ

     

    4. 결론!!!!! + 내가 그동안 궁금했던 것

    그래서 내린 결론은,,

    타겟 버전이 iOS 17.0 이상이면 무조건 @Observable 매크로를 쓰자

     

    사실 위 숲의 스마일님이 연사하는 컨퍼런스에서도 내린 결론이기 때문에 이미 알고 있던 사실이고,
    이제는 내가 그동안 궁금했던 것을 실험 해보겠다.

     

    @StateObject vs @ObservedObject

    전에 이둘의 차이를 공부한 것이

    • 객체(ViewModel)를 View가 책임져야할때는 @StateObject
    • View를 객체가 책임져야할때는 @ObservableObject

    따라서, 제일 상위뷰에서 ViewModel을 초기화 해줄때는 @StateObject,

    이후 하위 뷰에서 해당 뷰모델을 받을때는 @ObservedObject로 결론지었다.

     

    그래서 왜 그래야하는지? 가 궁금해서 아래 코드처럼 구현했다.

    final class ViewModel2: ObservableObject {
        @Published private(set) var count = 0
        
        init() {
            print("ViewModel 생성됨")
        }
        
        func increase() {
            count += 1
        }
        
        deinit {
            print("ViewModel 소멸됨")
        }
    }
    
    struct ObservableObject2View: View {
        
        @State private var count = 0
        
        var body: some View {
            VStack {
                Text("Parent Count: \(count)")
                
                Button("증가") {
                    count += 1
                }
                
                ChildView()
            }.font(.largeTitle)
        }
    }
    
    
    private struct ChildView: View {
        
        // @StateObject로 바꾸면 초기화 되지않음
        @ObservedObject var viewModel = ViewModel2()
        
        var body: some View {
            VStack {
                Text("Child Count: \(viewModel.count)")
                
                Button("증가") {
                    viewModel.increase()
                }
            }
        }
    }

     

     

    ParentCount를 증가 시키면 ChildCount가 초기화 되는 것을 볼 수 있다.

    ParentView가 body를 재계산하고 ChildView가 다시 그려지면서 @ObservedObject로 생성한 viewModel이 초기화 되는 것이였다.

     

     

    따라서 @StateObject로 바꿔보면,, 원하는대로 동작하는 것을 확인할 수 있다.

     

    내가 실험했던 풀코드는 여기서

    https://github.com/Monfi98/SwiftUIRender-Practice

     

    GitHub - Monfi98/SwiftUIRender-Practice

    Contribute to Monfi98/SwiftUIRender-Practice development by creating an account on GitHub.

    github.com

     

    댓글