회사 프로젝트는 정말 다양한 기술로 구성되어 있습니다. 많은 기술을 시도하고 공부하며 적용하고 더 좋은 구조를 찾아 발전하고 성장해나갑니다.
UIKit, RxSwift, ReactorKit, RxFlow, SwiftUI, Combine, Asycn Await, MVVM, MVC등 많은 기술을 거쳐왔습니다.
최근에는 SwiftUI와 MVVM을 주로 적용시켜왔습니다.
UIKit을 사용 할 때에는 MVVM-InOut패턴과 RXSwift을 적용해 어느정도의 데이터 흐름을 파악하기 용이했습니다.
Input을 정의해 이벤트가 어디서 시작되는지 알 수 있었고 스트림을 통해 방출되는 데이터는 Output을 활용해 뷰가 구독하고 갱신을 했습니다.
하지만 SwiftUI로 넘어오며 @Binding을 활용해 데이터의 양방향 흐름이 가능해졌습니다.
부모뷰의 모델이 갖고있는 데이터를 자식뷰에 전달하면 자식뷰가 이 데이터를 변경할 시 부모뷰의 모델이 변경됩니다.
class BindingObservableModel: ObservableObject {
@Published var text: String = ""
func addText() {
text += "\(text.count)"
}
}
struct BindingTEST: View {
@ObservedObject var viewModel = BindingObservableModel()
var body: some View {
Button(
action: {
viewModel.addText()
},
label: {
Text("ParentView Button Text: \(viewModel.text)")
}
)
Spacer()
.frame(height: 50)
BindingTESTSubView(text: $viewModel.text)
}
}
struct BindingTESTSubView: View {
@Binding var text: String
var body: some View {
Button(
action: {
deleteText()
},
label: {
Text("SubView Button Text: \(text)")
}
)
}
func deleteText() {
text.removeLast()
}
}
Binding은 양방향 데이터 흐름이 가능하기 때문에 부모뷰에서 전달받은 text: Binding<String>을 변경해도 부모가 관리하는 Data도 함께 변경됩니다.
그럼 Binding이 아닌 일반 text로 넘겼을 때는 어떨까요
물론 불가능 합니다.
SwiftUI의 상태 관리 방법 @State, @Binding, @ObservedObject등을 통해 상태관리를 해야합니다.
그럼 만약 자식뷰가 어떠한 로직을 개별적으로 담당해서 수행해야 할 때는 어떻게 해야할까요.
셀이 있는데 각 셀마다 상당히 복잡한 로직을 갖고있고, 각 셀이 담당하게 하려면?
1. 뷰모델에 Published를 만들고 부모에서 데이터가 변경되었을 때 자식도 동일한 데이터를 유지하기 위해 바인딩을 한다?
class BindingTESTSubViewModel: ObservableObject {
@Published var text: String
private var cancellables = Set<AnyCancellable>()
init(text: Binding<String>) {
self.text = text.wrappedValue
self.$text
.dropFirst()
.sink(receiveValue: {
text.wrappedValue = $0
})
.store(in: &cancellables)
}
func deleteText() {
text.removeLast()
}
}
struct BindingTESTSubView: View {
@ObservedObject var viewModel: BindingTESTSubViewModel
var body: some View {
Button(
action: {
viewModel.deleteText()
},
label: {
Text("SubView Button Text: \(viewModel.text)")
}
)
}
}
이 코드는 잘 작동합니다.
만약 부모뷰에서 데이터가 변경된 경우 SubView의 ViewModel이 ObservedObject이므로 다시그려질 수 있습니다.
자식뷰에서 데이터가 변경된 경우 자식뷰의 published변수를 생성자에 전달받은 Binding에 바인딩 시켜놓았으니 부모뷰의 데이터가 변경됩니다.
이 구조는 깊이가 조금만 깊어져도 데이터의 변경이 어디서 이뤄지는지 파악하기 힘들기 때문에 선호하지 않습니다.
** 만약 이 경우 자식뷰의 ViewModel이 StateObject라면 다시 그려지지 않습니다. StateObject는 자신의 뷰가 생명주기를 담당하기 때문입니다. ObservedObject라면 부모뷰에서 전달받은 데이터가 변경된다면 재 호출 되기 때문에 다시 그려질 수 있습니다.
2. 자식뷰의 뷰모델이 Binding을 사용한다.
class BindingTESTSubViewModel: ObservableObject {
@Binding var text: String
init(text: Binding<String>) {
self._text = text
}
func deleteText() {
text.removeLast()
}
}
struct BindingTESTSubView: View {
@ObservedObject var viewModel: BindingTESTSubViewModel
var body: some View {
Button(
action: {
viewModel.deleteText()
},
label: {
Text("SubView Button Text: \(viewModel.text)")
}
)
}
}
이 경우에도 잘 작동합니다.
자식뷰의 뷰모델이 Binding자체를 받아 양방향 바인딩이 가능하고 ObservedOjbect이기 때문입니다
이 경우 StateObject라면 어떻게 될까요
뷰는 Published가 변경되면 이를 감지하고 다시그립니다.
ObservedOjbect일 때 그려질 수 있었던 이유는 ViewModel의 데이터가 변경되고 업데이트가 되고 뷰가 다시 로드되서이지 ObservableObject의 @Binding을 바라보고 있는 View는 다시 그려지지 않습니다.
Published가 변경되어야 ObservableObject의 objectWillChange가 이벤트를 발생시켜 뷰를 다시그립니다.
이렇게 SwiftUI는 양방향 바인딩이 가능합니다.
여기서 SwiftUI와 MVVM의 문제점을 느꼈습니다.
뷰와 뷰모델의 구조가 복잡해질수록 언제 어디서 어느 데이터가 변경되는지 파악하기 매우 힘듭니다.
그래서 TCA를 학습하던중 의문점이 생겼습니다.
뷰 갱신이 필요한 데이터라면 지금까지 그냥 Binding으로 전달했는데 단방향 아키텍처와 어울리지 않다는 생각에 View 업데이트가 이루어 지는 방식을 다시 고민했습니다.
사실 많은 블로그가 State업데이트를 View가 감지하는 방식이라고 Binding을 많이설명합니다.
하지만 공식문서에서는 Binding을 아래와 같이 설명합니다.
A property wrapper type that can read and write a value owned by a source of truth.
소유된 값을 읽고 쓸 수 있게 해주는 친구입니다. 뷰 갱신을 위해 필요한게 아닙니다.
다음 코드를 봅시다
struct ParentView: View {
@State private var count = 0
var body: some View {
VStack {
Button("Increment") {
count += 1
}
ChildView(count: count)
}
}
}
struct ChildView: View {
let count: Int
var body: some View {
VStack {
Text("Count: \(count)")
Text("Fixed Text")
}
}
}
Binding이 아니어도 뷰 갱신은 잘 이루어집니다.
각 시점을 확인해볼까요
struct ChildView: View {
let count: Int
init(count: Int) {
self.count = count
print("ChildView init with count: \(count)")
}
var body: some View {
VStack {
Text("Count: \(count)")
.onAppear {
print("Text(\"Count: \\(count)\") appeared with count: \(count)")
}
Text("Fixed Text")
.onAppear {
print("Text(\"Fixed Text\") appeared")
}
}
}
}
ChildView는 Init이 호출되고 각 UI컴포넌트들의 ONAppear가 호출됩니다.
이후 count가 증가할 때 마다 Init이 계속 호출되지만 Init이 호출된 후 ONAppear가 다시 호출되진 않습니다.
ChildView가 부모뷰로 부터 값을 전달 받는 경우 그 값이 변경되면 ChildView 전체가 다시렌더링 됩니다.
즉 body가 다시 렌더링 됩니다.
SwiftUI는 뷰의 특정 부분만 부분적으로 다시 그리지는 않고 뷰 자체를 재 구성 하는 방식으로 렌더링을 관리합니다.
이 방식은 자식 뷰가 부모 뷰로부터 받는 값이 변경될 때 자식뷰에서 뷰 전체가 새로 그려지는 것처럼 동작하지만 SwiftUI내부적으로 실제로는 필요한 부분만 다시 그려지도록 합니다.
그래서 count가 변경되면 ChildView가 다시 랜더링될 때 ChildView의 body 전체 재 실행되며 각 뷰 들이 다시 평가됩니다.
하지만 SwiftUI내부 최적화로 변화가 없는 부분은 실제 화면 렌더링에선 영향을 받지 않습니다. 즉
Text("Count: \(count)") // count 변경 시 평가가 이루어지고 값이 변경되었다면 Text가 다시 렌더링됩니다.
Text("Fixed Text") // 재 평가는 되지만 성능에 영향을 주지 않도록 최적화 됩니다
즉, SwiftUI는 모든 하위 뷰의 body를 다시 호출하지만, 뷰 상태에 변경이 없다고 판단되면 이를 화면에 다시 그리지 않는 방식으로 성능을 최적화합니다.
또한 부모 뷰의 모델에서 Published 혹은 State로 관리 할 경우 내부적으로 StateObject를 사용하지 않는다면 최 하위 자식뷰 까지 갱신이 이루어집니다.