SwiftUI, The Source Of Truth

Eung7
11 min readFeb 15, 2024

--

SwiftUI에서 UI와 관련된 데이터 플로우에서 가장 중요한 것은 The Source Of Truth입니다. 직역 그대로 복사된 데이터들이 아니라 원본 데이터라는 뜻을 가지고 있습니다. SwiftUI는 원본 데이터의 변경으로 화면의 렌더링을 개발자의 의도적인 업데이트없이 가능하게 됩니다.

UIKit에서는 데이터와 화면간의 인터페이스를 Delegate나 Closure를 이용한 것을 자주 보셨을 겁니다. 그렇지만 SwiftUI에서는 The Source Of Truth때문에 이런 것들이 필요 없게 됩니다. SwiftUI에서 소개하는 툴은 대표적으로 두 가지입니다.

State

State는 대표적인 SwiftUI의 Source Of Truth입니다. 기존 데이터 모델의 @State 어노테이션을 추가하면 구조체에 한해서 Source Of Truth가 됩니다. State 데이터 모델과의 소통은 Binding 이라는 키워드를 붙여 소통할 수 있습니다. 아래 도식화를 참고하면, BookView에서 State로 Source Of Truth를 생성하고 자신의 하위 뷰에 Binding을 통해 같은 데이터 모델을 공유하고 있습니다.

아래는 State와 Binding을 통해 서로 다른 View 계층에서 어떻게 하나의 데이터 모델을 공유하는지에 대한 예시 코드입니다. BookView에서 State 데이터인 editorConfig라는 Source Of Truth를 생성했습니다. 그런다음 그 하위 뷰인 ProgressEditor에서도 Binding을 통해 EditorConfig라는 타입으로 변수를 선언했습니다.

그렇다면 ProgressEditor의 TextEditor가 note 속성을 변화시키면, State는 이 변경된 값을 감지해 BookView를 다시 그리게 됩니다.즉 데이터 모델의 의존성을 가지고 있는 뷰에서 변경을 하면, 개발자와의 의도와는 상관없이 화면은 다시 렌더링을 하게 됩니다.

struct EditorConfig {
var isEditorPresented = false
var note = ""
var progress: Double = 0
}
struct BookView: View {
@State private var editorConfig = EditorConfig()
var body: some View {

ProgressEditor(editorConfig: $editorConfig)

}
}

struct ProgressEditor: View {
@Binding var editorConfig: EditorConfig

TextEditor($editorConfig.note)

}

State는 더 나아가서 Binding을 State로 부터만 만들 수 있는 것은 아닙니다. Binding이 Binding을 만들 수 있습니다. 결국 Source Of Truth는 단 하나이기 때문이죠.

물론 State를 너무 남발하기 보다는 화면에서 필요한 데이터가 어떤 식으로 흘러가는지 스스로에게 질문을 던지는 것을 추천드립니다. 어떠한 데이터 변형도 없는 경우에는 오히려 일반적인 속성 값을 쓰는 것이 나은 방법일 수 있습니다.

ObservableObject

Apple이 발표하기를 State를 발표한 의도는 일시적인 UI 상태를 처리하기 위해 만들어졌다고 발표했습니다. 이것은 어떻게 보면 당연합니다. State를 가지고 있는 화면의 라이프 사이클에 의존하니까요. 일반적으로 데이터는 UI와 별도로 저장하고 처리할 수 있습니다. 만약 데이터의 고차원적인 작업을 원한다면(데이터 수명주기 관리, 영구적인 저장 및 동기화, 사이드 이펙트 처리) ObservableObject가 그 해결방법이 될 것입니다.

But State is designed for transient UI state that is local to view. — WWDC20

이제부터 ObservableObject의 구체적인 정의를 살펴보겠습니다. ObservableObject는 클래스에서만 사용할 수 있는 프로토콜입니다. 이 프로토콜을 채택하면 objectWillChange라는 Publisher를 얻게 되는데, 이것을 통해 ObservableObject의 값 변화를 감지할 수 있게 됩니다.

즉 특정 Class가 ObservableObject를 채택하는 경우는 새로운 Source Of Truth를 생성하는 것입니다. 화면과 ObservableObject간의 의존성을 설정하고 데이터의 변경을 감지하여 화면을 새롭게 렌더링합니다.

하지만 여기서 ObservableObject가 데이터 그 자체일 필요는 없습니다. UIKit에서 보편적으로 사용되는 MVVM의 ViewModel을 생각하시면 익숙하실 겁니다. 즉 값 타입의 데이터를 하나의 참조 타입으로 관리함으로써 저장, 화면의 수명주기 및 사이드 이펙트를 관리할 수 있게됩니다.

값 타입의 데이터 모델은 따로 존재하고 주황색으로 표시된 ObservableObject가 화면과 의존성을 유지하며 데이터를 관리하고 있는 모습입니다. 마치 관리자의 모습과 비슷합니다. 이렇게 되면 데이터에 대한 로직이 한 객체로 통일이 되어서 앱의 유지보수성이 증가될 수 있습니다.

구체적인 예시를 들어봅시다. 여기 CurrentlyReading이라는 ObservableObject를 채택하는 클래스가 있습니다. 여기에서의 book은 변하지 않는 데이터기 때문에 상수로 정의했고 progress는 계속해서 변하는 데이터기 때문에 @Published를 사용했습니다. 즉 해당 변수는 다른 화면에서 변경을 감지하여 화면을 업데이트하게 됩니다.

/// The current reading progress for a specific book.
class CurrentlyReading: ObservableObject {
let book: Book
@Published var progress: ReadingProgress

// …
}

struct ReadingProgress {
struct Entry : Identifiable {
let id: UUID
let progress: Double
let time: Date
let note: String?
}

var entries: [Entry]
}

즉 @Published는 ObservableObject를 관찰가능하도록 만드는 속성 Wrapper입니다. 일반적으로 변경될 수 있는 속성 값에 해당 어노테이션을 추가하기면 하면 됩니다. 그러면 위에서 보여준 기본 Publisher가 내부적으로 시스템에서 변경 값을 방출하여 의존성이 있는 화면을 다시 렌더링합니다.

여기서 화면과 ObservableObject 간의 의존성을 만드는 방법은 세 가지가 있습니다.

  • ObservedObject
  • StateObject
  • EnvironmentObject

ObservedObject

ObservedObject는 세 가지 방법 중에서 가장 간단하고 유연한 방법입니다. 만약 ObservableObject인 객체에 @ObservedObject 어노테이션을 추가한다면 그 인스턴스는 Source Of The Truth가 됩니다. 그렇다면 해당 화면과 의존성이 추가된 것이라고 이해할 수 있습니다.

즉 ObservedObject를 화면에서 사용하게 되면 ObservableObject의 objectWillChange인 Publisher를 내부적으로 구독합니다. 이것이 의존성을 가지는 모든 화면이 업데이트 되는 이유입니다.

State와 마찬가지로 ObservableObject도 @Binding을 통해서 @Published인 값을 변경할 수 있습니다. 아래와 같이 Toggle()을 클릭하면 $을 통해 ObservableObject의 @Pulished 속성 값을 변경합니다.

struct BookView: View {
@ObservedObject var currentlyReading: CurrentlyReading

var body: some View {
VStack {
BookCard(
currentlyReading: currentlyReading)

HStack {
Button(action: presentEditor) { /* … */ }
.disabled(currentlyReading.isFinished)

Toggle(
isOn: $currentlyReading.isFinished
) {
Label(
"I'm Done",
systemImage: "checkmark.circle.fill")
}
}
//…
}
}
}

StateObject

앞서 알아본 ObservedObject는 화면의 수명주기와는 관계없이 계속 유지됩니다. 이는 참조 타입의 특징을 잘 이용한 것이라고 알 수 있습니다. 하지만 가끔씩 화면과의 수명주기와 결합하는 경우도 필요합니다. 예를 들어 네트워킹이 필요한 작업(사진 불러오기 등)이 필요할 떄입니다. 그래서 Apple은 뒤늦게 StateObject를 도입했습니다.

@StateObject 어노테이션을 통해 쉽게 해당 방법을 적용할 수 있습니다.이 속성은 화면의 body값이 호출되기 바로 전에 호출됩니다. 절대 화면이 초기화될 때 생성되지 않습니다. 그리고 해당 객체는 화면의 라이프 사이클 동안 유지됩니다. CoverImageLoader는 이미지를 불러오는 ObservableObject입니다. 이미지를 불러오면 화면을 자동으로 업데이트합니다. 만약 화면이 더 이상 필요하지 않다면, CoverImageLoader를 해제합니다.

class CoverImageLoader: ObservableObject {
@Published public private(set) var image: Image? = nil

func load(_ name: String) {
// …
}

func cancel() {
// …
}

deinit {
cancel()
}
}
struct BookCoverView: View {
@StateObject var loader = CoverImageLoader()

var coverName: String
var size: CGFloat

var body: some View {
CoverImage(loader.image, size: size)
.onAppear { loader.load(coverName) }
}
}

EnvironmentObject

SwiftUI의 화면을 구성하다보면 많은 하위 뷰들이 생성됩니다. 그것이 재사용하기 쉽기 때문입니다. 하지만 다음과 같은 문제점이 발생할 수 있습니다. 화면 계층에서 특정 하위 뷰만 ObservableObject를 소유하고 싶은데, 지금까지의 방식으로는 모든 화면들에게서 해당 인스턴스를 넘겨받아야 합니다.

EnvironmentObject는 이 같은 문제를 해결할 수 있습니다. EnvironmentObject는 View Modifier이면서 동시에 Property Wrapper입니다. 최상위 뷰에서 View Modifier로 ObservableObject를 주입하면 하위 뷰에서 Property Wrapper를 통해 인스턴스를 읽을 수 있게 됩니다. Property Wrapper로 선언된 뷰에서만 의존성을 유지하므로 내부적으로 효율적으로 작동하는 것을 알 수 있습니다.

--

--