SwiftUI

SwiftUI의 onChange 사용 시 주의할 부분

GREEN.1229 2023. 9. 5. 09:49

안녕하세요. 그린입니다 🍏

이번 포스팅에서는 SwiftUI의 onChange 사용 구현 시 주의할 부분에 대해 한번 알아보려고 합니다 🙋🏻

 

우선, SwiftUI에서는 뷰에서의 프로퍼티 변화를 계속 감지하고 있다 변화가 일어나면 구현된 사항을 반영해주는 onChange 메서드가 있습니다.

 

iOS 17 이전에서는 아래 onChange 메서드를 이용하게 되고,

 

onChange(of:perform:) | Apple Developer Documentation

Adds an action to perform when the given value changes.

developer.apple.com

 

iOS 17 이후부터는 위 onChange 메서드가 deprecated되어 아래 onChange 메서드를 이용해야 합니다.

 

onChange(of:initial:_:) | Apple Developer Documentation

Adds a modifier for this view that fires an action when a specific value changes.

developer.apple.com

 

차이는 초기 값 파라미터의 여부입니다.

 

이번 핵심은 17 이전과 이후를 다루는것이 아닌 onChange 사용 시 잘못된 사용으로 CPU 과도 성능 부하에 대해 알아보고 한번 우회해보겠습니다 🕺🏻


onChange가 없는 일반적인 경우

우선 해당 메서드를 사용하지 않는 간단한 아래와 같은 코드가 있습니다.

 

struct ContentView: View {
  let colors: [Color] = (0..<100).map {
    _ in Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1))
  }
  @State var isScrolling: Bool = false
  
  var body: some View {
    VStack(spacing: 10) {
      Text(isScrolling ? "진행중" : "종료")
        .font(.title)
      
      ScrollView {
        VStack(spacing: 10) {
          ForEach(colors, id:\.self) { color in
            Rectangle()
              .fill(color)
              .frame(height: 50)
              .cornerRadius(10)
              .padding(.horizontal, 10)
          }
        }
      }
    }
  }
}

 

단순히 100개의 서로 다른 컬러의 Rectangle을 스크롤로 보여주고 스크롤될 수 있는것이죠.

이럴때 사실 Instrument로 CPU 사용량을 분석하며 스크롤을 마구마구 해줘도 아래와 같이 CPU를 크게 잡아먹는 일이 없습니다.

 

 

그런데 문제는 여기에 onChange가 들어가며 로직을 넣었을때 입니다.


onChange 사용으로 문제되는 경우

자, 그럼 다시 위 코드에서 구현 안된 isScrolling 값의 변화를 onChange로 한번 구현해보겠습니다.

 

struct ContentView: View {
  let colors: [Color] = (0..<100).map {
    _ in Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1))
  }
  @State var isScrolling: Bool = false
  
  var body: some View {
    VStack(spacing: 10) {
      Text(isScrolling ? "진행중" : "종료")
        .font(.title)
      
      ScrollView {
        VStack(spacing: 10) {
          ForEach(colors, id:\.self) { color in
            Rectangle()
              .fill(color)
              .frame(height: 50)
              .cornerRadius(10)
              .padding(.horizontal, 10)
          }
        }
        .background(
          GeometryReader { geometry in
            Color.clear
              .onChange(of: geometry.frame(in: .global)) { geometry in
                let scrollViewEnd = geometry.maxY
                let screenHeight = UIScreen.main.bounds.height
                isScrolling = screenHeight < scrollViewEnd
              }
          }
        )
      }
    }
  }
}

 

요런 코드가 있을때 살펴보면, 스크롤 시 마다 현재 위치 및 사이즈를 GeometryReader로 판별하고 이와 디바이스의 height를 비교하여 만약 스크롤이 마지막 사각형까지 왔다면, 즉 끝까지 내려왔다면 isScrolling 값을 true로 주어 현재 스크롤 상태 텍스트의 문구를 종료로 나타내도록 해줬습니다.

 

동일하게 동작시킬때, CPU 사용량은 어떻게 될까요?

 

 

보시면, 스크롤 시 기존과 달리 CPU 사용량이 3배 정도 증가하는것을 볼 수 있습니다.

물론 코드와 로직이 들어갔다고 하지만 너무 많은 사용량을 차지합니다.

 

이럴 경우, 현재는 onChange로 감지하는것이 하나지만 추후 여러개가 되거나 또는 뷰가 더 복잡하게 들어갈 수록 CPU 사용량은 커지게 되죠.

 

뷰를 나누는것도 한계가 있기에 왜 이 onChange로 인해 사용량이 뻥 튀겨지는지 알아보고 해결해야할 필요가 있습니다.


왜 그런걸까?

우선 원인은 onChange 내 구현 로직에 있습니다.

현재 onChange 내에서는 스크롤의 변화가 있을때마다 isScrolling값에 True나 False 값을 계속 대입하는 계산 로직이 들어가 있습니다.

 

즉, 현재 상태가 True여도 True를 계속 새로 넣어주게 되는것이죠.

 

그렇기에 스크롤이 일어날때 1초에도 수십번씩 이 로직이 들어가고 CPU 사용을 급격하게 잡아먹게 됩니다.

 

그런데 신기한건, 실 디바이스에서는 이렇게 CPU 사용량이 뻥 튀어지지만 실제 Xcode의 시뮬레이터로 확인하면 CPU 그래프가 평온하다는것입니다.

 

이는, 시뮬레이터는 macOS의 자원을 사용하기에 iOS의 자원을 사용하는 디바이스 실제 기기보다 더 이를 성능적으로 처리하기에 CPU가 출렁되지 않는것 같습니다 🥲

 

그럼 이걸 어떻게 해결해보면 좋을까요?

 

우선 하나의 방법은 대입하는 로직을 최소한으로 줄여 해결하는 방법입니다.


1️⃣ 해결 방안 

코드로 보시죠.

 

struct ContentView: View {
  let colors: [Color] = (0..<100).map {
    _ in Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1))
  }
  @State var isScrolling: Bool = false
  
  var body: some View {
    VStack(spacing: 10) {
      Text(isScrolling ? "진행중" : "종료")
        .font(.title)
      
      ScrollView {
        VStack(spacing: 10) {
          ForEach(colors, id:\.self) { color in
            Rectangle()
              .fill(color)
              .frame(height: 50)
              .cornerRadius(10)
              .padding(.horizontal, 10)
          }
        }
        .background(
          GeometryReader { geometry in
            Color.clear
              .onChange(of: geometry.frame(in: .global)) { geometry in
                let scrollViewEnd = geometry.maxY
                let screenHeight = UIScreen.main.bounds.height
                
                // 🙋🏻 변화된 부분
                if !isScrolling && screenHeight < scrollViewEnd {
                  isScrolling = true
                } else if isScrolling && screenHeight >= scrollViewEnd {
                  isScrolling = false
                }
              }
          }
        )
      }
    }
  }
}

 

즉, 현재 상태 값이 동일하고 조건에 부합하지 않는다면 로직을 타지 않게 구성해주는것입니다.

 

이렇게 된다면 CPU 사용량은 아래와 같습니다.

 

훨씬 낮아진것을 확인할 수 있어요!

 

onChange로 계속 감지하나 실제 값을 주입하는 로직을 타지 않게 되니 CPU 사용량을 줄일 수 있죠.

 

두번째 방법은 Combine의 debounce를 이용하는것입니다.


2️⃣ 해결 방안

코드로 볼까요?

 

import SwiftUI
import Combine

struct ContentView: View {
  let colors: [Color] = (0..<100).map { _ in Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)) }
  
  @State private var isScrolling = false
  @State private var cancellableSet = Set<AnyCancellable>()
  @State private var scrollSubject = CurrentValueSubject<Bool, Never>(false)
  
  var body: some View {
    VStack(spacing : 10) {
      Text(isScrolling ? "진행중" : "종료")
        .font(.title)
      
      ScrollView {
        VStack(spacing : 10) {
          ForEach(colors, id:\.self) { color in
            Rectangle()
              .fill(color)
              .frame(height : 50)
              .cornerRadius(10)
              .padding(.horizontal, 10)
          }
        }
        .background(
          GeometryReader { geometry in
            Color.clear
              .onChange(of : geometry.frame(in:.global)) { geometry in
                let scrollViewEnd = geometry.maxY
                let screenHeight = UIScreen.main.bounds.height
                
                scrollSubject.send(screenHeight < scrollViewEnd)
              }
          }
        )
        
      }
      .onAppear {
        scrollSubject
          .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
          .sink { self.isScrolling = $0 }
          .store(in: &cancellableSet)
      }
    }
  }
}

 

익숙한 Combine을 가져왔씁니다.

간단히 보기 위해 별도 뷰모델을 사용하지 않고 바로 뷰에서 사용하도록 가져왔어요.

 

즉, onChange가 발생하면 subject에 screenHeight와 scrollViewEnd 값을 판단해 send 해줍니다.

그런데 다른점은 debounce를 500 밀리세컨즈로 걸어줬기에 500 밀리세컨즈동안 값의 변경이 없어야지 isScrolling 값이 변경됩니다.

즉, 계속 스크롤을 연속으로 한다하더라도 값의 변화없이 로직을 타지 않죠.

 

그럼 한번 이것도 CPU 사용량을 봐볼까요?

 

 

훨씬 월등히 줄어들었죠?

 

다만, debounce는 스크롤이 일어날때마다 바로바로 상태값이 변경되지 않기에 만약 정말 0.1초라도 실시간으로 반영되어야 한다면 적합하지 않습니다.

 

또, 위 debounce 타임을 너무 적게 가져가도 사용자 인터랙션보다 적어지게 되어 계속 변경 로직을 탑니다.

 

그렇기에 현재 찾아본 해결 방안 1과 2를 비교해봐도 좋고 다른 방법을 사용해봐도 좋을것 같습니다!


마무리

가장 핵심적인건 onChange를 사용하되 성능을 생각하여 간단한 코드라도 다시 한번 돌아봐야 한다고 느꼈습니다 🥲