본문 바로가기
iOS/Combine

Combine 중복 바인딩 문제 (tableViewCell)

by 바등쪼 2023. 2. 15.

 

  • 관련 PR
    https://github.com/sopt-makers/SOPT-iOS/pull/66

문제 상황

위와 같은 뷰에서 각 cell의 체크 박스(버튼)의 토글 이벤트를 combine으로 활용하여 VC와 바인딩하려고 한다.

이 때,

위처럼, 버튼 클릭을 해도 토글이 되지 않고 다시 원상태로 돌아오는, 정확히 말하면 두번 토글되어 본래 상태로 돌아오는 문제가 발생했다.

VC에서 cell의 publisher를 구독하여 print 해보면 클릭하면 이벤트가 연속해서 2번 발생하는 상황이었다.

 

코드

  • VC
    public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            
            guard let cell = tableView.dequeueReusableCell(
                withIdentifier: PushAlarmPartTVC.className, for: indexPath)
                    as? PushAlarmPartTVC else { return UITableViewCell() }
            cell.selectionStyle = .none
            
            let cellType = PartCategory.allCases[indexPath.item]
            let isOn = pushToggleList[indexPath.item]
            cell.initCell(index: indexPath.row, title: cellType.title, isOn: isOn)
            
            cell.partButtonTapped
                .receive(on: RunLoop.main)
                .sink { indexSelected in
                    print(indexSelected)
                }.store(in: cancelBag)
            
            return cell
        }
  • cell
    private var index: Int?
    public lazy var partButtonTapped: Driver<(Int, Bool)> = {
        return self.stateButton.publisher(for: .touchUpInside)
            .map {
                self.stateButton.isSelected.toggle()
                return (self.index!, $0.isSelected)
            }
            .asDriver()
    }()

 

문제 발생 원인

원인을 발견하는데 시간이 오래 걸렸지만 정작 간단한 이유였다.

바로, tableView의 realoadData() 때문이었다.

해당 뷰는 뷰 로드 시점에 서버와 통신하여 사용자가 미리 체크한 요소의 정보를 받아와서 tableView를 reloadData() 하는 로직을 수행하고 있었다.

  • VC
private func bindViewModels() {
        let input = PushAlarmSettingViewModel.Input(viewDidLoad: Driver.just(()))
        let output = self.viewModel.transform(from: input, cancelBag: self.cancelBag)
        
        output.$pushSettingList
            .compactMap { $0 }
            .sink { model in
                self.pushToggleList = model.pushSettingList
                self.partListTableView.reloadData()
            }.store(in: self.cancelBag)
    }

realoadData()가 실행됨에 따라 cellForRowAt이 다시 동작하였고 cell마다 바인딩이 2번 발생한 것이다.

따라서 realadData가 없는 정적인 테이블뷰(콜렉션뷰)에서는 해당 문제가 발생하지 않는다.

 

해결 방법

  1. reloadData가 실행되어 cellForRowAt이 재실행될 때 기존에 바인딩 되어 있던 stream을 cancel 하면 된다.
  1. 이를 위해 각 stream을 구분할 수 있도록 cell의 개수만큼의 UUID 어레이를 만든다.
  1. UUID를 Key로 하고 AnyCancellable을 value로 하는 딕셔너리를 만든다.
  1. VC와 Cell의 바인딩이 생성되기 전에 그 cell의 UUID에 맞는 value가 딕셔너리에 이미 존재한다면 이를 cancel한다.
  1. 새로 구독을 하여 생기는 AnyCancellable을 앞서 만든 딕셔너리에 cell의 UUID를 키로 하여 저장한다.
  • VC
private let cellIDs = (1...7).map({ _ in UUID() })
private var cellBindBag: [UUID: AnyCancellable] = [:]

... 생략 ...

public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        guard let cell = tableView.dequeueReusableCell(
            withIdentifier: PushAlarmPartTVC.className, for: indexPath)
                as? PushAlarmPartTVC else { return UITableViewCell() }
        cell.selectionStyle = .none
        
        let cellType = PartCategory.allCases[indexPath.item]
        let isOn = pushToggleList[indexPath.item]
        cell.initCell(index: indexPath.item, title: cellType.title, isOn: isOn)
        
        let id = cellIDs[indexPath.item]
        cellBindBag[id]?.cancel()
        
        cellBindBag[id] = cell.partButtonTapped
            .receive(on: RunLoop.main)
            .sink { [weak self] indexSelected in
                guard let self = self else { return }
                self.pushToggleList[indexSelected.0] = indexSelected.1
            }
        
        return cell
    }

 

실행 결과

정상적으로 바인딩이 한 번만 이루어져 버튼이 잘 select 되는 것을 확인할 수 있다.

 

 

다른 해결 방법


1. Cell의 cancelBag을 사용한다. (정석적인 방법)

근본적인 원인

  • 기존의 방식대로 cellForRowAt에서 구독을 할 때 .store(in self.cancelBag)을 하게 되면 VC에 있는 cancelBag에 해당 cancellable을 저장한다는 의미이다. ⇒ 따라서 해당 구독은 VC의 생명주기와 같이간다. 하지만 우리는 cell의 생명주기와 구독이 같이 움직이기를 원한다. 이를 위해 cell에 cancelBag을 따로 만들고 해당 cancelBag에 구독을 저장한다. 
class PushAlarmPartTVC: UITableViewCell {
    
    // MARK: - Properties
    var cancelBag = CancelBag()
		...생략...

    override func prepareForReuse() {
      self.cancelBag = CancelBag()
  }
}
  1. cell에도 cancelBag을 생성한다.
  1. prepareForReuse() 함수를 override하여 cancelBag을 초기화 시켜준다. (cancelBag 내부에 있는 AnyCancellable들을 전부 cancel() 시키는 역할)
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
    guard let cell = tableView.dequeueReusableCell(
        withIdentifier: PushAlarmPartTVC.className, for: indexPath)
            as? PushAlarmPartTVC else { return UITableViewCell() }
    cell.selectionStyle = .none
    
    let cellType = PartCategory.allCases[indexPath.item]
    let isOn = pushToggleList[indexPath.item]
    cell.initCell(index: indexPath.item, title: cellType.title, isOn: isOn)
    
    cell.partButtonTapped
        .receive(on: RunLoop.main)
        .sink { [weak self] indexSelected in
            guard let self = self else { return }
            print(indexSelected)
            self.pushToggleList[indexSelected.0] = indexSelected.1
        }.store(in: cell.cancelBag)
    
    return cell
}
  1. VC의 cellForRowAt 함수에서는 위와 같이 cell.cancelBag에 해당 구독을 저장한다.

 

 

2. cell과의 바인딩을 위한 또다른 cancelBag을 생성하고 reloadData()를 호출하면 해당 cancelBag을 초기화 시켜준다.

public class PushAlarmSettingVC: UIViewController {
	private var cancelBag = CancelBag() // 기존의 viewModel과 바인딩을 위한 cancelBag
    private var cancelBagForCell = CancelBag() // cell과의 바인딩을 위한 cancelBag


    public override func viewDidLoad() {
    super.viewDidLoad()
     someTask()
    }
		
    private func someTask() {
        cancelBagForCell.cancel()  
        // cancel()는 set 내부의 AnyCancellable을 전부 cancel하도록 구현
        myTableView.reloadData()
    } 


    public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

    guard let cell = tableView.dequeueReusableCell(
        withIdentifier: PushAlarmPartTVC.className, for: indexPath)
            as? PushAlarmPartTVC else { return UITableViewCell() }
    cell.selectionStyle = .none

    let cellType = PartCategory.allCases[indexPath.item]
    let isOn = pushToggleList[indexPath.item]
    cell.initCell(index: indexPath.item, title: cellType.title, isOn: isOn)

    cell.partButtonTapped
        .receive(on: RunLoop.main)
        .sink { [weak self] indexSelected in
            guard let self = self else { return }
            print(indexSelected)
            self.pushToggleList[indexSelected.0] = indexSelected.1
        }.store(in: cancelBagForCell)

    return cell
    }
}

문제의 원인이 VC와 cell의 생명주기가 달라서 였기 때문에 이를 이용한 해결책이다.

  1. cell과의 바인딩을 위한 또다른 cancelBag을 생성한다. (기존의 cancelBag은 viewModel과의 바인딩 정보가 있기 때문에 이를 함부로 건들면 안되기 때문이다.)
  1. cell의 publisher를 구독할 때 방금 만든 새 cancelBag에 store 한다.
  1. tableView의 reloadData()를 호출할 때 해당 cancelBag에 있는 요소들을 전부 cancel 시켜준다. (기존의 구독 cancel) ⇒ reload 되어 cellForRowAt이 재실행되고 새로운 구독이 생겨도 해당 구독이 유일하도록 함.

 

 

 

 

 

 

 

댓글