티스토리 뷰

iOS 일반/iOS

Data Flow Through SwiftUI

찜토끼 2020. 2. 25. 14:41

WWDC 2019 세션 중 하나인 Data Flow Through SwiftUI의 흐름을 따라가며 요약/메모. SwiftUI와 Combine 영상을 다시 보던 중 정리할 겸 해서 포스팅합니다. 발표 시점으로부터 시간이 흐르며 리네이밍 된 API 등도 있어서 그건 따로 표기를 해두었습니다. 애플이 추구하는 방향, 핵심적인 개념 등은 변하지 않았기 때문에 아직 듣지 않으신 분들은 한 번 들어보시거나 이 포스트를 읽어보셔도 좋을 것 같습니다.

 


 

세션 개요

SwiftUI는  UI개발의 복잡성을 해결하기 위해 새롭게 고안된 것으로써, 심플하지만 강력한 툴입니다. 여러분이 일관성있는 아름답고 정확한 유저 인터페이스를 작성할 수 있도록 완전히 새롭게 만들어졌습니다. 완전히 예측 가능하며 오류도 없이 UI를 데이터에 의존하도록 연결하는 방법을 배워보세요. 또한 SwiftUI의 강력한 데이터 플로우 툴들을 숙지함으로써 각 케이스에 적합한 방법이 무엇인지 알아보세요.

 


우리가 살펴볼 것들

Principles of Data Flow
- SwiftUI는 데이터를 뷰의 계층에서 flow 시키는 툴이다. (뷰 계층에서 데이터가 왔다갔다 함)

Anatomy of an Update
- SwiftUI는 항상 정확하고 일관된 데이터의 표현을 위해 뷰 계층을 업데이트 시킨다. 내부적으로 이것을 어떻게 하는지 알아보자.

Understanding Your Data
- SwiftUI에서 제공해주는 툴들을 알아보고 자신의 데이터에 어떤 게 적합한지 이해해보자

 

What is data?

SwiftUI에서는 '데이터'가 1급 시민(first class citizen)

여기서의 '데이터'란 UI를 drive 하는 모든 정보를 말함. 다양한 형태를 가지고 있으며 모든 것이 데이터가 될 수 있다.

(ex) 토클 상태, 메시지 모델을 대변하는 객체 등

 

SwiftUI를 이해하기 위한 두 가지 개념(원칙)

<첫 번째 개념> 뷰에서 데이터를 읽을 때마다, 뷰에는 데이터에 대한 의존이 발생함

데이터가 변경될 때마다 뷰는 새로운 값을 반영해야 하기 때문

 

 

파란색 모듈(재생을 위한 PlayerView)에서 보라색 모듈로부터 특정 데이터(Bool타입의 isPlaying 변수)를 읽어들여야 한다고 해보자.

보라색 모듈에서 데이터가 변경될 때마다 UI 갱신을 위해 보라색 모듈을 업데이트해주어야 한다.

지금까지는 이것을 코드로 직접 처리했으며, 복잡한 노력을 필요로 했다.

그러나 SwiftUI에서는 더 이상 데이터와 뷰의 동기화를 따로 우리가 해줄 필요가 없다. SwiftUI는 선언적(declarative)이며, SwiftUI에서의 Data Dependency(데이터에 대한 의존성) 역시 그러하다. 단순히 SwiftUI의 지침만 따르면 이 프레임워크가 나머지를 알아서 해준다. (알아서 데이터 변경을 감지, UI 갱신 명령을 내림 등등)

 

<두 번째 개념> 뷰의 계층에서 읽어들이는 모든 데이터들은 Source of Truth 를 가진다

 

 

뷰에서 읽는 모든 데이터들은 결국 하나의 원천을 가진다는 뜻이다. (* Single Source of Truth(SSOT): 데이터가 여러 곳에 존재하지 않고 오직 한 곳에만 존재한다)

 

 

만약 데이터가 중복되어 존재할 경우 항상 싱크를 직접 맞추어주어야 한다. 일관적인 UI를 유지하는 것이 힘드며, 버그가 발생할 가능성이 높다.

예를 들면 위 그림에서는 View 두 개 각각을 위한 isPlaying 데이터가 각각 존재한다. Notification, KV observing, 그 외의 다른 이벤트 등으로 하나의 isPlaying 값이 변할 경우에는 항상 다른 하나의 isPlaying 값도 싱크를 맞춰 변경되어야 한다. 이런 복잡성이 버그를 유발한다.

class VideoView: UIView {
    
    var isPlaying: Bool = fasle {
        // 예를 들면 이런 코드를 말합니다
        // 서브뷰 각각이 isPlaying 변수를 들고 있으며,
        // 이 데이터들의 싱크가 맞아야만 UI의 정합성을 지킬 수 있습니다
        didSet {
            self.topMenu.isPlaying = self.isPlaying
            self.playControl.isPlaying = self.isPlaying
        }
    }
    
    private let topMenu = VideoTopMenuView(frame: .zero)
    private let playControl = VideoPlayControlView(frame: .zero)
}

이와 같은 구조를 다음처럼 개선하면 어떨까?

 

 

이런 식으로 데이터를 한 곳으로 모은 다음 두 개의 뷰가 그것을 참조하게 만든다면 데이터의 정합성, 일관성을 지킬 수 있다.
SwiftUI는 이 구조를 간단하게 만들 수 있도록 도와준다.

 

실전 예제를 통해 배우는 SwiftUI: 팟캐스트 플레이어

 

 

이런 팟캐스트 플레이어 앱을 SwiftUI를 사용해 만들어보자.

 

 

PlayerView 에 정의된 프로퍼티

  • episode 현재 노출되고 있는 에피소드. 상수
  • isPlaying 현재 재생중인지의 여부. 변수

body에는 VStack안에 UI요소들이 수직으로 정렬되어 있다.

  • 에피소드 타이틀
  • 에피소드 서브 타이틀
  • 재생 버튼

그러나 이 코드는 컴파일 시 재생 버튼 액션부에서 오류가 발생한다.

 

 

PlayerView 는 Struct, 즉 Value Type으로 선언되었기 때문에 내부 프로퍼티 값을 변경할 수 없는 것이다. 이전 이런 에러를 만나보았던 사람이라면 이때 머리에 mutating 키워드가 떠오를 것이다. Struct 등의 함수에 mutating을 명시함으로써 '이 함수 안에서는 Self의 상태가 변경될 수 있다'고 시스템에게 알려줄 수 있었다. 이와 유사하게 SwiftUI에서도 내부값 변경을 허용하게 만들 수 있는 방법을 제공한다.

 

@State

 

 

내부 프로퍼티에 @State Property Wrapper를 적용하여 '이 프로퍼티(isPlaying)의 값은 변경될 수 있으며, 이 값에 따라 Self(PlayerView)의 상태도 변경될 수 있다'고 시스템에게 알려줄 수 있다.

 

 

@State isPlaying 프로퍼티에 붙이면, isPlaying 변수를 위한 영구적인 저장공간이 할당되며 시스템은 그것을 의존성으로서 추적한다. 일반적인 뷰는 시스템에 의해 자주 재생성되지만, @State 가 붙었다면 시스템은 같은 뷰에 여러 번 업데이트가 일어나도 이 저장공간을 계속 유지해준다. 뷰 안에서만 isPlaying 상태를 소유/관리한다는 것을 확실히 하기 위해 이 프로퍼티는 private으로 선언되었다.

@State 변수의 값이 변할 때 뷰의 렌더링이 달라져야 한다는 것을 SwiftUI가 감지하게 된다.

 

 

위와 같은 구조에서 isPlaying 의 값이 true로 변하면 (유저가 재생 버튼을 탭하면) 어떻게 될까?

 

 

유저의 인터랙션에 따라 재생 버튼의 액션이 실행되고, isPlaying 이라는 State 변수 값이 변하고, State가 변경되었으므로 뷰 자신과 자식 뷰들의 Body가 모두 새롭게 랜더링된다. (Button > isPlaying 값 변경 > PlayerView > 자식뷰인 Text, Text, Button 에 각각 전파 > 전체적인 뷰가 갱신됨) 단, 프레임워크에서 오직 변경된 것에 대해서만 다시 렌더링해주기 때문에 이 과정은 효율적으로 수행된다.

이것이 '프레임워크가 디펜던시를 관리해준다'는 것이다.

Every @State is a source of truth.

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

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

뷰는 더이상 연속된 이벤트의 집합이 아니다. 상태를 가지는 함수이다.

이전에는 직접적으로 뷰 계층을 변경하는 방법으로 이벤트에 응답했었다. 예를 들면 유저의 인터랙션이 발생할 때마다 직접 서브뷰를 추가/제거하거나 뷰의 알파값을 변경하는 코드를 실행하는 방식이다.

 

 

SwiftUI에서는 단순히 상태(State)만을 변경하면 된다. State가 Source of truth로써 동작하여, 자연히 뷰가 변경된다. 이 부분이 SwiftUI의  선언적 구문(Declarative syntax)이 빛나는 부분이다.

이해를 돕기 위해 간단히 예제 코드를 작성해보았다.

// 기존의 완전히 수동적인 방식
class SampleView: UIView {
    
    var isHiddenCircle: Bool = false {
        didSet {
            self.circle.isHidden = self.isHiddenCircle
        }
    }
    
    var isHiddenRect: Bool = false {
        didSet {
            self.rect.isHidden = self.isHiddenRect
        }
    }
}

// 위를 조금 더 개선한 방식
class SampleView: UIView {
    
    var isHiddenCircle: Bool = false {
        didSet { self.updateAppearance() }
    }
    
    var isHiddenRect: Bool = false {
        didSet { self.updateAppearance() }
    }
    
    private func updateAppearance() {
        self.circle.isHidden = self.isHiddenCircle
        self.rect.isHidden = self.isHiddenRect
    }
}

// SwiftUI 방식
// 바로 위 코드와 비교해보면 원리는 같지만 좀 더 선언적인 문법, 효율성을 지님.
// 또한 @State & body 규약을 통해 예측 가능한 가독성 있는 코드이기 때문에
// 기존의 코드 룰을 모르는 제3자가 이 뷰를 건드릴 때에도 실수를 방지할 수 있다
// 이렇게 문법적으로 규약을 만들어 놓는 것의 장점이라고 생각한다
struct SampleView: View {
    
    @State var isHiddenCircle: Bool = false
    
    @State var isHiddenRect: Bool = false
    
    var body: some View {
        Circle()
            .isHidden(self.isHiddenCircle)
        Rectangle()
            .isHidden(self.isHiddenRect)
    }
}

 

 

Binding - 개념

우리는 여러 개의 재사용 가능한 컴포넌트로 UI를 구성하곤 한다. 예제에서도 플레이 버튼 부분을 재사용한 컴포넌트로 쪼개보자.

 

이 부분을 따로 빼서 새로운 컴포넌트로 만들자
새롭게 작성한 PlayButton View

 

그러나 이 PlayButton 에는 문제가 있다.

isPlaying 이라는 새로운 State가 PlayButton 에 정의되었기 때문에, 또 다른 Source of truth가 생성되었다.

Every @State is a source of truth.

게다가 이 State는 SuperView인 PlayerView 의 State와도 항상 싱크가 되어야 한다. 우리가 원하는 것은 이런 것이 아니다. isPlayng 이라는 상태 데이터는 한 번에 한 곳에만 존재하면 되는 데이터이다. PlayButton 은 자신의 isPlaying State를 따로 소유할 필요가 없으며, 단순히 PlayerView 가 소유하는 isPlaying State를 읽거나 변경할 수만 있으면 된다.

이럴 때 사용할 수 있는 것이 binding이다.

 

 

@Binding Property Wrapper를 사용할 시 Source of truth에 대한 명시적 의존을 소유 없이도 설정할 수 있다.

 

 

State를 바인딩해주는 방법: 달러($) 프리픽스를 붙여 프로퍼티를 넘겨주기 (위 예시 참고)

중요하니까 한 번 더 강조. PlayButton isPlaying 값의 복사본을 가지는 것이 아닌, 단순히 SuperView PlayerView isPlaying State를 참조하기만 하는 것이다. 따라서 프로퍼티 간의 싱크를 개발자가 직접 신경쓸 필요는 없다.

SuperView인 PlayerView 의 입장에서 본다면, Binding은 자신의 State에 대한 접근을 컴포넌트에게 허용해주는 방법이다.

 

Binding - 장점

SwiftUI 이전에 우리가 해왔던 방식을 되돌아보자.

 

SwiftUI 이전에 우리가 해왔던 방식

 

기존의 방식에서는 뷰 컨트롤러의 서브뷰들이 동일한 프로퍼티를 각각 들고 있었다. 이런 구조에서는 뷰 컨트롤러에서 데이터의 값이 변경되면 자신의 모든 서브뷰들에게 새로운 값을 셋팅해주는 과정(데이터 싱크를 맞추는 과정)이 필요하다.

 

Binding을 활용하면 오직 하나의 데이터만 관리하면 된다

 

SwiftUI에서는 더이상 그런 복잡한 관리를 일일히 할 필요가 없다. 데이터 디펜던시를 정의해놓기만 하면 프레임워크가 나머지를 알아서 해줄 것이다.

 

 

프레임워크에서 제공하는 컴포넌트에서도 바인딩을 여럿 찾아볼 수 있다. Toggle, TextField, Slider 등은 이것을 가져다 쓰는 쪽에서의 Source of truth를 유지시켜 주기 위해 바인딩으로 구현되어 있다.

코드로 간단히 비교해보자.

// UIKit의 UISlider
class SampleView: UIView {
    
    var sliderValue: Float = 0 {
        // UI 싱크를 맞추기 위해 아래와 같은 처리들을 하는 경우가 종종 있었다
        didSet { self.slider.value = self.sliderValue }
    }
    
    private let slider = UISlider(frame: .zero)
}


// SwiftUI의 Slider
struct SampleView: View {
    
    // 이 값이 변경되면 자동으로 UI도 갱신된다
    @State var sliderValue: Float = 0
    
    var body: some View {
        Slider(value: $sliderValue)
    }
}

 

 

Working With External Data

 

 

SwiftUI에는 데이터 관리를 위한 많은 방법들이 제공된다. 앞서 @State, @Binding 에 대해 알아보았으며 Swift의 Property는 이미 익숙할 것이므로 따로 설명하지 않겠다. 이제부터는 @Environment BindableObject (지금은 ObservableObject 로 변경되었기 때문에 앞으로는 ObservableObject 로 표기)에 대해 알아보자.

외부에서의 변화(External change)가 발생하는 경우에는 어떤 것들이 있고, 어떤 결과를 낳는지 생각해보자.

  • Timer fired > State 변경
  • Notification > State 변경
  • 그 외 등등 > State 변경

이처럼 SwiftUI에서는 외부 이벤트라도 결국은 State의 변화로 이어진다. 즉, 앞의 예제에서 유저의 인터랙션(액션)이 발생해 State가 변경되었던 것과 결과적으로는 별반 차이가 없다.

 

 

SwiftUI에서는 이런 외부 이벤트들을 Publisher라고 통칭한다. Publisher는 Combine Framework로부터 발생된다. (Combine은 시간에 따라 값을 처리하기 위한 선언적인 API로, 새로이 추가된 프레임워크.)

 

예제에 Publisher 적용해보기

앞서 개발하던 팟캐스트 플레이어에 타임스탬프(현재 어느 위치를 재생중인지)를 추가해보자.

 

 

@State 변수인 currentTime 을 추가하였고 body 에서 이 변수를 포맷에 맞게 표현하고 있다.

 

 

VStack 하단에 onReceive modifire를 추가하였다. 이제 타임스탬프가 변경되면 onReceive 블럭 안에서 currentTime 이 새로운 값으로 변경될 것이다. currentTime 은 State 변수이다. 따라서 State가 변경된 것으로 취급되고, body 를 통해 뷰의 렌더링이 다시 이루어진다. 그러면 currentTIme 에 대한 UI 갱신이 자동으로 이루어진다. 이런 과정을 거치는 동안 수동으로 작성된 코드나 invalid 체크 코드는 필요하지 않다는 것이 장점이다.

*부연설명: onReceive 는 Combine에서 제공해주는 것으로, Publisher가 receive(on:) 메서드를 통해 특정 큐/쓰레드에서 돌도록 지정한 것을 여기서 받아 처리할 수 있게 된다. 위 코드는 현재 타임스탬프가 변경될 때 fire되는 Publisher가 미리 작성되어 있다는 가정 하에 작성된 것이다.

 

ObservableObject Protocol & ObservedObject

ObservableObject 는 이미 소유/관리중인 모델이 있을 경우, 이 모델과 뷰의 동기화를 편하게 만들어주는 프로토콜들이다.

쉽게 말해서, 데이터가 변경되었을 때 그 상태를 알릴 필요가 있는 모델이라면 (=값의 변경을 외부에서 감지할 필요가 있는 모델이라면) ObservableObject 프로토콜을 따르도록 하자.

(영상에서는 BindableObject Protocol / ObjectBinding 으로 소개되었지만 현재는 위와 같이 대체되었다. 따라서 예제코드는 공식 PDF 대신 직접 작성해 첨부)

 

쉽게 이해하기 위해 팟캐스트 플레이어 예제에 적용해보자.

팟캐스트 플레이어는 한 유저의 모든 기기(아이폰, 아이패드, 맥북, ...)에서 싱크가 맞아야 할 필요가 있다. 아이폰에서 듣던 구간을 아이패드에서 이어서 들을 수 있게 하기 위함이다. 이럴 때 ObservableObject 를 사용하면 개발이 간단해진다.

class PodcastPlayerStore {
    var currentTime: TimeInterval
    var isPlaying: Bool
    var currentEpisode: Episode
    
    func advance() { ... }
    func skipForward() { ... }
    func skipBackward() { ... }
}

싱크를 맞추기 위해 사용할 모델을 하나 정의하였다. 이제 이 모델을 ObservableObject 프로토콜에 따르게 해보자.

이 프로토콜이 요구하는 것은 데이터가 변경되었을 때를 위한 Publisher이며 objectWillChange 프로퍼티에 이 Publisher를 정의해두어야 한다.

class PodcastPlayerStore: ObservableObject {
    
    // ...
    
    var objectWillChange = PassthroughSubject<Void, Never>()
    
    func advance() {
        self.currentEpisode = nextEpisode
        self.currentTIme = 0.0
        // Notify subscribers that the player changed
        self.objectWillChange.send()
    }
}

위 코드에서는 objectWillChange 프로퍼티에 PassthroughSubject 라는 Publisher를 하나 정의하였고, 데이터의 변경이 발생하는 advance() 메서드 안에서 이 Publisher의 send() 를 호출해주고 있다. 이 Publisher를 subscribe 하여 데이터의 변경 시점을 정확히 알 수 있게 된다. 즉, 언제 뷰를 업데이트해야 할지 알게 된다는 것이다.

 

 


뷰는 @ObservedObject 라는 Property Wrapper를 사용해 ObservableObject 모델에 의존할 수 있다. Automatic dependency tracking(모델이 변경될 때 뷰가 알아서 변경됨) 관점에서만 보면 앞서 다루었던 @State 와 다르지 않다.

struct MyView: View {
    @ObservedObject var model: PodcastPlayerStore
    ...
}

MyView(model: modelInstance)

코드로 작성해보면 이런 식이다. 이제 MyView PodcastPlayerStore 모델의 상태가 변경될 때(ex: 다음 에피소드 재생을 위한 advance 함수가 불렸을 때) 자동으로 그것을 알아채고 뷰를 업데이트할 수 있게 되었다.


EnvironmentObject 사용하기

지금까지 데이터가 변경되었을 때 이것을 자동으로 감지하기 위한 방법을 알아보았다. PodcastPlayerStore 모델을 MyView 에서 ObservedObject로 정의하는 방법이었다.

이번에는 SwiftUI의 Environment를 이용하는 방법을 소개한다. (SwiftUI의 Environment는 뷰 계층에게 데이터를 넣어주는 훌륭한 캡슐화 방법이다. 이 개념을 처음 접한다면 구글링을 해보자.)

예제의 PodcastPlayerStore 를 Environment로 만들고, 뷰에서는 해당 모델을 EnvironmentObject로 정의하면 된다.

 

 

.environmentObject(_:) 를 통해 PodcastPlayerStore를 Environment로 만들고, MyView 에서는 @Environment Property Wrapper 를 붙여서 이 모델에 대한 의존성을 정의해둘 수 있다.

 

 

이제 ObservedObject로 사용하던 때와 마찬가지로, player 프로퍼티의 상태가 변경될 때마다 뷰가 자동으로 업데이트 될 것이다.

그렇다면 언제 ObservedObject를 쓰고 언제 EnvironmentObject를 쓰면 좋은 것일까?

기본적으로는 ObservedObject를 사용하면 된다. 그러나 때때로 모델이 변경되었음을 여러 뷰를 거치며 전달해야 할 상황이 있을 것이다. 간단히 예를 들어 아래와 같은 뷰 계층이 있다고 해보자.

 

 

특정 모델이 변경되었을 때 초록색 뷰들이 업데이트되어야 한다면, 모델이 변경되었음을 전달하기 위해 실제로는 그 정보가 필요하지 않은 파란색 뷰들을 경유해야 할 것이다. 이런 케이스에서 EnvironmentObject를 사용하면 깔끔하게 처리할 수 있다.

 

 

 

어떤 것을 어디에 적용하면 좋을까?

지금까지 SwiftUI에서 Source of truth를 관리하는 여러 방법들을 알아보았다. 어떤 케이스에 어떤 것을 적용하는 것이 좋을지 생각해보자.

데이터의 생성/관리 주체 관점에서 생각하면 다음과 같이 나눠볼 수 있다.

 

 

@State
- Value Type
- 뷰의 로컬 데이터 (내부 프로퍼티)
- 뷰에서 소유되고 관리되어야 하는 데이터
- (ex) SwiftUI 프레임워크의 Button의 highlight 여부는 Button 내부에서 @State로 관리되고 있다. Touch 중일 때 highlighted 상태, Touch가 끝났을 때 highlighted 가 아닌 상태라는 것을 외부에서 관리할 필요없이 Button 내부에서만 관리하면 되기 때문이다.

ObservableObject
- Reference Type
- 이미 관리(소유)하고 있는 데이터에 적용하면 좋음
- 외부로부터의 데이터를 표현할 때

 

재사용가능한 컴포넌트를 만드는 경우도 생각해보자. 이 경우에는 데이터의 성격이 Read-only인지 Read-write인지에 따라 다음과 같이 나눠볼 수 있다.

 

 


Read-only 데이터
- 재사용을 위한 뷰에서 굳이 데이터를 변경할 필요가 없는 경우
- Swift Property, Environment 등을 사용하기 (데이터 변경 시, 감지하여 알아서 뷰가 업데이트 됨)

Read-write 데이터
- 재사용을 위한 뷰에서도 데이터를 변경할 일이 있는 경우 (ex: SwiftUI의 Toggle)
- @Binding을 사용하기 (값을 소유하지 않으면서도 읽고 쓸 수 있음)
- 예로 든 Toggle의 경우, on/off 데이터가 어디서 발생되었고 누가 관리하는지 Toggle View 자체에서는 알 필요가 없다. 따라서 isOn 여부를 Binding으로 받고 있는 것이다(참고).

 

 

이처럼 사용하고자 하는 데이터의 성격을 이해하고, Source of truth를 최소화하며, 재사용가능한 컴포넌트를 만들며 SwiftUI의 강력한 파워를 실감하도록 하자. <끝>

'iOS 일반 > iOS' 카테고리의 다른 글

iOS에서의 Audio Session  (5) 2021.06.03
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함