XLOG

[최적화] View 의 Update 를 최소화 하는 방법 본문

Swift/SwiftUI

[최적화] View 의 Update 를 최소화 하는 방법

X_PROFIT 2024. 7. 1. 20:18

1. SwiftUI 는 언제 View를 Update 하는가?

출처 : https://developer.apple.com/wwdc20/10040

우리가 SwiftUI 를 사용하며 View 를 정의할 때 @State, @StateObject 와 같은 변수를 정의하게 되면 View 는 Source of truth 를 만들어 관리를 한다. 이때 UI 를 통해 Action 을 발생시켜 Source of truth 의 값의 변화를 준다면 그 값의 변화를 View 에 알리고 View에서는 body 를 다시 compute 를 한다. body를 다시 compute 한다는 것은 body 내부에 코드를 한줄한줄 실행을 해준다는 의미이다.

2. 그렇다면 어떻게 Update 를 최소화 하는가?

2-1. SubView를 사용하여 View가 의존하는 변수를 최적화한다.

위에서 설명을 했지만, View 가 추적하고 있는 변수의 변화가 발생하게 되면 body 를 재실행하게 된다. 즉 모든 View Component 를 하나의 View 에 모두 정의하게 되면 모든 View의 코드들을 실행하게 된다. 하지만 Subview 를 정의하여 적용하게 되면 부모View 의 body 가 재실행되면서 Subview 가 init 이 되고 내가 정의한 View 는 기존 View 와의 변화를 확인하여 변화가 없으면 subview 의 body 를 재실행하지 않는다. 즉 UI 를 담당하는 body 를 update 하는 것은 본인 자신에게 있다고 할 수 있다. 코드로 비교를 해보자.

import Foundation

struct TestModel: Identifiable, Hashable {
    let id: String
    var name: String
}

class PracticeViewModel: ObservableObject {
    @Published var count: Int
    @Published var items: [TestModel]
    
    init() {
        self.count = 0
        self.items = [
            .init(id: UUID().uuidString, name: "number 1"),
            .init(id: UUID().uuidString, name: "number 2"),
            .init(id: UUID().uuidString, name: "number 3"),
            .init(id: UUID().uuidString, name: "number 4"),
            .init(id: UUID().uuidString, name: "number 5"),
        ]
    }
    
    func update(_ item: TestModel) {
        if let index = items.firstIndex(of: item) {
            var temp = self.items
            temp[index].name = "Change"
            self.items = temp
        }
    }
}


struct DataFlowOptimizeView: View {
    
    @StateObject private var vm: PracticeViewModel = .init()
    
    var body: some View {
        VStack {
            let _ = Self._printChanges()
            
            ForEach(vm.items) { item in
                HStack {
                    let _ = Self._printChanges()
                    Image(systemName: "person")
                    Text(item.name)
                        .padding()
                    Spacer()
                }
                .background( Color.randomColor )
                .padding()
            }
            
            Spacer()
            
            Button(action: {
                let item = TestModel(id: UUID().uuidString, name: "name \(vm.items.count)")
                vm.items.append(item)
            }) {
                Text("Add Item")
                    .padding()
                    .background( Color.randomColor )
            }
        }
        .padding()
    }
}

 

ViewModel.items 에 아이템을 추가하게 되면 StateObject 의 변화는 body를 재실행하게 되고 그로 인하여 For문에 모든 코드를 다시 실행하게 된다. 하지만 For 구문 내부를 Subview 로 적용해주게 된다면 단지 Subview 초기화만 진행을 하고 그 초기화로 인하여 변화된 부분을 확인하여 View update 를  진행하지 않게 된다.

struct DataFlowOptimizeView: View {
    
    @StateObject private var vm: PracticeViewModel = .init()
    
    var body: some View {
        VStack {
            let _ = Self._printChanges()
            
            ForEach(vm.items) { item in
                DataFlowOptimizeRowView(item: value)
            }
            
            Spacer()
            
            Button(action: {
                let item = TestModel(id: UUID().uuidString, name: "name \(vm.items.count)")
                vm.items.append(item)
            }) {
                Text("Add Item")
                    .padding()
                    .background( Color.randomColor )
            }
        }
        .padding()
    }
}

struct DataFlowOptimizeRowView: View {
    
    let item: TestModel
    
    init(item: TestModel) {
        self.item = item
        print("init ChildView")
    }
    
    var body: some View {
        HStack {
            let _ = Self._printChanges()
            Image(systemName: "person")
            Text(item.name)
                .padding()
            Spacer()
        }
        .background( Color.randomColor )
        .padding()
    }
}

기존 코드에서 ForEach 내부의 UI 를 DataFlowOptimizeRowView 로 따로 정의하여 사용을 해보자.

결과를 확인해보면 상위View 에 body가 재실행되면서 아이템의 갯수만큼 DataFlowOptimizeRowView 의 init 이 호출이 되지만 body 부분은 딱 한번 호출이 되는 것을 볼 수 있다. 이는 새로운 아이템이 추가가 되어 그 아이템에 대한 UI 를 그릴때 발생하는 것 이다.

여기서 약간의 트릭으로 ViewModifier 와 EnvironmentObject 를 활용한다면 View를 Subview로 바꾸어 사용할 수 있다. 이론상 StateObject 의 변화를 주게 되면  해당 ViewModel 을 EnvironmentObject 로 가지고 있는 모든 View는 body가 재실행된다. 이는 엄청난 낭비가 될 수 있다. 하지만 ViewModifer 를 이용하여 view에 content에 해당하는 body 의 실행을 최소화 할 수 있다.

struct DataFlowOptimizeRowView: View {
    
    let item: TestModel
    
    init(item: TestModel) {
        self.item = item
        print("init ChildView")
    }
    
    var body: some View {
        HStack {
            let _ = Self._printChanges()
            Image(systemName: "person")
            Text(item.name)
                .padding()
            Spacer()
        }
        .background( Color.randomColor )
        .padding()
        .modifier(MyModifier(item: item))
    }
}


struct MyModifier: ViewModifier {
    
    let item: TestModel
    @EnvironmentObject var vm: PracticeViewModel
    
    func body(content: Content) -> some View {
        Button(action: {
            // vm 의 기능 동작 정의
            vm.update(item)
        }) {
            content
        }
        
    }
}

기존의 Subview 를 MyModifier 로 감싸줌으로 인해서 subview body 에 해당하는 것은 environmentObject의 직접적으로 받지 않게 된다. 

 

2-2. Combine 활용

이전 StateObject 에 대한 포스팅을 작성했을 때도 얘기를 했지만, StateObject Published 프로퍼티의 변화가 있으면 body 를 재실행하게 된다. 그렇다면 Combine을 어떻게 활용을 하라는 것인가?

class PracticeViewModel: ObservableObject {
    @Published var publicshedOffset: CGFloat = .zero
    var combineOffset: CurrentValueSubject<CGFloat, Never> = .init(.zero)
}



struct DataFlowOptimizeView: View {
    
    @StateObject private var vm: PracticeViewModel = .init()
    
    var body: some View {
        let _ = Self._printChanges()
        ZStack {
            ScrollView {
                GeometryReader { reader in
                    Color.clear.preference(key: ViewOffsetKey.self, value: reader.frame(in: .named("scroll")).origin.y)
                }
                ForEach(0..<100, id: \.self) { index in
                    Text("index \(index)")
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(Color.randomColor)
                }
                
            }
            .padding()
            
            OffsetView(offset: vm.publicshedOffset)
        }
        .onPreferenceChange(ViewOffsetKey.self, perform: {
            vm.publicshedOffset = $0
        })
        .onReceive(vm.combineOffset, perform: {
            print($0)
        })
        .environmentObject(vm)
    }
}


struct OffsetView: View {
    
    let offset: CGFloat
    
    var body: some View {
        Text("\(offset)")
            .frame(maxWidth: .infinity)
            .padding()
            .background(Color.randomColor)
    }
}

 

극단적인 예로 Scroll 값에 따라 Published 의 변화를 준다면 해당 View에서는 StateObject 변화에 따라 body 내부를 모두 재실행하게 된다. 하지만 Combine 을 사용하게 되면 원하는 View에서만 onReceive를 해서 이와 같은 비효율을 막을 수 있다.

import SwiftUI

struct DataFlowOptimizeView: View {
    
    @StateObject private var vm: PracticeViewModel = .init()
    
    var body: some View {
        let _ = Self._printChanges()
        ZStack {
            ScrollView {
                GeometryReader { reader in
                    Color.clear.preference(key: ViewOffsetKey.self, value: reader.frame(in: .named("scroll")).origin.y)
                }
                ForEach(0..<100, id: \.self) { index in
                    Text("index \(index)")
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(Color.randomColor)
                }
                
            }
            .padding()
            
            OffsetView()
        }
        .onPreferenceChange(ViewOffsetKey.self, perform: {
            vm.combineOffset.send($0)
        })
        .environmentObject(vm)
    }
}

struct OffsetView: View {
    
    @State private var offset: CGFloat = .zero
    @EnvironmentObject var vm: PracticeViewModel
    
    var body: some View {
        Text("\(offset)")
            .frame(maxWidth: .infinity)
            .padding()
            .background(Color.randomColor)
            .onReceive(vm.combineOffset, perform: {
                self.offset = $0
            })
    }
}

 

 

3. 느낀점

회사 프로젝트를 진행하면서 subview 는 많아지고, 어느 순간 각자 작업한 내용에서 View가 다시 렌더링이 되는데 왜 되는지, 정확히 모르고 있다는 생각을 해서 시작한 공부였다. Published 가 있고 Concurrency 가 있는데 SwiftUI 에서 Combine을 사용할 필요가 있을까에 대한 고민을 한 적도 있다. 하지만 이렇게 DataFlow 를 어떻게 설계하는냐에 따라 View의 Update 횟수를 엄청나게 줄일 수 있다는 사실을 알 수 있었다. 

 

참고

https://developer.apple.com/wwdc20/10040

https://developer.apple.com/wwdc19/226

https://youtu.be/yvfv6N60-vY?si=ZJ7fIb7nL7qY4LpG

https://youtu.be/TOmxDvCz7e4?si=ZaORk7zZkbYDNsn4

https://youtu.be/GlLyPYWgHEo?si=hbzVWdKNlKlNgpRq