SwiftUI State and Data Flow

Updated:

🔷 데이터 흐름 이해하기

iOS 에서 데이터는 텍스트에 포함된 문자열이나 토글의 상태, 사용자 정보 등 UI를 표현하는데 사용되는 모든 정보를 의미합니다.

SwiftUI 에서 데이터를 변경하고 전달하는 방식은 기존과 크게 달라 졌습니다

👉 프로퍼티 잢 수정하기

  • 프로퍼티 값을 수정하려고 할때 body 내에서 값을 변경하려고 하면 self 를 변할 수 없다는 error 메세지가 나옵니다

  • 그래서 SwiftUI 에서는 뷰에서 어떤 상태를 저장하고 수정하는 방법으로 @State, @Binding 과 같은 몇 가지 새로운 도구를 제공합니다. 아래는 @State 를 사용해서 UI 상에서 데이터를 변경하는 예시 입니다

struct ContenView: View {
  @State private var framework: String = "UIKit"

  var body: some View {
    Button(framework) {
      self.framework = "SwiftUI"
    }
  }
}
  • 위의 코드와 같이 ContentView 에 @Stater 가 선언된 framework 프로퍼티는 항상 초기값만을 그대로 유지하고 있을 뿐 변경이 발생하더라도 직접 값을 바꾸는 대신, SwiftUI 에서 제공하는 저장소에 그 값을 전달하고 참조하는 형태로 동작합니다

  • SwiftUI 에서 이 값들이 언제 읽혀지고 변경되는지 알 수 있어서 적절한 시점에 뷰를 갱신하는 것과 같은 작업들을 대신 수행할 수 있습니다. UIKit 에서 다루던 데이터와 다른 새로운 데이터 흐름을 파악하는게 중요합니다

👉 데이터 흐름의 2가지 원칙

데이터 의존성

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

아래의 코드는 UIKit에서 데이터를 변경하는 대표적인 예시 코드 입니다

// count 값이 변경되면 뷰에 변경 정보를 반영
var count = 0 {
  didSet { countLabel.text = "\(count)"}
}

// 새로운 데이터가 추가되면 UITableView 갱신
func appendData() {
  data.append("New Data")
  tableView.reloadData()
}

위와 같이 UIKit 에서는 데이터가 추가되거나 변경되면, 변경 사항을 뷰에 반영하려고 tableView.reloadData() 와 같이 추가 코드를 작성해야 합니다. 이렇게 되면 데이터의 대한 의존성 정의를 수작업으로 하는 것으로 코드가 복잡해지고 code 양이 많아 질수로 bug 가 발생될 가능성이 높습니다

📌 SwiftUI 에서는 이런 문제를 해결하기 위해, 뷰가 어떤 데이터에 대해 의존성이 있는지 알려만 주면 나머지는 프레임 워크에서 알아서 처리하도록 설계 되어 있습니다

https://docs-assets.developer.apple.com/published/4fee13b0ffd4854249fa6d4740449865/13300/SwiftUI-SaDF-Overview@2x.png

프레임워크 내에서 데이터 변화에 동작이 수행되고 시스템은 변경된 상태를 감지해 해당 상태에 의존하고 있는 뷰를 갱신하여 새로운 버전의 UI 를 생성합니다.

예를 들어 Toggle 상태가 true, false 상태로 바뀔때마다 프레임 워크가 자동으로 이를 감지하고 자동으로 뷰를 새로 그려줍니다. body 프로퍼티를 다시 호출하게 되지만, 모든 것을 다시 그리는 것이 아니라 구조를 따라 내려가면서 @State 를 소유한 뷰를 비교하고 유효성을 검사하여 변경된 부분만 다시 랜더링 하기 때문에 매우 효율적으로 작업을 수행할 수 있습니다.

단일 원척 자룔 (Single Source of Truth)

뷰가 참조하는 데이터는 단일 원천 자료여야 합니다.

// SuperView 에서 다른 값을 전달해줍니다 : 원천 자료로서 다른 값을 전달해줌
struct SuperView : View {
  let name = "Jacob"
  var body: some View { SubView(name: name) }
}

// SubView 의 SuperView 의 name 으로 부터 파생된 자료라고보면 됨
struct SubView: View {
  let name: String
  let body: some View { Text("\(name)")}
}

단일 원천 자료를 가져야 한다는 것은 데이터 요소가 여러 곳으로 나뉘어 중복되지 않고 한 곳에서 다루어지고 수정되어야 한다는 것임

이때 생성자를 통해 값을 넘겨 주었다 하더라도 두 뷰가 각각 고유한 값을 가지며, 그 값이 중간에 변경될 수 있어 다시 불일치 문제가 발생할 수 있습니다. 이 문제를 피하기 위해선 값이 변할 때마다 별도의 동기화 작업을 해 주어야 합니다.

struct MainView: View {
  @state private var isFavorite: Bool = true
}
// MainView 에 있는 isFavorite 참조하기
struct DetailView: View {
  @Binding var isFavorite: Bool
}

@Binding 프로퍼티 래퍼는 전달받은 데이터를 읽거나 직접 변결할 수 있도록 만들어 진 타입으로, 상세 화면 그 자체가 별도의 원천 자료를 가지는 대신 메인 화면의 것을 참조하게 됩니다. 즉 개념적으로 @State 가 적용된 프로퍼티는 원천자료, @Binding 이 적용된 프로퍼티는 파생 자료에 해당합니다.

🔷 Data Tools

UIKit 에서는 단순히 어떤 값을 저장해 두기 우한 일반 프로퍼티와 상태 값을 지닌 프로퍼티를 별도로 구분하여 사용하지 않습니다. 하지만 SwiftUI 에서는 뷰의 상태를 저장해 두기 위해 @State 를 사용하며, 이것과 합쳐져 앱에 대한 원천 자료를 구성합니다. 그리고 이러한 것들로 파생된 데이터들까지 합쳐지면 앱에서 사용하는 전체 대이터가 만들어 지게 됩니다

👉 @State, @Binding

  • @State : 뷰 자체에서 가져야 할 상태 프로퍼티이자 원천 자료로, 어떤 데이터에 대한 영속적인 상태를 저장하고 관찰하는 역활을 수행합니다

  • @Binding: 상위 뷰가 가진 상태를 하위 뷰에서 사용하고 수정할 수 있게 해 주는 파생자료에 해당 합니다. 연산 프로퍼티의 형태로 사용되어 그 자신이 직접 값을 보유하지 않고, 값을 Read, Write 하고 개시 되고 다른 뷰에 갱신된 데이터를 전달하는 역활을 합니다

// Toggle과 Stepper 구현 예시.
// 뷰에서 읽고 변경할 값을 저장하기 위해 State가 사용되며
// 자식 뷰에서 상위 뷰의 값을 변경하기 위해 Binding이 사용됩니다.
struct Example01: View {
  // @State 는 자신의 UI 상태를 저장하기 위한 데이터를 설계되었으므로, 해당 뷰가 소유하고 관리한다는 개념을 명시적으로 나타내기 위해 항상 private 접근 레벨을 사용하는것이 좋습니다
  @State private var isFavorite = true
  @State private var count = 0

  var body: some View {
    VStack(spacing: 30) {
      // $isFavorite: 내부적으로 projectedValue 라는 프로퍼티를 이용하게 되는데 이 타입이 Binding 타입이기에 Binding 타입으 ㅣ매개 변수에 상태 프로퍼티의 값을 전달 해 줄수 있습니다
      // 토글에 Binding 타입을 사용하는 이유는, 토글은 ContentView 가 가진 상태를 표현하거나 변경하는 역활만 하면 됩니다
      Toggle(isOn: $isFavorite) {
        Text("isFavorite: \(isFavorite.description)")
      }

      // 같은 뷰 내에서 값을 읽거나 쓰는 경우는 접두어 없이 일반 변수 처럼 사용할 수 있습니다.
      Stepper("Count : \(count)", value: $count)
    }
    .padding()
  }
}

스크린샷

  • 위와 같이 상태 프포퍼티 @State 정의하는것은 SwiftUI 에 이 데이터는 변할 수 있고, 뷰가 이것에 의존성을 가지고 있다고 선언하는것이다. 즉, 데이터가 변경되면 변경사항을 감지해서 자동으로 View 에 갱신해 주는 것입니다.

👉 ObservableObject, @ObservableObject

  • @State가 뷰의 상태를 저장하고 다루기 위한 원천 자료로 이용되었다면, 뷰 외부의 모델이 가진 원천 자료를 다루기 위한 도구로써 ObservableObject 가 사용됩니다.

  • ObservableObject 는 프로토콜이며, AnyObject 를 채택하고 있으므로 구조체(struct), 열거형(enum) 타입을 사용할 수 없습니다.

  • @ObservedObject 는 ObservableObject 프로콜을 준수하는 모델에 해당 뷰가 의존성을 가진다는것을 알리기 위해 사용하는 속성입니다.

  • @State는 뷰 자신이 상태값을 가지는 데 반해, @ObservedObject 는 뷰 외부의 모델에 의존성을 가지고 그 데이터의 변화를 감지하기 위해 사용한다는 점이 다릅니다.

// User Model
final class User: ObservableObject {
  let objectWillChange = ObjectWillChangePublisher()

  let name = "Jacob"

  var score = 0
}

// ObservableObject 프로토콜을 채택한 모델을 다룰 때
// ObservedObject를 사용할 수 있습니다.
struct Example02: View {
  @ObservedObject var user = Ch05_1_DataFlow.User()

  var body: some View {
    VStack(spacing: 30) {
      Text(user.name).font(.title)

      // 버튼을 눌러도 View 에서는 변경되지 않습니다
      Button(action: { self.user.score += 1 }) {
        Text(user.score.description).font(.title)
      }
    }
    .padding()
    .border(Color.black)
  }
}

위의 코드를 실행해 보면 값이 변경되는데 View 전달 되지 않아서 화면에 표시에 변경되지 않습니다. 정확히 어떤 데이터가 변경사항을 어느 시점에 뷰에 전달할 것인지 알려 줘야 뷰에 반영이 됩니다

👉 @Published

  • 위의 기능을 가능하기 위해선 변수 앞에 @Published 를 선언하면 됩니다

@Published var score = 0

objectWillChange

  • @Published cㅓ럼 프로퍼티의 변경 시점에 즉시 알리는 것이 아닐, 그 시점을 자신이 정하여 알리고 싶은 경우도 있는데 이때는, Observable Object 프로토콜에 선언된 objectWillChange 프로퍼티를 이용할 수 있습니다
let objectWillChange = ObjectWillChangePublisher()
var score = 0 {
  willSet{ objectWillChange.send()}
}

실질적으로 @PublishedObjectWillChangePublisher 가 send 매서드를 호출하는 코드를 좀 간소화 한것입니다. send 매서드를 호출하는 코드를 작성하는 대신에 @Published 로 이용할 수 있습니다.

따라서 @Published sk ObjectWillChangePublisher 는 둘 모두 특정 시점에 관련있는 모든 객체에 알림을 전달하는 기능은 동일하며, 그 시점을 결정할 수 있는지에 대해서만 차이가 있습니다.

👉 @EnvironmentObject

@ObservedObject 가 모델에 대한 직접적인 의존성을 만드는 데 사용했다면, @EnvironmentObject 는 간접적인 의존성을 만드는데 사용하는 래퍼 타입입니다

image

위의 그림과 같이 특정 모델에 대한 참조를 뷰에 직접 전달하여 의존성을 만들어 줍니다. 그리고 그 자식 뷰의 동일 모델에 대한 참조를 전달하기 위해서는 매번 @Binding 을 이용해서 연결해 주어야 합니다

📌 @EnvironmentObject 를 사용하면 다음의 그림과 같이 다르게 됩니다

image

먼저 environmentObject 수식어를 이용해 특정 뷰에 대한 환경 요소로 Observable Object 모델을 등록 합니다. 그 뷰를 포함한 모든 자식 뷰에서 @Environment Object 프로퍼티 래퍼를 이용해서 등록해 두었던 모델에 대한 의존성을 만들 수 있습니다.

@ObservedObject VS @EnvironmentObject

image

  • @ObservedObject 뷰는 서브트리에서 해당 모델을 사용하지 않는 뷰가 있더라도 또 다른 자식 뷰가 사용한다면 꼭 모델을 넘겨 줘야 합니다 (빨간색 View 부분에만 model 의 데이터를 참조 한다고 할때, 부모뷰, 조상뷰에서 @Binding 을 통해서 전달해 주어야 합니다)

  • @EnvironmentObject 은 Environment 가 가진 속성을 함께 가지고 있기 때문에 부모뷰가 어떤 값을 가진다면 그 자식 뷰에 직접 전달받지 않고 어떤 뷰던지 동일한 데이터에 접근할 수 있게 되는것입니다 ( 빨간 View 가 Environment 에 직접 model 데이터의 연결 )

// User Model
final class User: ObservableObject {
  let name = "Jacob"
}

// EnvironmentObject를 이용하면 뷰의 서브 트리 전체가 동일한 데이터를 공유할 수 있습니다.
struct Example03: View {
  var body: some View {
    Ch05_1_DataFlow.Superview()
    // environmentObject 수식어에 자식 뷰에서 공통으로 사용할 수 있는 인스턴스를 만들어 전달합니다. 이 인스턴스는 ObservableObject 프로토콜을 준수 합니다
    // 이제 Superview 의 모든 자식 뷰는 @Environment Object 를 이용해 동일한 User 인스턴스에 접근 할 수 있습니다
      .environmentObject(Ch05_1_DataFlow.User())
  }
}
struct Superview: View {
  var body: some View { Ch05_1_DataFlow.Subview() }
}
struct Subview : View {
  @EnvironmentObject var user: User
  var body: some View {
    Text(user.name.description).font(.title)
  }
}

👉 @StateObject

  • iOS 14 이후에 업데이트 된 wrapper 로써 정의는 instantiates an observable object 입니다

  • SwiftUI 는 언제든지 뷰를 다시 만들 수 있습니다. 그래서 주어진 inputs 을 가지고 뷰를 초기화 하면 항상 동일한 뷰가 되는데, 뷰안에 데이터 객체인 ObservedObject 를 만드는 것은 안전하지 않습니다

  • 그래서 새로히 적용된 StateObject 응 사용함으로써 뷰안에서도 안전하게 ObservedObject 의 instance 를 만들 수 있습니다

	// Book Model
final class Book: ObservableObject {
  @Published var title = "Greate Expectations"

  // A unique identifier that never changes
  let identifier = UUID()
}

struct LibraryView: View {
  @StateObject var book = Book()

  var body: some View {
    BookView(book: book)
  }
}

struct BookView: View {
  @ObservedObject var book : Book

  var body: some View {
    BookEditView(book: book)
  }
}

struct BookEditView: View {
  @ObservedObject var book : Book
  //....
}

위의 코드에서 StateObject 는 ObservableObject 와 같이 동작하는데, 특이점은 자식뷰가 몇번이고, 다시 만들어도 view instance 에 대해 single object instance 를 만들 고 관리한다는 것입니다.

그래서 위의 예제에서 Library View 에 대해서는 unique Book Instance 를 가지게 되는것입니다.

🔷 M


🔶 🔷 📌 🔑 👉

🗃 Reference

Managing Model Data in Your App- https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app

SwiftUI @StateObject - https://eunjin3786.tistory.com/410

스윗한 SwiftUI - https://book.jacobko.info/#/book/1190014815

Categories:

Updated:

Leave a comment