apple/RxSwift, ReactorKit

RxSwift + MVVM: 예제 코드 흐름 따라가기 #2

lgvv 2021. 7. 18. 13:02

RxSwift + MVVM: 예제 코드 흐름 따라가기 #2

 

  • 현재 어렵게 느끼는 이유
    • 기존에 비동기 처리를 GCD등을 이용하여 처리해왔었고, 굳이 어떤 부분에서 rx를 이용해서 비동기를 처리해야하는지 설계 시 와닿지 않음
    • 아직까지는 rx를 사용해서, subject나 relay를 가지고 코드를 작성하는 것이 기존의 delegate를 이용한 코드보다 직관적으로 느껴지지 않는다.
    • 코드에 대한 이해가 부족하다. (사용법에 능숙하지 않다)

 

 

 

목차

1. APIService 코드 리뷰

2. API 요청을 통해 받아온 코드 핸들링

 - 테이블 뷰 아이템들을 구성하는 코드 리뷰

 - 받아온 데이터를 relay(or subject)를 활용해서 연결

3. bind란?

4. 선택된 아이템 가격 구하기 리뷰

5. 선택된 아이템의 총가격 구하기

6. 처음 보일때 하고 clear 버튼 눌렀을 때

7. order 버튼 클릭했을 때 코드 리뷰

 - OrderViewController 살펴보기

8. viewWillAppear 와 viewWillDisappear를 rx로 핸들링하기

9. orderList 부분을 통해 제약조건을 bind해보자

 - 깃헙에 있는 코드를 그냥 쓰는데, 아이템을 선택시 orderList로 들어갈 때 누락되는 버그 확인

10. orderedMenuItems 

11. itemsPriceAndVat 과 share에 대해서 알아보자

12. itemsPriceAndVat와 accept()

 

 

 

 

APIService

 

- rx로 활용하기 위해서는 함수의 리턴 타입을 아예 옵저버블 형식으로 반환해야 한다.

- 네트워크 작업이 정상적으로 처리 되었다면 데이터 스트림이 넘어올 것이고, emitter에 onNext를 통해서 넘어온 데이터를 이벤트를 발생시켜 보내줘야한다. 

 

import Foundation
import RxSwift

let MenuUrl = "https://firebasestorage.googleapis.com/v0/b/rxswiftin4hours.appspot.com/o/fried_menus.json?alt=media&token=42d5cb7e-8ec4-48f9-bf39-3049e796c936"

class APIService {
    static func fetchAllMenusRx() -> Observable<Data> {
        return Observable.create { emitter in
            fetchAllMenus() { result in
                switch result {
                case let .success(data):
                    emitter.onNext(data)
                    emitter.onCompleted()
                case let .failure(error):
                    emitter.onError(error)
                }
            }
            return Disposables.create()
        }
    }

    static func fetchAllMenus(onComplete: @escaping (Result<Data, Error>) -> Void) {
        URLSession.shared.dataTask(with: URL(string: MenuUrl)!) { data, res, err in
            if let err = err {
                onComplete(.failure(err))
                return
            }
            guard let data = data else {
                let httpResponse = res as! HTTPURLResponse
                onComplete(.failure(NSError(domain: "no data",
                                            code: httpResponse.statusCode,
                                            userInfo: nil)))
                return
            }
            onComplete(.success(data))
        }.resume()
    }
}

 

 

API 요청을 통해 받아온 코드 핸들링

 

- 릴레이를 하나 만들어서 여기를 기준 스트림으로 사용할 예정

- 배열 형태인데, 메뉴아이템과 갯수를 저장 받도록 선언

- 코드를 다시 해석해보면, 타입을 지정하고, 초기화까지 진행

let menuItems$: BehaviorRelay<[(menu: MenuItem, count: Int)]> = BehaviorRelay(value: [])

struct MenuItem: Decodable {
    var name: String
    var price: Int
}

 

 

테이블 뷰 아이템들을 구성하는 코드 리뷰

 

delegate및 dataSource를 사용하지 않고도 구성할 수 있음.

 

// 테이블뷰 아이템들
            menuItems$
            .bind(to: tableView.rx.items(cellIdentifier: MenuItemTableViewCell.identifier,
                                         cellType: MenuItemTableViewCell.self)) {
                index, item, cell in

                cell.title.text = item.menu.name
                cell.price.text = "\(item.menu.price)"
                cell.count.text = "\(item.count)"
                cell.onCountChanged = { [weak self] inc in
                    guard let self = self else { return }
                    var count = item.count + inc
                    if count < 0 { count = 0 }

                    var menuItems = self.menuItems$.value
                    menuItems[index] = (item.menu, count)
                    self.menuItems$.accept(menuItems)
                }
            }
            .disposed(by: disposeBag)

 

 

 

받아온 데이터를 Relay(or Subject) 활용해서 연결

위에 있는 코드는 받아온 받아온 데이터를 릴레이에 넣어서 테이블 뷰의 셀을 구성하는 코드

  • API를 요청
  • API요청의 결과를 서브젝트 전달
  • 서브젝트를 이용해 테이블을 구성
func fetchMenuItems() {
        activityIndicator.isHidden = false
        APIService.fetchAllMenusRx()
            .map { data in
                struct Response: Decodable {
                    let menus: [MenuItem]
                }
                guard let response = try? JSONDecoder().decode(Response.self, from: data) else {
                    throw NSError(domain: "Decoding error", code: -1, userInfo: nil)
                }
                return response.menus.map { ($0, 0) }
            }
            .observeOn(MainScheduler.instance)
            .do(onError: { [weak self] error in
                self?.showAlert("Fetch Fail", error.localizedDescription)
            }, onDispose: { [weak self] in
                self?.activityIndicator.isHidden = true
                self?.tableView.refreshControl?.endRefreshing()
            })
            .bind(to: menuItems$)
            .disposed(by: disposeBag)
    }

 

  • 데이터를 받아서 디코딩
  • 여기서 Data는 fetchAllMenusRx() 메서드를 통해서 반환된 옵저버블 타입
  • 메인 스케줄러에서 실행하고 do를 통해 작업을 항상 실행
  • 바인딩(구독) 작업을 통해 스트림의 이벤트를 받음

 

 

bind를 통해 데이터를 전달하는 과정

 

bind란?

 

.subscribe를 통해 스트림을 구독해서 컴플리션 및 방출 등을 모두 작성해야 한다면 bind는 subscribe를 내부적으로 구현하여 onNext 즉, 이벤트 방출의 기능만을 담당할 때 사용

 

drive는 메인스레드에서 사용한다는 것을 보장하여 즉, UI작업이 필요한 경우 사용.

drive는 bind에서 확장되었고 asDrive를 통해 타입을 전환하며, 릴레이에서 사용

 

릴레이에서 사용하는 이유는 infinity이기 때문

 

선택된 아이템 총개수 구하기 코드 리뷰

// 선택된 아이템 총개수
        menuItems$
            .map { $0.map { $0.count }.reduce(0, +) }
            .map { "\($0)" }
            .bind(to: itemCountLabel.rx.text)
            .disposed(by: disposeBag)

 

 

아래는 콘솔이랑 코드 이미지

 

로그를 찍어보기 위해 print해본 결과

 

 

맵을 통해서 수를 구하고, 수에 대해서 reduce를 통해서 전부 더함

이후에 map을 통해서 다시 문자열로 변경

이후에 bind를 통해서 UILabel에 연결

 

아래는 선택된 아이템들의 총 가격

// 선택된 아이템 총가격
        menuItems$
            .map { $0.map { $0.menu.price * $0.count }.reduce(0, +) }
            .map { $0.currencyKR() }
            .bind(to: totalPrice.rx.text)
            .disposed(by: disposeBag)

 

아래는 통화 전환하는 코드

extension Int {
    func currencyKR() -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.locale = Locale(identifier: "ko_KR")
        return formatter.string(from: NSNumber(value: self)) ?? ""
    }
}

 

 

 

처음 보일때 하고 clear 버튼 눌렀을 때

 

옵저버블 merge는 뷰가 보이거나 클리어 버튼이 눌렸을 때의 이벤트를 묶어서 처리

그리고 각각 mpa { _ in () } 로 되어 있는 이유는 아무런 아예 비우겠다는 의미이며,

withLatestFrom는 메뉴 아이템을 가장 최신 상태로 갱신

 

// 처음 보일때 하고 clear 버튼 눌렀을 때
        Observable.merge([rx.viewWillAppear.map { _ in () }, clearButton.rx.tap.map { _ in () }])
            .withLatestFrom(menuItems$)
            .map { $0.map { ($0.menu, 0) } }
            .bind(to: menuItems$)
            .disposed(by: disposeBag)

 

 

map에서는 메뉴 아이템은 그냥 메뉴 아이템을 반환하면 되지만,

clear의 경우 갯수를 초기화해주도록 0으로 매핑해서 내보냄.

 

 

 

코드 이해용 사진

 

 

 

oreder 버튼을 눌렀을 때

 

오더 버튼을 탭했을 때, withLatestFrom를 통해 메뉴아이템의 가장 최신 데이터를 가지고 오고,

map을 통해 가장 최신 데이터에서 갯수만큼을 다 더함.

 

do가 참조하는건, 메뉴 아이템의 정보이며 allCount의 경우에는 map을 통해 reduce한 값이 0이면 alert 메시지 노출

filter라는 코드가 있는데, 이 코드는 갯수가 1 이상이 아니면 아래코드로 내려보내지 않고 반환

 

 

// order 버튼 눌렀을 때
        orderButton.rx.tap
            .withLatestFrom(menuItems$)
            .map { $0.map { $0.count }.reduce(0, +) }
            .do(onNext: { [weak self] allCount in
                if allCount <= 0 {
                    self?.showAlert("Order Fail", "No Orders")
                }
            })
            .filter { $0 > 0 }
            .map { _ in "OrderViewController" }
            .subscribe(onNext: { [weak self] identifier in
                self?.performSegue(withIdentifier: identifier, sender: nil)
            })
            .disposed(by: disposeBag)

 

 

 

 

viewWillAppear 와 viewWillDisappear를 rx로 핸들링하기

 

메서드 스위즐링을 통해 viewDidLoad 등 기본 메서드들을 추가

        rx.viewWillAppear
            .take(1)
            .subscribe(onNext: { [weak navigationController] _ in
                navigationController?.isNavigationBarHidden = false
            })
            .disposed(by: disposeBag)

        rx.viewWillDisappear
            .take(1)
            .subscribe(onNext: { [weak navigationController] _ in
                navigationController?.isNavigationBarHidden = true
            })
            .disposed(by: disposeBag)

 

 

 

orderList 부분을 통해 제약조건을 bind해보자

oederList에 직접 rx를 데이터를 넣을건데, orEmpty의 경우에는 optional를 해제하여 처리

map 하는데 text를 좌상단부터 폰트를 20으로 높이는 다음과 같이 설정해서 주면 성공적으로 뷰를 구성 완료

그리고 이 정보를 바인드 해야하는데, 높이에 대한 정보잖아? textLabel를 보면 거기에 붙어있는 제약조건이다.

bind를 통해 제약조건 정보를 수정할 수 있음.

 

orderList

           ordersList.rx.text.orEmpty
            .map { [weak self] text in
                let width = self?.ordersList.bounds.width ?? 0
                let font = self?.ordersList.font ?? UIFont.systemFont(ofSize: 20)
                let height = self?.heightWithConstrainedWidth(text: text, width: width, font: font)
                return height ?? 0
            }
            .bind(to: ordersListHeight.rx.constant)
            .disposed(by: disposeBag)
            
            
   func heightWithConstrainedWidth(text: String, width: CGFloat, font: UIFont) -> CGFloat {
        let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
        let boundingBox = text.boundingRect(with: constraintRect, options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [NSAttributedString.Key.font: font], context: nil)
        return boundingBox.height
    }

 

 

orderedMenuItems 

let orderedMenuItems: BehaviorRelay<[(menu: MenuItem, count: Int)]> = BehaviorRelay(value: [])
            
            orderedMenuItems
            .map { $0.map { "\($0.menu.name) \($0.count)개" }.joined(separator: "\n") }
            .bind(to: ordersList.rx.text)
            .disposed(by: disposeBag)

 

위 코드의 경우에는 주문한 메뉴 아이템의 정보를 갖고 있는 릴레이.

쉽게 말해서 주문한 아이템에 대한 정보를 가진 배열 정도로 생각

주문된메뉴 아이템들에서 맵핑하는데, 우리가 MenuViewController에서 선택한 것들만 여기로 들어옴


주문으로 넣은 정보들

 

 

itemsPriceAndVat 과 share에 대해서 알아보자

 

맵을 통해서  아이템 각각의 가격을 구한 후에 다 더해서 다음 맵쪽으로 전달하고,

다음 맵에서는 위에서 내려온 int값을 가지고 다시 다시 설정할 것인데, 

프라이스는 그대로 리턴해주면 되고, vat부분은 vat 식에 따라서 설정을 해준 후에 내려보내 준다.

            let itemsPriceAndVat = orderedMenuItems
            .map { items in
                items.map { $0.menu.price * $0.count }.reduce(0, +)
            }
            .map { (price: Int) -> (price: Int, vat: Int) in
                (price, Int(Float(price) * 0.1 / 10 + 0.5) * 10)
            }
            .share(replay: 1, scope: .whileConnected)

 

그리고 share은 새로운 시퀀스를 생성하지 않고, 기존에 존재하는 시퀀스를 활용할 것이라는 의미.

그리고 이 작업은 itemPriceAndVat 시퀀스를 공유하도록 만드는 코드.

 

replay는 버퍼의 크기로 가장 최근에 방출한 인자값들을 공유하도록 내보내주도록 만들어주는 코드. 

whileConnected는 default value이면서 아무것도 subscribe가 존재하는 동안에만 유지.

 

forever도 scope 파라미터의 값으로 넣을 수 있는데, 이 경우에는 구독하는게 없어도 계속 살아있음.

 

이렇게 하는 이유로는 itemsPriceAndVat 다른 쪽에서 사용한다면 위의 작업을 하는 시퀀스를 계속 만들어줘야 하는데, 그런 것을 방지하기 위함.

 

즉, 네트워크 작업에서 여러번 호출을 막을 수 있어서 상당히 유용

 

 

itemsPriceAndVat와 accept()

 


이렇게 설정해두면 share을 통해서 여러개의 시퀀스가 계속 생성되는 것을 막고, itemPriceAndVat에 여러개가 bind되어 있어서 itemPriceAndVat의 값이 바뀌면 다른 것들도 그에 맞춰 세팅된다.

 

        itemsPriceAndVat
            .map { $0.price.currencyKR() }
            .bind(to: itemsPrice.rx.text)
            .disposed(by: disposeBag)

        itemsPriceAndVat
            .map { $0.vat.currencyKR() }
            .bind(to: vatPrice.rx.text)
            .disposed(by: disposeBag)

        itemsPriceAndVat
            .map { $0.price + $0.vat }
            .map { $0.currencyKR() }
            .bind(to: totalPrice.rx.text)
            .disposed(by: disposeBag)
        
        orderedMenuItems.accept(orderedMenuItems.value)

 

 

그리고 마지막 accept는 아래 이미지 설명

 

accept의 설명

 

 

rx에대한 모식도