일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- MVVM
- 함수형 프로그래밍
- github
- 쏙쏙 들어오는 함수형 코딩
- 함수형 코딩
- Apple Developer Academy @ POSTECH
- xcode
- CI
- os_log
- SwiftUI
- SwiftUI VStack
- swift CI 적용
- IOS
- firestore
- unittest
- 액션과 계산 데이터
- Swift thread
- Firebase
- XCTest
- print 단점
- LGTM
- 2기화이팅
- 하드디스크 삭제 원리
- 오픈소스
- combine
- ChatGPT
- swift github action
- auto_assign
- flutter
- Swift
- Today
- Total
개발공방
[애니또] MVVM + Combine Input & Output 구조 - 리팩토링 (4) 본문
노션에 먼저 작성한 후 블로그로 옮기기 때문에 노션이 보기에 더 편합니다
https://mingwan.notion.site/MVVM-Combine-Input-Output-2-3ad9c58dc84c4d0c9a14a34c52b25370?pvs=4
https://dev-workplace.tistory.com/23
[애니또] MVVM + Combine - 리팩토링 (3)
노션에 먼저 작성한 후 블로그로 옮기기 때문에 노션이 보기에 더 편합니다. https://www.notion.so/MVVM-Combine-323ab64639374213b1b1734fcea257e7?pvs=4 [애니또] ViewController와 View의 분리 - 리팩토링 (1) [애니또] View
dev-workplace.tistory.com
앞선 글에서 아주 간단한 Combine을 활용해 양방향 바인딩하는 방법을 알아보았다.
View 소개
이번엔 애니또에서 내가 담당하는 View에서 ViewModel을 적용해 볼 예정이다.
마니또 시작전에 사람들을 초대해서 인원을 모으고 다 모였다면 방장이 마니또를 시작할 수 있는 간단한 화면이다.
해당 View에서 일어날 수 있는 이벤트들을 Input으로 받고 transform 과정을 거쳐 Output으로 리턴하게끔 만들어 볼 것이다.
Input & Output 정리하기
우선 Input으로 들어갈 이벤트들을 정리해야한다.
Input
|
Output
|
이해하기 쉽게 Input의 번호와 Output의 번호를 연결해서 보면 좋을 것 같다.
1번에서 해당뷰에 들어왔을 때 방 정보조회 Input이 발생하면 해당 방 정보를 Output으로 내보낸다. 마찬가지로 2번에서도 코드 복사 버튼을 누르면 초대 코드가 Output으로 내보내진다.
Input & Output 코드 구현
그렇다면 Input을 코드로 구현해보자.
struct Input {
let viewDidLoad: AnyPublisher<Void, Never>
let codeCopyButtonDidTap: AnyPublisher<Void, Never>
let startButtonDidTap: AnyPublisher<Void, Never>
let editMenuButtonDidTap: AnyPublisher<Void, Never>
let deleteMenuButtonDidTap: AnyPublisher<Void, Never>
let leaveMenuButtonDidTap: AnyPublisher<Void, Never>
let changeButtonDidTap: AnyPublisher<Void, Never>
}
1번 이벤트는 해당 View에 진입했을 때 발생하는 이벤트이므로 viewDidLoad 단계에서 발생하는 이벤트라고 볼 수 있다.
각각의 이벤트를 AnyPublisher를 이용한 Stream으로 받을 예정이다.
Output도 코드로 구현해보자.
struct Output {
let roomInformation: CurrentValueSubject<Room, NetworkError>
let code: AnyPublisher<String, Never>
let manitteeNickname: PassthroughSubject<String, NetworkError>
let editRoomInformation: AnyPublisher<EditRoomInformation, Never>
let deleteRoom: PassthroughSubject<Void, NetworkError>
let leaveRoom: PassthroughSubject<Void, NetworkError>
let passedStartDate: AnyPublisher<PassedStartDateAndIsOwner, Never>
}
Input 코드와는 다르게 Output 코드는 조금 헷갈릴만 하다. CurrentValueSubject ,AnyPublisher, PassthroughSubject 각각이 사용되기 때문이다.
또, 네트워크 통신이 발생하는 이벤트의 에러 타입은 NetworkError로 만들었고 그렇지 않은 경우는 Never로 만들었다.
왜 Output이 저렇게 만들어졌는지 단번에 이해하기는 어려울 수 있으니, transform 함수의 코드를 보면서 이해해보자.
func transform(_ input: Input) -> Output {
input.viewDidLoad
.sink(receiveValue: { [weak self] _ in
guard let roomId = self?.roomIndex.description else { return }
self?.requestWaitRoomInfo(roomId: roomId)
})
.store(in: &self.cancellable)
let codeOutput = input.codeCopyButtonDidTap
.map { [weak self] _ -> String in
guard let self else { return "" }
return self.makeCode()
}
.eraseToAnyPublisher()
input.startButtonDidTap
.sink(receiveValue: { [weak self] _ in
guard let roomId = self?.roomIndex.description else { return }
self?.requestStartManitto(roomId: roomId)
})
.store(in: &self.cancellable)
let editRoomInformationOutput = input.editMenuButtonDidTap
.map { [weak self] _ -> EditRoomInformation in
guard let self else { return (Room.emptyRoom, .information) }
return self.makeEditRoomInformation()
}
.eraseToAnyPublisher()
input.deleteMenuButtonDidTap
.sink(receiveValue: { [weak self] _ in
guard let roomId = self?.roomIndex.description else { return }
self?.requestDeleteRoom(roomId: roomId)
})
.store(in: &self.cancellable)
input.leaveMenuButtonDidTap
.sink(receiveValue: { [weak self] _ in
guard let roomId = self?.roomIndex.description else { return }
self?.requestDeleteLeaveRoom(roomId: roomId)
})
.store(in: &self.cancellable)
let passedStartDateOutput = input.viewDidLoad
.delay(for: 0.5, scheduler: DispatchQueue.main)
.map { [weak self] _ -> PassedStartDateAndIsOwner in
guard let self else { return (false, false) }
return self.makeIsAdmin()
}
.eraseToAnyPublisher()
input.changeButtonDidTap
.sink(receiveValue: { [weak self] _ in
guard let roomId = self?.roomIndex.description else { return }
self?.requestWaitRoomInfo(roomId: roomId)
})
.store(in: &self.cancellable)
return Output(
roomInformation: self.roomInformationSubject,
code: codeOutput,
manitteeNickname: self.manitteeNicknameSubject,
editRoomInformation: editRoomInformationOutput,
deleteRoom: self.deleteRoomSubject,
leaveRoom: self.leaveRoomSubject,
passedStartDate: passedStartDateOutput
)
}
전체적인 구조는 이렇다.
하나하나씩 뜯어보면서 이해한다면 어떤 흐름으로 연결되는지 이해가 편할 것 같다.
우선 해당 ViewModel에 선언되어 있는 코드들을 먼저 보자. transform 함수를 이해하는데 도움이 될 것이다.
// DetailWaitViewModel.swift
final class DetailWaitViewModel {
let roomIndex: Int
private let detailWaitService: DetailWaitServicable
private var cancellable = Set<AnyCancellable>()
private let roomInformationSubject = CurrentValueSubject<Room, NetworkError>(Room.emptyRoom)
private let manitteeNicknameSubject = PassthroughSubject<String, NetworkError>()
private let deleteRoomSubject = PassthroughSubject<Void, NetworkError>()
private let leaveRoomSubject = PassthroughSubject<Void, NetworkError>()
private let changeButtonSubject = PassthroughSubject<Void, NetworkError>()
struct Input {
// 생략
}
struct Output {
// 생략
}
// ...
}
총 5개의 Subject가 있다. 이 부분이 왜 필요한지, 어떤식으로 연결되는지 살펴보자.
1. ViewDidLoad 이벤트 (Subject를 만들어서 이벤트를 전달하는 방법)
input.viewDidLoad
.sink(receiveValue: { [weak self] _ in
guard let roomId = self?.roomIndex.description else { return }
self?.requestWaitRoomInfo(roomId: roomId)
})
.store(in: &self.cancellable)
우선 1번의 Input은 viewDidLoad라는 이벤트가 발생하면 네트워크 통신을 통해 방 정보를 가져온다.
requestWaitRoomInfo 함수를 살펴보면,
private func requestWaitRoomInfo(roomId: String) {
Task {
do {
let room = try await self.detailWaitService.fetchWaitingRoomInfo(roomId: roomId)
self.roomInformationSubject.send(room) // 이벤트 전달
} catch(let error) {
guard let error = error as? NetworkError else { return }
self.roomInformationSubject.send(completion: .failure(error))
}
}
}
self.roomInformationSubject.send(room) 코드를 통해 Subject에 이벤트를 전달하는 걸 볼 수 있다.
error를 catch하는 상황에서는 error를 전달한다.
그리고 Transform 함수의 리턴으로는 위에서 언급했던 5개의 Subject중 하나인 roomInformationSubject를 넘겨준다.
// DetailWaitViewModel.swift
private let roomInformationSubject = CurrentValueSubject<Room, NetworkError>(Room.emptyRoom)
// ...
return Output(
roomInformation: self.roomInformationSubject,
// 생략
)
View와 연결하는 부분인 ViewController에서 코드는 에러가 뜬다면 Alert을 띄우고, 값이 넘어온다면 receiveValue 를 통해 View를 업데이트 해준다.
// DetailWaitViewController.swift
private func bindOutputToViewModel(_ output: DetailWaitViewModel.Output) {
output.roomInformation
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] result in
switch result {
case .finished: return
case .failure(_):
self?.makeAlert(title: "에러 발생")
}
}, receiveValue: { [weak self] room in
self?.detailWaitView.updateDetailWaitView(room: room)
})
.store(in: &self.cancellable)
// 생략
}
2. CodeCopyButtonDidTap 이벤트
codeCopyButtonDidTap 이벤트가 발생하면 makeCode() 함수를 리턴해주는 Publisher를 변수로 만든다.
let codeOutput = input.codeCopyButtonDidTap
.map { [weak self] _ -> String in
guard let self else { return "" }
return self.makeCode()
}
.eraseToAnyPublisher()
makeCode 함수는 해당 방 정보에서 초대코드를 빼와서 리턴해주는 함수다.
private func makeCode() -> String {
let roomInformation = self.roomInformationSubject.value
guard let code = roomInformation.invitation?.code else { return "" }
return code
}
Output으로는 만들어준 codeOutput을 리턴해준다.
return Output(
roomInformation: self.roomInformationSubject,
code: codeOutput,
// 생략
)
ViewController에서는 해당 코드를 전달받아서 ToastView를 띄워준다.
// DetailWaitViewController.swift
private func bindOutputToViewModel(_ output: DetailWaitViewModel.Output) {
output.roomInformation
// 생략
output.code
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] code in
self?.showToastView(code: code)
})
.store(in: &self.cancellable)
3. StartButtonDidTap 이벤트
startButtonDidTap 이벤트가 발생하면 requestStartManitto 함수를 실행한다.
input.startButtonDidTap
.sink(receiveValue: { [weak self] _ in
guard let roomId = self?.roomIndex.description else { return }
self?.requestStartManitto(roomId: roomId)
})
.store(in: &self.cancellable)
requestStartManitto 함수는 1번의 예시와 비슷하다. 네트워크 통신 함수를 실행하고 성공한다면 변수로 선언된 Subject에 send를 통해 이벤트를 전달한다.
private func requestStartManitto(roomId: String) {
Task {
do {
let manittee = try await self.detailWaitService.patchStartManitto(roomId: roomId)
guard let nickname = manittee.nickname else { return }
self.manitteeNicknameSubject.send(nickname)
} catch(let error) {
guard let error = error as? NetworkError else { return }
self.manitteeNicknameSubject.send(completion: .failure(error))
}
}
}
+) 혹시 여기서 왜 nickname이 Output인지 의아한 사람들을 위해 설명하자면, 마니또 시작 버튼을 누르면 내가 챙겨줘야하는 마니띠가 누군지 알아야 하기 때문에 nickname을 리턴으로 받는다.
Output으로는 만들어둔 manitteeNicknameSubject를 리턴한다.
return Output(
roomInformation: self.roomInformationSubject,
code: codeOutput,
manitteeNickname: self.manitteeNicknameSubject,
// 생략
)
ViewController에서는 에러가 넘어온다면 Alert을 띄우고, 성공했다면 마니띠 확인 View로 넘기는 함수를 실행한다.
// DetailWaitViewController.swift
output.manitteeNickname
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] result in
switch result {
case .finished: return
case .failure(_):
self?.makeAlert(title: "에러 발생")
}
}, receiveValue: { [weak self] nickname in
self?.presentSelectManittoViewController(nickname: nickname)
})
.store(in: &self.cancellable)
나머지 부분들도 위와 같은 방식으로 진행된다. 그럼 ViewController의 코드는 어떻게 되는지 살펴보자.
ViewController에서의 연결
ViewModel을 가지고 있는 ViewController에서의 Stream 연결은 어떻게 되는지 전체 코드로 먼저 보고 각각이 어떤 함수인지 설명해보겠다.
ViewController 바인딩 전체 코드
// DetailWaitViewController.swift
final class DetailWaitViewController: BaseViewController {
// MARK: - property
private let deleteMenuButtonSubject = PassthroughSubject<Void, Never>()
private let leaveMenuButtonSubject = PassthroughSubject<Void, Never>()
private let changeButtonSubject = PassthroughSubject<Void, Never>()
private var cancellable = Set<AnyCancellable>()
private let detailWaitViewModel: DetailWaitViewModel
// 생략
override func viewDidLoad() {
super.viewDidLoad()
// 생략
self.bindViewModel()
self.setupBind()
}
// MARK: - func
private func transformedOutput() -> DetailWaitViewModel.Output {
let input = DetailWaitViewModel.Input(
viewDidLoad: self.viewDidLoadPublisher,
codeCopyButtonDidTap: self.detailWaitView.copyButtonPublisher,
startButtonDidTap: self.detailWaitView.startButtonPublisher,
editMenuButtonDidTap: self.detailWaitView.editMenuButtonSubject.eraseToAnyPublisher(),
deleteMenuButtonDidTap: self.deleteMenuButtonSubject.eraseToAnyPublisher(),
leaveMenuButtonDidTap: self.leaveMenuButtonSubject.eraseToAnyPublisher(),
changeButtonDidTap: self.changeButtonSubject.eraseToAnyPublisher())
return self.detailWaitViewModel.transform(input)
}
private func bindViewModel() {
let output = self.transformedOutput()
self.bindOutputToViewModel(output)
}
private func bindOutputToViewModel(_ output: DetailWaitViewModel.Output) {
output.roomInformation
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] result in
switch result {
case .finished: return
case .failure(_):
self?.makeAlert(title: "에러 발생")
}
}, receiveValue: { [weak self] room in
self?.detailWaitView.updateDetailWaitView(room: room)
})
.store(in: &self.cancellable)
output.code
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] code in
self?.showToastView(code: code)
})
.store(in: &self.cancellable)
output.manitteeNickname
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] result in
switch result {
case .finished: return
case .failure(_):
self?.makeAlert(title: "에러 발생")
}
}, receiveValue: { [weak self] nickname in
self?.presentSelectManittoViewController(nickname: nickname)
})
.store(in: &self.cancellable)
output.editRoomInformation
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] editInformation in
self?.showDetailEditViewController(roomInformation: editInformation.roomInformation,
mode: editInformation.mode)
})
.store(in: &self.cancellable)
output.deleteRoom
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] result in
switch result {
case .finished: return
case .failure(_):
self?.makeAlert(title: "오류 발생")
}
}, receiveValue: { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
})
.store(in: &self.cancellable)
output.leaveRoom
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] result in
switch result {
case .finished: return
case .failure(_):
self?.makeAlert(title: "오류 발생")
}
}, receiveValue: { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
})
.store(in: &self.cancellable)
output.passedStartDate
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] (isPassedStartDate, isAdmin) in
self?.showStartDatePassedAlert(isPassedStartDate: isPassedStartDate, isAdmin: isAdmin)
})
.store(in: &self.cancellable)
}
private func setupBind() {
self.detailWaitView.deleteMenuButtonSubject
.sink(receiveValue: { [weak self] _ in
self?.deleteRoom()
})
.store(in: &self.cancellable)
self.detailWaitView.leaveMenuButtonSubject
.sink(receiveValue: { [weak self] _ in
self?.leaveRoom()
})
.store(in: &self.cancellable)
}
// 생략
}
func transformedOutput()
해당 함수는 Input을 통해 Output을 리턴하는 함수이다.
private func transformedOutput() -> DetailWaitViewModel.Output {
let input = DetailWaitViewModel.Input(
viewDidLoad: self.viewDidLoadPublisher,
codeCopyButtonDidTap: self.detailWaitView.copyButtonPublisher,
startButtonDidTap: self.detailWaitView.startButtonPublisher,
editMenuButtonDidTap: self.detailWaitView.editMenuButtonSubject.eraseToAnyPublisher(),
deleteMenuButtonDidTap: self.deleteMenuButtonSubject.eraseToAnyPublisher(),
leaveMenuButtonDidTap: self.leaveMenuButtonSubject.eraseToAnyPublisher(),
changeButtonDidTap: self.changeButtonSubject.eraseToAnyPublisher())
return self.detailWaitViewModel.transform(input)
}
각각의 이벤트에 대한 Publisher를 Input으로 넣어서 전달해 준다. Button에 관한 Publisher는 View나 ViewController에서 Subject로 선언해둔 값을 할당해줬다.
func bindViewModel()
해당 함수는 위에서 만든 함수를 이용해서 output값을 bindOutputToViewModel로 함수로 넘겨주는 간단한 함수이다.
private func bindViewModel() {
let output = self.transformedOutput()
self.bindOutputToViewModel(output)
}
func bindOutputToViewModel()
해당 함수는 위의 ViewModel에서의 연결 과정 중 1, 2번을 설명하면서 ViewController의 코드가 나온 부분이다.
1, 2번 외에 부분을 전체 코드를 통해 살펴보면 같은 맥락으로 진행된다.
여기서의 핵심은 Output을 받고난 후의 액션을 적어준다는 것이다. (View를 업데이트 하거나, ToastView를 띄우거나, Navigation을 이동하거나 등등)
private func bindOutputToViewModel(_ output: DetailWaitViewModel.Output) {
output.roomInformation
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] result in
switch result {
case .finished: return
case .failure(_):
self?.makeAlert(title: "에러 발생")
}
}, receiveValue: { [weak self] room in
self?.detailWaitView.updateDetailWaitView(room: room)
})
.store(in: &self.cancellable)
output.code
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] code in
self?.showToastView(code: code)
})
.store(in: &self.cancellable)
output.manitteeNickname
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] result in
switch result {
case .finished: return
case .failure(_):
self?.makeAlert(title: "에러 발생")
}
}, receiveValue: { [weak self] nickname in
self?.presentSelectManittoViewController(nickname: nickname)
})
.store(in: &self.cancellable)
output.editRoomInformation
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] editInformation in
self?.showDetailEditViewController(roomInformation: editInformation.roomInformation,
mode: editInformation.mode)
})
.store(in: &self.cancellable)
output.deleteRoom
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] result in
switch result {
case .finished: return
case .failure(_):
self?.makeAlert(title: "오류 발생")
}
}, receiveValue: { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
})
.store(in: &self.cancellable)
output.leaveRoom
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] result in
switch result {
case .finished: return
case .failure(_):
self?.makeAlert(title: "오류 발생")
}
}, receiveValue: { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
})
.store(in: &self.cancellable)
output.passedStartDate
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] (isPassedStartDate, isAdmin) in
self?.showStartDatePassedAlert(isPassedStartDate: isPassedStartDate, isAdmin: isAdmin)
})
.store(in: &self.cancellable)
}
결론
위의 과정을 통해 MVVM 패턴을 Combine을 활용해서 적용해볼 수 있다. 그리고 Input Output 구조를 활용해서 조금 더 직관적인 코드를 만들 수 있다.
Input Output을 처음 적용한다면 먼저 Input이 뭔지 정리해보고, 그에따른 Output이 뭔지 정리해본 후 코드를 작성하는 방법이 훨씬 도움이 될 것이다.
다음번으로는 ViewModel의 테스트 코드를 작성해보겠다. MVVM의 장점 중 하나인 테스트가 용이하다. 라는 장점을 활용하지 못한다면 MVVM의 장점을 십분 활용하지 못하기 때문이다.
해당 Pull Request 링크 : https://github.com/DeveloperAcademy-POSTECH/MC2-Team5-Firefighter/pull/462
'Swift > UIKit' 카테고리의 다른 글
[애니또] MVVM + Combine - 리팩토링 (3) (2) | 2023.06.15 |
---|---|
[애니또] MVVM을 선택한 이유 - 리팩토링 (2) (0) | 2023.06.07 |
[애니또] ViewController와 View의 분리 - 리팩토링 (1) (0) | 2023.04.19 |
Swift UIBezierPath 크리스마스 트리 (4) | 2022.12.25 |
Swift DispatchQueue 기본 원리 (8) | 2022.07.14 |