개념부터!! 왜 velog에는 목차 기능이 없는걸까?? 불편하다...
이번주는 데이터를 어떻게 묶을지에 대해 공부했다. 개념이 헷갈리기 때문에 따로 정리해야함.
상태 프로퍼티는 뷰의 데이터 변화를 관리하고, 상태가 변경될 때 UI를 자동으로 업데이트하는 역할을 한다.
뷰 내부에서만 상태를 유지할 수 있으며, 뷰가 새로 생성될 때마다 초기화된다. 값이 변경되면 자동으로 UI가 업데이트된다.
@State private var count = 0
(코드생략)
count += 1
count 변수는 $ 없이 바로 사용되었다. $ 표시는 Binding을 통해 값을 전달하거나 @Binding 속성을 가진 변수에 할당할 때 필요하다.
@Binding과 @State의 차이 : 바인딩은 실제 값을 저장하지 않고 상위 뷰의 @State 변수를 참조한다. 바인딩을 사용하면 값이 복사되지 않고, 원본 값의 메모리 주소를 참조하여 직접 수정할 수 있다. @State 변수를 Binding이 필요한 곳에 전달할 때 $ 기호를 붙이면 Binding을 생성하여 원본 값을 직접 수정할 수 있도록 하는 역할을 한다.
바인딩은 값을 복사하지 않고 원본 값을 참조하기 때문에 불필요한 데이터 복사 없이 성능을 최적화한다.
뷰 계층이 복잡해지면 바인딩을 계속 전달하기 어려워지며, 앱의 중요한 상태는 @State만으로 효율적으로 관리하기 어렵다. 그리고 뷰가 상태를 직접 관리하므로 MVVM 구조를 구현하기 어렵다.
ObservableObject 객체는 뷰가 직접 상태를 관리하는 것이 아니라, 상태를 따로 관리하는 객체를 만들고 그 변화를 감지하여 UI를 자동으로 갱신하는 방식을 사용한다. 게시자와 구독자 간의 관계를 쓴다.
이 동작을 위해 ObservableObject와 함께 @Published 속성 래퍼를 사용하면 특정 값이 변경될 때 이를 자동으로 감지하고 UI를 업데이트 할 수 있다.
@Published의 특징 :
ObservableObject 내에서 선언된 속성을 감지하고 병경 사항을 자동으로 알린다.
@Published 속성이 변경되면 이를 구독하는 모든 뷰가 자동으로 업데이트 된다.
SwiftUI에서 @State와 달리 클래스 기반 객체에서 사용 가능하다.
class CounterViewModel: ObservableObject {
@Published var count: Int = 0
}
struct CounterView: View {
@ObservedObject var viewModel: CounterViewModel = .init()
var body: some View {
VStack {
Text("\(viewModel.count)")
Button(action: {
viewModel.count += 1
}, label: {
Text("카운트 증가 버튼")
})
}
}
여기서 .init()은 뭘까?
.init()은 스위프트에서 생성자 호출을 간략하게 표현하는 문법이다. 보통 객체를 생성할 때 CounterViewModel()처럼 클래스의 이름을 직접 써서 인스턴트를 만들지만, 스위프트에서는 타입이 이미 명확한 경우 .init()으로 생략해서 쓸 수 있다.
그럼 왜 .init()이 가능한가?
변수 viewModel의 타입이 CounterViewModel이라고 이미 명확하게 선언되어 있어서 컴파일러가 CounterViewModel()이라고 안 써도 자동으로 CounterViewModel()을 호출한다.
즉,
var viewModel: CounterViewModel = .init()
➡ 컴파일러가 내부적으로 아래와 같이 변환함
var viewModel: CounterViewModel = CounterViewModel()
그럼 다시 ObservableObject로 돌아와서, 얘의 단점은 뭘까?
@ObservedObject는 객체의 생명주기를 직접 관리하지 않는다. @ObservedObject는 상위 뷰에서 전달 받은 ObservableObject를 구독할 수는 있지만, 뷰가 새로 생성될 때마다 새로운 인스턴스를 참조할 가능성이 있다.
즉, 뷰가 다시 렌더링될 때 기존의 ObservableObject 인스턴스를 유지하는 것이 아니라, 새로운 객체를 참조할 가능성이 있어 기존 상태가 초기화될 위험이 존재한다.
@ObservedObject는 부모 뷰에서 항상 인스턴스를 전달해야 하기 때문에 뷰 계층이 깊어질수록 상태를 전달하는 과정이 복잡해지는 문제가 발생한다.
그렇다면 생명주기란 무엇일까? 생명 주기를 직접 관리하지 않는다는 건 정확히 어떤 의미일까? 또한 위의 단점 사례를 보자.
생명 주기란 객체가 생성되고 사용되다가 소멸하는 과정을 의미한다.
SwiftUI에서 View 생명 주기:
스유에서는 데이터가 변경되면 관련된 뷰가 다시 렌더링되는데, 이 과정에서 뷰가 다시 생성되거나, 사라졌다가 새로 그려질 수 있다. 즉, 뷰는 필요할 때마다 동적으로 생성되고 파괴도는 구조다. 이때 뷰가 다시 생성될 때 기존 상태를 유지할 수 있느냐 없느냐가 중요하다.
뷰가 직접 객체의 생명주기를 관리하지 않는다는 것은 뷰가 새로 생성될 때, 기존 인스턴스를 유지하는 보장이 없다는 뜻이다.
import SwiftUI
class CounterViewModel: ObservableObject {
@Published var count = 0
}
struct CounterView: View {
@ObservedObject var viewModel = CounterViewModel() // <- 새로운 객체가 매번 생성될 가능성이 있음
var body: some View {
VStack {
Text("Count: \(viewModel.count)")
Button("증가") {
viewModel.count += 1
}
}
}
}
다시 한번 코드를 보면, CounterView는 스유에서 상태 벼놔에 따라 다시 렌더링될 수 있다. 만약 CounterView가 다시 렌더링되면, @ObservedObject var viewModel = CounterViewModel() 부분이 다시 실행된다. 그러면 기전 CounterViewModel의 인스턴스가 아니라, 새로운 CounterViewModel()이 생성된다. 결과적으로 이전 상태(count값)가 초기화되어 0으로 리셋되는 문제가 발생할 수 있다!
import SwiftUI
class CounterViewModel: ObservableObject {
@Published var count = 0
}
struct ParentView: View {
@ObservedObject var viewModel = CounterViewModel() // 부모에서 객체를 생성
var body: some View {
VStack {
Text("부모 뷰: \(viewModel.count)")
ChildView(viewModel: viewModel) // 자식 뷰에 전달
}
}
}
struct ChildView: View {
@ObservedObject var viewModel: CounterViewModel // 부모에서 전달받음
var body: some View {
VStack {
Text("자식 뷰: \(viewModel.count)")
Button("증가") {
viewModel.count += 1
}
}
}
}
부모에서 자식으로 반드시 @ObservedObject를 넘겨줘야한다. 그러나 만약 뷰 계층이 3~4단계 이상 깊어진다면? 계속해서 @ObservedObject를 전달해야 해서 코드가 복잡해진다. 또, 모든 뷰가 같은 객체를 공유해야 하므로 의존성이 강해진다.
@ObservedObject는 CounterViewModel의 생명주기를 직접 관리하지 않기 때문에 만약 ParentView가 사라졌다가 다시 생성되면, viewModel도 새로 만들어질 수 있다. 그러면 count 값이 초기화되어 이전 상태를 잃을 위험이 있다!
이러한 단점을 보완하고자 등장한게 @StateObject다.
그럼 뭐가 좋아졌을까?
뷰가 다시 렌더링되더라도 기존의 상태를 유지한다. @StateObject는 뷰의 생명주기 동안 객체를 유지하며, 뷰가 다시 생성될 때도 새로운 객체를 만들지 않고 기존 객체를 유지한다. 즉, @StateObject를 사용하면 참조를 선언한 뷰가 소유권을 가지게 된다. 그러므로 소유하는 뷰 혹은 자식 뷰에 의해 계속 필요로 하는 동안에는 메모리에서 파괴되지 않는다.
객체의 생명 주기를 스유가 자동으로 관리한다.
@StateObject는 객체를 뷰의 생명 주기에 맞춰 자동으로 생성 및 해제하므로, 우리가 직접 객체의 생명 주기를 관리할 필요가 없다.
메모리 관리와 상태 유지 측면에서 매우 중요한 개선점이다.
상태 관리가 더욱 직관적으로 이뤄진다.
@ObservedObject와 달리, 부모 뷰에서 항상 상태를 전달할 필요 없이, 개별 뷰에서 상태를 직접 관리할 수 있다.
@StateObject는 스유의 프로퍼티 래퍼 중 하나이며, 내부적으로 객체의 생명주기를 스유가 직접 관리할 수 있도록 설계되었다.
이를 위해 @StateObject는 일반적인 프로퍼티가 아니라 스유의 상태 저장 메커니즘과 깊게 연관되어 있다.
SwiftUI의 @StayeObject를 직접적으로 구현한 코드를 볼 수는 없지만, 비슷한 방식으로 구현해보자.
@StateObject가 적용된 프로퍼티는 뷰가 생성될 때 단 한 번만 객체를 초기화함.
SwiftUI는 이 객체를 해당 뷰의 생명주기 동안 유지하고 관리함.
뷰가 다시 렌더링되더라도, 기존의 객체가 유지되도록 동작함.
SwiftUI의 State Storage Mechanism을 이용해 객체의 메모리를 자동으로 관리함.
내부적으로 DynamicProperty를 준수하여, SwiftUI가 상태 변경을 감지할 수 있도록 함.
라는 특성이 있다는 걸 리마인드하자.
@propertyWrapper
struct CustomStateObject<ObjectType: ObservableObject>: DynamicProperty {
@State private var storedObject: ObjectType
init(wrappedValue: @autoclosure @escaping() -> ObjectType) {
self._storedObject = State(wrappedValue: wrappedValue())
}
var wrappedValue: ObjectType {
get { storedObject }
}
var projectedValue: ObjectType {
get {storedObject}
}
}
설명:
@propertyWrapper: 스위프트의 프로퍼티 래퍼 기능을 사용하여 @StateObject처럼 동작하는 커스텀 프로퍼티를 만들었다.
DynamicProperty: 스유에서 동적인 상태를 저장하고 변경을 감지할 수 있도록 하는 프로토콜이다. @StateObject도 내부적으로 DynamicProperty를 준수하는 구조체이지 않을까? 싶었다. 이를 통해 스유가 이 객체의 변화를 감지하고, 뷰를 자동으로 업데이트할 수 있게 된다.
@State private var storedObject : @State를 사용해서 내부적으로 객체를 저장하고 관리하도록 했다. @State는 스유에서 상태를 저장하는데, 뷰가 다시 렌더링되더라도 기존의 객체를 유지하는 역할을 한다.
생성자에서 @autoclosure 사용: @autoclosure를 사용하면 wrappedValue로 전달된 인스턴스를 필요할 때만 생성할 수 있다. 객체를 지연 생성(lazy initialization)할 수 있게 한다. 즉, 객체 생성 시점을 SwiftUI가 관리할 수 있게 되는 것이다.
@autoclosure은 기본적으로 비탈출(Non-Escaping) 클로저다. 즉, 원래 함수 내부에서만 실행할 수 있고, 저장할 수 없다. 하지만 우리는 이 클로저를 State 프로퍼티에 저장해야 하므로, 탈출 가능하게 만들어야 한다. 그래서 @escaping을 붙여서 객체를 나중에 실행할 수 있도록 했다.
클로저란 코드에서 변수처럼 저장하고 전달할 수 있는 익명 함수다. 변수처럼 사용할 수 있는 함수라고 생각하면 된다.
func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
이 함수를 클로저로 변형해보자.
let addClosure: (Int, Int) -> Int = { (a: Int, b: Int) in
return a + b
}
여기서 { (a: Int, b: Int) in ... } 부분이 클로저(Closure)다.
스위프트는 클로저 문법을 더 짧게 쓸 수 있도록 다양한 Syntax Sugar(축약 문법)을 제공한다.
let addClosure: (Int, Int) -> Int = { a, b in
return a + b
}
클로저의 타입을 이미 명확하게 선언했으므로 (Int, Int) -> Int 클로저 내부에서 파라미터의 타입을 생략 가능하다.
let addClosure: (Int, Int) -> Int = { a, b in a + b }
클로저 내부의 코드가 한 줄이면 return을 생략할 수 있다.
let addClosure: (Int, Int) -> Int = { $0 + $1 }
$0, $1은 첫 번째, 두 번째 매개변수를 의미한다.
self._storedObject = State(wrappedValue: wrappedValue()) :
wrappedValue()를 호출해서 객체를 한번 실행한 후, State 래퍼에 저장한다. 즉, 뷰가 리렌더링되어도 storedObject는 기존 객체를 유지할 수 있게 된다.
객체 접근 방식으로 wrappedValue와 projectedValue 를 사용했다. wrappedValue 프로퍼티를 사용하면 객체에 접근할 수 있다. projectedValue는 필요하면 $변수명 형태로 바인딩된 객체를 가져올 수도 있다.
warppedValue란?
@propertyWrapper를 사용하면 래퍼 내부에서 감싸고 있는 값을 wrappedValue라고 한다. 즉, wrappedValue는 CustomStateObject 안에서 관리할 실제 객체를 의미한다. 여기서는 ObjectType이 ObservableObject이므로 wrappedValue는 ObservableObject의 인스턴스가 된다.
다시 @StateObject로 돌아가자. 예제코드는 아래와 같다.
/* viewModel 작성 */
import SwiftUI
class CounterViewModel: ObservableObject {
@Published var count: Int = 0
}
import SwiftUI
struct CounterView: View {
@StateObject var viewModel: CounterViewModel = .init()
var body: some View {
VStack {
Text("\(viewModel.count)")
Button(action: {
viewModel.count += 1
}, label: {
Text("카운트 증가 버튼")
})
}
}
}
그럼 언제 @StateObject와 @ObservedObject를 사용해야할까?
@StateObject를 사용해야하는 경우 :
뷰에서 새로운 ObservableObject를 생성하고, 해당 객체를 해당 뷰의 생명 주기 동안 유지해야할 때 (ViewModel을 뷰에서 직접 생성하여 해당 뷰의 상태를 독립적으로 관리할 경우)
뷰가 다시 생성되더라도 기존 상태를 유지해야할 때 (네트워크 요청 상태를 유지해야하는 경우)
@ObservedObject를 사용해야 하는 경우:
부모 뷰에서 생성된 ObservableObject를 하위 뷰에서 사용해야 할 때,
객체의 생명 주기를 직접 관리하고 싶을 때 (ObservableObject가 특정 뷰에 의존하지 않고, 앱 전반에서 사용될 경우)
@Observalbe 매크로를 활용한 방식: 기존의 @Published 프로퍼티 래퍼와 @ObservableObject 프로토콜을 대체하는 역할을 하며, Swift Macro 기능을 활용하여 자동으로 옵저버블 동작을 수행한다.
/* viewModel */
import Foundation
@Observable
class CounterViewModel {
var count = 0
}
/* View */
import SwiftUI
struct ContentView: View {
var viewModel: CounterViewModel = .init()
var body: some View {
VStack {
Text("\(viewModel.count)")
Button(action: {
viewModel.count += 1
}, label: {
Text("카운트 증가합니다.")
})
}
.padding()
}
}
#Preview {
ContentView()
}
기존에는 @Binding을 이용했으나 새로운 방식에서는 @Bindable을 활용한다. 다만, @Observable 매크로가 걸려있을 경우에만 이용 가능하다.
import SwiftUI
import Observation
@Observable
class Counter {
var count = 0
}
struct CounterView: View {
private var counter = Counter()
var body: some View {
VStack {
Text("Count: \(counter.count)")
Button("Increment") {
counter.count += 1
}
ChildView(counter: counter)
}
}
}
struct ChildView: View {
@Bindable var counter: Counter
var body: some View {
Button("Child Increment") {
counter.count += 1
}
}
}
@Observable를 사용하면 얻을 수 있는 이점 :
코드가 간결해짐. 클래스에 @Observable만 붙이면 자동으로 상태 변화를 감지한다.
뷰 업데이트 메커니즘이 더 효율적으로 바뀜. 뷰의 body에서 프로퍼티를 직접 읽어야지만 뷰가 업데이트된다.
데이터 모델 객체를 Optional로 가질 수 있다.
상위 뷰에서 설정된 값들을 하위 뷰에서 자동으로 가져와 사용할 수 있도록 하는 전역적인 데이터 전달 방식이다.
@Environment의 주요 특징:
뷰 계층을 따라 자동으로 데이터 전달
뷰 간 데이터 전달을 쉽게 할 수 있다 : @Binding처럼 상위 뷰에서 일일이 데이터를 전달할 필요 없어, 전역적으로 설저오딘 값을 사용할 수 있어 보다 간결한 코드 작성이 가능하다.
사용자 정의 @EnvironmentObject 값 활용 가능 : @EnvironmentObject는 ObservableObject를 활용하여 앱의 전역적인 상태를 관리할 때 사용된다.
import SwiftUI
struct EnvironmentExampleView: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
VStack {
Text("현재 색상 모드: \(colorScheme == .dark ? "다크 모드" : "라이트 모드")")
.padding()
.background(colorScheme == .dark ? Color.black : Color.white)
.fore(colorScheme == .dark ? Color.white : Color.black)
}
.padding()
}
}
#Preview {
EnvironmentExampleView()
}
왜 @EnvironmentObject를 사용할까?
전역적으로 상태를 공유할 수 있다.
데이터를 자동으로 업데이트한다. : @Published 속성을 사용하면 데이터가 변경될 때 자동으로 UI가 갱신된다.
class UserViewModel: ObservableObject {
@Published var username: String = "초기 사용자"
}
import SwiftUI
struct ParentView: View {
@StateObject var userViewModel: UserViewModel = .init()
var body: some View {
NavigationStack {
VStack {
Text("현재 사용자: \(userViewModel.username)")
.font(.title)
NavigationLink("프로필 화면으로 이동", destination: ProfileView().environmentObject(userViewModel)
NavigationLink("설정 화면으로 이동",
destination: SettingsView().environmentObject(userViewModel)
)
}
}
}
}
/* 자식 뷰(프로필 뷰) */
import SwiftUI
struct ProfileView: View {
@EnvironmentObject var userViewModel: UserViewModel // 부모 뷰에서 전달된 환경 객체 사용
var body: some View {
VStack {
Text("프로필 화면")
.font(.largeTitle)
Text("사용자 이름: \(userViewModel.username)") // 부모 뷰의 상태가 자동 반영됨
.font(.title)
Button("이름 변경") {
userViewModel.username = "새로운 사용자" // 값이 변경되면 모든 뷰에 즉시 반영
}
.padding()
.background(Color.blue)
.foregroundStyle(.white)
}
}
}
#Preview {
ProfileView()
.environmentObject(UserViewModel())
}
/* 자식 뷰(환경 설정 뷰) */
import SwiftUI
struct SettingsView: View {
@EnvironmentObject var userViewModel: UserViewModel // 동일한 전역 객체 사용
var body: some View {
VStack {
Text("설정 화면")
.font(.largeTitle)
TextField("사용자 이름 변경", text: $userViewModel.username) // TextField로 직접 변경 가능
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
}
#Preview {
SettingsView()
.environmentObject(UserViewModel())
}
우선 UserDefaults부터 보자. UserDefaults는 싱글톤 패턴을 따르는 클래스로 설계되어 있다.
UserDefaults를 통해 데이터를 저장하려면 UserDefaults.standard 객체의 set(_:forKey:) 메서드를 사용한다.
let defaults = UserDefaults.standard
// 기본 자료형 데이터 저장하기
defaults.set(true, forKey: "isLoggedIn") // Bool 값 저장
defaults.set(25, forKey: "userAge") // Int 값 저장
defaults.set("John", forKey: "username") // String 값 저장
// 배열과 딕셔너리 저장하기
defaults.set(["Apple", "Banana", "Orange"], forKey: "fruits")
defaults.set(["name": "John", "age": 25], forKey: "userDetails")
@AppStorage는 내부적으로 UserDefaults를 활용하며, 간단한 키-값 저장 방식을 제공한다.
@AppStorage("username") var username: String = "Guest"
위 코드는 기본 형태입니다. “userName”이라는 키에 해당하는 값이 UserDefaults에 저장된다.
앱을 종료하고 다시 실행해도 “userName” 키의 값은 유지된다.
스유에서 화면 크기나 뷰의 상대적인 위치를 동적으로 조정할 때 사용한다.
import SwiftUI
struct ContentView: View {
var screenSize: CGRect {
guard let windowScene = UIApplication.shared.connectedScnes.first as? UIWindowScene else {
return .zero
}
return windowScene.screen.bounds
}
var body: some View {
VStack {
Text("Screen Width: \(screenSize.width)")
Text("Screen Height: \(screenSize.height)")
}
}
}
UIApplication.shared는 현재 실행 중인 앱의 공용(application-wide) 객체이다. iOS 앱에서 앱 전체의 상태를 관리하는 싱글톤 객체(Singleton Object) 이다. 앱의 생명 주기(lifecycle), 알림(notification), UI 상태 관리 등을 담당한다.
Scene이란?
앱에서 여러 개의 화면(윈도우)를 가질 수 있으며, 각 윈도우는 Scene 객체로 관리된다. connectedScenes에는 현재 실행 중인 모든 Scene이 포함된다.
.first as? UIWindowScene:
connectedScenes는 Set 타입이라 순서가 없지만, first를 사용하면 첫 번째 Scene을 가져온다. UIWindowScene은 앱이 실행 중인 화면(Window)을 포함하는 Scene이다. iOS에서 UIWindowScene을 통해 현재 화면의 크기, 상태 등을 가져올 수 있다.
return windowScene.screen.bounds:
windowScene.screen은 현재 UIWindowScene이 연결된 UIScreen 객체를 나타낸다. UIScreen은 iPhone, iPad의 물리적인 화면 정보를 제공한다. screen.bounds는 해당 화면의 크기를 CGRect로 반환한다. 이 값은 픽셀 단위가 아니라 point 단위로 주어진다. 예를 들어, iPhone 14 Pro Max의 실제 픽셀 해상도는 2796 × 1290이지만, bounds의 크기는 430 × 932이다. 이는 iOS가 점(point) 단위로 좌표를 관리하고, 필요에 따라 Retina 스케일(배율) 을 적용하기 때문이다.
디바이스 화면 크기에 따라 자동으로 크기 조절되는 버튼 만들기
import SwiftUI
struct ResponsiveButtonView: View {
var body: some View {
GeometryReader { geometry in
VStack {
Text("반응형 버튼")
.frame(width: geometry.size.width * 0.6, height: geometry.size.height * 0.1)
.background(Color.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
}