cleanUrl: /programming/layout-driven-ui

UI개발에서 가장 어려운 부분: 데이터 의존성 관리

가장 빈번하면서도 크리티컬한 UI버그는, UI가 “최신 상태”의 데이터를 제대로 표현하지 못하는 현상일 것입니다. 네트워크에서 로딩이 끝난 후에도 로딩인디케이터가 계속 돌아가고 있다던지, 좋아요를 눌렀는데 좋아요 숫자가 올라가지 않는다던지, 메시지를 다 읽었는데도 “메시지 안 읽음” 표시가 남아있다던지…

이런 종류의 버그들은 왜 계속해서 튀어나오는 것일까요? 근본적인 이유는, 데이터가 바뀐다고 UI가 자동으로 업데이트 되지 않기 때문 입니다. 즉, 데이터가 바뀐 순간을 개발자가 정확히 캐치하고, 이를 일일이 UI에 제대로 업데이트해야 하는데, 이 일일이 중에 뭐 하나라도 빠뜨리면 버그가 발생하게 됩니다.

그나마 일일이 중에 뭐 하나를 빠뜨리는 버그는 캐치하기 쉬운 편입니다. 더 어려운 상황은, “업데이트의 순서”에 로직이 영향을 받는 경우지요. 예를 들어 볼까요?

extension ViewController {

    func viewDidAppear () {
        self.startkNetworkLoading()
    }

    func networkLoadingDidStart () {
        self.showLoadingIndicator()
    }

    func networkLoadingDidEnd() {
        self.removeLoadingIndicator()
    }

    func viewDidDisAppear() {
        self.cleanUpThings()…
    }
}

이런 코드에서, 우리는 종종 viewDidAppear ➡️networkLoadingDidStart ➡️networkLoadingDidEnd➡️ viewDidDisAppear 의 순서대로 위 콜백들이 불릴 것이라 기대합니다. 하지만 이 순서는 얼마든지 바뀔 수 있습니다. 예컨대 네트워크 로딩이 끝나기 전에 유저가 뒤로가기 버튼을 누르면, networkLoadingDidEnd 보다 viewDidDisappear 가 먼저 불리게 됩니다. 그 결과로 self.removeLoadingIndicator 가 불리지 않게 되면 로딩 인디케이터가 사라지지 않는 버그가 발생 합니다.

즉, UI프로그래머는, 데이터들을 일일이 관리해야 할 뿐만 아니라, 순서에 맞게 관리해야 합니다. 만약 관리해야 될 데이터가 3개라면, 데이터가 업데이트 될 수 있는 순서는 3!=6 개의 경우의 수가 생깁니다. 4개가 되면 4!=24개의 경우의 수가 생깁니다. 5개가 되면 5!=120 개…. 이 시점부터는 인간의 두뇌로 관리 할 수 있는 영역이 아닙니다. 우리가 UI버그를 자주 만나게 되는 이유입니다.

SwiftUI는 이를 어떻게 해결하는가?

Data가 바뀔 때 UI가 자동으로 업데이트 되지 않는 이런 문제가 SwiftUI에서는 아예 발생하지 않습니다. 어떻게 이게 가능한 걸까요? 간단합니다. SwiftUI 프레임워크는 언제나 Data를 기준으로, 또 Data가 바뀔 때마다 UI를 그리기 때문입니다!

struct SwiftUIView: View {
    // @State로 표시된 데이터가 바뀌면, SwiftUI가 뷰를 다시 그립니다.
    @State var text:String = "Hello World"
    @State var isLoading:Bool = false
    var body: some View {
        ZStack {
            Text(text)
            if isLoading {
                LoadingIndicator()
            }
        }
    }
}

SwiftUI프레임워크는 @State 로 표시된 Data들을 계속 감시하고 있다가, 변경사항이 발생하면 그 즉시 View struct의 새 인스턴스를 만들고, 이 인스턴스를 기준으로 화면을 새로 렌더링 합니다. Data가 바뀔 때마다 Data를 기준으로 화면을 그리니, 언제나 정확한 Data가 화면에 표시 될 수 밖에 없습니다.

UIKit에서 이를 흉내 낼 수 있는 방법: LayoutDriven UI

이 처럼 SwiftUI를 쓰면 우리를 괴롭혔던 수많은 버그들과 이별 할 수 있습니다. 문제는 SwiftUI는 iOS13이상 부터 쓸 수 있고, 대부분의 앱은 iOS13을 아직 지원하지 않는다는 사실이죠. 우리는 그렇다면 iOS 13을 지원 할 때까지, 계속 기존의 버그들과 함께 살아야 하는 것일까요? 그렇지는 않습니다. 비록 SwiftUI를 프로덕션에서 바로 쓰지는 못해도, SwiftUI의 문제 해결 방식은 얼마든지 UIKit에서 흉내 낼 수 있기 때문입니다. 그 방법은 바로 WWDC2018의 Adding Delights to Your iOS App 에서도 소개되었던, LayoutDrivenUI 라는 방식입니다.

LayoutDrivenUI는 아주 간단한 개념입니다.

  1. UIView에 영향을 미치는 모든 데이터 관련 변수들의 didSet 에 setNeedsLayout을 겁니다
  2. setNeedsLayout은 비동기적으로 layoutSubView를 호출합니다.
  3. 해당 View를 최신화 하는 코드를 모두 layoutSubView 안에서 호출되도록 합니다.

끝입니다!코드로 보면 다음과 같습니다.

class CardView: UIView {

    var text:String = "" {
        didSet {
            // SwiftUI의 @State와 비슷합니다.
            setNeedsLayout()
        }
    }

    var fontSize: CGFloat = 14 {
        didSet {
            setNeedsLayout()
        }
    }

    @IBOutlet private var textLabel:UILabel!

    override func layoutSubviews() {
        super.layoutSubviews()
        textLabel.text = text
        textLabel.font = textLabel.font.withSize(fontSize)
    }

}