SwiftUI 뷰 업데이트를 반복하지 않는 방법
https://fatbobman.com/en/posts/avoid_repeated_calculations_of_swiftui_views/
How to Avoid Repeating SwiftUI View Updates | Fatbobman's Blog
This article will guide you in optimizing view calculations in SwiftUI. The methods include optimizing construction parameters, breaking down views, implementing Equatable, and controlling event sources. Understanding these principles is crucial for excell
fatbobman.com
해당 글의 한국어 번역글입니다 :)
최근 몇 년간 SwiftUI에 관한 많은 글과 책들이 나왔기 때문에, 개발자들은 "뷰는 상태의 함수(views are functions of state)"라는 기본 개념에 익숙해져야 합니다. 각 뷰는 자신에게 상응하는 상태를 가지고 있으며, 그 상태가 변경되면 SwiftUI가 해당 뷰의 body 값을 재계산합니다.
만약 뷰가 반응해서는 안 되는 상태에 반응하거나, 뷰의 상태가 포함되지 않아야 하는 멤버를 포함하고 있다면, SwiftUI에 의해 뷰의 불필요한 업데이트(중복된 계산)를 유발할 수 있습니다. 이러한 상황이 자주 발생하면, 애플리케이션의 상호작용 응답에 직접적인 영향을 미치거나 지연을 유발할 수 있습니다.
보통, 이러한 종류의 중복된 계산 행위를 "과도한 계산" 또는 "중복 계산"이라고 부릅니다. 이 글에서는 이와 유사한 상황이 발생하는 것을 줄이는(심지어 피하는) 방법을 소개하여 SwiftUI 애플리케이션의 전반적인 성능을 향상시키는 방법을 소개하겠습니다.
뷰 상태의 조합
뷰 업데이트를 주도하는 소스를 "진실의 원천(Source of Truth)"이라고 합니다. 이에는 다음과 같은 것들이 포함됩니다:
- @State 및 @StateObject와 같은 프로퍼티 래퍼를 사용하여 선언된 변수들
- View 프로토콜을 준수하는 뷰 타입의 생성 매개변수
- onReceive와 같은 이벤트 소스
하나의 뷰는 여러 종류의 진실의 원천을 포함할 수 있으며, 이들이 함께 뷰 상태를 구성합니다 (뷰 상태는 복합적입니다).
다음 섹션에서는 각 유형의 Source of Truth의 구현 원리와 주도 메커니즘의 차이를 기반으로, 상응하는 최적화 기술을 분류하고 소개할 것입니다.
DynamicProperty 프로토콜을 준수하는 프로퍼티 래퍼
거의 모든 SwiftUI 사용자들은 SwiftUI를 처음 배우는 첫 날에 @State 및 @Binding과 같이 뷰 업데이트를 트리거하는 프로퍼티 래퍼를 마주하게 될 것입니다.
SwiftUI가 계속 발전함에 따라 이러한 프로퍼티 래퍼가 더 많이 소개되고 있습니다. SwiftUI 4.0을 기준으로 알려진 것들로는 @AccessibilityFocusState, @AppStorage, @Binding, @Environment, @EnvironmentObject, @FetchRequest, @FocusState, @FocusedBinding, @FocusedObject, @FocusedValue, @GestureState, @NSApplicationDelegateAdaptor, @Namespace, @ObservadObject, @ScaledMetric, @SceneStorage, @SectionedFetchRequest, @State, @StateObject, @UIApplicationDelegateAdaptor, @WKApplicationDelegateAdaptor 및 @WKExtentsionDelegateAdaptor 등이 있습니다. 변수를 진실의 원천으로 만들 수 있게 하는 모든 프로퍼티 래퍼는 한 가지 특징을 공유합니다 - DynamicProperty 프로토콜을 준수
따라서, 이러한 종류의 진실의 원천으로 인한 반복적인 계산을 최적화하는 데 DynamicProperty 프로토콜이 어떻게 작동하는지를 이해하는 것이 특히 중요합니다.
DynamicProperty가 작동하는 방식
애플은 DynamicProperty 프로토콜에 대한 많은 정보를 제공하지 않습니다. 유일하게 공개된 프로토콜 메서드는 "update"이며, 완전한 프로토콜 요구 사항은 다음과 같습니다:
public protocol DynamicProperty {
static func _makeProperty<V>(in buffer: inout _DynamicPropertyBuffer, container: _GraphValue<V>, fieldOffset: Int, inputs: inout _GraphInputs)
static var _propertyBehaviors: UInt32 { get }
mutating func update()
}
_makeProperty 메서드는 전체 프로토콜의 핵심입니다. _makeProperty 메서드를 통해 SwiftUI는 뷰가 뷰 트리에 로드될 때 필요한 데이터(값, 메서드, 참조 등)를 SwiftUI의 관리되는 데이터 풀(managed data fool)에 저장하고, AttributeGraph에서 뷰를 진실의 원천과 연결하여 뷰가 해당 변경에 응답할 수 있도록 합니다(SwiftUI 데이터 풀의 데이터가 변경될 때 뷰를 업데이트).
@State를 예로 들어:
@propertyWrapper public struct State<Value> : DynamicProperty {
internal var _value: Value
internal var _location: SwiftUI.AnyLocation<Value>? // SwiftUI의 관리되는 데이터 풀에 데이터에 대한 참조
public init(wrappedValue value: Value)
public init(initialValue value: Value) {
_value = value // 인스턴스를 생성할 때에는 초기값만 일시적으로 저장됩니다.
}
public var wrappedValue: Value {
get // guard let _location else { return _value } ...
nonmutating set // _location이 가리키는 데이터만 수정할 수 있습니다.
}
public var projectedValue: SwiftUI.Binding<Value> {
get
}
// 이 메서드는 뷰가 뷰 트리에 로드될 때 호출되어 연결을 완료합니다.
public static func _makeProperty<V>(in buffer: inout _DynamicPropertyBuffer, container: _GraphValue<V>, fieldOffset: Int, inputs: inout _GraphInputs)
}
- State를 초기화할 때 initialValue는 State 인스턴스의 내부 속성인 _value에만 저장됩니다. 이 시점에서 State에 의해 래핑된 값은 SwiftUI의 관리되는 데이터 풀에 저장되지 않으며, SwiftUI는 아직 이를 속성 그래프의 뷰와 연결하여 진실의 원천으로 지정하지 않았습니다.
- SwiftUI가 뷰를 뷰 트리에 로드할 때, _makeProperty를 호출하여 데이터를 관리되는 데이터 풀에 저장하고 속성 그래프에서 연결 작업을 완료하고, 관리되는 데이터 풀에 데이터에 대한 참조를 _location에 저장합니다 (AnyLocation은 AnyLocationBase의 하위 클래스이며 참조 유형입니다). wrappedValue와 projectedValue의 get 및 set 메서드는 모두 _location에 대한 작업입니다. SwiftUI가 뷰 트리에서 뷰를 제거할 때, 관련된 SwiftUI 데이터 풀도 정리됩니다. 결과적으로, State로 래핑된 변수의 수명은 뷰의 수명과 정확히 동일하게 됩니다. 따라서 해당 값이 변경될 때 SwiftUI는 해당 뷰를 자동으로 업데이트(재계산)합니다. SwiftUI에서 많은 사람들을 괴롭힌 문제 중 하나는 뷰의 생성자에서 State로 래핑된 변수의 값을 변경할 수 없는 이유입니다. 위의 과정을 이해하면 이 문제에 대한 답을 얻을 수 있습니다.
struct TestView: View {
@State private var number: Int = 10
init(number: Int) {
self.number = 11 // 변경이 효과가 없습니다.
}
var body: some View {
Text("\(number)") // 첫 번째 실행 시, 10이 표시됩니다.
}
}
생성자에서 self.number = 11과 같은 값을 할당할 때, 뷰가 아직 로드되지 않았으므로 _location이 nil이며, 따라서 할당이 wrappedValue set 작업에 영향을 주지 않습니다.
@StateObject와 같은 프로퍼티 래퍼는 참조 유형을 위해 설계되었습니다. SwiftUI는 해당 프로퍼티로 래핑된 객체 인스턴스(ObservableObject 프로토콜을 준수하는)를 뷰와 연결합니다. objectWillChange(ObjectWillChangePublisher) 퍼블리셔가 데이터를 보낼 때, 뷰가 업데이트됩니다. objectWillChange.send를 통해 수행된 모든 작업은 인스턴스 내 속성의 내용이 수정되었는지 여부에 관계없이 뷰를 새로 고칩니다.
@propertyWrapper public struct StateObject<ObjectType> : DynamicProperty where ObjectType : ObservableObject {
internal enum Storage { // 내부에서 정의된 열거형을 사용하여 뷰가 로드되었는지와 데이터가 데이터 풀에 의해 관리되었는지를 나타낼 수 있습니다.
case initially(() -> ObjectType)
case object(ObservedObject<ObjectType>)
}
internal var storage: StateObject<ObjectType>.Storage
public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType) {
storage = .initially(thunk) // 초기화, 뷰 아직 로드되지 않음.
}
@_Concurrency.MainActor(unsafe) public var wrappedValue: ObjectType {
get
}
@_Concurrency.MainActor(unsafe) public var projectedValue: SwiftUI.ObservedObject<ObjectType>.Wrapper {
get
}
// DynamicProperty에서 요구하는 메서드에서 인스턴스를 관리되는 데이터 풀에 저장하고 뷰를 관리되는 인스턴스의 objectWillChange에 연결하는 것을 구현합니다.
public static func _makeProperty<V>(in buffer: inout _DynamicPropertyBuffer, container: _GraphValue<V>, fieldOffset: Int, inputs: inout _GraphInputs)
}
@ObservedObject와 @StateObject의 가장 큰 차이점은 ObservedObject가 SwiftUI 관리 데이터 풀에 객체 인스턴스에 대한 참조를 저장하지 않는다는 것입니다 (StateObject는 관리되는 데이터 풀에 인스턴스를 저장합니다). 대신 ObservedObject는 뷰 유형 인스턴스의 참조 객체의 objectWillChange와 뷰를 연결합니다.
@ObservedObject var store = Store() // 뷰 유형 인스턴스가 생성될 때마다 새로운 Store 인스턴스가 생성됩니다.
SwiftUI는 뷰 유형 인스턴스를 예측할 수 없는 시점에 생성합니다 (뷰가 로드되는 시점이 아닐 수도 있음). 따라서 위의 코드를 사용하여 @ObservedObject가 불안정한 참조 인스턴스를 가리키도록 할 때마다 새로운 참조 객체가 생성되므로, 어떤 이상한 현상을 만나기 쉽습니다.
불필요한 선언 회피
현재 뷰 외부에서 수정할 수 있는 모든 진실의 원천(DynamicProperty 프로토콜을 준수하는 프로퍼티 래퍼)은 뷰 유형에서 선언될 때, 해당 뷰가 뷰 body에 사용되는 여부와 관계없이 새로 고침 신호를 제공할 때마다 현재 뷰가 새로 고쳐집니다.
예를 들어, 다음 코드에서:
struct EnvObjectDemoView:View{
@EnvironmentObject var store: Store
var body: some View{
Text("abc")
}
}
현재 뷰에서 스토어 인스턴스의 속성 또는 메서드를 호출하지 않더라도, 해당 인스턴스의 objectWillChange.send 메서드가 호출될 때마다 (예: @Published로 래핑된 속성이 수정될 때), 해당 인스턴스와 연결된 모든 뷰(현재 뷰 포함)가 새로 고쳐집니다.
@ObservedObject와 @Environment에서도 유사한 상황이 발생할 수 있습니다.
struct MyEnvKey: EnvironmentKey {
static var defaultValue = 10
}
extension EnvironmentValues {
var myValue: Int {
get { self[MyEnvKey.self] }
set { self[MyEnvKey.self] = newValue }
}
}
struct EnvDemo: View {
@State var i = 100
var body: some View {
VStack {
VStack {
EnvSubView()
}
.environment(\.myValue, i)
Button("change") {
i = Int.random(in: 0...100)
}
}
}
}
struct EnvSubView: View {
@Environment(\.myValue) var myValue // 선언되었지만 body에서 사용되지 않은 경우
var body: some View {
let _ = print("sub view update")
Text("Sub View")
}
}
EnvSubView의 body에서 myValue가 사용되지 않더라도, 조상 뷰가 EnvironmentValues에서 myValue를 수정하면 여전히 새로 고쳐집니다.
코드를 확인하고 사용되지 않는 선언을 제거하여 이 접근으로 인한 중복 계산을 피할 수 있습니다.
다른 제안 사항
- 뷰 계층을 점핑할 때는 Environment 또는 EnvironmentObject을 사용을 고려해보세요.
- 느슨하게 결합된 상태 관계의 경우, 동일한 뷰 계층에 여러 EnvironmentObject를 주입하여 상태를 분리하는 것을 고려해보세요.
- 적절한 시나리오에서는 @Published 대신 objectWillChange.send를 사용할 수 있습니다.
- 상태를 분할하고 뷰 갱신 가능성을 줄이기 위해 third-party 라이브러리를 사용하는 것도 고려해보세요.
- 중복 계산을 완전히 피하는 것을 추구하지 마세요; 의존성 주입 편의성, 애플리케이션 성능 및 테스트 난이도 사이의 균형을 유지하세요.
- 완벽한 해결책은 없습니다. 인기 있는 프로젝트인 TCA와 같은 경우라도, 높은 세분화 및 다중 수준 상태 분할에 직면했을 때 명백한 성능 병목 현상이 발생할 수 있습니다.
뷰 생성 매개변수
SwiftUI 뷰의 반복 계산 동작을 개선하려는 시도에서, 개발자들은 종종 DynamicProperty 프로토콜을 준수하는 프로퍼티 래퍼에 집중합니다. 그러나 뷰 유형의 생성 매개변수를 최적화하는 것이 때로는 더 큰 이득을 줄 수 있습니다.
SwiftUI는 뷰 유형의 생성 매개변수를 진실의 원천(Source of Truth)으로 취급합니다. DynamicProperty 프로토콜을 준수하는 프로퍼티 래퍼가 뷰 업데이트를 적극적으로 주도하는 메커니즘과는 달리, SwiftUI에서 뷰를 업데이트할 때, SwiftUI는 자식 뷰의 인스턴스가 변경되었는지 확인하여 해당 자식 뷰를 업데이트할지 여부를 결정합니다 (대부분은 생성 매개변수 값의 변경으로 인한 것입니다).
예를 들어, SwiftUI가 ContentView를 업데이트할 때, SubView의 생성 매개변수(name, age)의 내용이 변경되면, SwiftUI는 SubView의 body를 계산하여 (뷰를 업데이트하여) 다시 렌더링합니다.
struct SubView {
let name: String
let age: Int
var body: some View {
VStack{
Text(name)
Text("\(age)")
}
}
}
struct ContentView {
var body: some View{
SubView(name: "fat" , age: 99)
}
}
간단하고 대략적이며 효율적인 비교 전략
뷰가 존재하는 동안 SwiftUI는 보통 뷰 유형의 인스턴스를 여러 번 생성한다는 것을 알고있습니다. 인스턴스를 생성하는 이러한 작업에서 대부분의 목적은 뷰 유형의 인스턴스가 변경되었는지 확인하는 것입니다 (대부분의 경우, 변경은 생성 매개변수의 값 변경으로 인한 것입니다).
- 새로운 인스턴스를 생성합니다.
- 새로운 인스턴스를 현재 SwiftUI가 사용 중인 인스턴스와 비교합니다.
- 인스턴스가 변경되었다면, 현재 인스턴스를 새 인스턴스로 대체하고, 인스턴스의 body를 다시 계산하여 이전의 뷰 값을 새로운 뷰 값으로 대체합니다.
- 엔터티의 대체로 인해 뷰의 존재는 변경되지 않습니다.
SwiftUI는 뷰 유형이 Equatable 프로토콜을 준수할 필요가 없으므로, 매개변수나 속성을 기반으로 하는 것이 아니라 간단하고 대략적이지만 매우 효율적인 블록 기반 비교 작업을 채택합니다.
비교 결과는 두 인스턴스가 다른지 여부만을 증명할 뿐, SwiftUI는 이 차이가 body의 값이 변경될지 여부를 결정할 수 없습니다. 따라서 SwiftUI는 body를 맹목적으로 다시 계산할 것입니다.
중복된 계산을 피하기 위해 생성 매개변수의 설계를 최적화하여 실제로 업데이트가 필요할 때에만 인스턴스가 변경됩니다.
뷰 유형의 인스턴스를 생성하는 작업이 극도로 빈번하기 때문에, 뷰 유형의 생성자에서 시스템에 부담을 주는 작업을 수행하지 마십시오. 또한, DynamicProperty 프로토콜을 준수하는 래퍼를 사용하지 않고 뷰의 생성자에서 불안정한 값을 (예: 무작위 값) 설정하지 마십시오. 불안정한 값은 각 생성된 인스턴스가 다르게 되어 불필요한 새로고침이 발생합니다.
조각조각 분해
위의 비교 작업은 뷰 유형의 인스턴스 내에서 수행됩니다. 따라서 뷰를 여러 개의 작은 뷰(뷰 구조)로 분할하면 더 세분화된 비교 결과를 얻을 수 있으며 body의 일부 계산을 줄일 수 있습니다.
struct Student {
var name: String
var age: Int
}
struct RootView: View {
@State var student = Student(name: "fat", age: 88)
var body: some View {
VStack {
StudentNameView(student: student)
StudentAgeView(student: student)
Button("random age") {
student.age = Int.random(in: 0...99)
}
}
}
}
// 분할하여 작은 뷰로 나누기
struct StudentNameView: View {
let student: Student
var body: some View {
let _ = Self._printChanges()
Text(student.name)
}
}
struct StudentAgeView: View {
let student: Student
var body: some View {
let _ = Self._printChanges()
Text(student.age,format: .number)
}
}
위의 코드는 학생의 디스플레이 서브뷰를 시각화하는데 사용되지만, 생성 매개변수의 설계 문제로 인해 중복된 계산을 줄이지 못했습니다.
'Random age' 버튼을 클릭하여 나이 속성을 수정한 후에도 StudentNameView에서는 나이 속성이 사용되지 않더라도 SwiftUI는 여전히 StudentNameView와 StudentAgeView 모두를 업데이트합니다.
이는 우리가 서브뷰에 Student 타입을 매개변수로 전달하기 때문입니다. SwiftUI가 인스턴스를 비교할 때, 서브뷰에서 사용되는 학생의 어떤 속성인지에 관심이 없습니다. Student가 변경되는 한, 다시 계산될 것입니다. 이 문제를 해결하기 위해 우리는 서브뷰에 전달되는 매개변수의 타입과 내용을 조정하고, 서브뷰에서 필요한 데이터만 전달해야 합니다.
struct RootView: View {
@State var student = Student(name: "fat", age: 88)
var body: some View {
VStack {
StudentNameView(name: student.name) // 필요한 데이터만 전달
StudentAgeView(age:student.age)
Button("random age"){
student.age = Int.random(in: 0...99)
}
}
}
}
struct StudentNameView: View {
let name: String // 필요한 데이터
var body: some View {
let _ = Self._printChanges()
Text(name)
}
}
struct StudentAgeView: View {
let age: Int
var body: some View{
let _ = Self._printChanges()
Text(age,format: .number)
}
}
위의 수정 후, 이름 속성이 변경될 때에만 StudentNameView가 업데이트됩니다. 마찬가지로, 나이가 변경될 때에만 StudentAgeView가 업데이트됩니다.
뷰를 Equatable 프로토콜에 준수하여 비교 규칙을 사용자 정의하기
아마도 어떤 이유로 위의 방법을 사용하여 생성 매개변수를 최적화할 수 없을 것입니다. SwiftUI는 비교 규칙을 조정하여 동일한 결과를 얻는 또 다른 방법을 제공합니다.
- 뷰를 Equatable 프로토콜에 준수하도록 만듭니다.
- 뷰의 비교 규칙을 사용자 정의합니다.
이전 버전의 SwiftUI에서는 Equatable 프로토콜을 준수하는 뷰에 사용자 정의 비교 규칙을 활성화하기 위해 EquatableView로 뷰를 래핑해야 했습니다. 최근 버전에서는 이를 요구하지 않습니다.
위의 코드 예제를 사용:
struct RootView: View {
@State var student = Student(name: "fat", age: 88)
var body: some View {
VStack {
StudentNameView(student: student)
StudentAgeView(student: student)
Button("random age") {
student.age = Int.random(in: 0...99)
}
}
}
}
struct StudentNameView: View, Equatable {
let student: Student
var body: some View {
let _ = Self._printChanges()
Text(student.name)
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.student.name == rhs.student.name
}
}
struct StudentAgeView: View, Equatable {
let student: Student
var body: some View {
let _ = Self._printChanges()
Text(student.age, format: .number)
}
static func== (lhs: Self, rhs: Self) -> Bool {
lhs.student.age == rhs.student.age
}
}
이 방법은 뷰 유형의 인스턴스 비교에만 영향을 미치며, DynamicProperty 프로토콜을 준수하는 프로퍼티 래퍼에 의해 생성된 새로고침에는 영향을 미치지 않습니다.
Closure - 쉽게 간과하는 해결법
생성 매개변수의 타입이 함수인 경우, 조금의 부주의함이 중복 계산으로 이어질 수 있습니다.
예를 들어, 다음 코드에서:
struct ClosureDemo: View {
@StateObject var store = MyStore()
var body: some View {
VStack {
if let currentID = store.selection {
Text("Current ID: \(currentID)")
}
List {
ForEach(0..<100) { i in
CellView(id: i) { store.sendID(i) } // 서브뷰의 버튼 액션을 후행 클로저를 사용하여 설정
}
}
.listStyle(.plain)
}
}
}
struct CellView: View {
let id: Int
var action: () -> Void
init(id: Int, action: @escaping () -> Void) {
self.id = id
self.action = action
}
var body: some View {
VStack {
let _ = print("update \(id)")
Button("ID: \(id)") {
action()
}
}
}
}
class MyStore: ObservableObject {
@Published var selection:Int?
func sendID(_ id: Int) {
self.selection = id
}
}
CellView 뷰에서 버튼을 클릭하면, 모든 CellView 뷰(현재 목록 표시 영역)가 재계산됩니다.
이는 처음 간단히 봤을 때, CellView에서 업데이트를 유발할 Source of Truth를 사용하지 않았기 때문입니다. (뷰가 업데이트 되지 않을 것처럼 보입니다.) 그러나 우리가 스토어를 클로저에 배치했기 때문에, 버튼을 클릭하면 스토어가 변경되어, SwiftUI가 CellView의 인스턴스를 비교할 때 변경을 인식하게 됩니다.
CellView(id: i) { store.sendID(i) }
이 문제를 해결하는 두 가지 방법이 있습니다:
- CellView를 Equatable 프로토콜에 준수하도록 만들고, action 매개변수를 비교하지 않도록합니다.
struct CellView: View, Equatable {
let id: Int
var action: () -> Void
init(id: Int, action: @escaping () -> Void) {
self.id = id
self.action = action
}
var body: some View {
VStack {
let _ = print("update \(id)")
Button("ID: \(id)") {
action()
}
}
}
static func == (lhs: Self, rhs: Self) -> Bool { // CellView를 Equatable 프로토콜에 준수하도록 만들고, action 매개변수를 비교하지 않도록합니다.
lhs.id == rhs.id
}
}
ForEach(0..<100) { i in
CellView(id: i) { store.sendID(i) }
}
- 또 다른 방법은 생성자 매개변수에서 함수 정의를 수정하여 CellView에서 store를 제외합니다.
struct CellView: View {
let id: Int
var action: (Int) -> Void // 함수 정의 수정
init(id: Int, action: @escaping (Int) -> Void) {
self.id = id
self.action = action
}
var body: some View {
VStack {
let _ = print("update \(id)")
Button("ID: \(id)") {
action(id)
}
}
}
}
ForEach(0..<100) { i in
CellView(id: i, action: store.sendID) // store를 제외하고 store에서 sendID 메서드를 직접 전달합니다.
}
이벤트 소스
SwiftUI 라이프사이클로 완전히 전환하기 위해, Apple은 뷰 내부에서 이벤트를 직접 처리할 수 있는 일련의 뷰 수정자(modifiers)를 제공했습니다. 예를 들어, onReceive, onChange, onOpenURL, onContinueUserActivity 등입니다. 이러한 트리거는 이벤트 소스로 불리며, 뷰의 상태 구성 요소인 Source of Truth로도 간주됩니다.
이러한 트리거들은 뷰 수정자의 형태로 존재하기 때문에, 그들의 라이프사이클은 연관된 뷰의 생존 기간과 완전히 일치합니다. 트리거가 이벤트를 수신하면, 현재 뷰가 다른 상태를 변경하든지 상관없이 현재 뷰가 업데이트됩니다. 따라서 이벤트 소스로 인한 중복 계산을 줄이기 위해 다음과 같은 최적화 아이디어를 고려할 수 있습니다:
- 라이프사이클을 제어
이벤트를 처리해야 할 때에만 연관된 뷰를 로드하고, 트리거의 라이프사이클을 제어하기 위해 연관된 뷰의 생존 기간을 사용하세요. - 영향 범위 축소
트리거를 위한 별도의 뷰를 생성하여 뷰 업데이트에 미치는 영향을 최소화하세요.
struct EventSourceTest: View {
@State private var enable = false
var body: some View {
VStack {
let _ = Self._printChanges()
Button(enable ? "Stop" : "Start") {
enable.toggle()
}
TimeView(enable: enable) // 별도의 뷰를 사용하면 onReceive가 TimeView만 업데이트하도록 할 수 있습니다.
}
}
}
struct TimeView:View{
let enable:Bool
@State private var timestamp = Date.now
var body: some View{
let _ = Self._printChanges()
Text(timestamp, format: .dateTime.hour(.twoDigits(amPM: .abbreviated)).minute(.twoDigits).second(.twoDigits))
.background(
Group {
if enable { // 필요할 때에만 트리거를 로드
Color.clear
.task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1000000000)
NotificationCenter.default.post(name: .test, object: Date())
}
}
.onReceive(NotificationCenter.default.publisher(for: .test)) { notification in
if let date = notification.object as? Date {
timestamp = date
}
}
}
}
)
}
}
extension Notification.Name {
static let test = Notification.Name("test")
}
SwiftUI는 클로저를 주 스레드에서 실행합니다. 클로저 내부의 작업이 비용이 많이 든다면 클로저를 백그라운드 큐로 보내는 것을 고려해야 합니다.
요약
이 글은 SwiftUI에서 뷰를 다시 계산하는 것을 피하는 몇 가지 기술에 대해 설명합니다. 현재 문제를 해결할 수 있는 방법을 찾는 것 외에도, 이러한 기술의 원리에 집중하길 바랍니다.