apple/RxSwift, ReactorKit

🐉 RxSwift 4Hour - Step3(Rx)

lgvv 2021. 7. 18. 13:02

내가 도저히 모르겠어서 코드를 하나하나 보면서 해석해보는 시간을 가지려고 해.

 

왜 내가 어렵게 생각했을까? 

-> 기존에 비동기 처리를 GCD등을 이용하여 처리해왔었고, 굳이 어떤 부분에서 rx를 이용해서 비동기를 처리해야하는지 설계시에 확 떠오르지 않는다.

-> rx를 사용해서, subject나 relay를 가지고 코드를 작성하는 것이 기존의 delegate를 이용한 코드보다 직관적으로 느껴지지 않는다.

-> 코드에 대한 이해가 부족하다. (사용법에 능숙하지 않다)

 

아무튼 마스터하기는 해야하는데, 테이블 뷰 구성도 쉽게 못하는 걸로 보아서,, 일단 코드를 해석하는 것 부터 해보려고 한다.

 

(목차)
1. APIService

2. API 요청으로 받아온 데이터는 어떻게 처리가 될까?

 - 테이블 뷰 아이템들을 구성하는 방법⭐️ 중요 ⭐️

 - 받아온 데이터를 relay(or subject)에 붙이는 방법

3. bind? 그게 뭐야 그래서?

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

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

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

7. order 버튼 눌렀을 때 ❗️(map) 맵을 통해 여러 곳을 참조하는 기술을 알아볼 수 있다.

 - 여기부터는 OrderViewController

8. viewWillAppear 와 viewWillDisappear를 rx로

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

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

10. orderedMenuItems 

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

12. itemsPriceAndVat와 accept()

 

🟠 (의문) 세그를 통해 화면을 전달하였을 때, 0보다 큰 정보를 어떻게 담고 있는거지?

그러니까 count가 더 큰것만을 어떻게 담는지 로직적으로 이해가 가지 않는다.

내 예상으로는 filter에서 전달하기 전에 0인 것들은 걸러져서 라고 생각하나 아닐 수도 있다

 

 

 

✅ APIService

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

여기서 주목해야하는 점은 fetchAllMenusRx이다. 

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

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

 

✅ 2. API 요청으로 받아온 데이터는 어떻게 처리가 될까?

그래 API 요청을 통해 데이터를 받아오는 것 까지는 좋았어. 그러면 이 데이터들은 어떻게 처리가 되는 것일까?

 

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

즉, 여기에 데이터를 다 붙여서 여기서 받아다가 처리할거야.

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

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

배열 형태인데, 메뉴아이템과 갯수를 저장받게 선언되어 있다.

코드를 다시 해석해보면, 타입을 지정하고, 초기화까지 해준 모습이다.

 

✅ 테이블 뷰 아이템들 ⭐️ 중요 ⭐️

// 테이블뷰 아이템들
            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)

delegate및 dataSource를 사용하지 않잖아 그치?

그러면!! 잘봐두자...! 메뉴 아이템 스트림에 테이블 뷰를 bind해서 메뉴아이템에 있는 정보들을 테이블 뷰에서 사용하는거다.

bind에 클로저에 있는 index, item, cell에 대한 정보

저기서 cell에 타이틀 프라이스 카운트는 말그대로 스토리보드에서 IBOulet으로 연결된 정보이다.

onCountChanged의 클로저 부분을 보면은, inc의 정보는 cell로 들어가서 함수를 보면 1 또는 -1 이다.

item.count는 IBOulet으로 연결된 textLabel의 값이다.

다음은 menuItems에 self~ value를 선언 후 대입하는데, value를 rxRelay에서 값을 넣어주는 정보다. 

근데 Relay에 onNext로 받아온 정보가 들어있다.

그리고 메뉴 아이템의 인덱스에 메뉴 아이템과 카운트를 넣어준다. 튜플 형식으로 묶어 넣는 이유는 애초에 릴레이가 선언된 형태가 이와 같은 모습으로 선언되어서 이다.

마지막으로 accept인데 릴레이에서는 on을 사용할 수 없어서 onNext로 감싸 subject에게 흘려보내는 역할을 한다.

 

 

✅ 받아온 데이터를 relay(or subject)에 붙이는 방법

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

쉽게 말해서, tableView( cellForRow )와 같은 기능을 하는 코드였다.

모식도를 보자면

🔸 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를 받아와서 디코딩한다. 

여기서 data는 fetchAllMenusRx() 을 옵션키를 눌러서 확인해 보면 여기서 반환하는 옵저버블 타입의 데이터이다.

디코딩을 할 예정이다.

디코딩하는건 rx보다 파서작업에 가까우니 읽어보도록 하고, 리턴 값으로는 맵을 통해서 반환한다.

그리고 메인 스케줄러에서 do를 통해서 작업을 수행하는데, alert는 메인 스케줄러에서 실행되어야 하기 때문이고, dispose될 시에는 인디케이터를 숨기고 테이블 뷰를 리프레시 하는데 끝 쪽의 값을 잡을 수 있게 한다. 

이게 없다면 계속 맨 위로 가는 불편함을 초래한다.

그리고 바인드를 통해 메뉴 아이템에 붙인다.

바인드 과정을 통해 데이터의 값이 붙는것을 확인할 수 있다.

 

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

 

✅ 3. bind? 그게 뭐야 그래서?

https://nsios.tistory.com/66

 

[RxSwift] bind, subscribe, drive

Rx를 사용하다보면 헷갈리는 용어들과 언제 사용해야 맞는 건지 등등 사용법은 비슷한데 다른 API들이 있어요 우선 사용하면서 느낀게 bind, subscribe, drive가 있었어요 각 개념들을 정리해보고 생각

nsios.tistory.com

쉽게 말해서 subscribe의 작업에서 넥스트, 에러, 컴플릿을 다 해줘야한다면, bind의 내부에 subscribe가 이미 구현되어 있어서 onNext를 하는 간단하게 넘기는 기능만 수행할 때 사용한다.

drive의 경우에는 메인 스레드에서 사용한다는 것을 보장하며, 바인드에서 더 확장하여 asDrive 타입으로 바꿔주어야 하며, drive도 bind와 비슷하지만, drive는 서브젝트가 아닌 릴레이에서 사용한다.

 

✅ 4. 선택된 아이템 총 가격 구하기

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

이게 무슨말이야? 

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

처음 맵에 걸린 $0는 메뉴 아이템의 결과에 대한 정보야

 

맵은 A -> (map) -> B 이런식으로 가공하는 과정으로 하나의 함수의 기능을 거친다고 생각하면 좋은데, 

$0의 배열의 정보 중 count의 값들을 0부터 전부 더하겠다는 의미야

그리고 그 값을 아래의 "\($0)" 통해서 문자열로 바꿔줘

reduce의 경우에는 (1,2,3,4,5).reduce(0, +)로 사용하면 반환값이 15 하나야.

물론 각 계산마다 반환해주는 메소드도 존재해!! 

 

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

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

위에 한번 보았던 코드라 크게 어렵지는 않을거야.

총 가격의 경우에도 잘 보자. $0으로 배열을 전부 불러오고 메뉴의 가격 * 메뉴의 갯수를 하면 각각의 가격을 찾을 수 있어.

그렇게 되면 만약 메뉴가 5개라고 가정하고, 각각의 가격은 (1,2,3,4,5), 각각의 갯수이 (0,1,0,2,3) 이라고 하자.

그러면 메뉴 가격과 갯수를 곱한 후의 반환되어 있는 값은 (0,2,0,8,15)로 되어 있고, reduce를 통해 다 더해서 최종적은 하나의 값만 찾는걸로 해

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

다음 맵에서 이렇게 사용하던데, 결국은 String을 반환하고 있잖아? 아까 "\($0)" 과 동일한 결과야

그 후에 보여줄 UI쪽 Label에 바인드 하면 끝!

 

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

// 처음 보일때 하고 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)

이건 또 뭐니 ^_^

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

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

withLatestFrom는 메뉴 아이템을 가장 최신 상태로 갱신해주겠다는 말이다.

코드 이해용 사진

map에서는 메뉴 아이템은 그냥 메뉴 아이템을 반환하면 되지만, clear의 경우 갯수를 초기화해주도록 0으로 매핑해서 내보내면 된다.

 

 

✅ 7. oreder 버튼을 눌렀을 때

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

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

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

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

그런데 잘 생각해보자. alert를 보여주는건 아무것도 선택하지 않았을 때인데, 만약에 subscribe에 있는 코드를 실행하면 문제가 되겠지? 그래서 filter라는 코드가 있는데, 이 코드는 갯수가 1 이상이 아니면 아래코드로 내려보내지 않고 반환해버린다.

즉, 아이템 갯수가 0이면 아래로 내려가지 못하고 종료된다.

🌟 그 다음에 map을 통해서 오더뷰 컨트롤러를 $0로 사용하도록 변경해준다. 즉, 그 안에 있는 정보를 사용하는걸로 전환한다는 이야기

 

 

 

✅ 8. viewWillAppear 와 viewWillDisappear를 rx로

        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)

rx로는 이렇게 사용하고, 네비게이션 컨트롤러로 화면 전환할 때, 바를 숨길거냐 말거냐를 설정하는 코드

 

 

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

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

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
    }

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

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

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

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

 

✅ 10. 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에서 선택한 것들만이 여기로 들어오게 된다.

주문으로 넣은 정보들

 

 

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

            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)

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

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

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

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

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

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

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

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

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

❗️네트워크 작업에서 여러번 호출을 막을 수 있어서 상당히 유용하다.

https://jusung.github.io/shareReplay/

 

[RxSwift] Share(replay:)

anObservable.share(replay:1) 같은 코드를 보신적 있으실 겁니다. Share 연산자는 언제 써야하는 걸까요?

jusung.github.io

 

✅ 12. itemsPriceAndVat와 accept()

        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)

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

그리고 마지막 accept는 

accept의 설명

 

 

rx에대한 모식도

화살표의 방향은 내가 -> 구독한다 이런 의미임.

 

 

 

 

 

(참고)

https://jcsoohwancho.github.io/2019-08-05-RxSwift%EA%B8%B0%EC%B4%88-Relay/

 

RxSwift기초 - Relay

Subject에 이어서 오늘은 Relay에 대해서 알아보도록 하겠습니다. Relay는 기존의 Variable을 대체하기 위한 개념입니다.(정확히는 BehaviorRelay가 Variable을 대체합니다.) Variable이라는 이름에서 알 수 있듯

jcsoohwancho.github.io