引言
上一篇介绍了MVVM、组件化的基本概念,这一篇咱们就来讲讲代码。
首先看一下效果图:
界面有很多是模仿网易云音乐的,再来看一下代码结构:
关于代码组织
- AppMusic: 主要功能模块,包含用户界面和业务逻辑
- AudioService: 音频服务提供模块,包含播放器、歌词解析和数据请求
- Fatal: 错误定义模块,出错情况是多方面的,可能来自于后台也可能来自于所使用的类库(系统或第三方)。一般情况下错误可以通过两个维度来描述,errorCode,errorMessage,ErrorConvertible协议提供了对这一层的抽象,所有出错情况最后都转换成ErrorConvertible,通过提取errorMessage来提醒用户
- Fate: 通用功能模块,主要是一些Extension
- FDNamespacer: 命名空间模块,提供object.fd.property/object.fd.method()的访问形式来替代object.fd_property/object.fd_method()
- FOLDin: 通用控件模块,包含自定义导航条、进度条和占位图等
- Mediator:组件化中间件的实现
- RxMoya:对Moya提供了Rx支持,并内建了错误处理和缓存
- SwiftyHUD: 对MBProgressHUD的封装
关于viewModel
可以认为viewModel就是一个黑箱,只要提供给它输入,它就能产生输出。现在让我们聚焦在AudioSheetListViewController来看一下具体怎么定义和使用:
// viewModel声明
private let viewModel: AudioSheetListViewModelType = AudioSheetListViewModel()
// viewModel实现
protocol AudioSheetListViewModelInputs {
/// 加载歌曲列表
func fetchAudioList(by type: Int)
/// 上拉加载更多
func pullToRefresh(by type: Int, offset: Int)
/// 修改喜欢状态
func mutateLikeStatus(_ audio: MusicInfo, at indexPath: IndexPath)
}
protocol AudioSheetListViewModelOutputs {
/// 返回的歌曲
var audioList: Observable<[MusicInfo]> { get }
/// 上拉加载返回的歌曲
var audioListAppended: Observable<[MusicInfo]> { get }
/// 刷新控件的状态
var pullToRefreshState: Observable<MJRefreshState> { get }
/// 修改喜欢的结果
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 {
// 实现协议,处理输入输出
}
可以看到viewModel是一个协议类型,仅仅对外暴露了两个属性,inputs和outputs,分别代表输入输出,而inputs和outputs同样也是协议类型。这样做的好处是提供了良好的封装性,因为你不能直接访问具体实现类。viewModel已经有了,接下来我们在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)
// 处理上拉数据返回
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)
// 更新刷新控件状态
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
}
// 接口貌似不稳定 http code 403贼多
SwiftyHUD.show(message: error.message)
}
}.disposed(by: disposeBag)
}
至此viewModel的outputs已经全部关联好了,只需要触发inputs一切就形成了闭环。比如我希望在viewDidAppear中请求数据,所以我在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数据源的包装类-ValueCellDataSource。实际上它包装了一个二维数组来对应indexPath,数组是私有的,但是可以通过方法来访问。
viewModel接收到输入以后,在内部通过私有Subject来转发:
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()
至此就触发了网络请求加载资源。注意到这里使用了materialize()来进行错误处理。我们知道一旦AudioProvider.fetchAndConvertAudioList(by: $0)发出了一个错误,fetchRelay就会dispose,所有订阅的observer都会被清除,这不是我们希望的。materialize操作符将所有事件重新包装成Event,这样就避免了发出error,接着使用RxSwiftExt提供的elements和errors操作符就可以很方便的提取元素和错误。网络请求处理完毕,viewModel.outputs.audioList也就发出了元素,接下来只要刷新tableView处理一些状态就可以了。
现在让我们来整理一下流程:viewDidAppear -> viewModel.inputs -> subject forwarding -> viewModel.outputs -> update UI。viewModel处理了业务逻辑,view只需要绑定输出,触发输入,整个过程非常清晰。
关于MVVM对MVC的兼容
AudioPlayerViewController就是一个不涉及viewModel的例子。鉴于播放器页面更新频繁,且状态其实是共享自AudioStreamer(基本上都是单例),我在这里使用了通过XXXViewDataSource协议来获取AudioStreamer的状态的方式,相比直接持有这些状态,适时的获取更简单也更不容易出错。
其他
Asserts文件夹下的图片经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)
}
}
语言表述难免有疏漏不明之处,如果你还是很疑惑,移步看一下代码吧。