Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[REFACTOR] CurriculumView Diffable 및 MVVM(Combine)-C로 리팩터링 (#179) #187

Merged
merged 5 commits into from
Nov 24, 2023

Conversation

ffalswo2
Copy link
Contributor

@ffalswo2 ffalswo2 commented Nov 23, 2023

🌱 작업한 내용

  • CurriculumView 기존 DataSource, DiffableDataSource로 변경
  • CurriculumView MVC-C에서 MVVM(Combine)-C로 리팩토링

🌱 PR Point

❗️cell내에 Button action이 여러번 불리는 문제 발생

Step 1.

  1. cell내부에서 publisher를 생성해서 cell내부의 subject로 값을 전달하고 해당 stream을 cell의 cancelBag에 저장합니다
  2. vc내부에서 cell의 subject를 통해 값을 전달받는 stream을 만들고 해당 stream을 vc의 cancelBag에 저장합니다

기존에 정한 원칙(#165) 을 기반으로 delegate pattern대신 publisher를 활용해 cell에서의 버튼 이벤트를 상위 객체(ViewController)에게 넘겨줍니다.

// Cell 내부
var bookmarkTapSubject = PassthroughSubject<Void, Never>()

bookMarkButton.tapPublisher
.sink { [weak self] _ in
   self?.bookmarkTapSubject.send(())
}
.store(in: &cancelBag) // cell 내부에 있는 cancelBag
// ViewController
private func setDataSource() {
        self.datasource = UITableViewDiffableDataSource(tableView: self.curriculumTableView, cellProvider: { tableView, indexPath, itemIdentifier in
        let cell = CurriculumTableViewCell.dequeueReusableCell(to: tableView)
        cell.rightArrowButtonTapped
            .sink { [weak self] _ in
                self?.rightArrowButtonTapped.send(indexPath)
            }
            .store(in: &self.cancelBag) // ViewController.cancelBag
        ...
    })
}

기존의 방법대로 했을 때 cell내에 있는 버튼을 누를 때 버튼 이벤트가 동시에 여러번 발생하는 문제가 있었습니다.

근본적인 원인 파악: cancelBag에 stream이 누적되고 있음.

CollectionView는 여러개의 데이터를 효율적으로 보여주기 위해 미리 생성해놓은 Cell을 reuse하는 방식을 채택하고 있습니다.
기존의 Cell을 reuse하는 과정에서 새로운 데이터를 cell에 덧씌울 때 해당 setDataSource()메서드가 불리게 되고, 이 때마다 새로운 stream이 생성되어 ViewController의 cancelBag에 계속해서 담기게 되어 cancelBag에 stream들이 누적해서 쌓이게 됩니다. (아래 사진 참고)

vc의 cancelBag이 datasource로인해서 계속 누적되고 cell의 cancelBag은 init이불리는 초기상황에서 1개만 값이 들어가고 계속 유지됩니다

image

이 때 cell내에 있는 cancelBag은 cell이 처음 init될 때, 생성된 1개의 stream을 각각 가지고 있고, 추가적으로 cancelBag의 요소가 늘어나지 않습니다.
image

즉, cell내에서의 버튼 이벤트는 한번만 발생하고 있지만, cell이 reuse되면서 setDataSource메서드내에서 새로 생성된 subscription들이 ViewController의 cancelBag에 저장되면서 여전히 stream이 살아있기 때문에 버튼 이벤트가 다수 발생하게 됩니다.

image

Step 2.

  1. datasource로 인해 vc의 cancelBag에 stream이 누적되기때문에 문제가발생하니까 vc의 stream을 cell의 cancelBag에 저장한다

ViewController의 setDataSource내에서 생성되는 subscription을 cell내부의 cancelBag에 보낸다고 하더라도, 처음 생성된 N개의 cell에 있는 각각의 cancelBag에 있는 stream들에게 버튼 이벤트가 전달됩니다. 따라서 생성된 cell이 3개고 각각의 cancelBag에 3, 4, 5개의 stream이 있다면 첫번째 cell을 눌렀을 때는 아래 print문이 3번, 두번째 cell은 4번, 세번째 cell은 5번 각각 또 다른 결과를 보입니다.

// ViewController - setDataSource() 내부
cell.rightArrowButtonTapped
                .sink { [weak self] _ in
                    print("🚀 ViewController: sending Button tapped ...")
                    self?.rightArrowButtonTapped.send(indexPath)
                }
                .store(in: &cell.cancelBag)

결국 이 또한 stream을 붙들고 있는 위치만 바꿀뿐, 모든 stream이 살아있기 때문에 버튼 액션의 동시다발적인 전달을 막지는 못합니다.

Step 3.

  1. cell의 prepareForReuse를 사용하면 dataSource가불리기전에 기존 cell의 cancelBag을 조절할수있다
  2. prepareForReuse를 통해 cancelBag을 초기화시키고 datasource로 인해 vc에서 생긴 stream을 저장하면 cell의 stream을 한개로 유지시켜보자

따라서 지금 현재 사용되는 stream을 제외하고 나머지 stream들을 끊어줘야 합니다.

// Cell
override func prepareForReuse() {
    super.prepareForReuse()
    cancelBag.removeAll()
}

cell의 prepareForReuse() 생명주기를 이용해서 setDataSource로 cell을 reuse하기 이전에 cancelBagremoveAll시킴으로써 나머지 stream들을 끊어주었습니다.

image

위 사진을 보면 ViewController에서 store된 AnyCancellable과 cell 내부에서의 버튼 이벤트를 Sink하면서 생긴 AnyCancellable, 이렇게 2개로 cancelBag의 count가 2로 유지되는 것을 볼 수 있습니다. 재사용하기 위해 만들어진 기본 cell들입니다.

이 후 prepareForReuse가 불리면서 cancelBag을 날리면서 0이 된 후에 1이 되는 것을 볼 수 있는데, 여기서 1은 ViewController에서 store된 AnyCancellable이고, Cell내에 버튼 이벤트를 Sink하고 있던 stream이 사라져 prepareForReuse가 불린 이후에는 어떤 cell의 버튼 이벤트를 감지할 수가 없게 됩니다.

view가 나타난초반에는 init을 통해 cell내부에서 데이터를 받아서 vc와 연결시켜주는 cell의 subject와의 stream이 cell의 cancelBag에 들어가고 datasource로인해 cell에서받은 data를 vc에서 받는 stream을 cell의 cancelBag에서 받아서 cell이 vc에게 데이터를 전달할수있게되지만 scroll을 해서 cell이 prepareForReuse를 호출하는 순간 vc에 데이터를 보내줄 cell내부에서 데이터를 받아서 vc와 연결시켜주는 cell의 subject와의 stream이 사라지게되고 직후에 datasource가 불려 cell내부의 subject에서 부터 값을 vc가 받는 stream이 저장됩니다 이렇게되는 경우 cell내부에서 cell내부의 subject로의 stream이 끊겨 vc가 cell내부에서 값을 받을수없게됩니다

Step 4.

  1. 이전과정을 통해 알게된건 vc의 cancelBag과 cell의 cancelBag이 tableview의 datasource로인해서 stream이 누적되면안된다는점
  2. cell내부에서의 stream과 cell과 vc와의 stream을 항상 1개로 유지시켜야하는점
  3. 1,2번의 조건을 만족하기 위해서는 vc와 cell의 subject를 바로 연결시키고 해당 stream을 datasource가 실행될때마다 어떤 cancelBag에서도 누적시키지않기위해서 cell의 cancelBag에다 저장하고 prepareForReuse가 불리고 datasource가 실행된다는 UI Cycle을 활용하여 prepareForReuse에서 canceBag을 초기화시켜주면 cell의 life cycle로인해서 cell와 vc의 stream이 1개로 유지시켜야합니다

이를 해결하기 위해서는 cell내부에서 별도의 stream을 만들지 않아야 합니다. 별도의 stream을 만들지 않는 아래의 두가지 선택지가 있었습니다.

  • button의 접근제한자를 풀고 ViewController에서 button.tapPublisher에 바로 접근하는 방법
  • 기존의 addTarget방식으로 버튼 이벤트를 받은 후에 public publisher에게 전달하여, 이를 ViewController가 Sink하는 방법

이 중 button의 접근제한자를 풀고 직접적으로 접근하는 것이 아닌 후자의 방식을 통해서 문제를 해결했습니다.

moveToArticleListByWeekButton.addButtonAction { sender in
    self.rightArrowButtonTapped.send(())
}

📮 관련 이슈

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
♻️Refactoring 리펙터링 🦁민재 민재's 🦁의성 의성's 🦁찬미 찬미's
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[REFACTOR] CurriculumView MVVM(Combine)-C로 리팩터링
2 participants