[RxSWIFT]#3 UIコンポーネントとの併用


0.例示的なプログラムの説明



簡単な(?)アプリケーションが作成されました.

1.フォルダ構造


熊揚げgithub Step 3コードを見ればいいです.
Step 3:UIKETのみで作成したコード
Step3+Rx : RxSwift
Step3+Rx+MVVM : RxSwift + MVVM
完了したコードを使用します.
Step 3+emptyを開き、レッスンに沿って行います.
その中.
Domain
Menu > Model > Model.swift-menuのモデル
Menu > Service > APIService.swift-取得メニューのクラス
Pages
MenuList > VC, Cell
Order > VC
この構造からなる.

2. step3


1.プロジェクトの状況を仮定する


理論的には,クライアント側はAPI,設計後に開発を行うが,実際の作業ではそうではないことが多い.APIやデザインが分からないまま、企画書だけ読んで開発しています.APIは後で接続され、デザインも後で追加されます->最適構造MVVM~~

2.開発~


ViewController == VC

1. Menu.swift-Viewのモデルを作成する(ビューに表示されるモデルのみ)

struct Menu {
    var name: String = ""
    var price: Int = 0
    var count: Int = 0
    
    init(name: String, price: Int, count: Int) {
        self.name = name
        self.price = price
        self.count = count
    }
}

2.MenuVC-メニュー(ViewModelアレイ)を作成し、Table Viewに接続します。


3.MenuListViewModelを使用してメニューレイアウトを個別に管理-ビューに情報をインポートする場合は、VCでviewModelインスタンスを作成してインポートするだけです。

// MenuListViewModel
class MenuListViewModel {
    let menus: [Menu] = [
        Menu(name: "보아", price: 1105, count: 0),
        Menu(name: "태연", price: 309, count: 0),
        Menu(name: "효연", price: 922, count: 0),
        Menu(name: "슬기", price: 210, count: 0),
        Menu(name: "웬디", price: 224, count: 0),
        Menu(name: "카리나", price: 411, count: 0),
        Menu(name: "윈터", price: 101, count: 0)
    ]

    var itemsCount: Int = 5
    var totalPrice: Int = 10000
}

4.viewModelのtotalPrice、itemsCount情報を取得してみます。正しく導入されているかどうかはわかりませんが、onOrderボタンを押すと総価格が100増加します。

// MenuVC
class MenuViewController: UIViewController {
    // MARK: - Life Cycle
    
    // viewModel 인스턴스 생성해서 정보 받아오기
    let viewModel = MenuListViewModel();
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        updateUI()
    }

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        let identifier = segue.identifier ?? ""
        if identifier == "OrderViewController",
            let orderVC = segue.destination as? OrderViewController {
            // TODO: pass selected menus
        }
    }

    func showAlert(_ title: String, _ message: String) {
        let alertVC = UIAlertController(title: title, message: message, preferredStyle: .alert)
        alertVC.addAction(UIAlertAction(title: "OK", style: .default))
        present(alertVC, animated: true, completion: nil)
    }

    // MARK: - InterfaceBuilder Links

    @IBOutlet var activityIndicator: UIActivityIndicatorView!
    @IBOutlet var tableView: UITableView!
    @IBOutlet var itemCountLabel: UILabel!
    @IBOutlet var totalPrice: UILabel!

    @IBAction func onClear() {
    }

    @IBAction func onOrder(_ sender: UIButton) {
        // TODO: no selection
        // showAlert("Order Fail", "No Orders")
//        performSegue(withIdentifier: "OrderViewController", sender: nil)
        viewModel.totalPrice += 100
        updateUI()
    }
    
    // updateUI로 따로 함수를 뺐음. 근데 우리가 이걸 매번 호출해줘야되냐..? 싫다.
    func updateUI() {
        totalPrice.text = "\(viewModel.totalPrice)"
        itemCountLabel.text = "\(viewModel.itemsCount)"
    }
}

extension MenuViewController: UITableViewDataSource {
    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
    }
}

毎回updateUI()と呼びますか?やめて!viewModelが変わればviewも変わる!->観測性を利用する。

// 여기부터는 바뀌는 부분만 필기.
class MenuListViewModel {
    let menus: [Menu] = [
        Menu(name: "보아", price: 1105, count: 0),
        Menu(name: "태연", price: 309, count: 0),
        Menu(name: "효연", price: 922, count: 0),
        Menu(name: "슬기", price: 210, count: 0),
        Menu(name: "웬디", price: 224, count: 0),
        Menu(name: "카리나", price: 411, count: 0),
        Menu(name: "윈터", price: 101, count: 0)
    ]
    
    var itemsCount: Int = 5
    var totalPrice: Observable<Int> = Observable.just(10000)
}
// MenuVC
var viewModel = MenuListViewModel()
var disposeBag = DisposeBag()

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

@IBAction func onOrder(_ sender: UIButton) {
    // TODO: no selection
    // showAlert("Order Fail", "No Orders")
    // performSegue(withIdentifier: "OrderViewController", sender: nil)
    
    // viewModel.totalPrice += 100
    // 근데.. viewMode.totalPrice (Observable)에 값을 어떻게 보낼건데..?
    // Subject를 사용하자.
    // 근데 onNext 는 값을 보내기만 하는데 어떻게 쌓지? -> scan
    viewModel.totalPrice.onNext(100)
}

6.Publish Subject:updateUIを価格変更の論理に書き込む必要はありません。ViewDidLoadで1回購読するだけで、自動的に値が変更されます。


subject:observerは値を受信してもよいし、値を送信してもよい
// MenuListViewModel
class MenuListViewModel {
    let menus: [Menu] = [
        // 생략
    ]
    
    var itemsCount: Int = 5
    var totalPrice: PublishSubject<Int> = PublishSubject()
}


// MenuVC
override func viewDidLoad() {
    super.viewDidLoad()

    // 처음 불러오기.
    viewModel.totalPrice
        .scan(0, accumulator: +) // 100 보내기만 하는걸 쌓아줌.
        .map { $0.currencyKR() }
        .subscribe(onNext: {
            self.totalPrice.text = $0
        })
        .disposed(by: disposeBag)
}

@IBAction func onOrder(_ sender: UIButton) {
    // Subject가 등장!
    viewModel.totalPrice.onNext(100) // 100을 보내기만 함 
}
それではMenuListをSubjectに変えて見た時にItemsCountTotalPriceをそこから持ってきてください
class MenuListViewModel {
    
    // 그러면..? 메뉴 리스트를 지켜보다가 값을 가지고 오면 되겠네
    // lazy var menuObservable = Observable.just(menus)
    // 근데 메뉴는 정보를 받아서 바뀔 수도 있어야지?
    // init으로 menu array를 빼면서 lazy도 뺄 수 있게 되었다.
    var menuObservable = PublishSubject<[Menu]>()
    
    lazy var itemsCount = menuObservable.map { menu in
        menu.map { $0.count }.reduce(0, +)
    }
    lazy var totalPrice = menuObservable.map { menu in
        $0.map { $0.price * $0.count }.reduce(0, +)
    }
    
//    Subject : 값을 보내줄 수도 있고, 값을 받아올 수도 있음.
    
    init() {
        let menus: [Menu] = [
            Menu(name: "오지지", price: 15000, count: 0),
            Menu(name: "SMTOWN", price: 39000, count: 0),
            Menu(name: "오지지", price: 12000, count: 0),
            Menu(name: "오지지", price: 12000, count: 0),
            Menu(name: "오지지", price: 12000, count: 0),
            Menu(name: "오지지", price: 12000, count: 0)
        ]
        
        menuObservable.onNext(menus)
    }   
}
このつながりをstreamと呼ぶ.

7.ラベルはRxCoCocoa、バインドTable View


1.上ではViewModelのデータをSubjectに変換し、ビューでもそれに応じてデータを受信して画面に表示します。


subscribeでデータを受信できます.
// viewDidLoad
viewModel.itemsCount
	.map { "\($0)" }
	.subscribe(onNext: {
		itemCountLabel.text = $0
	})
	.disposed(by: disposeBag)
RxCocoaを使ってbindでデータを受信できます!
// RxCocoa : RxSiwft 기능을 Extension 으로 UI 에 추가한 것.
// 일반 text와의 차이점은 bind 인데,
// subscribe 대신 bind(to: itemCountLable.rx.text) 를 하면,
// 순환 참조가 안생긴다.
// 대신 UI 작업이기 때문에 꼭 MainScheduler로 바꾸고 동작하게 해야한다.
viewModel.itemsCount
	.map { "\($0)" }
	.observeOn(MainScheduler.instance)
	.bind(to: itemCountLabel.rx.text)
	.disposed(by: disposeBag)

2.tableView binding:tableView itemもバインドできます。

// viewDidLoad
// bind 할 것 이므로 dataSource 연결 끊고 관련 코드 없애도 됨.
tableView.dataSource = nil

viewModel.menuObservable
    .bind(to: tableView.rx.items(cellIdentifier: "MenuItemTableViewCell", cellType: MenuItemTableViewCell.self)) { index, item, cell in
        //앞의 bind를 실행하면서 tableView의 items에 cellId, cellType을 넣으면
        // (ObservableType) -> ((@escaping (Int, Sequence.Element, Cell) -> Void) -> Disposable)
        // 위와 같은 escaping closure 를 리턴하게 됨.
        //  그러면 함수를 실행 한 다음에 index, item, cell 을 가지고 tableView의 item에 정보를 전달할 수 있다.
        cell.title.text = item.name
        cell.price.text = "\(item.price)"
        cell.count.text = "\(item.count)"
    }
でも…?実行できますが、table viewには何のデータもありません・・・menuobserverableはPublishSubjectだから!

8.Behavior Subject:データは生成されましたが、menuobserverableを購読したため、以前に生成されたデータは受信できません。Behavior Subjectに変更します。

// MenuListViewModel
var menuObservable = BehaviorSubject<[Menu]>(value: [])
正常に動作するように、orderボタンを押してtableViewプロジェクトを変更します.
// MenuVC
@IBAction func onOrder(_ sender: UIButton) {
    viewModel.menuObservable.onNext([
        Menu(name: "a", price: Int.random(in: 0..<10) * 100, count: Int.random(in: 0..<10)),
        Menu(name: "b", price: Int.random(in: 0..<10) * 100, count: Int.random(in: 0..<10)),
        Menu(name: "c", price: Int.random(in: 0..<10) * 100, count: Int.random(in: 0..<10)),
    ])
}

menuObserverableは配列のみ変更され、itemsCount、totalPriceも自動的に変更されます.

9.ボタンを有効にする


1.clearボタン

// clear 버튼을 누르면 count가 다 0으로 바뀌어야 한다.

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

// MenuListViewModel
func clearAllItemSelections() {
    // menu를 바꿔야하니까 menuObservable을 가져와서 menu count 0 으로 바꾼다.
    menuObservable
        .map { menus in
            menus.map { m in
                Menu(name: m.name, price: m.price, count: 0)
            }
        }
        .take(1) // 버튼 눌렀을 때 한번 하고 죽어야함. 계속 남아있으면서 clear 하면 안됨.
        .subscribe(onNext: {
            self.menuObservable.onNext($0)
        })
}

2+-ボタン:cellではcellで処理する必要があります。


方法viewModelを変数としてセルに渡し、受信して処理します.
メソッド2.+-ボタンのIBACTIONで実行する関数をTable View cell bindで処理する
方法delegate
class MenuItemTableViewCell: UITableViewCell {
    @IBOutlet var title: UILabel!
    @IBOutlet var count: UILabel!
    @IBOutlet var price: UILabel!

    // 방법1. viewModel을 넘겨주고 받아서 처리하던가.
    var viewModel: MenuListViewModel! //을 받아서 처리하거나.

    // 방법2. 쉬운 방법은 onChange를 넘겨서 처리.
    var onChange: ((Int) -> Void)?

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

// MenuVC > viewDidLoad
viewModel.menuObservable
    .bind(to: tableView.rx.items(cellIdentifier: "MenuItemTableViewCell", cellType: MenuItemTableViewCell.self)) { index, item, cell in
        cell.title.text = item.name
        cell.price.text = "\(item.price)"
        cell.count.text = "\(item.count)"
        
        cell.onChange = { [weak self] increase in
            self?.viewModel.changeCount(item: item, increase: increase)
        }
    }
    .disposed(by: disposeBag)
    
// MenuListViewModel
func changeCount(item: Menu, increase: Int) {
    _ = menuObservable
        .map { menus in
            menus.map { menu in
                // menu에 id 추가해서 관련된 부분 바꾸자.
                if menu.id == item.id {
                    return Menu(id: menu.id, name: menu.name, price: menu.price, count: menu.count + increase)
                } else {
                    return Menu(id: menu.id, name: menu.name, price: menu.price, count: menu.count)
                }
            }
        }
        .take(1)
        .subscribe(onNext: {
            self.menuObservable.onNext($0)
        })
}

10. fetch


これまでのようにコードを記述する場合は、viewModelを使用してテストするだけです.testcaseの作成が容易になりました.前述のように、APIが出る前にアプリを作成してから降りる場合は、model->viewModelで一度ページングすれば良いのですが、ロジックが修正されている場合はviewModelに触れるだけで良いのです.
// APIService
import Foundation

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 {
    // 기존 fetch 코드
    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()
    }
    
    
    // 근데 그러면 기존 프로젝트를 Rx로 리팩토링할 때 아예 다 새로만드나요?
    // 아니요! 기존 fetch 함수를 사용하면 됩니다.
    // legacy를 rx로 감싸서 리팩토링하면 된다.
    static func fetchAllMenusRx() -> Observable<Data> {
        return Observable.create() { emmiter in
            fetchAllMenus { result in
                switch result {
                case .success(let data):
                    emitter.onNext(data)
                    emitter.onCompleted()
                case .failure(let err):
                    emitter.onError(err)
            }
            return Disposables.create()
        }
    }
}
// MenuListViewModel
    init() {
        _ = APIService.fetchAllMenusRx()
            .map { data -> [MenuItem] in
                struct Response: Decodable {
                    let menus: [MenuItem]
                }
                let response = try! JSONDecoder().decode(Response.self, from: data)
                return response.menus
            }
            // menuItem -> menu 
            // menu에 extension 작성해서 바꾼다.
            .map { menuItems in
                var menus: [Menu] = []
                // 원래 여기도 map으로 하려고 했으나 id에 index 를 사용하기 위해서 enumerated().forEach 사용
                menuItems.enumerated().forEach { (index, menuItem) in
                    let menu = Menu.fromMenuItems(id: index, item: menuItem)
                    menus.append(menu)
                }
                
                return menus
            }
            .take(1)
            .bind(to: menuObservable)
    }
// model -> view를 위한 model 로 바꾸는 코드.
extension Menu {
    static func fromMenuItems(id: Int, item: MenuItem) -> Menu {
        return Menu(id: id, name: item.name, price: item.price, count: 0)
    }
}
これにより、コードを作成し、APIを取得した後、fetch、init、extensionだけを追加して、正常なアプリケーションを作成することができます.
ソース
4時間終了
https://youtu.be/iHKBNYMWd5I
RxSwift 4時間でgithub終了
https://github.com/iamchiwon/RxSwift_In_4_Hours