SwiftUI

[SwiftUI] 성능 최적화의 여정(1) - 뷰의 렌더링 최적화

하이D:) 2024. 12. 12. 09:48

 

성능 최적화에 대한 관심이 지대한 요즘.. 이것저것 테스트 해보다가 WWDC까지 찍먹하고 글을 썼지만 이론적으로만 공부하는 게 아니고 실제 프로젝트에 적용해보고 싶어서 힘이 닿는 대로 프로젝트 성능에 대한 최적화를 하는 글을 써볼까 한다.. 포부는 원대하지만 막상 글을 제대로&꾸준히 쓸 수 있을지 걱정되는 현실 😅

 

일단 그 첫 번째 여정으로 프로젝트를 진행하면서 가장 많이 개선의 필요성을 느꼈던 지점인 '불필요한 뷰 렌더링'을 최적화 하는 글을 작성해볼까 한다.

 

 

 


 


커스텀뷰를 만드는 방법은 다양하다.

 

some VIew를 리턴하는 함수/연산 프로퍼티로 만들 수도 있고, 구조체로 만들수도 있다. 그럼 이렇게 만든 커스텀 뷰를 여러 개 띄울 때, 예를 들어 ForEach로 여러 개 요소를 반복적으로 나열했을 때 어떨까?

 

UIKit에서 비슷한 경우가 테이블뷰의 셀 재사용일 것 같은데, 사용자가 테이블 뷰를 스크롤하는 동안 셀의 재사용 메커니즘에 따라 셀이 재사용되며 새로운 요소들이 렌더링 되게 된다. 반면 SwiftUI에서는 눈에 보이지 않는 뷰에 대해서도 렌더링에 대해 우리가 직접 고려를 해주어야 한다.

 

 

 

 

📍하위뷰를 구성할 때 문제 상황 

 

아래 코드는 Vstack 안에 ForEach를 담아줌으로써 반복된 요소에 대한 리스트 뷰를 만들어주는 코드이다. 구조체로 만든 하위 뷰의 경우, 현재 뷰에서 보이는 요소의 개수와 상관없이 전체 데이터 요소의 개수만큼 하위뷰의 init과 body가 호출되었다.

 

struct MyStoreListView: View {
	
	// ...
    
    var body: some View {
        ScrollView{
            VStack{
                ForEach(vm.output.myStoreList, id: \.id) { myStore in
	                
	                // ... 
	                
                    StoreInfoHeaderView(
                        placeName: myStore.KakaoPaceName,
                        categoryText: categoryText,
                        addressName: myStore.addressName
                    )
                    .padding()
                    
                    Divider()
                        .foregroundColor(.gray)
                }
            }
        }
        .onAppear{
            // ...
        }
        
    }
}

 

struct StoreInfoHeaderView : View {
    var placeName : String
    var categoryText : String
    var distance : String? = nil
    var addressName : String
    
    init(placeName: String, categoryText: String, distance: String? = nil, addressName: String) {
        self.placeName = placeName
        self.categoryText = categoryText
        self.distance = distance
        self.addressName = addressName
        
        print("---init---")
    }
    
    var body: some View {
		    let _ = print("-body-")
        VStack{
					// ...
        }

    }
}

 

 

 

 

 

▶️ 그렇담 구조체 커스텀뷰가 아닌 함수나 연산 프로퍼티로 정의된 커스텀뷰의 경우에는?

아래 코드는 some View를 반환하는 함수로 뷰룰 만들어 하위뷰로 넣어주었다.  이 경우에도 마찬가지로 보이지 않는 scroll뷰의 요소까지 모두 로드되었다.

struct MyStoreListView: View {
	
	// ...
    
    var body: some View {
        ScrollView{
            VStack{
                ForEach(vm.output.myStoreList, id: \.id) { myStore in
	                
	                // ... 
	                
                    storeInfoHeaderView(
                        placeName: myStore.KakaoPaceName,
                        categoryText: categoryText,
                        addressName: myStore.addressName
                    )
                    .padding()
                    
                    Divider()
                        .foregroundColor(.gray)
                }
            }
        }
        .onAppear{
            // ...
        }
        
    }
}
    func storeInfoHeaderView(placeName: String, categoryText: String, distance: String? = nil, addressName: String) -> some View {
        let _ = print("---storeInfoHeaderView---", placeName)
        return VStack{
 			//...
        }
    }

 

 

 

보이지 않는 뷰까지 렌더링 되다는 것은 누가 봐도 비효율적인 뷰 렌더링이라고 판단할 수 있다. 그럼 이제 이걸 어떻게 개선할 수 있을까?

 

 

 

 

 

📍개선 방법

 

1️⃣  LazyVStack

forEach를 담기 위해 VSack을 쓰는 게 아니라 LazyVStack을 사용해서 개선해 볼 수 있다. 이렇게 하면 보이는 만큼 구조체 커스텀뷰의 init과 body가 호출된다.

struct MyStoreListView: View {
    
    // ...
    
    var body: some View {
        
        ScrollView{
            LazyVStack {
                ForEach(vm.output.myStoreList, id: \.id) { myStore in
                
	                //...
                    StoreInfoHeaderView(
                        placeName: myStore.KakaoPaceName,
                        categoryText: categoryText,
                        addressName: myStore.addressName
                    )
                    .padding()
                    
                    Divider()
                        .foregroundColor(.gray)
                        .padding(.horizontal)
                }
            }
            
        }
        .onAppear{
            // ... 
        }

        
    }
}

 

 

 

 

 

 

2️⃣ List

ScrollView, LazyVStack, ForEach 이 모든 걸 쓰지 않더라도 List를 사용하면, List 자체적으로 보이는 만큼만 뷰가 렌더링 되도록 최적화해 준다. 이건 직전 블로그 글인 뷰의 성능 개선(3) 에서도 작성한 부분! ( [SwiftUI] 뷰의 성능 개선(3) -Identifier의 중요성 )

struct MyStoreListView: View {

	 //...
    
    var body: some View {

        List(vm.output.myStoreList, id: \.self) { myStore in
        
	         //...
            StoreInfoHeaderView(
                placeName: myStore.KakaoPaceName,
                categoryText: categoryText,
                addressName: myStore.addressName
            )
            .padding(.vertical)
            
        }
        .listStyle(.plain)
        .onAppear{
            //...
        }
        
    }
}

 

 

 

이렇게 init이나 body가 호출되는걸 잘 고려해 주면 뷰렌더링을 지연시키는 것뿐만 아니라 init이나 body 안에 특정 작업이 있을 때 소비되는 리소스도 아낄 수 있다.

 

 

 

 


 

 

📍 네비게이션 되는 뷰를 구성할 때의 문제점 

 

만약 하위 뷰가 아니라 LavigationLink로 연결되는 뷰(LavigationLink 클로저 안에 있는 뷰)가 forEach안에 있다면 어떨까??

이 경우에도 위의 문제 코드처럼 VStack 내에 여러 뷰가 있다면 실제 뷰에 대한 init이 모두 호출된다(push 되지도 않았는데 init이 미리 호출된다는 의미!)

 

이게 왜 문제가 될까??

 

내비게이션이 넘어가는 뷰의 init에 네트워킹 같은 무거운 작업들이 있을 경우에는 필요이상의 작업들이 발생하게 된다. 이런 문제를 제외하고도 내비게이션 되었을 때의 뷰인데, 내비게이션도 전에 미리 초기화된다는 거 자체가 좀 찝찝하다. 그럼 이걸 개선하기 위해서는?

 

 

 

📍개선 방법

 

1️⃣  LazyVStack

위에서 하위뷰에 대해 개선했던 대로 LazyVStack으로 바꿔보면, 화면에 보이는 만큼의 뷰가 init되게 된다.

근데 여전히 push 되기 전의 뷰인데 init이 되는 것이 이상해 보이고, 만약 init에 네트워킹 코드 같은 리소스가 많이 드는 코드가 있다면 뷰가 넘어가기도 전에 필요 없는 작업이 실행될 것이다.

 

아얘 뷰의 init이 실행되지 않도록 어떤 조치를 취해보면 어떨까? 

 

 

2️⃣ NavigationLazyView라는 Wrapper 생성

실제 네비게이트 처리되는 뷰의 init과 body 실행이 지연되도록 wrapper를 만들어줄 수 있다.

struct NavigationLazyView<Content : View >: View {
    let closure : () -> Content
    
    init(_ closure: @autoclosure @escaping () -> Content) {
        print("🌸 NavigationLazyView init")
        self.closure = closure
    }
    
    var body: some View {
        closure()
    }
}
...
        ScrollView{
            VStack{
                ForEach(vm.output.myStoreList, id: \.id) { myStore in
                    // ...
                    NavigationLink{
                        NavigationLazyView{ //⭐️
                            StoreInfoHeaderView(
                                placeName: myStore.KakaoPaceName,
                                categoryText: categoryText,
                                addressName: myStore.addressName
                            )
                            .padding()
                        }
                    } label: {
                        // ...
                    }

                    
                    Divider()
                        .foregroundColor(.gray)
                        .padding(.horizontal)
                }
            }
        }
        .onAppear{
            // ...
        }
 ...

 

 

-> NavigationLazyView Init만 실행되고 실제 네비게이트 되는 뷰의 init은 실행되지 않는다. 여기서도 알 수 있듯, init이 호출되는 시점과 body가 호출되는 시점은 다른데 push 되기 전에는 NavigationLazyView Init만 실행되고 실제 뷰가 전환되는 시점에 NavigationLazyView의 body가 실행되면서 실제 뷰의 init과 body가 lazy 하게 실행될 수 있도록 하는 것!

 

 

 

3️⃣ NavigationLazyView + LazyVStack/List

화면에 보이는 만큼의 NavigationLazyView init을 실행함으로써 더욱 최적화를 하고 싶다면?? 

화면에 보이지 않는 뷰에 대한 렌더링을 지연시켜 주는 LazyVStack 혹은 List를 결합해서 사용하면 된다!

...
        ScrollView{
            LazyVStack{ //⭐️
                ForEach(vm.output.myStoreList, id: \.id) { myStore in
                    // ...
                    NavigationLink{
                        NavigationLazyView{ 
                            StoreInfoHeaderView(
                                placeName: myStore.KakaoPaceName,
                                categoryText: categoryText,
                                addressName: myStore.addressName
                            )
                            .padding()
                        }
                    } label: {
                        // ...
                    }

                    
                    Divider()
                        .foregroundColor(.gray)
                        .padding(.horizontal)
                }
            }
        }
        .onAppear{
            // ...
        }
 ...

 

 

 

 

 

 

 


 

성능에 대한 첫번째 글을 작성해보았는데, 역시 구현단이 아닌 성능을 생각하는 코드 구현은 생각도 많이 하고, 프로그래밍 언어와 사용하는 UI프레임워크 등이 어떻게 동작하는지에 대한 기반 지식이 있어야해서 어렵다.. 그래도 테스트 많이 해보면서 성장의 기회로 삼아야겠다! 성능에 대한 다음 글이 잘 작성되길 바라면서 이 글을 마무리해본다!