RxSwift 기능 학습
- idInputField.text : @과.com이 포함 된 경우, 오른쪽의 bullet 색상이 '초록색'으로 바뀐다.
- pwInputField.text : 6글자 이상인 경우, 오른쪽의 bullet 색상이 '초록색'으로 바뀐다.
- ID와 PW의 bullet이 둘 다 초록색인 경우, LOGIN 버튼이 파란색으로 변하고 활성화 된다.
- ID와 PW의 text가 각각 '[email protected]', 'password'와 일치하는 경우 로그인 버튼을 통해 다음 화면으로 이동할 수 있다.
- 로직
private func checkEmailValid(_ email: String) -> Bool { return email.contains("@") && email.contains(".com") } private func checkPasswordValid(_ password: String) -> Bool { return password.count > 5 } private func changeValidViewStatus(_ view: UIView, status valid: Bool) { if valid { view.backgroundColor = .green } else { view.backgroundColor = .red } } override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool { return matchStatus }
- 외부 변수 생성
let idValid: BehaviorSubject<Bool> = BehaviorSubject(value: false) let idString: BehaviorSubject<String> = BehaviorSubject(value: "") let passwordValid: BehaviorSubject<Bool> = BehaviorSubject(value: false) let passwordString: BehaviorSubject<String> = BehaviorSubject(value: "") var matchStatus: Bool = false
- Bind Input
private func bindInput() { // input : id 입력, pw 입력 idField.rx.text.orEmpty .bind(to: idString) // idField의 text를 외부변수에 전달 .disposed(by: disposeBag) idString // idField의 text를 전달받은 외부변수 .map(checkEmailValid) // 해당 변수 값의 email valid 여부 체크 -> bool .bind(to: idValid) // vaild 여부에 대한 결과를 외부 변수에 전달 .disposed(by: disposeBag) pwField.rx.text.orEmpty .bind(to: passwordString) // pwField의 text를 외부변수에 전달 .disposed(by: disposeBag) passwordString // pwField의 text를 전달받은 외부변수 .map(checkPasswordValid) // 해당 변수 값의 password valid 여부 체크 -> bool .bind(to: passwordValid) // vaild 여부에 대한 결과를 외부 변수에 전달 .disposed(by: disposeBag) } ```
- Bind Output
private func bindOutput() { // bullets - id idValid.subscribe(onNext: { valid in self.changeValidViewStatus(self.idValidView, status: valid) }) .disposed(by: disposeBag) // bullets - password passwordValid.subscribe(onNext: { valid in self.changeValidViewStatus(self.pwValidView, status: valid) }) .disposed(by: disposeBag) // login Button Observable.combineLatest(idValid, passwordValid, resultSelector: { $0 && $1 }) .subscribe { enabled in if enabled { self.loginButton.backgroundColor = .systemBlue } else { self.loginButton.backgroundColor = .lightGray } } .disposed(by: disposeBag) // check ID & PW Observable.combineLatest(idString, passwordString) .map({ $0 == "[email protected]" && $1 == "password" }) .subscribe { self.matchStatus = $0 } .disposed(by: disposeBag) }
- resume 버튼을 누르면 고양이 사진을 받는다.
- stop 버튼을 누르면 하얀 화면을 띄운다.
- resume 버튼을 누르면 https://thecatapi.com/의 API 서버로부터 고양이 사진 URL을 랜덤으로 받는다.
- 서버로부터 받은 URL 데이터를 외부 변수에 저장한다.
- 외부 변수에 저장된 URL 데이터를 통해 이미지를 다운받는다.
- 이미지가 다 다운받아지면 화면에 띄운다.
- 로직
@IBAction func resumeButtonTapped(_ sender: Any) { HttpClient.shared.getImageURL() .bind(to: imageURL) .disposed(by: disposeBag) imageURL.subscribe { string in self.rxImageLoader(string) .observe(on: MainScheduler.asyncInstance) .bind(onNext: { self.imageView.image = $0 }) .disposed(by: self.disposeBag) } .disposed(by: disposeBag) } @IBAction func stopButtonTapped(_ sender: Any) { disposeBag = DisposeBag() imageView.image = nil }
- 외부 변수
var imageURL: PublishSubject<String?> = PublishSubject<String?>()
- 네트워킹
func getImageURL() -> Observable<String?> { return Observable.create { observer in let request = AF.request(URL(string: url)!, method: .get, headers: header) .responseDecodable(of: Cat.self) { response in switch response.result { case .success(let data): observer.onNext(data.randomElement()?.url) case .failure(let error): observer.onError(error) } } return Disposables.create { request.cancel() } } }
- Image Loader
private func rxImageLoader(_ urlString: String?) -> Observable<UIImage?> { return Observable.create { emitter in let url = URL(string: urlString!)! let task = URLSession.shared.dataTask(with: url) { data, response, error in if error != nil { emitter.onError(error!) return } guard let data = data else { emitter.onCompleted() return } let image = UIImage(data: data) emitter.onNext(image) } task.resume() return Disposables.create { task.cancel() } } }
- Alamofire + RxSwift를 통한 보다 더 깔끔한 네트워킹 작업 구현
- viewDidLoad() 시점에 서버로부터 멤버 데이터를 배열 형태로 받는다.
- 비동기 작업이 완료되면 tableView를 reload 한다.
- tableView dataSource cellForRowAt에 indexPath.row를 사용하여 각각의 row에 해당되는 멤버의 데이터를 셀에 전달한다.
- 전달받은 데이터로 셀의 UI를 구성한다.
- viewDidLoad()
func setData() { NetworkManager.shared.getMembers() .observe(on: MainScheduler.instance) .subscribe { [weak self] members in self?.data = members self?.tableView.reloadData() } .disposed(by: disposeBag) }
- Data Loader (Alamofire)
func getMembers() -> Observable<[Member]> {
return Observable.create { emitter in
let requset = AF.request(URL(string: MEMBER_LIST_URL)!,
method: .get)
.responseDecodable(of: [Member].self) { response in
switch response.result {
case .success(let data):
emitter.onNext(data)
case .failure(let error):
emitter.onError(error)
}
}
return Disposables.create {
requset.cancel()
}
}
}
func loadImage(from url: String) -> Observable<UIImage?> {
return Observable.create { emitter in
let request = AF.request(URL(string: url)!)
.response { response in
switch response.result {
case .success(let data):
emitter.onNext(UIImage(data: data!))
case .failure(let error):
emitter.onError(error)
}
}
return Disposables.create {
request.cancel()
}
}
}
- RxSwift를 활용한 검색 포털 만들기
- SnapKit, Then, Alamofire 같이 활용하기
- Naver 검색 API 활용하기
1. searchBar의 text를 구독하여 변경되는 값을 외부 변수에 저장
2. 외부 변수의 값이 변경 될 때마다 네트워킹 요청
3. 네트워킹을 통해 나온 결과값을 tableView에 display
4. 해당되는 셀의 url을 detailVC에 전달
5. detailVC - 전달받은 url을 통해 해당 웹페이시 webview에 띄우기
- 애플 전화기 theme의 더치페이 계산기 앱 만들기
- MVVM 패턴
- Unit test 구현
- RxSwift로 진행하는 첫 미니 토이 프로젝트
1. UITextField.rx.text는 유저가 직접 키보드로 텍스트필드를 선택해서 입력한 이벤트만 방출한다. 따라서 앱 상의 숫자패드를 입력하여 textField.text를 변경해도 해당 이벤트는 방출되지 않는다.
- 아이디어 로직:
- VC : 버튼이 눌리면 외부변수 buttonSubject: PublishSubject 에 어떤 버튼이 눌렸는지를 담는다.
- VC : buttonSubject를 구독한 후, ButtonCommand의 case에 따라 분기처리 한다.
- VC: 각 case에 필요한 Observer를 viewModel에 요청한다.
- viewModel에서는 VC에게 줄 Observer를 생성해준다.
- viewModel로부터 받은 Observer를 각각의 외부변수(Subject)에 바인딩한다.
- viewModel은 각 외부변수(Subject)를 조합하여 Output을 VC에게 준다.
- viewModel로부터 받은 Output으로 UI를 그린다.
- 코드 :
ViewController
ViewModel
1. SUT(테스트 대상) : CalculatorViewModel
2. 테스트 시나리오 작성 및 테스트 검증 (GWT 형식) : 'totalAmount: 1000, personCount: 4' 일 경우의 output을 검증하는 test case 작성 및 검증
- 입금, 출금, 입출금 내역 확인 가능한 Mock 입출금 앱 만들기
- ReactorKit을 활용한 MVVM 패턴
- 반응형 데이터 전달 구현
- DI
- Unit Test
-
Unit Test 형식:
1. SUT : System Under Text (테스트 대상) 2. 테스트 시나리오 작성 및 테스트 검증 : GWT 형식 (Given, When, Then 형식)
-
SUT: TransactionViewController (입출금 작업이 일어나기에 비즈니스 로직이 가장 많이 일어나는 View에 Reactor에 대한 단위 테스트 실행)
-
DI(의존성 주입): BankAccount 프로토콜 생성하여 해당 프로토콜을 채택한 모든 객체가 Reactor(ViewModel)의 생성자에 들어갈 수 있도록 Dependency Injection 완료 된 상태로 테스트 진행
-
MockData: Unit Test의 참거짓을 비교 판단할 데이터 대상이 필요하기에 BankAccount 프로토콜을 채택한 MockData 객체 생성 및 사용하여 테스트 진행
-
Reactor (Action에 대해 처리할 작업, Muataion에 따른 State 변경에 대한 단위 테스트)
-
Reactor -> View (Reactor의 상태값, State를 View가 잘 구독하고 있는지에 대한 단위 테스트)
- currentBalance : NotificationCenter 활용;
- TransactionReactor : 입출금 action이 일어날 때마다 valueDidChange에 대해 true 값을 보냄
- TransactionVC : Reactor의 valueDidChanged를 구독, true 일 경우 currentBalance에 대한 Notification post
- MainVC : currentBalance에 대한 NotificationCenter의 옵저버로 등록된 상태. 노티 받을 때마다 Action.currentBalanceDidChanged에 바뀐 값을 바인딩
- MainReactor : action에 대한 mutation 진행, state값 변경
- MainVC : state값 구독하고 있으므로 UI 변경
- TransactionReactor
func mutate(action: Action) -> Observable<Mutation> { switch action { case .deposit(let value): return Observable.concat([ Observable.just(.valueDidChanged(true)), Observable.just(.increaseBalance(value)), Observable.just(.addTransactionHistory(.deposit(value))), Observable.just(.valueDidChanged(false)) ]) case .withdraw(let value): return Observable.concat([ Observable.just(.valueDidChanged(true)), Observable.just(.decreaseBalance(value)), Observable.just(.addTransactionHistory(.withdraw(value))), Observable.just(.valueDidChanged(false)) ]) } }
- TransactionVC
reactor.state .map({ $0.statusDidChanged }) .filter({ $0 != false }) .map({ [weak self] _ in self!.balanceView.balanceLabel.text! }) .subscribe(onNext: { value in NotificationCenterManager.postCurrentBalanceChangeNotification(value: Int(value)!) }) .disposed(by: disposeBag)
- historyList : push할 Reactor의 State를 구독하는 방식 활용
- MainReactor : historyListDidUpdated([Transaction]) 라는 액션을 받아 State를 mutating 하도록 로직 구현
- MainVC : TransactionVC를 push 하는 시점에 transactionVC.reactor.state 중 transactionHistory 구독, MainReactor의 history와 일치하지 않는지 여부 판단 후 historyListDidUpdated 액션으로 newValue 전달
- TransactionVC에서 값이 바뀌면 이를 구독하고 있는 MainVC에게 저절로 데이터가 전달됨.
- MainVC에 전달된 데이터는 Reactor에게 Action으로써 또 전달됨. Reactor는 Action에 대해 State 변경함. MainVC는 변경된 State에 대한 UI 작업 처리함.
- MainReactor
func mutate(action: Action) -> Observable<Mutation> { switch action { ... case .historyListDidUpdated(let newHistoryList): return Observable.just(.updateHistoryList(newHistoryList)) } } func reduce(state: State, mutation: Mutation) -> State { switch mutation { ... case .updateHistoryList(let newHistoryList): self.account.history = newHistoryList return state } }
- MainVC
actionButton.rx.tap .map({ reactor.transactionReactor }) .map({ let transactionVC = TransactionViewController() transactionVC.reactor = $0 return transactionVC }) // transactionVC push하기 전에 transactionVC state 구독 시작 .subscribe(onNext: { [weak self] transActionVC in transActionVC.reactor?.state .map({ $0.transactionHistory }) .filter({ $0 != reactor.currentState.historyList }) // MainReactor의 Action에 바인딩 .map({ Reactor.Action.historyListDidUpdated($0) }) .bind(to: reactor.action) .disposed(by: self!.disposeBag) self?.navigationController? .pushViewController(transActionVC, animated: true) }) .disposed(by: disposeBag)
기존에 진행한 카카오맵 클론 토이 프로젝트를 RxSwift, RxCocoa, ReactorKit를 활용해 리펙토링하기
- RxSwift-Tutorial-1 (아이디 비밀번호 매칭) : https://github.com/iamchiwon/RxSwift_In_4_Hours
- RxSwift-Tutorial-3 (멤버 리스트 tableView에 띄우기) : https://github.com/iamchiwon/RxSwift_In_4_Hours
- RxSwift-Tutorial-4 (검색 포털 만들기) : 네이버 API 공식 개발자 문서 https://developers.naver.com/docs/serviceapi/search/web/web.md#%EC%9B%B9%EB%AC%B8%EC%84%9C
- RxSwift-Tutorial-6 (가상 입출금 앱) : ReactorKit https://github.com/ReactorKit/ReactorKit