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)
}