값에 대한 의존성은 있지만 View 자체는 변하지 않는 경우가 있다. 값의 변경만으로 동일한 View를 다시 렌더링하면 앱의 성능이 저하될 것이다. SwiftUI equatable 수정자의 올바른 사용법을 알아보자. Xcode 12.4 이후로 알쏭달쏭했던 View의 == 연산자 호출 조건에 대해서도 정리했으니 도움이 될 것이다.
@frozen struct EquatableView<Content> : View where Content : Equatable, Content : View
래핑된 View의 새 값이 이전 값과 같을 경우 해당 View가 업데이트되는 것을 방지한다.
EquatableView
는 Equatable
프로토콜을 준수하는 Content
(View)를 래핑하는 컨테이너 View이다. SwiftUI는 EquatableView
에 업데이트 요청이 들어왔을 때 Content
의 ==
연산자로 동등 비교를 진행하여 필요없는 업데이트를 방지한다.
주의:
EquatableView
로 래핑되지 않은 View는 동등 비교시 사용자 정의된==
연산자 함수를 사용하지 않는다.
EquatableView(content: MyView())
func equatable() -> EquatableView<Self>
View 프로토콜에 정의된 equatable
수정자는 EquatableView
를 편리하게 사용할 수 있도록, 일반 View를 EquatableView
로 래핑해 반환한다. 물론 equatable
을 사용하는 View는 Equatable
프로토콜을 준수해야만 한다.
MyView().equatable()
protocol Equatable
Equatable
프로토콜을 준수하려면 다음 함수를 필수적으로 구현해야만 한다.
static func == (lhs: Self, rhs: Self) -> Bool { ... }
EquatableView
는 ==
함수만을 사용하므로 다른 함수들을 추가적으로 구현할 필요는 없다.
struct ContentView: View {
@State var text: String
var body: some View {
MyView(text: text)
.equatable()
// EquatableView(content: MyView())
// 기능상 동일
}
}
struct MyView: View, Equatable {
var text: String
var body: some View {
Text(text)
}
static func == (lhs: MyView, rhs: MyView) -> Bool {
print("== called")
return lhs.text == rhs.text
}
}
글이 여기에서 끝난다면 좋았겠지만, Xcode 12.4 전후로 View의 ==
연산자 호출 조건이 달라졌다. 예를 들어, Xcode 12.4 이후로는 위 예제에서 text 프로퍼티를 Int 타입으로 바꾸거나, Binding 프로퍼티로 바꾸면 위 작성된 ==
연산자가 호출되지 않게 되었다. SwiftUI View의 내부 렌더링 과정에 변화가 있었던 듯싶은데, 관련된 공식 자료가 거의 없다보니 아무리 인터넷을 뒤져봐도 ==
연산자의 호출 조건을 제대로 정리한 글을 찾을 수 없었다. 애플이 애플했다
이어지는 글에서는 POD에 대한 개념 이해를 바탕으로 View의 ==
연산자 실행 조건을 정리한다.
만약 특정 타입이 그저 데이터를 보관하기만 할뿐이고 복사, 이동, 삭제 등의 추가 개념이 부여되지 않았다면, 해당 타입을 trivial이라 부른다. POD(plain old datatype)로 불리기도 한다. Trivial 인스턴스는 해당 데이터 비트를 복제하는 것으로 복사할 수 있으며, 할당을 해제하는 것으로 제거할 수 있다. 타입 내 모든 멤버가 trivial인 경우에만 해당 타입이 trivial임이 인정된다.
Swift는 다음 값 타입들만을 POD 타입으로 분류한다:
Int
(Int8, UInt, etc...)Float
(Float16, Double, etc...)Range< POD >
ClosedRange< POD >
Optional< POD >
나머지 값 타입 및 모든 참조 타입은 non-POD 타입으로 분류한다:
String
Character
Array
Dictionary
Set
Error
Result
State
Binding
func _isPOD<T>(_ type: T.Type) -> Bool
함수를 사용하면 특정 타입이 POD 타입인지 검사할 수 있다.
let result = _isPOD(String.Self)
print(result) // false
여기서 사용하는 SwiftUI View는 다음 전제조건을 기반으로 한다:
Equatable
준수(==
연산자 구현)EquatableView
로 래핑(또는 .equatable()
수정자 사용)구글링 및 테스트를 통해 얻은 결론은 다음과 같다.
View가 적어도 1개 이상의 non-POD 데이터 프로퍼티를 가진 경우에만 사용자 정의된
==
연산자가 호출된다.
- View 데이터 프로퍼티가 POD 뿐이라면 사용자 정의된
==
연산자는 무시되며 View는 reflection(*)을 통해 프로퍼티를 각각 비교한다. 프로퍼티가 구조체라면Equatable
준수 여부에 따라 해당 프로퍼티 구조체의==
연산자 또는 reflection 중 한가지를 택해 실행된다. 구조체 내에 또다른 구조체 멤버가 포함된 경우 해당 방식이 재귀적으로 실행된다.
소유한
DynamicProperty
(State
,Binding
등) 프로퍼티의 값 변경은 View의 동등 비교 과정 없이 업데이트된다.
- 이는
DynamicProperty
가 View에 업데이트를 요청하기 전에 자체적으로 동등 비교 연산 단계를 포함할 수도 있기 때문이다.
(*) reflection이 궁금한 분들은 zedd님의 글을 참조하자.
위 글이 알쏭달쏭하게 느껴진다면, 몇 가지 간단한 예시로 이해도를 높여보자.
우선 글 초반부에 등장했던 예제를 축약해서 다시 살펴보도록 하겠다.
struct MyView: View, Equatable {
// 데이터 프로퍼티...
var body: some View { ... }
// 값 변경이 일어나는 데이터는 body에서 읽고 있다고 가정한다.
// 값 변경이 일어나지 않는 데이터는 body에서 읽고 있지 않을 수도 있다.
static func == (lhs: MyView, rhs: MyView) -> Bool { ... }
}
데이터 프로퍼티 부분을 살펴보자. 두 종류의 프로퍼티가 있다고 가정하겠다.
var value1: Int
@State var value2: Int
두 프로퍼티 중 value2
가 non-POD이므로 MyView
는 프로퍼티 값 변경 시 ==
연산자를 호출한다. 즉 인자값으로 전달받은 value1
의 경우, 상위 View로부터 값 변경이 일어나면 ==
연산자가 호출된다. 그러나 SwiftUI가 저장하여 참조되는 value2
의 경우, (직접/하위 Binding
으로부터)값 변경이 일어나면 DynamicProperty
구현체 내에서 ==
연산을 진행하고 View에 바로 업데이트 신호를 보낸다. 이때 View는 ==
연산을 진행할 기회를 얻지 못한다.
또 다른 예시를 보자.
var value1: Int
var value2: String
마찬가지로, value2
의 타입 String
은 non-POD이므로 MyView
는 프로퍼티 값 변경 시 ==
연산자를 호출한다. 두 프로퍼티 모두 DynamicProperty
인스턴스가 아니므로, 어떤 프로퍼티든 상관없이 상위 View로부터 값 변경이 일어나면 ==
연산자가 호출된다.
var value1: Int
var value2: Bool
그러나, 모든 프로퍼티가 POD일 경우 MyView
의 사용자 정의된 ==
연산자는 무시된다. SwiftUI는 reflection을 사용하여 각 멤버를 개별적으로 비교하는 내재된 알고리즘을 통해 동등 비교를 진행한다.
다음은 내가 각각의 경우에 대해 테스트했던 결과이다. View에서 사용자 정의된 ==
연산자는 항상 true
를 반환시켜 view update 방지를 시도하도록 했다.
static func == (lhs: Self, rhs: Self) -> Bool {
print("== called")
return true
}
additional argument는 테스트할 때 값 변경 주체 이외의 프로퍼티를 나타낸 것이고, 테스트 결과는 View의 ==
연산자 실행 여부와 View의 업데이트 여부를 튜플로 표현한 것이다.
테스트는 Xcode 13.2과 동일한 SDK를 사용하는 swift playground를 통해 진행했다.
Mutation (+additional property) | from Sub View | Directly | from Parent View |
---|---|---|---|
StateObject | (X, O) | (X, O) | - |
ObservedObject(*) | (O, O) | (O, O) | (O, O) |
State<nonPOD/POD> | (X, O) | (X, O) | - |
State(+Argument-nonPOD/POD) | (X, O) | (X, O) | - |
Binding<POD> | (X, O) | (X, O) | (X, O) |
Binding<nonPOD> (*) | (O, O) | (O, O) | (O, O) |
Argument-POD | - | - | (X, O) |
Argument-nonPOD | - | - | (O, X) |
Argument-POD(+State<nonPOD/POD>) | - | - | (O, X) |
Argument-POD(+Binding<nonPOD/POD>) | - | - | (O, X) |
Argument-POD(+Argument-POD) | - | - | (X, O) |
Argument-POD(+Argument-nonPOD) | - | - | (O, X) |
nonPOD value를 가진 Binding
및 ObservedObject
의 경우 어째서인지 View의 == 연산을 수행하지만, 연산 결과와 관계없이 View를 업데이트한다.
Binding
및 ObservedObject
를 포함한 DynamicProperty
값 변경의 모든 경우에 View의 == 연산이 수행되지 않는다. (뭐지)여기까지 읽었다면 리렌더링을 방지할 수 있는 다른 대안들에 대해 궁금할 수 있다. 다음과 같은 대안들을 생각해볼 수 있겠다:
DynamicProperty
에 ==
연산자 구현==
연산자 구현ObservableObject
에서 objectWillChange.send()
직접 호출여기서 각각에 대해 정리하다간 내 시간이 사르르 녹아 사라질 것만 같아서 다음 기회에 작성하기로 했다. 각자 고민해 보시길.
가볍게 생각했던 주제였는데 잘 정리된 기준글이 없어서 매우 당황스러웠다. 아니 사실 2019년도 글이 몇개 있긴 했는데... Xcode 12.4 전후로 진행된 SwiftUI 잠수함 패치가 EquatableView를 '버그 때문에 현재 정상적으로 사용할 수 없는 기능'처럼 둔갑시켜버렸다. 어쩌면 정말 버그일지도 모르지만... (글쎄, 1년 반이 넘도록 안 고치는 것도 문제 아닐까) 아래 링크를 걸어두었지만, 존 하퍼 님의 트위터 글이 아니었다면 같은 곳만 빙빙 멤돌다가 대안을 찾아 떠났을 것이다. 아마 POD가 뭔지도 평생 모르지 않았을까
또한 DynamicProperty 작동 방식 및 활용법, SwiftUI View의 렌더링 파이프라인 등에 대해 공부할 필요성을 느꼈다. 결국 View의 == 연산자만으로는 많은 제약이 있고, 위 두 사항에 대한 빠싹한 이해도가 뒷받침되어야만 SwiftUI를 자유자재로 다룰 수 있는 것이 아닐까 싶다.