iOS MVVM最佳实践(二)

作者: anyoptional | 来源:发表于2019-04-09 17:04 被阅读8次

    引言

      上一篇介绍了MVVM、组件化的基本概念,这一篇咱们就来讲讲代码。
      首先看一下效果图:

    歌单.gif 播放器.gif

    界面有很多是模仿网易云音乐的,再来看一下代码结构:


    代码结构.png

    关于代码组织

    • 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)
        }
    }
    

    语言表述难免有疏漏不明之处,如果你还是很疑惑,移步看一下代码吧。

    相关文章

      网友评论

        本文标题:iOS MVVM最佳实践(二)

        本文链接:https://www.haomeiwen.com/subject/txpbiqtx.html