본문 바로가기
🍎 iOS/DevNote

[SwiftUI] ObservableObject, @StateObject, @ObservedObject 알아보기

by @Eddy 2024. 3. 25.
728x90

[Protocol] ObservableObject

  • Class 전용 프로토콜로, 데이터가 변경되면 Observer에게 알림을 보내는 타입
  • iOS 17.0부터는 Observable() 매크로 사용이 권장됨

ObjectWillChange

  • ObservableObjectPublisher타입으로, 객체가 수정되기 전에 전달하는 publisher이다.
  • objectWillChange.send() 메서드를 사용해 변경사항을 subcriber에게 전달한다.
    • 변경사항이 있을 때마다 objectWillChagne.send() 를 해야한다면, 몹시 불편할 것 같다.
    • 이를 보완하는 방법으로 @Publshed 라는 property wrapper가 있다.
import SwiftUI

class TestViewModel: ObservableObject {
    var count = 0
    
    func run() {
        count += 1
        objectWillChange.send() // 변경사항이 있음을 전달함.
    }
}

struct TestView: View {
    @ObservedObject var testViewModel = TestViewModel()
    
    var body: some View {
        Button {
            testViewModel.run() // 변경사항이 있음을 전달받아 View를 다시 그림.
        } label: {
            Text("\(testViewModel.count)")
        }
    }
}

@Published

  • Class 전용 property wrappers로, @Published로 표시된 property를 publish하는 타입
  • @Published로 선언된 property값이 변경되면 property의 willSet에 퍼블리싱이 발생하며, 해당 객체에 종속된 모든 View를 업데이트한다. 또한 자식뷰에서도 property값을 변경할 수 있으며, 이 값을 다른 Observer에게 전파할 수 있다.
// Published
class Weather: ObservableObject {
    @Published var temperature: Double = 0
}

@ObservedObject, @StateObject

  • ObservableObject 프로토콜을 따르는 객체를 필요로 하는 Property wrapper로, ObservableObject 프로토콜은 객체의 값이 변경되기 전에 알려주는 publisher이다.
  • ObservedObject와 StateObject 둘 다 ObservableObject 프로토콜을 따른다.

@ObservedObject

  • ObservableObject를 구독하고, ObservableObject가 변경될 때마다 view를 업데이트하는 기능을 가지고 있다.
final class MyViewModel: ObservableObject {
    @Published var count: Int = 0
	
    func increment() {
        count += 1
    }
}

struct MyView: View {
    @ObservedObject var viewModel = MyViewModel() // 관찰가능한 객체
	
    var body: some View {
        Button {
            viewModel.increment()
        } label: {
            Text("\\(viewModel.count)") // 버튼을 누르면 count 증가
        }
    }
}

 

 

 

 

 

  • 하지만, 작은 문제가 있었으니,, View가 새롭게 그려질 때, 데이터가 초기화되는 문제가 있다.
    • 부모뷰의 State가 변경되어 View가 새롭게 렌더링 되면서 데이터가 초기화되는 모습

struct ParentView: View { // 부모뷰
    @State private var count = 0
    
    var body: some View {
        Button {
            count += 1
        } label: {
            // 버튼을 누르면 State값 변경. View 렌더링
            Text("State: \(count)") 
        }
        
        MyView()
    }
}

final class MyViewModel: ObservableObject {
    @Published var count: Int = 0
    
    func increment() {
        count += 1
    }
}

struct MyView: View { // 자식뷰
    @ObservedObject var viewModel =  MyViewModel()
    
    var body: some View {
        Button {
            viewModel.increment()
        } label: {
            Text("ObservedObject: \(viewModel.count)")
        }
    }
}
  • 이런 문제를 쉽게 해결하기 위해 @StateObject가 등장하게 되었다.

 

@StateObject

  • ObservedObject과 동일하게, ObservableObject를 구독하고, ObservableObject가 변경될 때마다 view를 업데이트하는 기능을 가지고 있다.
  • 하지만 StateObject는 생성 당시 1회만 init이 되고 @State의 속성을 따라 부모 뷰가 새롭게 그려지더라도, StateObject가 구독하고 있는 ObservableObject는 Single Source of Truth(단일 원본 데이터)를 참조하고 있기 때문에, view의 LifeCycle에 의존하지 않는다.
  • 따라서 아래처럼 동작한다.
    • 부모뷰의 State가 변경되어 View가 새롭게 렌더링 되어도, 데이터가 초기화되지 않는 모습.

struct ParentView: View {
    @State private var count = 0
    
    var body: some View {
        Button {
            count += 1
        } label: {
            Text("State: \(count)")
        }
        
        MyView()
    }
}

final class MyViewModel: ObservableObject {
    @Published var count: Int = 0
    
    func increment() {
        count += 1
    }
}

struct MyView: View {
    @StateObject private var viewModel =  MyViewModel() // 수정
    
    var body: some View {
        Button {
            viewModel.increment()
        } label: {
            Text("StateObject: \(viewModel.count)")
        }
    }
}

 

  • (의문) 근데 이렇게 동작하면, StateObject는 메모리에서 해제가 안 되는 게 아닐까? → 메모리 누수 일어나는거 아니야???
    • 실제로 init은 view가 처음 그려질 때 한 번 호출되고, deinit이 동작하지 않는 것으로 보아, 메모리에서 해제되려면 MyView가 메모리에서 해제되어야 하는 것처럼 보였다. ( State로 뷰가 Re-Rendering 되는 것과는 별개로.. )
    • 사실, StateObject는 View의 identity를 참조해 LifeCycle을 유지하는데, 이는 View의 id() modifier를 사용하면 알 수 있다. identity를 수정하면, ObservedObject와 동일한 동작이 이뤄진다.
    • 왜냐하면, id()가 변경되면 view가 새롭게 그려지면서 id가 변경되고, StateObject가 이를 비교해 다른 View(id)라고 인식하여 재 초기화를 하게 된다. 이러한 동작은 State에서도 주의해야 했던 성능과 비용, 사이드이펙트가 발생시킬 수 있다는 문제가 있다.
    • 당연하게도 State의 속성을 가진 State, GestureState, FocusState로도 초기화될 수 있기 때문에 주의해야 한다.

struct ParentView: View {
    @State private var count = 0
    @State private var identity = 0
    
    var body: some View {
        Button {
            count += 1
            identity += 1
        } label: {
            Text("State: \\(count)")
        }
        
        MyView()
            .id("\\(identity)")
    }
}

final class MyViewModel: ObservableObject {
    @Published var count: Int = 0
    
    init() {
        print("메모리 할당")
    }
    
    func increment() {
        count += 1
    }
    
    deinit {
        print("메모리 해제")
    }
}

struct MyView: View {...}

// identity 변경 시
  // 메모리 할당
  // 메모리 해제
  // 메모리 할당
  // 메모리 해제

 

 

P.S

Single Source of Truth(SSoT, 단일 데이터 원본)

SwiftUI는 Property's Storage라는 외부 데이터공간을 관리하고 있다.

Property's Storage에 있는 데이터가 SSoT이다.

반응형

댓글