RxSwift 적용 - siwonkim0/ios-project-manager GitHub Wiki
크게 세가지 부분에서 데이터 전달 방식을 클로저 -> RxSwift로 리팩토링하였다
뷰모델에 Input과 Output을 정의했다 뷰, 뷰컨트롤러에서 들어오는 입력 값을 Input으로 정의하고 Input과 데이터를 가공하여 뷰에 보여질 데이터를 Output으로 바인딩하였다
1. 앱 실행시 ListVC에 Data 불러오기
2. ListVC에서 할일 터치시 EditDetailVC로 Data 전달
3. EditDetailVC에서 할일 수정시 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를 적용해 정상적으로 네트워킹하여 데이터를 가져왔을 때
변경되는 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에서 할일(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로 리팩토링하여 기존에 정의됐던 클로저와 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)
}기존에는 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()
}
}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)
}