IOS/SwiftUI

Data Flow Through SwiftUI

gangmin 2023. 9. 7. 18:10

저번 포스트에서 SwiftUI 의 뷰 구성에 대해서 알아보았습니다. 이와 함께 보면 좋을 WWDC 영상이 있어서 보면서 느낀점과 함께 내용을 정리해보려고 합니다.

‼️ WWDC 영상을 보면서 해석을 한 부분이 많기 때문에 틀린 부분이 있을 수 있습니다.

 

SwiftUI의 데이터

SwiftUI 는 데이터를 일급 시민으로 보고 있습니다. 데이터의 일반적인 의미인 컴퓨터가 처리할 수 있는 정보 라는 의미보다는 UI를 그릴 수 있는 모든 정보라는 의미로 이야기를 합니다. 다양한 형태의 값을 가지고 있으며, 이들은 모두 데이터가 될 수 있습니다. 예를 들어서 버튼의 누름의 상태와 같은 것들도 데이터가 될 수 있다고 합니다.

 

SwiftUI의 데이터 원칙

1. 뷰에서 데이터를 읽으면 뷰에 대한 종속성이 발생한다.

저희는 데이터가 변경될 때마다 뷰의 새로운 값을 반영하도록 변경해야 합니다. 이 과정에서 데이터는 뷰에 대해서 종속성이 발생하게 됩니다. 즉, 데이터의 변경이 일어나는 행위는 뷰의 변경을 시켜야 한다는 것이 성립되게 됩니다. 다음 그림을 보면 좀 더 쉽게 이해할 수 있다.

 

 

PlayerView_는 어떤 미디어를 재생하는 뷰이다. 또한, 이 미디어가 재생되고 있는 지에 대한 정보는 _isPlaying_이라는 데이터이다. _isPlaying_이라는 데이터가 변경되는 순간 우리가 해야 할 일이 있다. 뷰의 재생 버튼을 일시정지 버튼으로, 일시정지 버튼은 재생 버튼으로 변경해야 한다. 일반적으로 위와 같은 작업을 수동적으로 수행하게 된다면, 빠르게 코드가 복잡해지고 많은 버그가 발생할 가능성이 높아집니다. 하지만, _SwiftUI 는 뷰를 선언형으로 구현하는 것과 더불어서 데이터 의존성도 동일합니다. _SwiftUI_의 지침만 따르면 알아서 해줄 것입니다.

 

2. 뷰 계층 구조에서 읽고 있는 모든 데이터는 오직 한개의 원천을 가진다.

 

 

뷰에서 읽고 있는 모든 데이터들은 결국 하나의 원천을 가지고 있습니다. 데이터는 항상 한개의 생성되는 위치가 있습니다. 저희는 이를 복사하여서 값을 전달하는 작업들을 수동적으로 해왔습니다. 이런 수동적인 작업을 하게 되면, 항상 동기화 상태를 유지하기 위해서 굉장히 많은 코드를 작성해야 할 것입니다. 또한, 이 과정에서 버그가 발생할 가능성이 높아지게 됩니다.

import UIKit

class PlayView: UIView {
    var isPlaying: Bool = false
}

class MenuView: UIView {
    var isPlaying: Bool = false
}

class PlayerViewController: UIViewController {
    private let playView = PlayView()
    private let menuView = MenuView()

    private var isPlaying: Bool = false {
        didSet {
            playView.isPlaying = isPlaying
            menuView.isPlaying = isPlaying
        }
    }
}

 

다음 코드를 보게 되면, PlayerViewController 에서 생성된 데이터가 PlayViewMenuView 에게 데이터를 전달하는 것을 볼 수 있습니다. 이를 통해서 저희는 각각에 맞는 뷰의 변경을 작성해야 할 것입니다. 이런 작업들을 수동적으로 수행하게 되면, 버그가 많이 발생할 수 있기 때문에 SwiftUI 에서는 이를 제거하기 위해서 데이터를 참조하는 방법을 활용하게 됩니다.

 

일단 코드 뚜드려!!

다음과 같이 작성하면 구조체에서 mutating하게 값을 바꿀 수 없엇!!! 와 같은 에러를 마주치게 될 것입니다. 이 오류가 발생하는 이유에 대해서 생각을 해보면, PlayerView는 구조체로 구현되어 있고, 버튼의 터치로 인해서 내부 프로퍼티를 변경하려고 하기 때문에 발생하는 것이다. 이를 해결하기 위해서는 mutating 키워드를 가지는 함수를 구현해야 한다. 이와 유사하게 SwiftUI 에서는 이를 허용하는 방법을 제공한다.

 

@State

SwiftUImutating 의 문제를 해결하기 위해서 State Property Wrapper 를 제공합니다. 이 프로퍼티 래퍼는 시간이 지남에 따라서 값이 변경힐 수 있고, View 가 이를 의존하고 있는지에 대해서 알릴 수 있게 해줍니다. 이런 속성이 변하게 되면, body의 뷰를 다시 생성하게 됩니다.

유저의 상호작용을 통해서 재생 버튼의 액션이 실행되고, isPlaying 프로퍼티가 변하게 됩니다. 이 변화에 대해서 뷰 자신과 자식 뷰들의 body를 모두 새롭게 랜더링하게 됩니다. 이 과정은 직접적으로 변화를 일으키는 것이 아니라, 프레임워크 내부에서 일어나기 때문에 효율적으로 수행된다고 합니다.

 

이것이 '프레임워크가 의존성을 관리해준다.' 의 의미입니다.

Every @State is a source of truth.

State를 정의할 때마다, 뷰가 소유하는 SOT를 새롭게 하나 정의된다는 점을 기억하자.

Views are a functions of state, not of sequence of events.

View는 더 이상 이벤트의 집합이 아니라, 상태를 가지는 함수이다.

이전에는 이벤트에 대해서 뷰를 직접적으로 변경하는 방식으로 응답하였습니다. 하지만, SwiftUI에서는 상태만을 변경하면 됩니다. 그러면, SwiftUI 가 알아서 해줄 것입니다.

 

@Binding

우리는 재사용성을 위해서 뷰를 작게 유지해야 합니다. 그러기 위해서 PlayButton을 구성해봅시다.

 

새로운 상태를 구현하여서 또 다른 Source Of Truth를 생성하게 되었습니다. 이는 새로운 원천을 생성하게 되었기 때문에 PlayerView 가 가지고 있는 isPlaying 과 다른 데이터입니다. 저희는 이전에 있던 값을 지속적으로 활용하고 싶습니다.

이를 해결하기 위해서 Binding Property Wrapper를 활용하게 됩니다. 이는 $를 통해서 값을 전달할 수 있게 됩니다. 이를 통해서 값을 전달하게 되면, 값을 복사하는 것이 아니라 참조와 비슷하게 값에 대한 접근을 허용하게 되는 것입니다.


만약, 우리가 Controller를 통해서 구성하였다고 생각해봅시다. 위에서 보았던 코드처럼 값이 변경될 때마다, 하위 뷰에게 값을 볷하여서 전달해야 합니다. 이를 통해서 Controller의 코드가 많아지고 비대해지게 됩니다. 이런 복잡성은 개발자가 짊어져야 할 무게가 되게 됩니다.

 

외부에서 발생하는 데이터

우리는 위에서 내부적으로 변경되는 데이터에 대해서 사용하는 방법과 이를 제공하는 Wrapper들을 살펴 보았습니다. 하지만, 뷰 내부가 아닌 외부에서 발생하는 데이터에 대해서 반영할 수 있는 방법이 필요하게 됩니다. 가령 예를 들어서 시간의 변경에 따라서 PlayerView의 text를 변경해야 하는 작업을 하려고 하면, 시간의 흐름은 뷰 내부적으로 발생하는 것이 아니라 외부에서 발생하는 데이터가 될 것입니다. 이를 해결하는 방법에는 어떤 것들이 있는지 알아봅시다.

 

 

외부에서 변화가 발생하는 경우들은 다음과 같습니다.

  • Timer Fired
  • Notification
  • 그 외 등등

 

이런 외부의 변화들이 발생시키는 결과는 동일합니다. @State의 변수를 변경시키는 것입니다. 즉, 외부의 이벤트를 처리하는 방식도 내부의 데이터에 대해서 처리하는 것과 동일합니다.
SwiftUI 에서 이들을 Publisher라고 부르고, 이는 Combine 프레임워크로 부터 발생하게 됩니다.

 

Observable Object Protocol & ObservedObject

이는 영상에서 BindableObject Protocol / ObjectBinding 으로 설명되었지만, 다음과 같이 이름이 변경되었습니다. ObservableObject는 이미 소유/관리 중인 모델이 있을 경우, 이 모델과 뷰의 동기화를 편하게 만들어 주는 프로토콜입니다. 즉, 데이터가 변경되었을 때 상태를 알릴 필요가 있는 데이터라면 ObservedObject를 따르도록 하면됩니다.

유저가 하나의 기기에서의 변경 사항을 모든 기기에 반영을 하고 싶어 한다. 이를 위해서는 ObservableObject를 사용하면 된다.

class PodCastPlayerStore: ObservableObject {
    @Published var currentTime: TimeInterval = 0.0
    @Published var isPlaying: Bool = false
    var currentEpisode: Episode

    var objectWillChange = PassthroughSubject<Void, Never>()

    init(episode: Episode) {
        self.currentEpisode = episode
    }

    func togglePlayState() {
        self.isPlaying.toggle()
        self.objectWillChange.send()
    }
}

 

다음과 같이 ObservableObject 프로포콜을 채택하여서 데이터 모델을 구현하였다. 이 프로토콜을 따르게 하면, objectWillChange 프로퍼티에 Publisher를 정의해야 한다. Play의 상태를 변경하는 메서드 내부에서 이 Publisher에게 변경이 이루어졌다는 것을 알리라고 시키게 된다. 이 Publisher가 이벤트를 방출하게 되면, SwiftUI는 변경이 이루어진 것을 알아 채고 뷰를 다시 렌더링 하게 된다.

 

 

이를 뷰에 전달하기 위해서는 @ObservedObject Wrapper를 통해서 변할 수 있는 모델이라는 것을 전달해주어야 한다.

'IOS > SwiftUI' 카테고리의 다른 글

TCA - 네트워크 요청하기  (0) 2023.10.18
TCA - What is ViewStore  (0) 2023.10.08
TCA 기본  (0) 2023.09.26