RxSwift 적용 - siwonkim0/ios-project-manager GitHub Wiki

크게 세가지 부분에서 데이터 전달 방식을 클로저 -> RxSwift로 리팩토링하였다

Input/Output Modeling 사용

뷰모델에 Input과 Output을 정의했다 뷰, 뷰컨트롤러에서 들어오는 입력 값을 Input으로 정의하고 Input과 데이터를 가공하여 뷰에 보여질 데이터를 Output으로 바인딩하였다

1. 앱 실행시 ListVC에 Data 불러오기
2. ListVC에서 할일 터치시 EditDetailVC로 Data 전달
3. EditDetailVC에서 할일 수정시 Data 업데이트

앱 실행시 ListVC에 Data 불러오기

클로저

기존 클로저 방식을 통해 viewModel이 가진 프로젝트가 정상적으로 네트워킹하여 데이터를 가져와 projects에 저장할 경우 tableView를 Reload하도록 구현하였다

# ListViewModel
private var projects: [Project] = [] {
    didSet {
	onUpdated?()
    }
}

func fetchAll() {
    projects = useCase.fetchAll()
}
# ListViewController
viewModel?.onUpdated = {
    self.tableViews.forEach 
        $0.reloadData()
    }
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let state = ((tableView as? ProjectListTableView)?.state)
    let cell = tableView.dequeueReusableCell(withClass: ProjectListTableViewCell.self)
    let project = retrieveSelectedData(indexPath: indexPath, state: state)
    let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date())

    if (project?.date) < yesterday {
        cell.populateDataWithDate(title: project?.title ?? "", body: project?.body ?? "", date: project?.date ?? Date())
    } else {
        cell.populateData(title: project?.title ?? "", body: project?.body ?? "", date: project?.date ?? Date())
    }

    return cell
}

RxSwift

이를 RxSwift를 적용해 정상적으로 네트워킹하여 데이터를 가져왔을 때
변경되는 output의 프로퍼티를 subscribe 하고 tableView 에 데이터를 바인딩하도록 변경했다

# ListViewController
let input = ProjectListViewModel.Input()
let output = self.viewModel?.transform(input: input)

output?.todoProjects
    .do(onNext: { [weak self] in
        (self?.todoTableView.tableHeaderView as? ProjectListTableHeaderView)?.populateData(title: $0.first?.state.title ?? "TODO",  count: $0.count)
    })
    .asDriver(onErrorJustReturn: [])
    .drive(
        todoTableView.rx.items(
            cellIdentifier: String(describing: ProjectListTableViewCell.self),
            cellType: ProjectListTableViewCell.self)
    ) { _, item, cell in
        guard let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date()) else { 
	    return 
	}
        if item.date < yesterday {
            cell.populateDataWithDate(title: item.title, body: item.body, date: item.date)
        } else {
            cell.populateData(title: item.title, body: item.body, date: item.date)
        }
    }.disposed(by: disposeBag)

ListVC에서 할일 터치시 EditDetailVC로 Data 전달

클로저

ListVC에서 할일(Cell) 터치시 기존엔 onCellSelected 클로저를 정의하여 DetailVC를 Present하기 전 데이터가 전달되도록 구현하였다
데이터 전달 방법은 이벤트가 발생한 셀의 index와 가진 데이터에 대한 정보(Project.state)를 전달하여 어떤 상태의 프로젝트 중 몇번째 index가 선택된 프로젝트인지 판단한다

# ListViewModel
var selectedProject: Project?
var onCellSelected: ((Int, Project) -> Void)?

private func retrieveSelectedData(indexPath: IndexPath, state: ProjectState) -> Project? {
    var selectedProject: Project?
    switch state {
    case .todo:
        selectedProject = todoProjects[indexPath.row]
    case .doing:
        selectedProject = doingProjects[indexPath.row]
    case .done:
        selectedProject = doneProjects[indexPath.row]
    }
    self.selectedProject = selectedProject
    return selectedProject

func didSelectRow(indexPath: IndexPath, state: ProjectState) {
    guard let selectedProject = retrieveSelectedData(indexPath: indexPath, state: state) else {
        return
    }
    onCellSelected?(indexPath.row, selectedProject)
}
# ListViewController
viewModel?.onCellSelected = { [weak self] index, project in
     guard let self = self, let viewModel = self.viewModel else {
          return
     }
     let editProjectDetailViewModel = viewModel.createEditDetailViewModel()
     let editViewController = EditProjectDetailViewController(viewModel: editProjectDetailViewModel, delegate: self)
     let destinationViewController = UINavigationController(rootViewController: editViewController)
     destinationViewController.modalPresentationStyle = .formSheet
     self.present(destinationViewController, animated: true, completion: nil)
}

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    guard let state = (tableView as? ProjectListTableView)?.state else {
        return
    }
    viewModel?.didSelectRow(indexPath: indexPath, state: state)
}

RxCocoa

이를 RxCocoa로 리팩토링하여 기존에 정의됐던 클로저와 Delegate Method를 제거함으로써 Bind Code의 코드 응집도를 높였음

# ListViewController
tableViews.forEach {
    $0.rx.modelSelected(Project.self)
        .subscribe(onNext: { [weak self] project in
            guard let viewModel = self?.viewModel else {
                 return
            }
            let editProjectDetailViewModel = viewModel.createEditDetailViewModel(with: project)
            let viewController = EditProjectDetailViewController(viewModel: editProjectDetailViewModel)
            let destinationViewController = UINavigationController(rootViewController: viewController)
                    
            destinationViewController.modalPresentationStyle = .formSheet
            self?.present(destinationViewController, animated: true, completion: nil)
        }).disposed(by: disposeBag)
    }

EditDetailVC에서 할일 수정시 Data 업데이트

클로저

기존에는 didTapDoneButton 클로저를 통해 done버튼 터치시 뷰가 dismiss되며 뷰모델의 모델을 서버에 업데이트하였다

# EditDetailViewController
projectDetailView.setEditingMode(to: false)
self.dismiss(animated: true) {
    self.updateListView()
}

private func updateListView() {
    guard let currentProject = viewModel?.currentProject else {
        return
    }
    let updatedProject = self.updatedViewData(with: currentProject)
    viewModel?.didTapDoneButton(updatedProject)
}
  • 뷰모델 update 메서드 호출시 usecase(domain)의 update 메서드를 호출하여 Repository 업데이트
  • UseCase의 메서드 내부에선 파라미터로 받은 project(업데이트, 수정된 프로젝트)의 id를 통해 repository의 data 중 특정 id를 만족하는 기존 프로젝트를 불러옴
  • 기존 Repository의 데이터 수정
# ListViewModel
func update(with project: Project) {
    useCase.update(with: project)
    fetchAll()
}

# usecase
func update(with project: Project) {
    let oldProject = fetch(with: project.id)
    let newProject = Project(id: oldProject.id, state: oldProject.state, title: project.title, body: project.body, date: project.date)

    projectRepository.update(with: newProject)
}

# Repository
func update(with project: Project) {
    projects.updateValue(project, forKey: project.id)
}
  • 이후 업데이트된 repo의 프로젝트(데이터)를 불러와 viewmodel을 업데이트하고, 클로저를 활용하여 테이블뷰 리로드를 통해 ListVC를 업데이트
# Repository
func fetchAll() -> [UUID: Project] {
    return projects
}

# usecase
func fetchAll() -> [Project] {
    return projectRepository.fetchAll()
        .map { $0.value }
        .sorted { $0.date > $1.date }
}

# ListViewModel
func fetchAll() {
    projects = useCase.fetchAll()
}
# ListViewController
viewModel.onUpdated = {
    self.tableViews.forEach {
        $0.reloadData()
    }
}

RxSwift

done 버튼을 RxCocoa를 활용하여 observable로 만들고 뷰모델에서 이를 subscribe하여 터치 이벤트가 발생하면 viewModel → useCase → Repository 로 데이터가 전달된다

# EditDetailViewController
private func configureBind() {
    let didTapButtonObservable = editButtonItem.rx.tap
        .do(onNext: { [weak self] in self?.toggleEditMode() })
        .scan(false) { lastState, _ in
            !lastState
        }
        .asObservable()
    
    cancelButton.rx.tap
        .subscribe(onNext: { [weak self] in
            self?.dismiss(animated: true)
        }).disposed(by: disposeBag)
    
    let input = EditProjectDetailViewModel.Input(didTapDoneButton: didTapButtonObservable)
    
    _ = viewModel?.transform(input: input)
}

private func toggleEditMode() {
    if !isEditing {
        projectDetailView.setEditingMode(to: true)
        isEditing = true
    } else {
        projectDetailView.setEditingMode(to: false)
        isEditing = false
        self.updateCurrentProject()
        self.dismiss(animated: true)
    }
}
# EditDetailViewModel
struct Input {
    let didTapDoneButton: Observable<Bool>
}

struct Output {

}

func transform(input: Input) -> Output {
    input.didTapDoneButton
        .subscribe(onNext: { [weak self] value in
            if value == false {
                self?.usecase.update(self?.currentProject ?? Project(id: UUID(), state: .todo, title: "", body: "", date: Date()), to: nil)
            }
            
        }).disposed(by: disposeBag)
    
    let output = Output()
    return output
}

# usecase
func update(_ project: Project, to state: ProjectState?) {
    guard let oldProject = fetch(with: project.id) else {
        return
    }
    var newProject = oldProject
    
    if let updatedState = state {
        newProject = Project(id: oldProject.id, state: updatedState, title: project.title, body: project.body, date: project.date)
    } else {
        newProject = Project(id: oldProject.id, state: project.state, title: project.title, body: project.body, date: project.date)
    }
    projectRepository.update(newProject)
}

repository가 datasource를 업데이트하고 완료되면 completable를 받아서 BehaviorRelay인 [Project]에 데이터를 업데이트한다

# repository
private lazy var projects = BehaviorRelay<[Project]>(value: [])

func update(_ project: Project) {
    if networkConnection.isConnected == true {
        Completable.zip(
            remoteDataSource.update(project),
            localDataSource.update(project))
            .subscribe(onCompleted: { [self] in
                getUpdatedDataFromLocalDataSource()
            }).disposed(by: disposeBag)
    } else {
        localDataSource.update(project)
            .subscribe(onCompleted: { [self] in
                getUpdatedDataFromLocalDataSource()
            }).disposed(by: disposeBag)
    }
}

private func getUpdatedDataFromLocalDataSource() {
    self.localDataSource.fetch().subscribe(onSuccess: { project in
        self.projects.accept(project)
    }).disposed(by: self.disposeBag)
}
⚠️ **GitHub.com Fallback** ⚠️