RxSwift를 사용한 비동기 프로그래밍 #3
기획서만 나오고 디자인과 백엔드가 나오기 전에 개발해보자
[기획내용]
- 메뉴 + - 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 해서 값을 받을 수 있지만 외부에서 값을 통제할 수 도 있다.
다시 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)
잠깐 rxcocoa 내용
Rxcocoa : rxswift의 요소들을 UIkit 뷰에 extension 해서 접목시킨것
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 는 필요없어진다.
여기서 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)
})
}
메뉴 + - 카운트 부분
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 는 알아서 돌아갈 것이다.