Diffable DataSource + ReactorKit으로 복잡한 UI 관리하기

서론

  • UIDiffableDataSource는 iOS13 및 WWDC19부터 공개된 기존 고전 방식의 DataSource를 대체하는 새로운 API입니다. 이 API를 통해서 기존 UI를 구현하는 방식보다 훨씬 간단하고 효율적으로 구현할 수 있습니다.
  • 추가적으로 DiffableDataSource와 잘 어울리는 레이아웃을 그리는 API인 Compositional Layout과 CollectionViewCell인 이 세 가지를 이용하여 화면을 구성하는 방법을 소개해드릴 예정입니다.

  • 제가 진행했던 페이버 프로젝트는 ReactorKit과 RxSwift의 조합을 사용하고 있습니다.
  • ReactorKit은 MVVM의 Rx화를 시켜 통일화를 시켜준 라이브러리이기 떄문에 Reactor를 ViewModel이라고 생각하셔도 무방합니다.

 

기존 DataSource의 문제점

  • 단순한 단일 섹션을 가진 화면을 구성하는 것은 어렵지 않습니다.
  • 하지만 다중 섹션을 가지고 있고, 경우의 수에 따라서 특정 섹션이 사라지기도 하고 생겨나기도 하는 화면이 있습니다.
  • 이런 화면은 기존 reloadData로만 화면을 업데이트하기에는 조금 버거워 보일 수 있습니다. 사용자 경험도 저하시킬 수 있고요.

 

데이터 정의하기

  • 많은 섹션들과 아이템을 관리하기 위한 가장 좋은 툴은 enum이라고 할 수 있습니다.
  • Associated Value를 통해 각 섹션 및 아이템에 들어갈 구체적인 데이터 타입을 정의합니다.
// MARK: - Item

enum ProfileSectionItem: Hashable {
  case profileSetupHelper(ProfileHelperType)
  case anniversarySetupHelper
  case favors(Favor)
  case anniversaries(Anniversary, isMine: Bool)
  case memo(String?)
  case friends(Friend)
}

// MARK: - Section

enum ProfileSection: Hashable {
  case anniversarySetupHelper
  case profileSetupHelper
  case favors
  case anniversaries
  case memo
  case friends
}
  • 한 가지 주의할 점은 우리는 DiffableDataSource를 사용할 때에는 데이터가 Hashable 프로토콜을 채택해야합니다.
  • 이전 데이터과 현재 데이터를 Hash Value로 판단하여 무엇이 이전과 다른지 파악하고, 만약 데이터가 바뀌었다면 렌더링이 자동으로 되게끔 하는 것이 우리의 목표입니다.

 

레이아웃 정의하기

  • 다음은 각 섹션의 헤더와 셀들의 레이아웃을 정의해야합니다.
  • UICompositionalLayout  을 사용하여 각 섹션과 아이템들의 '레이아웃'을 정의합니다.
  • CompositionalLayout은 섹션과 아이템이 많을 수록 코드가 굉장히 길어질 수도 있습니다. 그래서 최대한 ViewController가 너무 방대해지지 않도록 분리해서 구현해주시는 것이 적합합니다.
extension ProfileSection: Composable {
  public var item: UICollectionViewComposableLayout.Item {
    switch self {
    case .anniversarySetupHelper:
      return .listRow(height: .absolute(250))
    case .profileSetupHelper:
      return .grid(
        width: .absolute(250),
        height: .absolute(250)
      )
    case .favors:
      return .grid(
        width: .estimated(100),
        height: .absolute(32)
      )
    case .anniversaries:
      return .listRow(
        height: .absolute(95)
      )
    case .memo:
      return .listRow(
        height: .estimated(130)
      )
    case .friends:
      return .grid(
        width: .absolute(60),
        height: .absolute(87)
      )
    }
  }
}

비즈니스 로직 처리하기

  • 이제 ViewModel에서 관련 섹션과 아이템의 객체들을 만들어서 단방향 흐름을 구현합니다.
final class ProfileViewReactor: Reactor { 
 enum Action { ... }
 enum Mutation { ... }

 struct State {
  var sections: [ProfileSection] = []
  var items: [[ProfileSectionItem]] = []
  var user = User()
}
  • State에는 위에서 정의했던 enum의 배열 값이 각각 들어있습니다.
  • 만약 사용자의 액션(인풋)이 들어오면 데이터를 변경하고 자동으로 UI를 변경하고 싶습니다.
  • ReactorKit에는 이와 알맞은 메서드가 있습니다. 바로 transform(state:) 입니다.
func transform(state: Observable<State>) -> Observable<State> {
    return state.map { state in
      var newState = state
      var newSections: [ProfileSection] = []
      var newItems: [[ProfileSectionItem]] = [] // 아이템은 2차원 배열

      // 취향
      if !state.favorItems.isEmpty {
        newSections.append(.favors)
        newItems.append(state.favorItems)
      } 

      // 기념일
      if !state.anniversaryItems.isEmpty {
        newSections.append(.anniversaries)
        newItems.append(state.anniversaryItems.prefix(3).wrap())
      }

      // 친구
      if !state.friendItems.isEmpty {
        newSections.append(.friends)
        newItems.append(newState.friendItems)
      }

      newState.sections = newSections
      newState.items = newItems
      return newState
    }
  }
  • transform(state:)는 ViewController에 전달할 Observable가 전달되기 직전에 호출되어, 데이터 변경 값에 따라서 원하는 UI를 재구성할 수 있습니다. 

 

VC와 UI 바인딩

func bind(reactor: ProfileViewReactor) {
 reactor.state.map { (sections: $0.sections, items: $0.items) }
      .asDriver(onErrorRecover: { _ in return .empty()})
      .drive(with: self, onNext: { owner, sectionData in
        var snapshot = NSDiffableDataSourceSnapshot<ProfileSection, ProfileSectionItem>()
        snapshot.appendSections(sectionData.sections)
        sectionData.items.enumerated().forEach { idx, items in
          snapshot.appendItems(items, toSection: sectionData.sections[idx])
        }
        DispatchQueue.main.async {
          owner.dataSource.apply(snapshot)
        }
      })
      .disposed(by: self.disposeBag)
}
  • ReactorKit은 bind(reactor:)를 통해 State와 구독 관계를 맺을 수 있습니다.
  • 이제 가공된 items와 sections을 VC에서 받을 준비가 되어있어야 합니다. sections와 items는 항상 같이 사용 되어야 하기 때문에, 두 값을 동시에 구독합니다.
    • sectionData는 sections와 items가 들어있는 튜플
    • 먼저 스냅샷에 섹션을 추가
    • 다음에는 sectionData의 enumerated()를 하여 순회
    • sections와 items에 있는 [Item]은 1:1 대응이기 때문에 sections[index]의 아이템들은 차례대로 삽입
    • 마지막으로 apply을 통해 새로운 snapshot을 적용
  • 이제 의도적인 reloadData없이 자동으로 데이터 값이 바뀐다면 화면 렌더링을 새롭게하는 작업이 완료되었습니다.

 

마치며

  • 이번 글에서는 ReactorKit와 DiffableDataSource를 통해 복잡한 UI 관리를 어떻게 효율적으로 할 수 있을지 고민하는 시간이 되었습니다.
  • 고전적인 DataSource를 사용해도 충분히 구현할 수 있지만, 시간이 지나면서 사용자 경험의 욕구가 늘어나고 앱이 고도화 되기때문에 최신 기술을 도입하고 리펙토링 하는 것을 주저하지 않고 프로젝트에 녹여내는 것이 중요한 덕목이 됩니다.

 

Reference