SwiftUI / @StateObject, @ObservedObject, @EnvironmentObject

Minsang Kang·2023년 10월 25일
1

SwiftUI

목록 보기
11/12

이번에는 Object 형태로 값을 전달하는 @StateObject 및 @ObservedObject, @EnvironmentObject 각각 Apple Developer 문서를 통해 알아보고자 합니다.

Apple Developer 문서에 표현된 StateObject, ObservedObject, EnvironmentObject 탐방
https://developer.apple.com/documentation/swiftui/stateobject
https://developer.apple.com/documentation/swiftui/observedobject
https://developer.apple.com/documentation/swiftui/environmentobject

UPDATE: 2023-10-28 13:50

관계

사실 처음에는 @StateObject@ObservedObject, @EnvironmentObject간의 관계가 없는 줄 알고 각각 알아보고자 했어요
하지만 사실 알고보니 셋은 뗄 수 없는 관계였고, 핵심은 @StateObject 였답니다!

따라서 @StateObject 내용을 소개하며 추가로 @ObservedObject와 @EnvironmentObject 내용이 담겨졌습니다!

그리고 추가로 @ObservedObjectObservableObject 은 각각 프로퍼티래퍼프로토콜로 엄연히 다른 친구입니다!ㅋㅋㅋ
(iOS17 신기능으로 Observable 프로토콜도 추가되었다는 사실! ㅎㅎ..)

그러면 @StateObject에 대해 알아볼께요!

@StateObject

https://developer.apple.com/documentation/swiftui/stateobject

ObservableObject 프로토콜을 채택한 객체를 인스턴스화하는 property wrapper 타입

Overview

  • App, Scene, View 등 view 계층구조 내에서 ObservableObject 프로토콜을 채택한 reference 타입single source of truth 형태로 저장하기 위하여 @StateObject 속성을 적용한 property를 사용하세요.
  • SwiftUI에서 제공하는 스토리지 관리와 충돌할 수 있는 memberwise initializer에서 state를 설정하는 것을 방지하려면 private로 사용하세요.
class DataModel: ObservableObject {
    @Published var name = "Some Name"
    @Published var isEnabled = false
}

struct MyView: View {
    @StateObject private var model = DataModel() // Create the state object.

    var body: some View {
        Text(model.name) // Updates when the data model changes.
        MySubView()
            .environmentObject(model)
    }
}

  • SwiftUI는 @StateObject property를 선언하는 container의 수명동안 단 한번만 instance를 생성합니다.
  • 예를 들어, SwiftUI는 view의 입력이 변경되는 경우는 instance를 생성하지 않지만, view의 identify가 변경되면 새 instance를 생성합니다.
  • ObservableObject 프로토콜을 채택한 객체 내 @Published property들의 값이 변경되면 SwiftUI는 이 @Published property값을 사용하는 모든 view를 자동으로 업데이트 합니다.
  • 예시코드의 경우 @Published property인 name을 통해 표시되는 Text의 경우 name값이 변경되면 SwiftUI가 자동으로 업데이트하여 표시합니다.
  • struct나 string, integer와 같이 value 타입의 경우 @State 프로퍼티래퍼를 사용하세요
  • Observable() 프로토콜타입의 instance의 경우도 @State 프로퍼티래퍼를 사용하세요

여기까지 한번 Overview를 살펴봤는데요
간단하게 말하자면

  • string, integer와 같은 값 타입을 single source of truth로 지닐 경우엔 @State 프로퍼티래퍼를 사용
  • 참조 타입(reference) 값을 single source of truth로 지니고 싶은 경우엔 @StateObject 프로퍼티래퍼를 사용
  • 단, ObservableObject 프로토콜을 채택해야 하며 내부 property 들은 @Published 속성이여야 합니다.

여기서 두가지를 먼저 살펴봐야할 것 같은데요, 바로 ObservableObject 프로토콜과 @Published 프로퍼티래퍼를 살펴보겠습니다!

ObservableObject

https://developer.apple.com/documentation/Combine/ObservableObject

변경사항을 방출하는 Publisher를 지닌 객체 타입

ObservableObject는 @Published property 값이 변경되기 전에 변경사항을 방출하는 objectWillChange publisher를 합성합니다.

class Contact: ObservableObject {
    @Published var name: String
    @Published var age: Int

    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }

    func haveBirthday() -> Int {
        age += 1
        return age
    }
}

let john = Contact(name: "John Appleseed", age: 24)
cancellable = john.objectWillChange
    .sink { _ in
        print("\(john.age) will change")
}
print(john.haveBirthday())
// Prints "24 will change"
// Prints "25"

간단하게 보면 @Publisher property 들을 지닌 class 타입입니다.
다만 ObservableObject 프로토콜을 채택하면 objectWillChange publisher를 합성해주는 개념인 것 같아요!

objectWillChange

https://developer.apple.com/documentation/combine/observableobject/objectwillchange-2oa5v

  • 객체가 변경되기 전에 방출하는 publisher
  • Default implementation을 통해 제공됩니다.

ObservableObject를 채택하면 객체 내 objectWillChange publisher가 합성된다고 했죠?
이 publisher의 역할이 바로 객체 내 여러 @Published property 들이 있을텐데 그 중 값이 변경되면 object 단에서 변경되었다고 방출해주는 publisher 역할인 것 같습니다.

이를 통해 ObservableObject 객체 자체의 변경사항을 감지할 수 있는 것이죠!

@Published

https://developer.apple.com/documentation/combine/published

property를 publish 하는 property wrapper 타입
import Combine 필요

  • @Published 표현을 붙여 property를 생성하여 이 값의 publisher가 생성됩니다.
  • $를 통해 publisher에 접근이 가능합니다.
class Weather {
    @Published var temperature: Double
    init(temperature: Double) {
        self.temperature = temperature
    }
}

let weather = Weather(temperature: 20)
cancellable = weather.$temperature
    .sink() {
        print ("Temperature now: \($0)")
}
weather.temperature = 25

// Prints:
// Temperature now: 20.0
// Temperature now: 25.0

위 설명에 잠깐 나왔지만, property wrapper 타입이므로 projectedValue를 살펴볼께요!

projectedValue

https://developer.apple.com/documentation/combine/published/projectedvalue

publisher 값

네, 있는 그대로 @Published 프로퍼티래퍼를 통해 일반 property를 publisher로 사용 가능하게 바꿔주는 프로퍼티래퍼 입니다!
그리고 publisher를 접근해야 하는 경우 $를 통해 접근이 가능하죠!

지금까지 UIKit에서 Combine을 통한 MVVM 패턴이 바로 이런식으로 만들어졌던 것이였습니다
ViewModel@Published 변수를 지니고 있고, 값이 변경되면 VC에서 $를 통해 publisher를 sink 하여 값 변화를 받아 ui로 표시하는 식이였던 것이죠!

  • property 값이 변경되면 property의 willSet 블록에서 publish 합니다.
  • 이를 통해 subscriber가 수신하여 newValue를 받습니다.
  • 이러한 과정이 willSet에서 이뤄지므로 property 값이 새로 설정되기 전에 수신받게됩니다.
  • 위의 예시의 경우 두번째 값 변경을 통한 sink 내에서 25를 수신받습니다.
  • 하지만 만약 sink 내에서 weather.temperature 값을 접근하면 20이 반환될것입니다.

위 내용을 토대로 한번 sink 내에서 weather.temperature 값도 같이 출력해봤습니다 결과는?

class Weather {
    @Published var temperature: Double
    init(temperature: Double) {
        self.temperature = temperature
    }
}

let weather = Weather(temperature: 20)
cancellable = weather.$temperature
    .sink() {
        print ("Temperature now: \($0), \(weather.temperature)")
}
weather.temperature = 25

// Prints:
// Temperature now: 20.0, 20.0
// Temperature now: 25.0, 20.0

적힌대로 25가 아닌 변경되기 전 값인 20이 출력되었습니다!
이를 통해 sink를 통해 subscribe를 하는 경우 수신받은 값을 사용해야한다는 점을 주의해야겠습니다!

  • @Published 변수는 오직 class 내에서만 사용 가능합니다.

정리

이렇게 @StateObject를 알아보면서 ObservableObject 프로토콜과 @Published 프로퍼티래퍼를 살펴봤습니다.
이를 통해 몇가지를 알 수 있었어요!

  • @StateObjectclass 내 @Published 값들을 지닌 ObservableObject를 single source of truth 형태로 지니기 위한 프로퍼티래퍼
  • ObservableObject 프로토콜은 객체 내 존재하는 @Published 값의 변경을 objectWillChange publisher를 통해 송출하는 class 내 값 변경을 publish 하기 위한 프로토콜
  • @Published 프로퍼티래퍼는 property 값이 변경되면 publish하여 subscribe(sink) 하는 곳에서 값 변화를 수신하도록 하는 publisher

그리고 추가로 보태자면

  • UIKit에서 ViewController - ViewModel 간 데이터 바인딩
    • class 타입의 ViewModel을 생성
    • ViewModel 내 UI로 표시되기 위한 값들을 @Published 변수로 생성
    • @Published 값 변화를 통해 ViewController로 publish
    • ViewController 에서 ViewModel의 @Published 변수들을 $를 통해 publisher를 접근
    • publisher들을 .sink 하여 값 변화를 수신 후 UI로 표시
  • SwiftUI에서 View - Model 간 데이터 바인딩
    • class 타입이며 ObservableObject 프로토콜을 채택한 Model을 생성
    • Model 내 UI로 표시되기 위한 값들을 @Published 변수로 생성
      @Published 값 변활르 통해 View로 publish
    • SwiftUI View 내에서 Model을 @StateObject 변수로 생성
    • stateObject변수명.@Published변수명을 접근하는 식으로 UI를 표시
    • @Published 값이 변화되면 SwiftUI는 class 자체의 변화를 감지하여 자동으로 해당 값을 사용한 View들을 업데이트 하여 표시

이렇게 SwifUI 내에서 UI와 관련된 데이터들을 함께 지니는 식으로 구현할 수 있는 기반이 될 수 있는 것이죠!

Share state objects with subviews

그러면 @State 값을 @Binding을 통해 하위 view로 전달했듯이, @StateObject를 하위 view로 어떻게 전달할까요?

  • @StateObject 변수로 할당된 Observable 프로토콜 타입의 객체를 @ObservedObject 프로퍼티래퍼를 통해 하위 view로 전달할 수 있습니다.
  • 또는, environmentObject(_:) view 모디파이어를 통해 하위 view 내 environment에 객체를 추가합니다.
  • 그런 다음 하위 view 내에서 @EnvironmentObject 프로퍼티래퍼를 통해 객체를 읽을 수 있습니다.
struct MySubView: View {
    @EnvironmentObject var model: DataModel

    var body: some View {
        Toggle("Enabled", isOn: $model.isEnabled)
    }
}
  • stateObject 내 @Published 프로퍼티의 양방향 바인딩을 가져오고자 하는 경우 $ 연산자를 사용합니다.
  • 이를 통해 Toggle와 같은 View 내에서 isEnabled 값을 제어할 수 있습니다.

정리하면

  • @StateObject를 @State와 같이 최상단 view에 single source of truth 형식으로 지님
  • @ObservedObject를 통해 @StateObject를 전달
  • .environmentObject(_:) 모디파이어를 통해 environment를 설정, 하위 view 내에서 @EnvironmentObject를 통해 접근

이렇게 정리가 될 수 있습니다!
그리고 객체 내 존재하는 @Published 프로퍼티의 양방향 바인딩이 필요하면 $를 통해 접근하면 됩니다!

그러면 여기서 @ObservedObject@EnvironmentObject를 살펴보겠습니다.

@ObservedObject

https://developer.apple.com/documentation/swiftui/observedobject

ObservableObject 프로토콜을 채택한 객체의 변화를 subscribe 하여 view를 표시하는 property wrapper 타입

  • @Published property를 지닌 ObservableObject 객체를 @ObservedObject property로 SwiftUI view 내 추가하여 @Published 값 변화로 인해 view가 업데이트 되도록 할 수 있습니다.
  • @StateObject 로 할당된 ObservableObject 객체를 하위 view에 전달하고자 하는 경우 @ObservedObject를 사용하여 전달합니다.
  • 아래 예시코드는 ObservableObject 객체로 model를 정의하고, model을 @StateObject로 할당한 후, @ObservedObject를 통해 하위 view로 전달합니다.
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 객체 내 @Published를 선언하고, @StateObject로 지녀 @ObservableObject로 하위 view로 전달하여

SwiftUI view 내에서 @Published 변수의 값, 또는 $를 사용한 양방향 바인딩을 사용하여 ui로 데이터를 표시한다! 라고 이해하시면 되겠습니다!

  • @Published 값이 변경되면 SwifUI는 object를 의존하는 모든 view를 업데이트합니다.
  • 예시코드의 Toggle과 같이 하위 view 내에서 @Published 값을 변경하여 다른 observer들에게 전파할 수 있습니다.
  • ObservedObject의 기본값을 할당하지마세요. 오로지 view의 입력 역할의 @Published property로만 사용하세요.

여기서 재밌는 내용으로는, Property Wrapper 내용의 init을 통한 초기값 설정과는 좀 다르게 데이터 흐름의 시작점이 View가 되어야 한다는 뜻으로 초기값을 할당하지 말라는 내용인 것 같아요!

주의사항

  • Observable 프로토콜을 채택한 객체를 @ObservedObject로 사용하지 마세요. 컴파일 오류가 발생합니다.
  • Observable 객체의 바인딩이 필요한 경우 @Bindable 프로퍼티래퍼를 사용하세요.

간단하게 @ObservedObject 는 오로지 ObservableObject 프로토콜의 객체만 사용 가능!
그리고 Observable 프로토콜의 객체는 @Bindable 프로퍼티래퍼를 사용!

그리고 만약 상위 view에서 하위 view로 바인딩 가능한 model을 전파하고자 하는 경우 @ObservedObject 를 사용!
이렇게 보면 되겠습니다.

wrappedValue

프로퍼티래퍼 이므로 wrappedValue 내용을 살펴볼께요
https://developer.apple.com/documentation/swiftui/observedobject/wrappedvalue

ObservedObject 객체가 참조하는 기본값(underlying value)

  • wrappedValue 값은 observedObject의 data 접근을 제공합니다.
  • @ObservedObject 프로퍼티래퍼의 변수명으로 wrappedValue 값 접근을 제공합니다.
struct MySubView: View {
    @ObservedObject var model: DataModel

    var body: some View {
        Text(model.name) // Reads name from model's wrapped value.
    }
}

wrappedValue 값이 변경된 즉시 새 값을 읽을 수 있지만, SwiftUI는 비동기적으로 view를 업데이트합니다. 따라서 ui가 즉시 업데이트되지 않을 수 있습니다.

projectedValue

https://developer.apple.com/documentation/swiftui/observedobject/projectedvalue

ObservedObject의 property들에 대한 binding 값을 반환

  • $ 기호를 붙여 binding 값을 접근합니다.
  • 예를 들어 Toggle이 $model.isEnabled 값을 통해 isEnabled 값을 제어할 수 있습니다.
struct MySubView: View {
    @ObservedObject var model: DataModel

    var body: some View {
        Toggle("Enabled", isOn: $model.isEnabled)
    }
}

여기서 살짝 정리해보자면

  • model.$isEnabled: ObservedObject 인스턴스(wrappedValue) 의 @Published 프로퍼티 의 publisher 값
    • 즉, Publisher<Value> 값 입니다.
  • $model.isEnabled: ObservedObject 내 property들의 binding 중 isEnabled @Published 프로퍼티의 binding 값
    • 즉, Binding<Value> 값 입니다.

이제는 Property Wrapper의 특성을 이해하고, $의 경우 projectedValue 값이다는 점을 토대로 각 프로퍼티래퍼별로 어떤 projectedValue 값을 반환해주는지에 따라 잘 쓰면 되겠죠?

@EnvironmentObject

이제 @ObservedObject를 이해했으니 다음으로 @EnvironmentObject를 살펴봅시다!
https://developer.apple.com/documentation/swiftui/environmentobject

상위 view, 또는 조상 view가 제공하는 ObservableObject 객체에 대한 property wrapper 타입

  • @EnvironmentObjectObservableObject 객체 변화를 감지하여 view를 업데이트합니다.
  • 하위 view로 @EnvironmentObject를 전달(설정)하고자 하는 경우 .environmentObject(_:) 모디파이어를 통해 설정합니다.
  • Obserbable 객체의 경우 @Environment 를 사용하여 접근하며, environment(_:) 모디파이어를 사용하세요.

그리고 .environmentObject(_:) 뷰 모디파이어도 함께 살펴볼께요!

environmentObject(_:)

https://developer.apple.com/documentation/swiftui/view/environmentobject(_:)

view 계층구조 내 ObservableObject를 제공

  • @EnvironmentObject 프로퍼티래퍼를 통해 ObservableObject 프로토콜을 준수한 객체를 environment에 추가합니다.
  • 이를 통해 ObservableObject 객체를 view 계층구조 내에서 하위 모든 view에서 접근이 가능합니다.

이것이 바로 @EnvironmentObject 내용이였습니다.
@ObservedObject 를 통해 model을 전달받는 방식과는 다르게
@EnvironmentObject 를 통해 하위 모든 view에서 접근가능한 형태로 전달이 가능하다는 것이죠!

정리

여기까지 @ObservedObject 내용과 @EnvironmentObject 내용을 살펴봤습니다.
두 프로퍼티래퍼는 공통점과 차이점으로 분리를 해볼 수 있을 것 같아요!

공통점

  • ObservableObject 객체를 하위 view로 전달

차이점

  • @ObservableObject의 경우 상위 view의 @StateObject를 인자로 전달
  • @EnvironmentObject의 경우 상위 view에서 .environmentObject(_:) 모디파이어로 설정시 하위 모든 view에서 접근 가능

추가로 UIKit에서 SwiftUI를 사용하기 위한 UIHostingViewController를 사용할시에도 .environmentObject(_:) 모디파이어를 통해 모든 view에서 접근가능한 객체를 설정할수도 있답니다!

Initialize state objects using external data

길게 돌고 돌아서 @StateObject를 하위 view로 전달하는 @ObservedObject 방법과 @EnvironmentObject 방법을 살펴봤습니다.

  • StateObject의 초기값이 의존성 주입에 따라야 하는 경우 container의 init 내에서 객체의 init을 호출할 수 있습니다.
  • 예를 들어, container의 init 시점에 의존성 주입으로 받은 name을 초기값으로 설정하고자 하는 경우, View의 init을 명시한 후 내부에서 StateObject의 init을 호출하여 수행할 수 있습니다.
struct MyInitializableView: View {
    @StateObject private var model: DataModel

    init(name: String) {
        // SwiftUI ensures that the following initialization uses the
        // closure only once during the lifetime of the view, so
        // later changes to the view's name input have no effect.
        _model = StateObject(wrappedValue: DataModel(name: name))
    }

    var body: some View {
        VStack {
            Text("Name: \(model.name)")
        }
    }
}

  • SwiftUI는 오직 View의 init을 호출할때만 StateObject를 초기화합니다.
  • 이를 통해 View의 입력이 변경되어도 stateObject의 안정적인 저장공간을 제공합니다.
  • 그러나 StateObject를 명시적으로 초기화하면 예상치 못한 동작이나 원치 않은 side effect가 발생할 수 있습니다.
  • 위의 MyInitializableView 예시코드에서 만약 init의 name 값이 변경되면, SwiftUI는 View의 초기화를 다시 실행하지만, StateObject의 초기화는 처음 호출할때만 동작하므로, model 내 name 값은 변경되지 않습니다.

  • StateObject의 명시적인 초기화는 StateObject가 의존하는 외부 데이터가 container의 instance가 변경되지 않을 때 적합합니다.
  • 예를 들어, 서로 다른 상수값을 사용하여 두 개의 View를 만들 수 있습니다.
var body: some View {
    VStack {
        MyInitializableView(name: "Ravi")
        MyInitializableView(name: "Maria")
    }
}

중요

  • configurable한 StateObject여도 여전히 private로 선언합니다.
  • 이를 통해 framework의 저장공간 관리와 충돌로 인한 예상치 못한 결과로 멤버별 초기화를 통해 실수로 매개변수가 설정되는 것을 방지합니다.

여기서는 보통 StateObject를 선언시 Model() 식으로 할당하는것이 제일 베스트이지만,
StateObject를 생성하는 View의 의존성주입으로 인해 Model을 생성하고자 하는 경우에 대한 내용이 있었습니다.

중요한점은 View의 init시점이자 최초 한번만 StateObject의 초기화가 동작되기 때문에 의존성 값이 변하지 않는 값이여야 한다는 점이 중요하겠습니다!

Force reinitialization by changing view identity

  • 만약 View의 입력값 변화로 인해 StateObject를 다시 초기화하고자 하는 경우엔 View의 identity를 변경하면 됩니다.
  • 유일한 방법으로 id(_:) 모디파이어를 사용하여 변경되는 값에 view의 identity를 바인딩 합니다.
  • 예를 들어, 이름 입력값이 변경되면 MyInitializedView의 identity도 변경되도록 할 수 있습니다.
MyInitializableView(name: name)
    .id(name) // Binds the identity of the view to the name property.
  • 만약 ForEach 내부에서 View를 표시한다면 id(_:) 모디파이어를 암시적으로 받습니다.

  • 둘 이상의 값 변경에 따라 다시 초기화하고자 하는 경우 Hasher를 사용하여 단일 식별자로 결합해야 합니다.
  • 예를 들어, name 또는 isEnabled 값이 변경될 때 MyInitializedView 에서 model를 업데이트하려는 경우 두 변수를 단일 hash 값으로 결합할 수 있습니다.
var hash: Int {
    var hasher = Hasher()
    hasher.combine(name)
    hasher.combine(isEnabled)
    return hasher.finalize()
}

  • 위 hash 값을 identifier 값으로 사용하여 view에 적용합니다.
MyInitializableView(name: name, isEnabled: isEnabled)
    .id(hash)

  • 값의 변경에 따라 다시 초기화되는 StateObject를 하는 과정으로 인한 성능저하를 고려하세요.
  • 또한, view의 identity 값을 변경하면 side effect가 발생할 수 있습니다.
  • 예를 들어, SwiftUI는 view의 identity가 함께 변경되는 경우 변경사항을 자동으로 애니메이션화 하지 않습니다.
  • 또한, identity가 변경되면 State, FocusState, GestureState 등의 관리하는 property 들이 모두 재설정됩니다.

마지막으로, StateObject가 다시 초기화될 수 있는 유일한 방법으로 container의 identity(id) 값이 변경되는 경우를 살펴봤습니다!
하지만 SwiftUI 에서 identity 값이 변경되면 모든것이 초기화되며 다시 그려지는 부하가 발생하므로 주의가 필요한 부분으로 보입니다!

따라서 외부 의존성으로 StateObject 객체를 초기화하고자 하는 경우엔 상수값 정도로 만드는 것이 좋을 것 같습니다!

만약 상위 View에서 입력된 값을 토대로 변경이 필요한 상황이라면 @EnvironmentObject를 사용하는 방법도 존재하겠죠?

정리

이렇게 많은 내용들이 포함된 @StateObject 내용을 살펴봤습니다.
보다 정리된 형태로 Model Data를 사용하는 내용이 정리된 다음글을 살펴보시길 바랍니다!

다음글: SwiftUI / Monitoring data changes in your app

profile
 iOS Developer

0개의 댓글