[회고] 신입 iOS 개발자가 되기까지 feat. 카카오 자세히보기

🍎 Apple/SwiftUI

[SwiftUI] Observable macro를 통해 모델 데이터를 만들고 관리하는 방법

inu 2023. 10. 28. 14:57
반응형

본 게시글은 애플 개발자 문서의 Managing model data in your app 아티클을 의역하여 작성되었습니다.


Managing model data in your app

SwiftUI 앱은 사용자가 인터페이스를 통해 수정할 수 있는 Data를 화면에 표시해준다. 이 데이터를 다루기 위해, 앱은 Data Model을 생성하고 사용한다. 이들은 데이터를 표시하기 위한 일종의 커스텀 타입이다. Data Model은 데이터와 상호작용하는 View와 데이터 자체에 대한 분리를 가능하게 한다. 이 분리는 모듈화에 기여하며, 테스트 용이성도 증대하고, 앱의 작동방식을 더 쉽게 추론하도록 도와준다. (helps make it easier to reason about how the app works.)

 

데이터 모델의 인스턴스와 화면에 나타나는 정보가 명확히 동기화되는 것은 쉽지 않은 일이다. 만약 데이터 모델이 여러 뷰와 동시에 상호작용하고 있다면 더더욱 그렇다.

 

SwiftUI는 Observation을 이용해 앱의 데이터를 최신으로 동기화한다. Observation을 통해 SwiftUI의 View는 Observable Data Model들에 대해 Dependencies를 형성할 수 있게 되고, 데이터가 변경되면 UI를 업데이트할 수 있게 된다.

Make model data observable

데이터 변경사항을 UI에 표시하려면, Data Model에 Observable을 적용해라. 이 매크로는 컴파일 타임에 데이터 모델에 Observation Support를 추가한다. 이는 데이터 모델이 자신이 보유하려는 데이터 속성에만 집중할 수 있도록 해준다.

 

예를 들어 다음 코드를 보자.

@Observable class Book: Identifiable {
    var title = "Sample Book Title"
    var author = Author()
    var isAvailable = true
}

Observable 매크로는 내부적으로 데이터 모델이 Observable protocol 을 따르도록 만든다. 하지만 프로토콜을 단독으로 사용하지 마라. 프로토콜 단독으로는 아무일도 할 수 없기 때문이다. 반드시 매크로를 활용해라.

Observe model data in a view

View는 Observable Data Model에 Dependency를 형성한다. 아래 코드를 예시로 들면, body 내부에서 Book 인스턴스의 프로퍼티에 접근하면서 Dependency가 생긴다. 만약 body에서 Observable Data의 어떤 프로퍼티에도 접근하지 않으면 어떠한 Dependency도 형성되지 않는다.

struct BookView: View {
    var book: Book

    var body: some View {
        Text(book.title)
    }
}

관찰중이던 프로퍼티가 변경되면, SwiftUI는 View를 업데이트한다. 하지만 만약 관찰 중이지 않은 다른 프로퍼티가 변경되면 View는 영향을 받지 않고 불필요한 업데이트를 피한다. 위 코드에서는 title이 변경되었을 경우에만 업데이트가 동작할 것이다.

 

View가 직접 값을 보유하고 있지 않더라도 이는 마찬가지로 동작한다. (Singleton object or Global object)

var globalBook: Book = Book()

struct BookView: View {
    var body: some View {
        Text(globalBook.title)
    }
}

Observation은 computed property의 변화도 잘 관찰한다. 아래 코드에서도 Available한 Book의 개수 변화를 잘 체크해서 보여줄 것이다.

@Observable class Library {
    var books: [Book] = [Book(), Book(), Book()]

    var availableBooksCount: Int {
        books.filter(\\.isAvailable).count
    }
}

struct LibraryView: View {
    @Environment(Library.self) private var library

    var body: some View {
        NavigationStack {
            List(library.books) { book in
                // ...
            }
            .navigationTitle("Books available: \\(library.availableBooksCount)")
        }
    }
}

Collection 자체의 변화를 인지할 수도 있다. 아래 코드를 보면, body에서 books에 접근하고 있기 때문에 books에 대한 dependency가 형성된다. 만약 books collection에 삽입, 삭제, 이동, 교체 등의 변화가 일어나면 view가 업데이트될 것이다.

struct LibraryView: View {
    @State private var books = [Book(), Book(), Book()]

    var body: some View {
        List(books) { book in 
            Text(book.title)
        }
    }
}

하지만 LibraryView는 body에서 book의 book.title을 직접적으로 접근하지 않고 있기 때문에, 이에 대한 dependency가 형성되지 않는다. LibraryView는 List를 보유한다. List는 내부적으로 @escaping 클로저를 통해 List의 Item들을 만들고, 이를 기반으로 화면에 View를 그린다. 이는 곧 List의 내부 Item인 Text item이 book.title에 대한 dependency를 가진다는 것이다. 만약 특정한 Book의 title이 변경되면, 이에 대한 dependency를 가지고 있는 Text item에만 업데이트가 일어날 것이다.

 

Observation은 Observable한 객체의 프로퍼티 중 body에서 접근하고 있는 프로퍼티들만 추적한다.

 

Observable 데이터 모델을 다른 뷰와 공유해서 사용하는 경우도 있을 것이다. 이 데이터를 받는 곳의 body에서 만약 데이터 모델의 특정 프로퍼티를 읽고있다면 Dependency를 형성한다. 아래의 코드에서 book 데이터 모델을 받는 BookView는 book의 title에 대해 Dependency가 생긴다.

struct LibraryView: View {
    @State private var books = [Book(), Book(), Book()]

    var body: some View {
        List(books) { book in 
            BookView(book: book)
        }
    }
}

struct BookView: View {
    var book: Book

    var body: some View {
        Text(book.title)
    }
}

만약 뷰가 어떠한 dependency도 없다면 데이터의 변경에도 View에 아무런 변화가 없을 것이다. 이는 데이터 모델을 전달하고 전달하더라도 그 중간에 있는 View에는 아무런 영향도 주지 않음을 의미한다.

// Will not update when any property of `book` changes.
struct LibraryView: View {
    @State private var books = [Book(), Book(), Book()]

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

// Will not update when any property of `book` changes.
struct LibraryItemView: View {
    var book: Book

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

// Will update when `book.title` changes.
struct BookView: View {
    var book: Book

    var body: some View {
        Text(book.title)
    }
}

하지만 만약 레퍼런스 타입의 Observable 데이터 모델의 참조 자체가 변경되면 View에 업데이트가 발생한다. 이는 데이터 모델이 Observable이라서가 아니라, 데이터 모델 참조 자체가 View의 일부이기 때문이다. 아래 코드에서 Book 자체가 변경되면 view가 업데이트 될 것이다.

struct BookView: View {
    var book: Book

    var body: some View {
        // ...
    }
}

Observable 데이터 모델은 다른 객체를 통해 접근하더라도 Dependency 형성이 가능하다. 아래 코드에서 두번째 Text View는 author.name에 대해 dependency를 형성한다.

struct LibraryItemView: View {
    var book: Book

    var body: some View {
        VStack(alignment: .leading) {
            Text(book.title)
            Text("Written by: \\(book.author.name)")
                .font(.caption)
        }
    }
}

Create the source of truth for model data

Model Data를 Source of truth로 만들고 싶으면, private var로 변수를 선언하고 Observable Model Type의 인스턴스를 생성해라. 그리고 @State 프로퍼티 래퍼로 감싸라. 예를 들면 아래와 같이 Book 인스턴스를 감싸면 된다.

struct BookView: View {
    @State private var book = Book()

    var body: some View {
        Text(book.title)
    }
}

Book 인스턴스를 State로 감싸면, SwiftUI에게 해당 인스턴스의 저장공간을 관리하라고 말하고 있는 것이다. BookView가 재생성 될 때마다, book 변수는 해당 인스턴스에 연결된다. 이것이 view에게 하나의 source of truth를 형성하는 과정이다.

 

App이나 Scene 인스턴스의 단계에서 State Object를 생성해도 된다. 예를 들면 아래의 코드는 Library의 인스턴스를 App의 Top-Level에서 형성하고 있다.

@main
struct BookReaderApp: App {
    @State private var library = Library()

    var body: some Scene {
        WindowGroup {
            LibraryView()
                .environment(library)
        }
    }
}

Share model data throughout a view hierarchy

Library와 같은 데이터 모델을 보유하고 있다면, 이를 앱 하위의 특정 장소에 공유하고 싶을 것이다. 두가지 방법이 있다.

  • View hierarchy의 view 각각에 모델 객체를 전달하는 것
  • 모델 객체를 View의 Environment로 추가하는 것

View hierarchy의 view 각각에 모델 객체를 전달하는 방법은 편하지만, View hierarchy가 깊어지면 이를 어디에서 필요로하는지 알기 어려워 코드 이해에 안좋은 영향을 준다.

 

그래서 Environment에 모델 객체를 추가하는 방식이 더 선호된다. environment(_:_:)혹은 environment(_:) modifier를 사용하면 되는데, 전자의 경우 좀 더 과정이 복잡하다.

 

전자의 경우 EnvironmentKey 를 커스텀해서 생성해야한다. EnvironmentValues 를 확장해서 custom key를 가질 수 있도록 get과 set을 설정한다. 예시는 아래 코드와 같다.

extension EnvironmentValues {
    var library: Library {
        get { self[LibraryKey.self] }
        set { self[LibraryKey.self] = newValue }
    }
}

private struct LibraryKey: EnvironmentKey {
    static var defaultValue: Library = Library()
}

이제 실질적으로 environment를 넣어줄 때는 아래 코드와 같이 하면 된다.

@main
struct BookReaderApp: App {
    @State private var library = Library()

    var body: some Scene {
        WindowGroup {
            LibraryView()
                .environment(\\.library, library)
        }
    }
}

environment에서 해당 객체를 가져오기 위해서는 아래 코드와 같이 하면 된다.

struct LibraryView: View {
    @Environment(\\.library) private var library

    var body: some View {
        // ...
    }
}

후자 메서드인 environment(_:)를 사용하면 별도로 environment value를 custom하지 않아도 된다. 다만 반드시 Observable protocol을 conform한 (Observable macro를 사용한) 객체만 넘겨주어야 한다.

@main
struct BookReaderApp: App {
    @State private var library = Library()

    var body: some Scene {
        WindowGroup {
            LibraryView()
                .environment(library)
        }
    }
}

대신 이를 사용하는 곳에서는 key 대신 Model data의 type을 넘겨준다.

struct LibraryView: View {
    @Environment(Library.self) private var library

    var body: some View {
        // ...
    }
}

만약 상위에서 environment로 값을 넘겨준 것이 확실하지 않다면 에러가 발생할 수 있기 때문에 optional 처리를 해주는 것도 좋은 방법이다. 만약 값이 없는 경우 SwiftUI에서는 nil을 반환하기 때문이다.

@Environment(Library.self) private var library: Library?

Change model data in a view

앱이 열린 상태에서 데이터가 바뀌는 경우는 흔하다. 어떤 View에서든지 이 변경사항이 바로 반영되어야 한다. Observation과 함께라면 어떠한 프로퍼티 래퍼나 바인딩없이도 데이터 변화를 반영할 수 있다. 아래 코드에서도 어떠한 프로퍼티 래퍼도 존재하지 않지만 isAvailable의 변화를 감지하고 반영한다.

struct BookView: View {
    var book: Book

    var body: some View {
        List {
            Text(book.title)
            HStack {
                Text(book.isAvailable ? "Available for checkout" : "Waiting for return")
                Spacer()
                Button(book.isAvailable ? "Check out" : "Return") {
                    book.isAvailable.toggle()
                }
            }
        }
    }
}

하지만 View에서 변경가능한 프로퍼티의 값을 변경하기 전에 바인딩이 필요한 경우가 있을 수 있다. 바인딩을 제공하려면 모델 데이터를 @Bindable 프로퍼티 래퍼로 감싸주어야 한다. 예를 들어 다음 코드에서는 book 변수를 Bindable로 감싸주었다. 그런 다음 TextField로 title 프로퍼티를 변경하고, Toggle로 isAvailable 프로퍼티를 변경한다. 그리고 $을 통해 각 프로퍼티를 바인딩한다.

struct BookEditView: View {
    @Bindable var book: Book
    @Environment(\\.dismiss) private var dismiss

    var body: some View {
        VStack() {
            HStack {
                Text("Title")
                TextField("Title", text: $book.title)
                    .textFieldStyle(.roundedBorder)
                    .onSubmit {
                        dismiss()
                    }
            }

            Toggle(isOn: $book.isAvailable) {
                Text("Book is available")
            }

            Button("Close") {
                dismiss()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

Bindable 프로퍼티 래퍼는 프로퍼티를 포함해 Observable 객체인 변수에도 사용이 가능하다. 이는 global 변수인 경우이든, local 변수이든 마찬가지이다. 이는 곧 이렇게도 활용이 가능하다는 것이다.

반응형