본문 바로가기
iOS/RxSwift

RxSwift를 사용한 비동기 프로그래밍 #3

by HaningYa 2020. 5. 29.
728x90


 

https://github.com/iamchiwon

 

iamchiwon - Overview

https://iamchiwon.github.io http://www.makecube.in - iamchiwon

github.com


기획서만 나오고 디자인과 백엔드가 나오기 전에 개발해보자

[기획내용]

  • 메뉴 + - count 요소
  • 메뉴에는 이름과 가격이 있다.
  • clear 로 전부 취소할 수 있다.
  • 전체 가격을 표시할 수 있다.
  • 등등

일단 화면을 기획에 맞게 구성한다.

1. ViewModel 을 만든다.

import Foundation
// View를 위한 모델 : ViewModel
struct Menu {
    var name : String
    var price : Int
    var count : Int
}

2. TableVIew 에 ViewModel 을 집어넣는다.

menu 를 담을 array를 만든다.

let menus : [Menu] = [
        Menu(name:"튀김1",price : 100, count: 0),
        Menu(name:"튀김1",price : 100, count: 0),
        Menu(name:"튀김1",price : 100, count: 0),
        Menu(name:"튀김1",price : 100, count: 0),
        Menu(name:"튀김1",price : 100, count: 0),
        Menu(name:"튀김1",price : 100, count: 0),
        Menu(name:"튀김1",price : 100, count: 0)

    ]

row count 수정

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return menus.count
    }

cell 에 데이터 뿌리기

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "MenuItemTableViewCell") as! MenuItemTableViewCell
        
        let menu = menus[indexPath.row]
        cell.title.text = menu.name
        cell.price.text = "\(menu.price)"
        cell.count.text = "\(menu.count)"

        return cell
    }

 

3. Menus를 MenuListViewModel 로 옮긴다.

viewModel 에 view 에 필요한 데이터를 추가로 작성해준다.

예를들어 상품 합계와 총 상품 갯수등 화면에 필요한 데이터들

class MenuListViewModel {
    let menus : [Menu] = [
        Menu(name:"튀김1",price : 100, count: 0),
        Menu(name:"튀김1",price : 100, count: 0),
        Menu(name:"튀김1",price : 100, count: 0),
        Menu(name:"튀김1",price : 100, count: 0),
        Menu(name:"튀김1",price : 100, count: 0),
        Menu(name:"튀김1",price : 100, count: 0),
        Menu(name:"튀김1",price : 100, count: 0)

    ]
    var itemsCount : Int = 5
    var totalPrice : Int = 1000
}

menus 가 있던 자리는 ViewModel 이 채워준다.

    let viewModel = MenuListViewModel()
override func viewDidLoad() {
        super.viewDidLoad()
        
        itemCountLabel.text = "\(viewModel.itemsCount)"
        totalPrice.text = viewModel.totalPrice.currencyKR()
    }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.menus.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "MenuItemTableViewCell") as! MenuItemTableViewCell
        
        let menu = viewModel.menus[indexPath.row]
        cell.title.text = menu.name
        cell.price.text = "\(menu.price)"
        cell.count.text = "\(menu.count)"

        return cell
    }

4. Order 버튼 누를때 마다 100원씩 올려보자

@IBAction func onOrder(_ sender: UIButton) {
        // TODO: no selection
        // showAlert("Order Fail", "No Orders")
//        performSegue(withIdentifier: "OrderViewController", sender: nil)
        viewModel.totalPrice += 100
    }

여기서 문제는 totalPrice 는 실제로 100씩 올라가지만 UI 에 반영되지 않는다.

itemCountLabel.text = "\(viewModel.itemsCount)"
totalPrice.text = viewModel.totalPrice.currencyKR()

이 코드를 updateUI()를 만들어 매번 호출할 수도 있는데 이거 귀찮다. 

값이 바뀌면 자동으로 바꼈으면 좋겠다!

 

Rx 두둥등장

viewModel 의 totalPrice 를 Observable 로 선언해주고

//    var totalPrice : Int = 10_000
var totalPrice : Observable<Int> = Observable.just(10_100)

viewcontroller 에서  totalPrice 를 subscribe 한다.

let disposeBag = DisposeBag()


viewModel.totalPrice
            .map{$0.currencyKR() }               
            .subscribe(onNext: {
                self.totalPrice.text = $0
            })
        .disposed(by: disposeBag)

그럼 외부에서  viewModel.totalPrice 값을 어떻게 바꾸는가??

 

그래서 나온게 Subject 이다.

 

Observable 처럼 subscribe 해서 값을 받을 수 있지만 외부에서 값을 통제할 수 도 있다.

 

[Subject 공식 문서]

 

ReactiveX - Subject

If you have a Subject and you want to pass it along to some other agent without exposing its Subscriber interface, you can mask it by calling its asObservable method, which will return the Subject as a pure Observable. See Also

reactivex.io

 

다시 viewModel 의 totalPrice를 PublishSubject로 바꿔주고

    var totalPrice : PublishSubject<Int> = PublishSubject()

버튼을 눌렀을 때 동작을 넣어준다. 

@IBAction func onOrder(_ sender: UIButton) {
        // TODO: no selection
        // showAlert("Order Fail", "No Orders")
//        performSegue(withIdentifier: "OrderViewController", sender: nil)
        viewModel.totalPrice.onNext(100)

    }

 

이상태로 실행하게 되면 totalPrice는 100으로 고정된다.

 

100씩 더하고 싶으면 scan 을 추가한다.

 

viewModel.totalPrice
            .scan(0,accumulator: +)
            .map{$0.currencyKR() }
            .subscribe(onNext: {
                self.totalPrice.text = $0
            })
        .disposed(by: disposeBag)

그럼 order 버튼을 누를 떄 마다 100원씩 올라가게 된다.

 

결론 : 맨 처음 한번만 subscribe 하면 값이 바뀔때 마다 next 로 전달되서 계속 바뀐다. updateUI를 쓸 필요가 없다 와 개꿀이다.

 

5. totalPrice는 price x count 인데?

viewModel을 수정한다.

  lazy var menuObservable = Observable.just(menus)
    
//    var itemsCount : Int = 5
//    var totalPrice : Int = 10_000
    lazy var itemsCount = menuObservable.map{
        $0.map{$0.count}.reduce(0,+)
    }
    
    lazy var totalPrice = menuObservable.map{
        $0.map{$0.price * $0.count}.reduce(0,+)
    }

 

메뉴의 카운트가 바뀌면 menuObservable 의 값이 바뀌고 그렇게 되면 totalPrice 와 itemsCount 가 계산된다.

 

여기서 menuObservable은 외부에서도 값이 바뀌어야 하니 subject로 수정한다.

 

class MenuListViewModel {
    
    lazy var menuObservable = PublishSubject<[Menu]>()
    
//    var itemsCount : Int = 5
//    var totalPrice : Int = 10_000
    lazy var itemsCount = menuObservable.map{
        $0.map{$0.count}.reduce(0,+)
    }
    
    lazy var totalPrice = menuObservable.map{
        $0.map{$0.price * $0.count}.reduce(0,+)
    }
    
    init() {
        let menus : [Menu] = [
            Menu(name:"튀김1",price : 100, count: 0),
            Menu(name:"튀김1",price : 100, count: 0),
            Menu(name:"튀김1",price : 100, count: 0),
            Menu(name:"튀김1",price : 100, count: 0),
            Menu(name:"튀김1",price : 100, count: 0),
            Menu(name:"튀김1",price : 100, count: 0),
            Menu(name:"튀김1",price : 100, count: 0)

        ]
        menuObservable.onNext(menus)
    }
}

 

하나의 Observable 이 하나는 totalCount, 하나는 totalPrice로 되는 Observable 를 만든것이다.

 

각각의 totalPrice 와 totalCount를 viewcontroller 에서 subscribe 한다.

 viewModel.itemsCount
            .map{"\($0)"}
            .subscribe(onNext : {
                self.itemCountLabel.text = $0
            })
        
        viewModel.totalPrice
            .map{$0.currencyKR() }
            .subscribe(onNext: {
                self.totalPrice.text = $0
            })
        .disposed(by: disposeBag)

observable 로 stream 을 만들었다 (강의중필기)

 


잠깐 rxcocoa 내용

Rxcocoa : rxswift의 요소들을 UIkit 뷰에 extension 해서 접목시킨것

.rx 하면 나오는 것들

itemCountLabel.rx.text 는 리턴 타입이 Bind 이다. 

그래서

viewModel.itemsCount
            .map{"\($0)"}
            .bind(to: itemCountLabel.rx.text)
            .disposed(by: disposeBag)
            
viewModel.totalPrice
            .map{$0.currencyKR() }
            .bind(to: totalPrice.rx.text)
        .disposed(by: disposeBag)
            
//viewModel.itemsCount
//           .map{"\($0)"}
//            .subscribe(onNext : {
//                self.itemCountLabel.text = $0
//            })

이런식으로 subscribe 대신  바꿔쓸 수 있다.

또한 순환참조 없이 데이터를 접근할 수 있다.

 

*만약 UI 업데이트가 발생하는데 Observable의 쓰레드는 Main 이 아닐떈

.observeOn(MainScheduler.instance)

를 추가해주면 된다  

 

TableView 도 bind 해보자

datasource 에는

  • cell identifier
  • cell type

tableView.dataSource = nil
        viewModel.menuObservable
            .observeOn(MainScheduler.instance)
            .bind(to: tableView.rx.items(cellIdentifier: cellId, cellType: MenuItemTableViewCell.self)) { index, item, cell in
                cell.title.text = item.name
                cell.price.text = "\(item.price)"
                cell.count.text = "\(item.count)"
                
        }
            .disposed(by: disposeBag)

 

이렇게 하면 TableView 의 datasource 는 필요없어진다.

 

datasource 빠이!

여기서 menuObservable 은 PublishSubject 로 되어있다.

 

그말은 어떠한 데이터가 입력 되어야 전달이 된다.

 

그래서 init 이후에 publish 로 데이터가 들어가기 때문에 

 

나중에 subscribe 하더라도 가장 마지막에 들어갔던 데이터를 받고싶다면

 

초기값을 가지는 behaviorSubject 로 바꿔야 한다.

    lazy var menuObservable = BehaviorSubject<[Menu]>(value: [])

 

한번 확인해 보자

 @IBAction func onOrder(_ sender: UIButton) {
        // TODO: no selection
        // showAlert("Order Fail", "No Orders")
//        performSegue(withIdentifier: "OrderViewController", sender: nil)
//        viewModel.totalPrice.onNext(100)
        viewModel.menuObservable.onNext([
        Menu(name: "changed", price: 100, count: 2)
        ])

    }

만약 정상적으로 작동한다면

totalCount 는 2로 바뀌고 totalPrice 는 200원이되며 tableview 도 changed 항목 하나만 있어야 한다.

 

 

동작

rxcocoa를 사용해서 데이터 소스를 사용안하고 메뉴 배열의 값만 바꿨는데 count, price, tableview 전부 바뀌는 걸 볼 수 있다.

 

아래 코드로 바꾸면 계속 값이 바뀌는걸 확인할 수 있다.

viewModel.menuObservable.onNext([
            Menu(name: "changed", price: Int.random(in: 100...1000), count: Int.random(in: 0...3)),
            Menu(name: "changed", price: Int.random(in: 100...1000), count: Int.random(in: 0...3)),
            Menu(name: "changed", price: Int.random(in: 100...1000), count: Int.random(in: 0...3)),
            Menu(name: "changed", price: Int.random(in: 100...1000), count: Int.random(in: 0...3))
        ])

 

onClear 버튼 메소드

viewModel 에 clearAllItemSelections 함수를 만들어 주고 viewcontroller 에서 호출한다.

viewcontroller 코드

@IBAction func onClear() {
        viewModel.clearAllItemSelections()
    }

viewmodel 코드

func clearAllItemSelections(){
        menuObservable
            .map{menus in
                menus.map { m in
                    Menu(name: m.name, price: m.price, count: 0)
                }
        }
        .subscribe(onNext: {
            self.menuObservable.onNext($0)
        })
    }

그런데 매번 값이 바뀔 때마다 위 코드 스트림을 타게 된다. 그래서 한번 사용하고나면 끝나게 만들어야한다.

.take(1)를 추가한다.

func clearAllItemSelections(){
        menuObservable
            .map{menus in
                menus.map { m in
                    Menu(name: m.name, price: m.price, count: 0)
                }
        }
        .take(1)
        .subscribe(onNext: {
            self.menuObservable.onNext($0)
        })
    }

이 상태에서 clear 버튼을 누르면
0으로 초기화 된다


메뉴 + - 카운트 부분

cell 코드에서 viewModel 을 호출해도 되지만 viewcontroller 에 있는 tableview를 bind 한 observable 에 구현을 할 것이다.

 

카운트가 바뀌는 부분을 클로져로 구현한다.

 

 var onChange : ((Int) -> Void)?
    

    @IBAction func onIncreaseCount() {
        onChange?(+1)
    }

    @IBAction func onDecreaseCount() {
        onChange?(-1)
    }

그리고 tableView observable 에 코드를 추가한다.

 cell.onChange = {[weak self] increase in
                    self.viewModel.changeCount(item,increase)
                }

이제 viewModel 에서 changeCount를 만들어 주면 된다.

 

func changeCount(item : Menu, increase : Int){
        menuObservable
            .map{menus in
                menus.map { m in
                    if m.id == item.id {
                        return Menu(id:m.id,
                                    name: m.name,
                                    price: m.price,
                                    count: m.count+increase)
                        
                    }else{
                        return Menu(id:m.id,
                                    name: m.name,
                                    price: m.price,
                                    count: m.count)
                    }
                    
                }
        }
        .take(1)
        .subscribe(onNext: {
            self.menuObservable.onNext($0)
        })
    }

동작한다.


ViewController 에는 View의 요소만 담는다.

  • view에 어떻게 보여질지 요소를 잡아서 어떤 형태로 화면에 뿌릴지 구현한다.
  • 각종 로직(어떤 데이터, 어떤 메소드 처리, 에러)들을 viewModel 이 처리한다.
  • view에서는 처리하지 않고 그대로 표시만 해준다. 
  • 데이터 변경이 있으면 전부 viewModel 로 넘겨준다. (아무처리하지 않는다.)
  • 그렇게 되면 각종 버그들, 예를들어 상품의 갯수는 음수가 되면 안된다 등에 대한 테스트 케이스를 짤 때 viewModel 를 가지고 테스트 케이스를 만드는게 훨씬 쉽다.
  • viewModel 만 테스트 되면 viewcontroller 는 알아서 돌아갈 것이다.

 

728x90

댓글