이번에는 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 내용이 담겨졌습니다!
그리고 추가로 @ObservedObject
와 ObservableObject
은 각각 프로퍼티래퍼
와 프로토콜
로 엄연히 다른 친구입니다!ㅋㅋㅋ
(iOS17 신기능으로 Observable
프로토콜
도 추가되었다는 사실! ㅎㅎ..)
그러면 @StateObject에 대해 알아볼께요!
https://developer.apple.com/documentation/swiftui/stateobject
ObservableObject 프로토콜을 채택한 객체를 인스턴스화하는 property wrapper 타입
- 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
프로퍼티래퍼를 살펴보겠습니다!
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를 합성
해주는 개념인 것 같아요!
https://developer.apple.com/documentation/combine/observableobject/objectwillchange-2oa5v
- 객체가 변경되기 전에 방출하는 publisher
- Default implementation을 통해 제공됩니다.
ObservableObject를 채택하면 객체 내 objectWillChange publisher가 합성된다고 했죠?
이 publisher의 역할이 바로 객체 내 여러 @Published property
들이 있을텐데 그 중 값이 변경되면 object 단에서 변경되었다고 방출해주는 publisher
역할인 것 같습니다.
이를 통해 ObservableObject 객체 자체의 변경사항을 감지
할 수 있는 것이죠!
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를 살펴볼께요!
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
프로퍼티래퍼를 살펴봤습니다.
이를 통해 몇가지를 알 수 있었어요!
@StateObject
는 class 내 @Published
값들을 지닌 ObservableObject를 single source of truth 형태
로 지니기 위한 프로퍼티래퍼ObservableObject
프로토콜은 객체 내 존재하는 @Published 값의 변경을 objectWillChange publisher를 통해 송출하는 class 내 값 변경을 publish
하기 위한 프로토콜@Published
프로퍼티래퍼는 property 값이 변경되면 publish
하여 subscribe(sink) 하는 곳에서 값 변화를 수신하도록 하는 publisher그리고 추가로 보태자면
ViewController
- ViewModel
간 데이터 바인딩class 타입
의 ViewModel을 생성@Published 변수
로 생성$를 통해 publisher를 접근
.sink 하여 값 변화를 수신
후 UI로 표시View
- Model
간 데이터 바인딩class 타입
이며 ObservableObject
프로토콜을 채택한 Model을 생성@Published 변수
로 생성@StateObject
변수로 생성stateObject변수명.@Published변수명
을 접근하는 식으로 UI를 표시SwiftUI는 class 자체의 변화를 감지하여 자동으로 해당 값을 사용한 View들을 업데이트
하여 표시이렇게 SwifUI 내에서 UI와 관련된 데이터들을 함께 지니는 식으로 구현할 수 있는 기반이 될 수 있는 것이죠!
그러면 @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
를 살펴보겠습니다.
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 내용을 살펴볼께요
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가 즉시 업데이트되지 않을 수 있습니다.
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 값을 반환해주는지에 따라 잘 쓰면 되겠죠?
이제 @ObservedObject
를 이해했으니 다음으로 @EnvironmentObject
를 살펴봅시다!
https://developer.apple.com/documentation/swiftui/environmentobject
상위 view, 또는 조상 view가 제공하는 ObservableObject 객체에 대한 property wrapper 타입
@EnvironmentObject
는ObservableObject
객체 변화를 감지하여 view를 업데이트합니다.- 하위 view로
@EnvironmentObject를 전달(설정)
하고자 하는 경우 .environmentObject(_:) 모디파이어를 통해 설정합니다.Obserbable
객체의 경우@Environment
를 사용하여 접근하며,environment(_:)
모디파이어를 사용하세요.
그리고 .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에서 접근가능한 객체를 설정할수도 있답니다!
길게 돌고 돌아서 @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의 초기화가 동작
되기 때문에 의존성 값이 변하지 않는 값
이여야 한다는 점이 중요하겠습니다!
- 만약 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를 사용하는 내용이 정리된 다음글을 살펴보시길 바랍니다!