UI개발에서 가장 어려운 부분: 데이터 의존성 관리
가장 빈번하면서도 크리티컬한 UI버그는, UI가 “최신 상태”의 데이터를 제대로 표현하지 못하는 현상일 것입니다. 네트워크에서 로딩이 끝난 후에도 로딩인디케이터가 계속 돌아가고 있다던지, 좋아요를 눌렀는데 좋아요 숫자가 올라가지 않는다던지, 메시지를 다 읽었는데도 “메시지 안 읽음” 표시가 남아있다던지…
이런 종류의 버그들은 왜 계속해서 튀어나오는 것일까요? 근본적인 이유는, 데이터가 바뀐다고 UI가 자동으로 업데이트 되지 않기 때문 입니다. 즉, 데이터가 바뀐 순간을 개발자가 정확히 캐치하고, 이를 일일이 UI에 제대로 업데이트해야 하는데, 이 일일이 중에 뭐 하나라도 빠뜨리면 버그가 발생하게 됩니다.
그나마 일일이 중에 뭐 하나를 빠뜨리는 버그는 캐치하기 쉬운 편입니다. 더 어려운 상황은, “업데이트의 순서”에 로직이 영향을 받는 경우지요. 예를 들어 볼까요?
extension ViewController {
func viewDidAppear () {
self.startkNetworkLoading()
}
func networkLoadingDidStart () {
self.showLoadingIndicator()
}
func networkLoadingDidEnd() {
self.removeLoadingIndicator()
}
func viewDidDisAppear() {
self.cleanUpThings()…
}
}
Swift
복사
이런 코드에서, 우리는 종종 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()
}
}
}
}
Swift
복사
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)
}
}
Swift
복사
즉, layoutSubView가 불리는 매 순간, 표현해야 하는 Data들을 기준으로 UIView를 최신화 하는 것이 LayoutDrivenUI의 핵심입니다. CardView 의 text 가 먼저 바뀌든, fontSize 가 먼저바뀌든간에 상관 없이, layoutSubView 안에서는 언제나 똑같은 순서로 최신화가 일어나므로, Data 변화의 순서에 상관없이 언제나 일관된 방식으로 UIView가 갱신됩니다.
LayoutDrivenUI는, 특히 애니메이션 구현을 굉장히 쉽게 만들어 줍니다. 적절한 주기에 data를 업데이트해주는 것 만으로도, 매우 자연스러운 애니메이션이 만들어집니다.
class ViewController: UIViewController {
@IBOutlet var cardView: CardView!
@IBOutlet var slider: UISlider!
@IBAction func valueChanged(_ sender: Any) {
cardView.text = "\(slider.value)"
cardView.fontSize = CGFloat(slider.value * 36)
}
}
Swift
복사
슬라이더가 움직임에 따라 사각형의 크기가 자연스럽게 변합니다.
또한 AnimateOptions 중 beginFromCurrentState를 활용하여, 다음과 같이 1회성 Data 업데이트에 대해서도 유려한 애니메이션을 손쉽게 구현 할 수 있습니다.
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.cardView.text = "Hello World"
self.cardView.fontSize = 30
UIView.animate(withDuration: 1,
delay: 0,
options: .beginFromCurrentState,
animations: { self.view.layoutIfNeeded() },
completion: nil)
}
Swift
복사
자연스럽게 사각형이 등장합니다
주의할 사항
1. layoutSubViews안에서 didSet으로 관리되는 변수들을 업데이트하면 안 됩니다. 그렇게되면 자연스럽게 setNeedsLayout이 불리게 되고, 그렇게 되면 layoutSubViews가 불리게 되고, 그렇게 되면 setNeedsLayout이 불리게 되고…. 그렇게 무한루프에 빠지게 됩니다.
2. 객체를 “생성하는” 코드를 layoutSubViews에 넣으면 안 됩니다. 대표적으로 NSLayoutConstraints 들을 생성하는 일 같은 것 말이죠. 왜냐하면 layoutSubView는 그야말로 화면이 갱신 될 때마다 불리는 녀석인데, 그 때마다 새로운 객체가 만들어지게 되면 순식간에 커다란 메모리 족적을 남기게 될 뿐만 아니라 예상치 못한 버그와 만나게 될 것입니다. layoutSubView에서는, 가급적 “기존에 만들어 두었던 객체들에 대한 업데이트”를 해야 합니다.
즉, 초기화 코드와 최신화 코드를 확실하게 분리하는 것이 중요합니다. 예컨대 아래의 BaseView같은 녀석을 프로젝트의 모든 UIView 서브클래스들이 상속받게 하는 방법이 있겠습니다.
class BaseView: UIView {
init(frame: CGRect {
initializeLayout()
initializeProperties()
}
override func layoutSubViews() {
super.layoutSubViews()
updateLayout()
}
func initializeLayout() {}
func initializeProperties() {}
func updateLayout() {}
}
class MyView: BaseView {
override func initializeLayout() {
/// 레이아웃 초기화 코드
}
override func initializeProperties() {
/// 그외 속성 초기화 코드
}
override func updateLayout() {
/// 최신화 코드
}
}
Swift
복사
깃헙의 다양한 UI 관련 코드들을 보면, 생각보다 쉽게 LayoutDrivenUI의 개념을 적용한 코드들을 많이 만나 볼 수 있습니다. 이런 코드들을 읽으면서 더 다양한 적용 방식을 고민해 보는 것도 재미있을 것 같습니다.
참조 : LayoutDriven-UI의 개념이 적용된 프로젝트들
마치며 : 더 좋은 질문이 더 좋은 해결책으로 이어집니다.
SwiftUI도 그렇고, LayoutDrivenUI도 제 생각엔 React의 문제해결 방식에 많은 영향을 받은 것 같습니다. 그런 측면에서 LayoutDrivenUI라는 개념이 특히 멋진 점은, React 의 방식으로 문제를 해결하기 ReactNative같은 거대한 프레임워크를 들여올 필요는 없다”는 점을 일깨워준 부분이라고 생각합니다. 문제 해결을 위해 “어떤 프레임워크를 쓸까” 보다는 “이 문제를 그 프레임워크는 어떻게 해결했나?”를 위주로 더 질문하고, 그 해결방식을 각자의 도메인에서 각자의 방식으로 구현하다보면 종종 더 우아하고 깔끔한 해결책이 나오기도 하는 것 같습니다.