프로젝트의 채팅 기능을 구현하던 중 사용자가 채팅 화면을 보고 있을 때, 다른 사용자가 채팅방에 입장하거나 퇴장할 때, 갑자기 메세지가 보이지 않는 버그가 발생했다.
이 문제를 해결하기 위해 ViewModel 생성자에서 디버깅을 해보았다.
그 결과 현재 사용자가 참여하고 있는 CarPool 데이터 모델이 업데이트 될 때마다 ViewModel이 초기화되고 있음을 알게되었다.
View 에서는 앱 전체에서 사용되는 ObservableObject인 AppData 객체를 가지고 있고, @ObservedObject 속성으로 선언된 ViewModel을 가지고 있다.
그리고 AppData에서 현재 사용자가 참여하고 있는 CarPool 데이터 모델을 @Published로 가지고 있다.
따라서 CarPool이 변경될 때마다 뷰를 다시 그린다.
결론부터 말하면, ViewModel을 @ObservedObject가 아닌 @StateObject로 선언해서 해결했다.
@ObservedObject는 상위뷰의 상태값이 변경되었을 때, 초기화 되지만, @StateObject는 초기화 되지 않고, ObservableObject 객체를 가지고 있는 뷰의 생명주기 동안 상태를 유지한다.
따라서 AppData에서 가지고 있는 CarPool 데이터 모델이 변경되면(사용자 입장 or 퇴장) @ObservedObject로 선언한 객체는 상위뷰의 상태가 변경되었으므로 초기화된다. 그래서 이 문제를 해결하기 위해 @StateObject로 변경했다.
위 문제 상황을 좀 더 간단한 예시로 바꿔 ObservedObject와 StateObject의 차이점에 대해서 명확히 이해할 수 있었다.
아주 간단한 카운터를 만들기 위한 ViewModel을 아래와 같이 만들어봤다.
extension CountView {
final class ViewModel: ObservableObject {
@Published var count: Int = 0
init() {
print("DEBUG: CountView's ViewModel is initialize")
}
func increase() { count += 1 }
}
}
ViewModel은 ObservableObject
를 채택한다. 이 프로토콜을 채택하면, 말 그대로 관찰가능한 객체가 되고, @Published
프로퍼티 래퍼 속성으로 선언된 변수, count
가 변경될 때, 외부에 변경사항을 알릴 수 있게된다.
만약 View에서 이 ViewModel을 가지고 있다면, count 가 변경되었을 때, count 를 사용하고 있는 뷰 계층 구조를 다시 그린다.
struct CountView: View {
@ObservedObject var viewModel = ViewModel()
var body: some View {
VStack(spacing: 10) {
Text("CountView's Count is \(viewModel.count)")
Button(action: {
viewModel.increase()
}, label: {
Text("Add Count")
})
}
}
}
위에서 만든 ViewModel을 사용하는 CountView 이다.
ViewModel 선언을 보면, @ObservedObject
프로퍼티 래퍼로 선언되었다.
애플 공식 문서를 보면 @ObservedObject를 다음과 같이 설명한다.
ObservableObject를 구독하고 해당 객체가 변경될 때마다 View를 무효화하는 프로퍼티 래퍼 타입입니다.
따라서 ViewModel을 구독하고, ViewModel의 count가 변경될 때마다 View를 업데이트 하기 위해서 @ObservedObject 프로퍼티 래퍼로 선언했다.
이 코드의 결과를 화면으로 보면, 아래와 같다.
Button을 누를 때마다 count 가 변경되고, count를 사용하고 있는 View가 다시 그려지면서 위와 같은 간단한 카운터를 만들 수 있다.
위와 같은 상황에서는 정상적으로 동작하고, 문제가 없어보인다. 하지만, @ObservedObject를 사용하다보면 상태가 유지되어야 할 것 같은데, 초기화되는 문제를 종종 맞닥뜨린다.
struct CountView: View {
@Binding var parentCount: Int
@ObservedObject var viewModel = ViewModel()
var body: some View {
VStack(spacing: 10) {
Text("CountView's Count is \(viewModel.count)")
Button(action: {
viewModel.increase()
}, label: {
Text("Add Count")
})
Text("ParentView's Count is \(parentCount)")
Button(action: {
parentCount += 1
}, label: {
Text("Add Count")
})
}
}
}
struct ParentView: View {
@State private var count: Int = 0
var body: some View {
NavigationView {
VStack {
Text("ParentView's count is \(count)")
NavigationLink {
CountView(parentCount: $count)
} label: {
Text("Navigate to CountView")
}
}
}
}
}
문제 상황과 비슷한 예시를 만들기 위해서 코드를 위와 같이 작성했다. 문제없이 동작할 것 같지만, 막상 돌려보면 문제가 발생한다.
ParentView의 count가 변경되면, CountView의 Count가 초기화된다.
무엇이 문제인지 확인하기 위해서 로그를 보면
ParentView의 count를 증가시킬 때마다 CountView의 ViewModel이 초기화 되고있다. 그래서 count가 0으로 돌아가는 것이다.
사실 생각해보면 이 결과는 당연하다. ParentView의 State 변수인 count가 변경되면, 해당 변수를 사용하고 있는 모든 하위 View들은 초기화된다. 그러면 CountView의 ViewModel도 초기화될 것이다.
이 문제점을 해결하기 위해서 iOS 14부터 StateObject가 등장했다.
애플 공식 문서에서는 다음과 같이 소개한다.
ObservableObject를 인스턴스화 하는 프로퍼티 래퍼 타입입니다.
이것만 봐서는 ObservedObject와 어떤 차이가 있는지 극명하게 드러나지 않는다.
다른 공식 문서를 찾아보면 추가적인 설명을 찾아볼 수 있다.
StateObject는 ObservedObject와 거의 똑같으나, StateObject는 하나의 객체로 만들어지고, View가 얼마나 초기화되든지 상관없이 별개의 객체로 관리된다.
즉, StateObject로 생성된 객체는 View의 생명 주기와 상관없이 SwiftUI가 별도의 공간에 저장해서 상태값을 유지할 것이다.
위 코드에서 ObservedObject로 선언한 것을 StateObject로 바꿔보자.
struct CountView: View {
@Binding var parentCount: Int
@StateObject private var viewModel = ViewModel()
var body: some View {
VStack(spacing: 10) {
Text("CountView's Count is \(viewModel.count)")
Button(action: {
viewModel.increase()
}, label: {
Text("Add Count")
})
Text("ParentView's Count is \(parentCount)")
Button(action: {
parentCount += 1
}, label: {
Text("Add Count")
})
}
}
}
ViewModel을 StateObject로 생성했다. 따라서 이 ViewModel은 ContentView가 초기화되는 것과 상관없이 SwiftUI가 별도의 공간에 저장하여 상태값을 유지할 것이다.
실제로 실행시켜보면, ParentView의 count 값이 증가하여 ContentView가 다시 초기화 된다고 하더라도 ViewModel의 count는 초기화되지 않는다.
실제로 ViewModel이 초기화되지 않는지 로그를 확인해보니 최초에 1번만 초기화되고, 이후에 ParentView의 count 를 증가시켜도 초기화되지 않는다.
class DataModel: ObservableObject {
@Published var name = "Some Name"
@Published var isEnabled = false
}
struct MyView: View {
@StateObject private var model = DataModel()
var body: some View {
Text(model.name)
MySubView(model: model)
}
}
struct MySubView: View {
@ObservedObject var model: DataModel
var body: some View {
Toggle("Enabled", isOn: $model.isEnabled)
}
}
애플 공식 문서에는 위와 같은 코드 예시를 보여주고 있다.
ObservableObject 객체를 생성할 때, StateObject로 생성하고, 해당 객체를 하위뷰에서 사용할 때에는 ObservedObject로 전달받아 사용하면 된다.