LayoutDrivenUI에 대한 공부

Uno·2021년 6월 6일
1

Tip-Swift

목록 보기
8/26
post-thumbnail

왜 데이터를 넣었는데, 안바뀌는거야!


UI 구현 작업을 하다보면, 다음과 같은 스트레스를 많이 받곤 합니다.

“나는 데이터를 넣었는데 너(프로그램, 앱이겠죠?)는 왜 그대로니!“

구체적인 예시를 들자면,
- 좋아요를 누른다 -> UI에 좋아요가 1이 올라간다. -> 근데 안올라가네?
- 특정 cell을 삭제한다 -> cell이 없어지고 아래있던 cell이 올라간다 -> 근데 외않되?
- 프로필 이미지를 변경한다 -> 이미지를 선택한다. -> 근데 왜 변경이 안된거지?
하나하나 제 경험에서 나온 스트레스들입니다…

그렇다면 이런 원인이 왜 생기는 것일까요?
Why!?

데이터가 바뀐다고 UI를 자동으로 업데이트해주지 않기 떄문입니다.

그러면 반문하실 수 있습니다.
“내가 UILabel에 .text에 값을 변경하면 값이 변경되던데요?”

네. 변경됩니다. 내부적으로 값이 변경되면 UI를 업데이트하도록 해주는 메소드가 있습니다.
(추후에 설명할게요!)

이렇게 특정 객체들은 우리가 직접 업데이트해주지 않아도 업데이트가 됩니다. 하지만 그 객체가 아닌 경우는 UI 버그로 이어집니다.


class ViewController: UIViewController {
	
	func viewDidApper() {
		networkingLoadingFromAPI()  // API에 데이터를 요청합니다.
	}

	func networkLoadingDidStart() {
		showLoadingIndicator()  // 로딩 인디케이터 애니메이션을 실행합니다.
	}

	func networkLoadingDidEnd() {
		removeLoadingIndicator()
	}

	func viewDidDisAppear() {
		...
	}

}

위 코드를 봅시다.
프로그래머는 다음과 같은 순서로 메소드가 호출되기를 기대하고 코딩을 합니다.
1. viewDidAppear
2. networkLoadingDidStart
3. networkLoadingDidEnd
4. viewDidDisAppear

아주 아름다운 상황입니다.
근데 유저가 로딩중에 뒤로가기를 클릭한다면? 이제 케이오스 파티가 벌어집니다.

viewDidAppear가 호출되고 로딩인디케이터가 돌아가는 도중에 뒤로가기를 누르면, 갑자기 viewDidDisAppear가 호출될 것 입니다. 그러면 인디케이터를 종료하는 메소드가 호출되지 않았으므로 인디케이터는 계속 남아있을 것입니다.

이런 로직이 하나가 아니라 3개만 된다고 하더라도, 어마어마하게 많은 경우의 수가 생기고 이에 해당하는 UI 버그를 잡으러 다녀야합니다.

(그래서 원문에서는 UI프로그래머라고 칭하고 있는데,) 프론트엔드 개발자라고 하겠습니다.
프론트단을 관리하는 개발자는 데이터를 관리 + 데이터 순서 관리 를 해야합니다.

SwiftUI에서는 이 문제를 Combine을 통해 해결하고 있죠.

SwiftUI는 상태를 기준으로 UI를 그린다.


(SwiftUI 개념을 설명하며 자세히 설명하고 여기서는 키워드만 언급하겠습니다.)

- @State 
- @Binding
과 같은 프로퍼티 랩퍼를 사용해서 Data -> UI 로 “알아서” 잘 그려줍니다. ~~스유짱짱~~

그래서 위와 같은 버그가 생길일이 없습니다.

잠시 UIKit에 대해 글을 적자면,
UIKit은 이밴트를 기준으로 변경됩니다. 그렇기에 어떤 이벤트가 발생하면 그에 맞는 메소드나 로직이 호출되고 그리고 UI를 그리거나 로직을 처리합니다. 이에 비해 SwiftUI는 상태를 기준으로 처리됩니다.
이러한 근본적인 차이가 있기에 UIKit에서는 버그가 발생하고 SwiftUI에서는 버그가 없거나 적습니다.

다시 돌아와서

원문 블로그에서 제시한 방법은 “LayoutDriven UI” 입니다. 이걸보며 정말 큰 도움이 될 것이라 생각했습니다. 앞으로 할 프로젝트에도 모두 적용해도 될 것 같습니다.

LayoutDriven UI - 한 줄기 빛…


여기까지 읽으셨다면, 머리속에 이런 생각이 드실 수 있습니다.

“그러면 SwiftUI 쓰면 되겠네. 스유 공부하러 갑니닷~~”

네 맞습니다. SwiftUI 사용하면, 위 문제에 대해 이별할 수 있을 것입니다. 문제가 있죠. SwiftUI를 사용하지 못하는 상황이면 어떨까요.

보통의 기업들은 현재 SwiftUI 도입에 대해 보수적입니다. 뭐 개인적인 추측이지만
- 지금까지 해온 작업들이 UIKit이고 그것으로 잘 해왔기에 굳이 SwiftUI를 도입할 이유가 당장은 없음.
- SwiftUI이 아무리 배우기 쉬워도 배우는데 시간을 쏟아야하는데, 그렇다고 경제적 이득이 존재하는지 증명할 수 없음.
- SwiftUI 숙달도가 부족하면 UIKit보다 생산성이 떨어질 우려가 있음.
- 기존 프로그래머들과의 의사소통
입니다.
개인적으로는 UIKit으로 해보고 SwiftUI로 바로 해보는 연습을 하고있습니다.

어째든 이러한 이유들 때문에, UIKit 프레임워크를 사용해야한다면 그에 대한 대안으로 “LayoutDriven UI”가 될 수 있다는 거죠.

LayoutDrivenUI는 “WWDC2019 - Adding Delights to your iOS App” 에서도 소개된 개념입니다.

개념은 다음과 같습니다.

1. UIView에 영향을 미치는 모든 데이터 변수에 didSet을 추가하고 "setNeedLayout" 메소드를 실행합니다.
2. setNeedsLayout 메소드 안에 layoutSubviews()를 비동기적으로 호출합니다.
3. view를 최신화하는 코드를 모드 layoutSubView 안에서 호출되도록 합니다.

“setNeesLayout” 메소드와 “layoutSubViews” 가 뭐지? 라는 질문이 생기겠죠?
iOS ) View/레이아웃 업데이트 관련 메소드 이 블로그가 상당히 잘 설명되어 있습니다! 참고 하시면 좋구요.

간략히 설명하자면,
- setNeedsDisplay 는 다음 드로잉 사이클에 이 View를 업데이트해야함을 시스템에 알려줍니다. 좀더 구어적으로 풀자면, “이거 업데이트해야하니까 할 일 목록에 올려” 라고 볼 수 있겠죠.
- layoutSubViews 메소드는 하위 뷰를 그려주는 메소드입니다. 그래서 이 메소드를 오버라이딩해서 어떤 객체에 어떤 데이터를 넣어줄지 작성하면 되죠. 하지만 단순히 작성만 한다고 자동으로 해주는 것은 아닙니다. 이를 반영하려면 setNeedsDisplay 메소드를 호출하면 됩니다. 그러면 다음 드로잉사이클 때, 업데이트 될 것입니다.
- 하지만 바로 레이아웃을 업데이트하고 싶다면 layoutIfNeeded() 메소드를 호출하면 됩니다.
결론)

layoutSubViews 에 데이터를 넣는다. -> setNeedsDisplay 혹은 layoutIfNeeded() 를 통해 반영한다.

그래서 위 개념을 적용한 예시를 보면 다음과 같습니다.


class baseView: UIView {
	
	// MARK: - Property
	var text: String = "" {
		didSet {
			// SwiftUI 에서 @State의 역할을 한다고 보면됩니다.
			setNeedsLayout()
		}
	}

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

	private var label: UILabel = {
		let label = UILabel()
		return label
	}()

	override func layoutSubViews() {
		super.layoutSubViews()
		label.text = text
		label.font = label.font.withSize(fontSize)
	}
}

이렇게 됩니다.

그리고 초기 시작할 때, animation을 부여하는 것도 가능합니다.
UIView.animate 메소드 에서 option 파라미터에 beginFromCurrentState 라는 항목이 있습니다. 이를 이용해서 한 애니메이션입니다.

유의사항


데이터가 변경 될 때마다 UI를 변경해주는 예제를 봤습니다. 이를 사용할 때, 유의해야할 사항들이 있습니다.

1. layoutSubViews 메소드 안에서 변수들 중 didSet이 걸려있는 변수에 변경사항이 있어선 안됩니다. 만약 그렇게 되면 didSet에서 setNeedsLayout 호출 -> layoutSubViews에서 didSet 호출 -> 영원의 포에버 가 발생할 것입니다.
2. 객체를 생성하는 코드를 layoutSubViews에 넣으면 안됩니다. 딱 생각해보면 이 상태에서 만약 무언가 생성하는 코드가 안에 있으면, 값이 변경될 때마다 무언가 생성되고 생성된다는 의미는 메모리를 차지한다는 것이죠. 그래서 블로그 원작자분이 주시는 지침은 “기존에 만들어 둔 객체를 변경하는 데 사용하라” 입니다.

결론적으로 초기화 코드와 업데이트 코드를 분리한다면 됩니다. 그래서 하나의 BaseView를 만들고 이를 상속받는 형식으로 진행하면 버그가 발생할 여지가 없겠죠?
참 친절하시고, 다시 한번 감사드립니다.

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() {
     /// 최신화 코드
  }
}

이렇게 내용은 마무리하겠습니다.

느낀점: 문제를 해결할 때는 미봉책보다는 문제의 뿌리를 뽑아보자.


이 분의 글을 읽으면서 최근 몇 개월간 공부하며 생겼던 자신감이 줄어들었습니다…
아직 많이 부족함을 다시 한 번 느끼게되네요.

최근들어 프론트개발자는 어떤것이 역량일까 고민하고 있었는데 조금 구체화되는 것 같네요.

- 데이터를 잘 보관하고 전달한다.
- 그 데이터에 맞게 UI를 업데이트 한다.

이 두가지가 그 항목 중 하나가 될 것 같습니다.

그 동안, 업데이트가 안된 부분만 수정하고 넘어갔던 저 자신을 반성하게 되네요. 구조적으로 이렇게 구성한다면, 많은 버그들을 해결할 수 있을 것 같습니다.

참고자료


SwiftUI의 방식으로 UIKit코드를 짜는 방법: Layout Driven UI - Dev Story of Sungdoo
Swift UIView.init() 에 대하여. 눼에? UIView()가 UIView의 initializer가… | by naljin | Medium
iOS ) View/레이아웃 업데이트 관련 메소드

profile
iOS & Flutter

0개의 댓글