SwiftUI 상태와 데이터 흐름

개요

SwiftUI에서 상태와 데이터 흐름에 대해 알아보고 상황에 맞는 데이터 흐름 도구들의 사용법을 익혀보겠다.

애플 공식문서




데이터 흐름


body 안에서 값을 변경하려고 하면 self는 변할 수 없다는 오류가 발생한다.

구조체 연산 프로퍼티의 getter 기본 속성이 nonmutating이기 때문이며

명시적으로 mutating get을 적어줘도 뷰 프로토콜의 body는 get으로 선언되었기 때문에 뷰 프로토콜을 준수하지 않게 된다.

우선 SwiftUI의 데이터 흐름부터 보자.

사용자의 액션 또는 외부의 이벤트에 의해 데이터가 바뀌었을 때, SwiftUI는 자동으로 UI, 상태가 바뀌는 부분을 업데이트한다.


출처




데이터 흐름의 원칙


Data Independence


뷰는 데이터가 변경될 때마다 그 값을 반영해야하므로 데이터에 대한 의존성을 가진다.

데이터가 변경되어 뷰를 새로 그려줄 때 body 프로퍼티를 다시 호출하지만

처음부터 다시 그리는게 아니라 뷰 계층을 내려가면서 유효성 검사를 통해 변경된 부분만 다시 렌더링한다.



Single Source of Truth


SwiftUI는 데이터를 크게 Source of Truth와 Derived data로 구분한다.

Source of Truth는 그 자체가 본질적인 데이터이고 Derived data는 Source of Truth에서 부차적으로 파생된 것이다.

Single Source of Truth는 본질적인 데이터는 여러 곳으로 중복되지 않고 한 곳에서 다루어지고 수정되어야 한다는 것을 의미한다.

SwiftUI는 앱의 데이터를 UI에 연결 시키기 위한 도구들을 제공한다.


View State


뷰를 재사용성 있게 하기 위해서 뷰 계층의 특정 데이터를 캡슐화하는 도구


  • State : 변할 수 있는 UI의 상태를 Locally하게 관리한다.
  • Binding : source of truth의 State값을 참조한다.




Model Data


앱의 data model과 View들을 연결시켜주는 도구

  • ObservedObject : ObservableObject 프로토콜을 준수한 model data를 참조한다.
  • EnvironmentObject : environment에 저장되어있는 관찰 가능한 객체(observable object)에 접근한다.
  • StateObject : 뷰에서 관찰 가능한 객체(observabel object)를 직접 인스턴스화한다.





@State


@State를 사용하면 저장 방식이 달라진다.

@State를 사용해서 래핑된 프로퍼티는 처음 선언된 공간에서 초기값을 그대로 유지하고 있는다.

만약 변경이 발생하면 SwiftUI에서 제공하는 저장소에 그 값을 전달하고 참조하는 형태로 동작하게 된다.

@State로 래핑된 프로퍼티는 뷰 자체에서 가져야 할 상태 프로퍼티이자 Source of Truth에 해당하는 데이터이다.

@State는 뷰 자신의 UI 상태를 저장하기 위한 데이터로 설계되었으므로 private으로 선언되는게 일반적이다.

간단하게 얘기해서 우리가 SwiftUI에게 “이 데이터가 변하면 뷰에 바로 반영시켜줘!” 라고 말하는거라고 생각하면 된다.

1
2
3
4
5
6
7
8
9
10
struct ContentView: View {
	@State private var isFaceUP: Bool = true
	var cnt: Int = 10
	
	var body: some View {
		if cnt > 10 {
			isFaceUP = !isFaceUP
		}
	}
}




@Binding


데이터를 직접 저장하는 것이 아니라 바인딩된 데이터의 Source of Truth의 값을 읽고 쓸 수 있게 하는 프로퍼티 래퍼이다.

간단하게 데이터를 저장하고 있는 프로퍼티와 데이터를 보여주고 변경하는 뷰를 양방향으로 이어주는 것이다.

@State 프로퍼티에 $접두어를 붙이면 다른 뷰에서 @Binding 프로퍼티에서 값을 변경할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct ContentView: View {
	@State private var isFaceUP: Bool = true
	@State private var cnt: Int = 10
	
	var body: some View {
		ChangeCnt(cnt : $cnt)
		if cnt > 10 {
			isFaceUP = !isFaceUP
		}
	}
}

struct ChangeCnt: View {
	@Binding cnt: Int
	cnt += 1
}

//preview에서 binding에 값넣기
changeCnt(cnt : .constant(10))




ObservableObject, @ObservedObject


To make the data changes in your model visible to SwiftUI, adopt the ObservableObject protocol for model classes.

@State가 뷰의 Source of Truth로 사용되었다면 뷰 외부의 뷰모델 혹은 모델이 가진 Source of Truth를 참조 타입으로 다루기 위한 것들이다.

SwiftUI에 모델의 데이터가 바뀌게하고 싶다면 모델 클래스에 ObservableObject 프로토콜을 채택해야한다.

1
protocol ObservableObject : AnyObject

ObservableObject는 프로토콜로 AnyObject를 채택하므로 구조체나 열거형에는 사용할 수 없다.

ObservableObject로 선언된 클래스는 objectWillChange를 사용할 수 있다. 이름으로 알 수 있듯이 send를 이용해서 바뀌는 프로퍼티를 알려준다.

이것을 일일이 안해주려면 @published로 선언하면 된다. 그러면 값이 바뀌면 자동으로 알려준다.

1
2
3
4
5
6
7
8
9
10
11
class Book: ObservableObject {
    @Published var title = "Great Expectations"
}

struct BookView: View {
    @ObservedObject var book: Book
    
    var body: some View {
        Text(book.title)
    }
}




@StateObject


SwiftUI는 시도때도 없이 뷰를 재생성한다. 그래서 주어진 입력에 따라서 초기화 되는 것은 중요한데 뷰안에서 observed object를 만드는 것은 안전하지 않다.

그래서 인스턴스화를 시킬 수 있는 @StateObject를 제공한다.

StateObject는 observed object같이 행동한다. 다른점은 각각의 뷰 인스턴스에서 별개의 object를 생성한다.

1
2
3
4
5
6
7
struct LibraryView: View {
    @StateObject private var book = Book()
    
    var body: some View {
        BookView(book: book)
    }
}




@EnvironmentObject


@ObservedObject가 모델에 대한 직접적인 의존성을 만드는 것이라면, 이는 간접적인 의존성을 만드는데 사용한다.

간단하게 모든 뷰가 읽을 수 있는 data이다.

뷰 계층 간의 데이터를 바인딩 해주고 데이터를 넘겨주는 것을 하기 싫다면 이거 쓰면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct SubView: View {
  @EnvironmentObject var book: Book
  
  var body: some View {
    Text(book.title)
  }
}

struct SuperView: View {
  var body: some View {
    SubView()
  }
}

struct ContentView: View {
  var body: some View {
    Superview().environmentObject(Book())
  }
}