SwiftUI와 Opaque Type, @ViewBuilder

면접 때 받은 질문인데... 머리로는 이해하고 있지만, 막상 입으로 설명하려고 하니 뭐라 설명할지 모르겠어서 이참에 정리해 본다.

 

Opaque Type이란?

protocol의 associated type의 타입을 모르는 상태에서 객체의 기능을 사용하는 타입이다. 제네릭의 반대라고 생각하면 편하다. 무슨 소리냐면..

 

제네릭을 사용할 때는 구현부에서 어떤 타입을 사용할지 모르는 상태로 구현한다.

class Stack<T> {
    var arr: [T]
    
    init(arr: [T]) {
        self.arr = arr
    }
    
    func input(_ obj: T) {
        arr.append(obj)
    }
}

그 후 사용 및 호출하는 쪽에서 상세한 타입을 지정한다.

let stack: Stack<Int> = .init(arr: [])

 

opaque타입은 프로토콜에서 associated type을 넣었을 때, 해당 프로토콜로 함수의 리턴값이나 프로퍼티의 타입을 지정하려고 하면 associated type에 대한 타입 추론이 불가능하기에 컴파일 오류가 뜨는데, 

protocol Stack {
    associatedtype objType
    var arr: [objType] { get }
}

opaque type을 사용하면 요렇게 구현부에 상세한 타입을 지정하고 호출하는 쪽에서는 어떤 프로토콜을 준수하는지만 알 수 있게 된다.

class IntStack: Stack {
    var arr: [Int] = []
}

func returnStack() -> some Stack {
        return IntStack()
}

또한 opaque 타입을 준수하는 프로퍼티나 함수는 한 개의 타입만을 반환해야 한다.

func returnStack(_ bool: Bool) -> some Stack {
        if bool {
            return StringStack()
        }
        return IntStack()
    }

위 코드처럼 두 개의 타입이 반환되는 경우는 불가능함.

좀 더 파고들 수 있지만.. swiftUI에서 왜 opaque type을 사용하는지에 대해 아는 게 주목적이기 때문에 이 정도면 충분하고 생각한다.

 

SwiftUI의 View

SwiftUI의 View는 프로토콜이다. Body라는 associated type을 가지고 있고 body 프로퍼티를 반드시 구현해야 프로토콜이다.

public protocol View {
    associatedtype Body : View
    @ViewBuilder @MainActor var body: Self.Body { get }
}

SwiftUI에서는 View를 그릴 때, modifier를 사용하여 View를 그린다. 또한 다양한 Stack과 Shape를 이용하여 View를 변형시킨다. 이때 이런 modifier는 하나의 View를 변형시키는 게 아닌, 다른 뷰를 추가하는 작업이다.

var body: some View {
        Text("Hello world")
            .background(.red)
            .clipShape(.capsule)
    }

요렇게 body값을 구성하면

요렇게 다른 뷰들이 뒤에 있는 것을 확인할 수 있다. 

이런 modifier 역시 내부적으로 View를 반환한다.

struct BorderedCaption: ViewModifier {
    func body(content: Content) -> some View {
        content
            .font(.caption2)
            .padding(10)
            .overlay(
                RoundedRectangle(cornerRadius: 15)
                    .stroke(lineWidth: 1)
            )
            .foregroundColor(Color.blue)
    }
}

 

 

그렇다면, 이 뷰의 associated type은 어떻게 되어있을까? 

출력해 본다면 

ModifiedContent<ModifiedContent<ModifiedContent<Text, _BackgroundStyleModifier<Color>>, _ClipEffect<Capsule>>, _AppearanceActionModifier>

이라고 출력된다. 

ModifiedContent라는 제네릭 타입에 Text가 있고, ClipEffect에 shape인 capsule이 있고, 이들을 다른 ModifiedContent가 제네릭 타입으로 하고 있고, 또 다른 ModifiedContent가 이를 제네릭 타입으로 하고 있다.

 

이렇게 View가 modifier와 다른 View 내부에 들어갈수록, body의 타입은 점점 복잡해져 간다. 단 2개의 modifier를 사용했는데도 읽기가 힘들다.

만약 opaque타입을 사용 안 하고 제네릭으로 View가 만들어진다면, 이를 호출하는 쪽(상위뷰)에서는 하위뷰에서 modifier를 하나 추가할 때마다 위와 같이 타입을 지정해야 한다.

 

이 문제 때문에 SwiftUI는 opaque 타입을 이용하여 상세한 타입을 감추는 방식을 사용한다.

 

@ViewBuilder

SwiftUI가 opaque타입을 사용하는 이유는 이제 알았다. 그런데 아까 opaque타입을 사용할 때는 하나의 타입만을 리턴할 수 있다고 했다.

하지만 다음과 같은 코드는 정상적으로 실행된다. 

var body: some View {
        if isTrue {
            Text("Hello world")
        } else {
            Circle()
        }
    }

이 이유를 알기 위해선 ViewBuilder가 어떻게 내부 View를 만드는지 알아야 한다.

ViewBuilder는 resultBuilder를 채택하고 있다.

resultBuilder는 buildBlock의 인자값으로 들어온 객체들을 하나로 만들 수 있다.

@resultBuilder
struct ArrayBuilder {
    static func buildBlock(_ components: [Int]...) -> [Int] {
        return Array(components.joined())
    }
}

@ArrayBuilder var array: [Int] {
    [1, 2, 3]
    [4, 5, 6]
    [7, 8]
    [9]
}

// array = [1, 2, 3, 4, 5, 6, 7, 8, 9]

ViewBuilder의 함수를 살펴보면 

buildBlock이라는 함수가 내부에 있는 것을 볼 수 있다. each-repeat를 이용해 클로저 내부의 View를 하나의 TupleView로 묶어 리턴하고, conditionally building content를 보면 조건에 따른 ConditionalContent라는 View를 리턴하는 것을 볼 수 있다. 즉 ViewBuilder는 이를 이용해 각 조건에 따른 View를 하나의 뷰로 묶어서 리턴을 함으로써 하나의 타입을 리턴하도록 하는 방식으로 구현해서 if문으로 분기가 가능한 것이 아닐까 하고 생각한다.