iOS MVVM最佳實踐(二)

引言

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

歌單.gif

播放器.gif

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


代碼結(jié)構(gòu).png

關(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)
    }
}

語言表述難免有疏漏不明之處,如果你還是很疑惑,移步看一下代碼吧。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 1月14日水彩打卡——菊花很多天沒有畫出一幅完整的畫了。最近有點意外的事情,我還沒適應(yīng)過來。 在畫畫中忘了自己,忘...
    尹菀柔閱讀 576評論 0 6
  • 總把自己過得不好的原因歸咎于別人的人,始終不可能過好!晚安自己
    檸檬茶文化閱讀 214評論 0 0
  • 玳安去后,應(yīng)伯爵問,那天在哪里結(jié)拜?是在哥的家里面,還是寺院里好?謝希大說,我們這邊只有兩個寺院,僧家是永福士,道...
    原野草閱讀 444評論 0 1

友情鏈接更多精彩內(nèi)容