引言
??上一篇介紹了MVVM、組件化的基本概念,這一篇咱們就來講講代碼。
??首先看一下效果圖:


界面有很多是模仿網(wǎng)易云音樂的,再來看一下代碼結(jié)構(gòu):

關(guān)于代碼組織
- AppMusic: 主要功能模塊,包含用戶界面和業(yè)務(wù)邏輯
- AudioService: 音頻服務(wù)提供模塊,包含播放器、歌詞解析和數(shù)據(jù)請求
- Fatal: 錯誤定義模塊,出錯情況是多方面的,可能來自于后臺也可能來自于所使用的類庫(系統(tǒng)或第三方)。一般情況下錯誤可以通過兩個維度來描述,errorCode,errorMessage,ErrorConvertible協(xié)議提供了對這一層的抽象,所有出錯情況最后都轉(zhuǎn)換成ErrorConvertible,通過提取errorMessage來提醒用戶
- Fate: 通用功能模塊,主要是一些Extension
- FDNamespacer: 命名空間模塊,提供object.fd.property/object.fd.method()的訪問形式來替代object.fd_property/object.fd_method()
- FOLDin: 通用控件模塊,包含自定義導(dǎo)航條、進(jìn)度條和占位圖等
- Mediator:組件化中間件的實現(xiàn)
- RxMoya:對Moya提供了Rx支持,并內(nèi)建了錯誤處理和緩存
- SwiftyHUD: 對MBProgressHUD的封裝
關(guān)于viewModel
??可以認(rèn)為viewModel就是一個黑箱,只要提供給它輸入,它就能產(chǎn)生輸出?,F(xiàn)在讓我們聚焦在AudioSheetListViewController來看一下具體怎么定義和使用:
// viewModel聲明
private let viewModel: AudioSheetListViewModelType = AudioSheetListViewModel()
// viewModel實現(xiàn)
protocol AudioSheetListViewModelInputs {
/// 加載歌曲列表
func fetchAudioList(by type: Int)
/// 上拉加載更多
func pullToRefresh(by type: Int, offset: Int)
/// 修改喜歡狀態(tài)
func mutateLikeStatus(_ audio: MusicInfo, at indexPath: IndexPath)
}
protocol AudioSheetListViewModelOutputs {
/// 返回的歌曲
var audioList: Observable<[MusicInfo]> { get }
/// 上拉加載返回的歌曲
var audioListAppended: Observable<[MusicInfo]> { get }
/// 刷新控件的狀態(tài)
var pullToRefreshState: Observable<MJRefreshState> { get }
/// 修改喜歡的結(jié)果
var likeStatus: Observable<(flag: Bool, indexPath: IndexPath)> { get }
/// 遭遇錯誤
var showError: Observable<ErrorConvertible> { get }
}
protocol AudioSheetListViewModelType {
var inputs: AudioSheetListViewModelInputs { get }
var outputs: AudioSheetListViewModelOutputs { get }
}
class AudioSheetListViewModel: AudioSheetListViewModelType
, AudioSheetListViewModelInputs
, AudioSheetListViewModelOutputs {
// 實現(xiàn)協(xié)議,處理輸入輸出
}
可以看到viewModel是一個協(xié)議類型,僅僅對外暴露了兩個屬性,inputs和outputs,分別代表輸入輸出,而inputs和outputs同樣也是協(xié)議類型。這樣做的好處是提供了良好的封裝性,因為你不能直接訪問具體實現(xiàn)類。viewModel已經(jīng)有了,接下來我們在viewDidLoad中綁定viewModel
private func performBinding() {
// 處理返回的歌曲
viewModel.outputs.audioList
.subscribeNext(weak: self) { (self) in
return { (audios) in
guard let audioSheet = self.audioSheet else { return }
self.tableHeaderView.configureWith(value: audioSheet)
self.dataSource.load(audioList: audios)
self.reloadData()
// NOTE: reload data first
self.view.hideSkeleton()
self.tableHeaderView.hideSkeleton()
self.placeholderView.state = audios.isEmpty ? .empty : .completed
}
}.disposed(by: disposeBag)
/// 上拉加載更多
tableView.refreshFooter.rx.refresh
.debounce(1, scheduler: MainScheduler.instance)
.filter { $0 == .refreshing }
.subscribeNext(weak: self) { (self) in
return { _ in
guard let type = self.audioSheet?.type else { return }
self.offset += self.dataSource.numberOfItems()
self.viewModel.inputs.pullToRefresh(by: type, offset: self.offset)
}
}.disposed(by: disposeBag)
// 處理上拉數(shù)據(jù)返回
viewModel.outputs.audioListAppended
.subscribeNext(weak: self) { (self) in
return { (audios) in
let indexPaths = self.dataSource.append(audioList: audios)
self.tableView.insertRows(at: indexPaths, with: .none)
self.view.hideSkeleton()
self.tableHeaderView.hideSkeleton()
self.placeholderView.state = self.dataSource.numberOfItems() == 0 ? .empty : .completed
}
}.disposed(by: disposeBag)
// 更新刷新控件狀態(tài)
viewModel.outputs.pullToRefreshState
.bind(to: tableView.refreshFooter.rx.refresh)
.disposed(by: disposeBag)
// 處理喜歡
viewModel.outputs.likeStatus
.subscribeNext(weak: self) { (self) in
return { (result) in
let flag = result.flag
let indexPath = result.indexPath
self.dataSource.load(flag: flag, at: indexPath)
self.tableView.reloadRow(at: indexPath, with: .none)
}
}.disposed(by: disposeBag)
// 處理失敗
viewModel.outputs.showError
.subscribeNext(weak: self) { (self) in
return { (error) in
self.view.hideSkeleton()
self.tableHeaderView.hideSkeleton()
if error.isFailedByNetwork {
self.placeholderView.state = .failed
} else {
self.placeholderView.state = .completed
}
// 接口貌似不穩(wěn)定 http code 403賊多
SwiftyHUD.show(message: error.message)
}
}.disposed(by: disposeBag)
}
至此viewModel的outputs已經(jīng)全部關(guān)聯(lián)好了,只需要觸發(fā)inputs一切就形成了閉環(huán)。比如我希望在viewDidAppear中請求數(shù)據(jù),所以我在viewDidAppear中寫下:
/// 加載歌曲
private func fetchAudioList() {
guard let sheet = audioSheet else { return }
if dataSource.numberOfItems() == 0 {
view.showAnimatedSkeleton()
tableHeaderView.showAnimatedSkeleton()
viewModel.inputs.fetchAudioList(by: sheet.type)
}
}
需要說明的是dataSource的實際類型是一個tableView/collectionView數(shù)據(jù)源的包裝類-ValueCellDataSource。實際上它包裝了一個二維數(shù)組來對應(yīng)indexPath,數(shù)組是私有的,但是可以通過方法來訪問。
??viewModel接收到輸入以后,在內(nèi)部通過私有Subject來轉(zhuǎn)發(fā):
fileprivate let fetchRelay = PublishSubject<Int>()
func fetchAudioList(by type: Int) {
fetchRelay.onNext(type)
}
接下來會來到viewMode的init方法:
let fetch = fetchRelay
.flatMap { AudioProvider.fetchAndConvertAudioList(by: $0).materialize() }
.share()
audioList = fetch.elements()
至此就觸發(fā)了網(wǎng)絡(luò)請求加載資源。注意到這里使用了materialize()來進(jìn)行錯誤處理。我們知道一旦AudioProvider.fetchAndConvertAudioList(by: $0)發(fā)出了一個錯誤,fetchRelay就會dispose,所有訂閱的observer都會被清除,這不是我們希望的。materialize操作符將所有事件重新包裝成Event,這樣就避免了發(fā)出error,接著使用RxSwiftExt提供的elements和errors操作符就可以很方便的提取元素和錯誤。網(wǎng)絡(luò)請求處理完畢,viewModel.outputs.audioList也就發(fā)出了元素,接下來只要刷新tableView處理一些狀態(tài)就可以了。
??現(xiàn)在讓我們來整理一下流程:viewDidAppear -> viewModel.inputs -> subject forwarding -> viewModel.outputs -> update UI。viewModel處理了業(yè)務(wù)邏輯,view只需要綁定輸出,觸發(fā)輸入,整個過程非常清晰。
關(guān)于MVVM對MVC的兼容
??AudioPlayerViewController就是一個不涉及viewModel的例子。鑒于播放器頁面更新頻繁,且狀態(tài)其實是共享自AudioStreamer(基本上都是單例),我在這里使用了通過XXXViewDataSource協(xié)議來獲取AudioStreamer的狀態(tài)的方式,相比直接持有這些狀態(tài),適時的獲取更簡單也更不容易出錯。
其他
??Asserts文件夾下的圖片經(jīng)pod打包后無法直接訪問,需要獲取其所在bundle才能訪問,具體就是:
# podspec打包
s.resource_bundles = {
'AppMusic' => ['AppMusic/Assets/*.{png,jpg}']
}
// 圖片訪問
class AppMusicBundleLoader: NSObject {}
extension Bundle {
static let resourcesBundle: Bundle? = {
var path = Bundle(for: AppMusicBundleLoader.self).resourcePath ?? ""
path.append("/AppMusic.bundle")
return Bundle(path: path)
}()
}
extension UIImage {
convenience init?(nameInBundle name: String) {
self.init(named: name, in: .resourcesBundle, compatibleWith: nil)
}
}
語言表述難免有疏漏不明之處,如果你還是很疑惑,移步看一下代碼吧。