본문 바로가기
iOS/SwiftUI

@State, @StateObject, @ObservedObject, @EnvironmentObject를 알아보자.

by 지금갑시다 2022. 5. 9.

 

2023.09.22 2차 수정!


하아.. 길고도 길었던 고난의 이해시간을 거쳐 드디어 이해가 된 아니, 쓰면서 내 이해가 맞나 확인하기 위해서 글을 씁니다.

 

위의 프로퍼티 래퍼들을 처음 보게 된 것은 Apple의 공식 document로 나와있는 SwiftUI Tutorial을 할 때 였지만, 약 1달 가량이 지난 지금 이해를 마치고 기분 좋은 마음으로 글을 작성해 봅니다!ㅎㅎ

 

먼저 제 이해를 도운 Reference들을 첨부하겠습니다.

https://purple.telstra.com/blog/swiftui---state-vs--stateobject-vs--observedobject-vs--environme

 

SwiftUI: @State vs @StateObject vs @ObservedObject vs @EnvironmentObject

SwiftUI property wrappers are one of the first concepts iOS developers should learn to ensure their app data flows predictably and reliably.

purple.telstra.com

개인적으로는 정말 좋은 예시와 너무 좋은 설명을 동반하고 있다고 생각하기에, 정독하여 한번 보시는 것을 추천드립니다.

 

 

자, 이제 한번 정리를 시작해 보겠습니다.

 

먼저 @StateObject, @ObservedObject, @EnvironmentObject 등의 프로퍼티 래퍼들은 하나의 공통점을 가집니다. 

 

바로 데이터가 업데이트 되었을때, 뷰의 업데이트를 사용해주기 위해서 존재한다는 것인데요, 

 

@State 라는 데이터의 상태를 관찰하며 값이 변경되면 뷰를 다시 그려주는 간단한 래퍼도 존재하지만, @State와 같은 프로퍼티 래퍼는 class와 같이 Reference 타입인 객체를 나타내기에 한계가 존재합니다.

 

어떤 한계가 있는지 예시를 적어보겠습니다.

class MyClass {
	var num: Int = 0
}


@State var myClass: MyClass = MyClass()

예시 코드가 100% 정확한 코드는 아니지만, @State의 한계에 대해 이해를 돕고자 적어보자면, 

 

@State로 다뤄주는 myClass 인스턴스 즉 객체(인스턴스와 객체는 엄연히 다르게 표현 될 수 있지만, 글에서의 맥락과 벗어남으로 같게 생각하겠습니다.)

myClass.num += 1

이라는 코드가 있을때, 

 

num이 현재 화면에 표시되고 있는 값이라면, myClass.num += 1 이 아무리 계속되어도 myClass의 num값은 변하겠지만, 화면은 업데이트가 되지 않습니다.

 

이유로는 @State의 값이 MyClass객체 자체를 가리키고 있기 때문에, 객체안의 num값은 변했을지라도, 객체자체의 변한 점은 없다고 인식되기 때문입니다.

 

따라서, 이럴 경우를 위해 @StateObject와 같은 프로퍼티 래퍼가 필요한 것입니다.

 

 

@StateObject는 직역한 단어의 뜻과 같이 객체를 위한 프로퍼티 래퍼입니다.

@State가 단순한 Value타입의 값들(String, Int...)에 최적화 되었다면, 객체와 같은 Reference타입의 변화에는 @StateObject가 최적이라고 볼 수 있죠.

 

@StateObject var myClass: MyClass = MyClass()

의 꼴로 생성되며, @State와 다른 점이라고는 @State가 @StateObject로 바뀐 점 하나 뿐입니다.

이름만 보더라도, @State와 @StateObject의 차이는 객체를 위한 프로퍼티 래퍼냐 아니냐? 로 볼 수 있습니다.

 

이제 위에 @State였기 때문에 나타나던 문제가 사라지고, myClass.num += 1 이 진행되면 그 값의 변경사항에 따라 뷰가 없데이트 되어 사용자로 하여금 바뀐 데이터를 볼 수 있게 됩니다. 바로 @StateObject가 붙어 객체의 값까지 관찰을 하고 있기 때문입니다.

 

그럼  @StateObject만 써준다면 아무 문제가 없이 어떠한 페이지에서도 그 값을 다룰 수 있게 될까요?

 

물론 아닙니다

 

@StateObject는 보통 사용되는 뷰들이 있다면, 제일 상위의 뷰에 존재해야 합니다. 왜 그래야 할까요?

 

import SwiftUI

class MyClass: ObservableObject {
    @Published var num: Int = 0
}

struct StateObjectNavigation: View {
    var body: some View {
        NavigationView {
            NavigationLink(destination: StateObjectNextPage()) {
                Text("Go next page")
            }
        }
    }
}

struct StateObjectNextPage: View{
    @StateObject var myClass = MyClass()
    
    var body: some View {
        VStack {
            Text(String(myClass.num))
            
            Button(action: {
                myClass.num += 1
            }, label: {
                Text("add StateObject num")
            })
            
            
        }
    }
}

struct StateObjectNavigation_Previews: PreviewProvider {
    static var previews: some View {
        StateObjectNavigation()
    }
}

 

제일 첫 코드블락인 MyClass의 구성부터 보게 되면,

@StateObject를 사용하기 위해 MyClass 클래스는 ObservableObject라는 프로토콜을 준수하고, num에는 @Published라는 새로운 프로퍼티 래퍼가 붙어있습니다.

 

ObservabelObject를 써줌으로써 다른 뷰에서 @StateObject로 받아줄 수 있게 되고, 

@Published가 변수 앞에 써져 있음으로 num이라는 변수가 변하는 것을 내뿜어주어서 확인할 수 있게해 num값이 변하는 것을 확인 가능하게 해줍니다.

 

자 이제 왜 상위에 존재해야 하는지에 대해서 찾아보자면,,

 

위 코드를 실행해보면, myclass의 num값 자체는 잘 변하지만, NavigationLink를 타고 나왔다 다시 들어가 본다면, num의 값은 초기의 0 값으로 복귀되어 있을 것이다. StateObjectNextPage가 초기화 되면서 myClass도 역시 다시 초기화 된다는 말입니다.

-> myClass의 경우에 전 페이지로 돌아갔다가 다시 들어오게 되면, 새로운 객체가 생기고, 원래의 객체는 deinit으로 초기화됩니다.

 

그말은 그럼 @StateObject가 StateObjectNextPage가 아니라 StateObjectNavigation에 있으면 해결되는 문제라는 거 아닌가?

 

맞습니다. 그래서 하위 뷰들에서 공통으로 쓰이는 객체라면 뷰들을 감싸고 있는 상위뷰에 존재하는게 객체가 초기화가 다시 되지 않고 같은 값을 쓸 수 있는 것이죠

 

그리고 StateObjectNavigation에 @StateObject가 존재한다면 StateObjectNextPage로는 값을 어떻게 전달해줄까?

라는 질문에 대한 답변들이 아직 건들이지 않은 두가지의 프로퍼티 래퍼인 @ObservedObject@EnvironmentObject 입니다.

 

거의 다 왔다! 지겨워도 조금만 더 참자!

이제는 다 이해해 보자!

 

 

@ObservedObject를 사용하여 상위 뷰에서 데이터를 전달해준 코드입니다.

import SwiftUI

class MyClass: ObservableObject {
    @Published var num: Int = 0
}

struct StateObjectNavigation: View {
    @StateObject var myClass = MyClass()
    
    var body: some View {
        NavigationView {
            NavigationLink(destination: StateObjectNextPage(myClass: myClass)){
                Text("Go next page")
            }
        }
    }
}

struct StateObjectNextPage: View {
    @ObservedObject var myClass: MyClass
    
    var body: some View {
        VStack {
            Text(String(myClass.num))
            
            Button(action: {
                myClass.num += 1
            }, label: {
                Text("add StateObject num")
            })
        }
    }
}

struct StateObjectNavigation_Previews: PreviewProvider {
    static var previews: some View {
        StateObjectNavigation()
    }
}

 

부모뷰에서 @StateObject로 만들어진 myclass를 하위 뷰의 파라미터로 전달해주고, 그 값을 받는 자식뷰에서는 @ObservedObject를 이용해서 그 데이터를 받아줍니다.

 

끝!

 

마치 @State값을 @Binding해오는 것과 동일한 과정이죠?

 

 

@EnvironmentObject를 사용한 데이터 가져오기도 봐보자, 무엇이 다를까?

 

import SwiftUI

class MyClass: ObservableObject {
    @Published var num: Int = 0
}

struct StateObjectNavigation: View {
    @StateObject var myClass = MyClass()
    
    var body: some View {
        NavigationView {
            NavigationLink(destination: StateObjectNextPage()) {
                Text("Go next page")
            }
        }
    }
}

struct StateObjectNextPage: View{
    @EnvironmentObject var myClass: MyClass
    
    var body: some View {
        VStack {
            Text(String(myClass.num))
            
            Button(action: {
                myClass.num += 1
            }, label: {
                Text("add StateObject num")
            })
        }
    }
}

struct StateObjectNavigation_Previews: PreviewProvider {
    static var previews: some View {
        StateObjectNavigation()
            .environmentObject(MyClass())
    }
}

다른 점으로는 자식뷰로 갈때 @ObervedObject는 하위뷰의 파라미터로 객체를 전달해준것에 반해 @EnvironmentObject는 그런 과정이 없습니다.

직접 전달해줄 필요가 없는 것입니다. @EnvironmentObject로 프로퍼티래퍼를 정해준다면, 직접 그 객체를 Environment에서 찾기 때문입니다.

 

Preview struct 에 .environmentObject(MyClass()) 가 들어간 것은 @EnvironmentObject를 사용했을때의 Preview 화면을 작동하게 만들어줍니다ㅎ

 

여기서 @ObservedObject와 @EnvironmentObject의 차이를 유추 할 수 있는데, 

 

@ObservedObject의 경우, 자식뷰가 여러개고 자식의 자식에서 데이터가 쓰인다면 2번 전달해주는 과정이 있을 것입니다. 하지만, 전역적으로 myClass 같은 변수를 관리해줄 필요가 없겠죠.

 

반면에, @EnvironmentObject의 경우는 뷰의 관계를 쭉 따라 데이터를 보낼 그럴 필요가 없을 것이라는 겁니다. @ObservedObject에 비해 많은 복잡한 하위 뷰들에서 공통적으로 사용될 때 이점이 있을 것입니다. 자식의 자식이더라도 데이터를 계속 이어올 필요 없이, @EnvironmentObject로 Environment에 있는 객체를 끌어다 뷰에서 사용하면 되는 것이지요.

 

이 얼마나 간편한가...

 

WWDC에서 위에 대해 설명하는 이미지를 첨부하자면, 

@EnvironmentObject는 선언해주고 가져오면 된다는 것입니다.

 

위와 같은 그림에서는 @ObservedObject로 객체를 가져오는데 복잡함이 있겠죠?!

 

그.리.고. 마지막으로는 위의 그림에서와 같이 처음 @StateObject가 선언된 화면에는 .environmentObject(MyClass())와 같은 설정 값 주입이 있어야 한다. 그래야 나중에 찾을 수 있을테니..!!

 

+알파!!

@StateObject의 경우 뷰 스택에서 상단에 있는게 선호되는 이유가 있다면, @StateObject가 선언된 뷰의 하위 뷰에서는 제대로 동작하지만, 선언된 해당 뷰의 상위 뷰로 가게되면, @StateObject로 만들어진 객체가 초기화가 된다. 이 경우에 굉장히 중요한 비즈니스 로직을 담당하고 있는 객체가 초기화가 되면 안되는건 당연하기에 하나의 기능을 담당하는 class단위로 @StateObject를 만드는 것을 추천합니다!

 

 따라서, @StateObject가 쓰이는 적절한 위치로는, 해당 객체가 초기화가 되어도 문제가 없는 선에서의 최대 계층에 있다면, 다른 말로 해보면, 해당 프로퍼티가 쓰이는 시퀀스의 최상단 뷰에 있는 것이 적절하다.

 

끝!