用RxSwift+MVVM來實作瀏覽github user repositories的App吧!

Sean
藍光宅男
Published in
12 min readMar 6, 2019

iOS developer在學習的初期,通常都是使用蘋果建議的MVC(Model-View-Controller)架構來寫作,學習門檻會比較低沒錯,等到專案後來所需的功能越來越多時,一個ViewController內可以有著上千行的Code也不意外,變成名符其實的MassiveVC架構了。

再者,為了完成使用者透過UI跟App的互動,Function內會使用很多層的closure,一路等待把值丟給最內層的closure,等到最內層的closure完成後再把值更新到UI,看到很多層的closure想必都要先昏倒一次。

而RxSwift + MVVM(Model-View-ViewModel)的方式就很適合解決上述的痛點:

  1. MVC架構底下的ViewController負責事情太多,要顯示UI,又要處理商業邏輯,整個ViewController太肥大難以閱讀,而且也難以測試。因此透過新增一個ViewModel的方式,把Controller負責商業邏輯的部分拆出來,讓Controller以後只要專心的做UI綁定跟顯示,大幅減少ViewController的負擔。
  2. 簡化程式碼,增加可閱讀性,不用再調用很多層的closure來完成一個功能。

先來看一下完成後的App的demo吧!

廢話不多說,就讓我們開始來使用RxSwift + MVVM吧!

  • 先看最主要的ViewController檔案 — SearchViewController.swift
class SearchViewController: UIViewController {    @IBOutlet private var tableview:UITableView!    private var searchController:UISearchController? = nil
private var viewModel:SearchViewModel!
private let disposeBag = DisposeBag()

override func viewDidLoad() {
super.viewDidLoad()
// 1. 初始化viewModel
viewModel = SearchViewModel()
definesPresentationContext = true
setSearchController()
setTableView()
}
private func setSearchController() {
// 初始化要嵌入在NavigationBar的SearchController
searchController = UISearchController(searchResultsController: nil)
searchController?.dimsBackgroundDuringPresentation = false
navigationItem.searchController = searchController
// 解釋(1)
searchController?.searchBar.rx.text
.orEmpty
.throttle(1.0, scheduler: MainScheduler.instance)
.distinctUntilChanged()
.bind(to: viewModel.keywords)
.disposed(by: disposeBag)
}

private func setTableView() {
let nib = UINib.init(nibName: "RepoCell", bundle: nil)
tableview.register(nib, forCellReuseIdentifier: "RepoCell")

// 解釋(2)
viewModel.repos
.asDriver(onErrorJustReturn: [])
.drive(tableview.rx.items(cellIdentifier: "RepoCell", cellType: RepoCell.self)) { (_, repo, cell) in
cell.setCell(data: repo)
}
.disposed(by: disposeBag)

// 解釋(3)
tableview.rx
.setDelegate(self)
.disposed(by: disposeBag)
}
}
extension SearchViewController:UITableViewDelegate {
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 60.0
}
}

解釋(1):

searchController?.searchBar.rx.text會返回使用者輸入在search欄內的字串,型別為ControlProperty。orEmpty則是把text的空值給過濾掉,不會往下傳遞throttle(1.0, scheduler: MainScheduler.instance)則是設定成每隔1.0秒才處理一次字串distinctUntilChanged()則是當字串有變動才往下傳遞,此用法是可以減少無謂的呼叫bind(to: viewModel.keywords)最後把text綁定到viewModel的keywords上,讓viewModel去處理keywords有收到新的字串時,呼叫github的API

解釋(2):

viewModel.repos是查詢結果中的repositories,為一個陣列asDriver(onErrorJustReturn: []) 當有出現錯誤時,將回傳一個空陣列[]drive(tableview.rx.items(cellIdentifier: "RepoCell", cellType: RepoCell.self)) { (_, repo, cell) in    cell.setCell(data: repo)
}
把repos綁定到 tableview上,在這邊使用cellIdentifier為"RepoCell"的客製化Cell,然後把值傳入到setCell(data:)這函式內來顯示repo資料

解釋(3):

.setDelegate(self) 因為cell的height還是需要SearchViewController來代理,所以呼叫setDelegate(self)

沒錯,從頭到尾都只有資料跟UI之間的綁定,麻煩的商業邏輯全部交由viewModel來時做了!接下來瞧瞧看我們的viewModel吧!

  • 負責所有商業邏輯的ViewModel 檔案— SearchViewModel.swift
import Foundation
import RxSwift
import Moya
import Moya_ObjectMapper
class SearchViewModel {
// 解釋(4)
public let repos:PublishSubject<[GitRepo]> = PublishSubject.init()
public var keywords:PublishSubject<String> = PublishSubject.init()
private let disposeBag = DisposeBag()
private let provider = MoyaProvider<NetworkManager>()
init() {
// 解釋(5)
keywords.asDriver(onErrorJustReturn: "")
.drive(onNext: { [weak self] string in
// 解釋(6)
self?.provider.rx
.request(.searchRepos(keywrod: string))
.mapArray(GitRepo.self)
.subscribe(onSuccess: { repos in
self?.repos.onNext(repos)
}, onError: { error in
self?.repos.onNext([])
})
.disposed(by: (self?.disposeBag)!)
})
.disposed(by: self.disposeBag)
}
}

解釋(4):

這邊宣告了兩個公開的變數 repos和keywords,都是PublishSubject類型,為了是可以被觀察,也可以主動發射新的Event另外兩個私密變數 disposeBag和provider,前者是用來回收這檔案所有用到rx的資源,確保在SearchViewModel被deinit後,rx資源可以被釋放。
provider則是MoyaProvider,然後宣告成NetworkManager類型,用來處理網路方面的API,會在稍後做解釋。

解釋(5):

asDriver(onErrorJustReturn: "") 是把Observable類型轉成Driver,並且當有錯誤發生值,預設return一個空字串""drive(onNext: { [weak self] string in } 這邊是當keywords這個Driver有收到新的值(onNext)時,要做的事情。

解釋(6):

request(.searchRepos(keywrod: string)) 是去請求provider發出API request,然後API類型為searchRepos(keyword:String),所以把收到的text當作參數傳給API request做查詢                mapArray(GitRepo.self) 因為有搭配ObjectMapper做把json轉成data struc的事情,所以呼叫mappArray(),然後給定要轉成的data類型,也就是GitRepo,所以會把前面request收到的json轉成GitRepo陣列繼續往下丟subscribe(onSuccess: { repos in
self?.repos.onNext(repos)
},
onError: { error in
self?.repos.onNext([])
})
這邊很簡單,就是訂閱要觀察的值,
如果成功的話,會在onSuccess這個closure內,對repos這個變數發出新的值(repos),所以在SearchViewController綁定到repos的tableview,此時就會做相對應的處理。
如果失敗的話,會在onError這個closure內,對repos這個變數發出發出空值([])。
  • 接著介紹處理Network API的檔案 — NetworkManager.swift
import Moya// 先宣告一個enum叫做NetworkManager,然後此App中,只需要一個API requset,
// 參數是一個為String型別叫keyword的值
enum NetworkManager {
case searchRepos(keywrod:String)
}
// 讓NetworkManager遵循Moya的TargetType這protocol並實作,來完成送出
// request的功能
extension NetworkManager:TargetType {
// 就是hostname
var baseURL: URL {
return URL(string: "https://api.github.com")!
}
// 就是request的url path
var path: String {
switch self {
case .searchRepos(let keyword):
return "/users/\(keyword)/repos"
}
}
// 就是http method,這邊要抓資料,所以用.get
var method: Method {
return .get
}
// 這邊是用來做unit test的,此App不需要,所以送空字串""
var sampleData: Data {
switch self {
case .searchRepos( _):
return "".data(using: String.Encoding.utf8)!
}
}
// 使用最基本的requstPlain
var task: Task {
switch self {
case .searchRepos(_):
return .requestPlain
}
}
// 沒需要特別設定的欄位,所以送空的dictionary
var headers: [String : String]? {
switch self {
case .searchRepos(_):
return [:]
}
}
}

到這邊就全部結束啦。由此可見,以後要做測試,只要針對ViewModel內的function做測試,ViewController現在就完全只負責UI的綁定跟顯示了,是不是讓整個code簡單很多呢!想下載整個專案的話,可以到 github 來取得喲!

整篇文章最困難的就是RxSwift的概念,有興趣的人可以看 RxSwift的中文文檔 ,先把第4章節(RxSwift核心)的Observable, Observer, Subject的概念搞懂後,再把第5章節(如何選擇操作符)的每個操作符看過一遍有點印象後,最後照著第6章節(更多示例)的專案實際做一遍,應該就更能體會Observable和Observer之間相互關係的概念了!

--

--