对沸点页面仿写的补充-RxSwift

2,186 阅读4分钟

0.JPEG

# 前言

诚如我在 上篇 中所说的,最近被大佬教育过没有实现 双向数据绑定 就不能成为完整 MVVM 架构。因此这篇文章中所说的 MVVM 仅代表表个人所理解的 iOS中的 MVVM 架构,如有不对之处请海涵(您老总不能来打我吧🤪)。

# 个人理解的 MVVM

在之前(2020年)写另一篇文章 的时候,刚学习使用 RxSwift 参考了一写文章发现大家对 ViewModel 层的使用有不同的方式,常见的为:

  1. ViewModel 的创建需要外部的参数,ViewModel 实际作用就是转换 input 输出一个 output

  2. ViewModel 的初始化不依赖于外部参数;ViewModel 对外提供函数调用,内部将此调用转换为一个 Observable<T> 的输出。

这里不会对两种方式的优劣进行评价,本人在实际项目中使用的是第二种方式,并使用协议区分了 InputOutput,示例如下:

protocol DynamicListViewModelInputs {

    func viewDidLoad()
    func refreshDate()
    func moreData(with cursor: String, needHot: Bool)
}

protocol DynamicListViewModelOutputs {

    var refreshData: Observable<DynamicDisplayModel> { get }
    var moreData: Observable<DynamicDisplayModel> { get }
    var endRefresh: Observable<Void> { get }
    var hasMoreData: Observable<Bool> { get }
    var showError: Observable<String> { get }
}

protocol DynamicListViewModelType {
    var input: DynamicListViewModelInputs { get }
    var output: DynamicListViewModelOutputs { get }
}

final class DynamicListViewModel: DynamicListViewModelType, DynamicListViewModelInputs, DynamicListViewModelOutputs {
...
...
}

外部使用:

// 创建 VM
private let viewModel: DynamicListViewModelType = DynamicListViewModel()
private let dataSource = DynamicListDataSource()
....

override func viewDidLoad() {
    super.viewDidLoad()
    ...

    // 调用 input
    viewModel.input.viewDidLoad()
}
...

func bindViewModel() {

    // 订阅 output
    viewModel.output.refreshData.subscribe(onNext: { [weak self] wrappedModel in
        self?.dataSource.newData(from: wrappedModel)
        self?.tableNode.reloadData()
    }).disposed(by: disposeBag)
    ...
}

这样 ViewModel 也做到了接口隔离,同样的 ViewModel 中并没有存储数据和状态变量,对于 TableNode 需要的数据抽离 DataSource 进行存储存储,进一步的分离了 ViewModel 的职责。ViewModel 只进行数据的加工,DataSource 负责数据的存储和提供中间状态。

如上所属 MVVM 虽然会增加类型,但其提高了代码可读性和可维护性。

# 对 RxSwift 使用的纠正

在原文中,我们简单的使用了 MoyaRxSwift,但实际项目中的网络请求和逻辑处理会比现在更为复杂,例如:要在读取首页数据的同时获取 推荐圈子 数据,仅在加载特定页(如第二页等)的数据时读取 推荐沸点 数据,增加 热门话题 等。以下仅以增加 推荐圈子 功能给与举例说明:

  1. 添加 推荐圈子 的接口调用和相关界面的展示。

  2. 如果 推荐圈子 读取失败则不展示相应的视图。

  3. 推荐圈子 不影响现有的功能。

您可以先思考下实现次需求要改动的地方。

我们先把原文中 RxSwift 使用不合理的地方进行修改。

# compactMapmap

ListViewModel 中的

private let loadDataSubject: BehaviorSubject<String?> = BehaviorSubject(value: nil)
...
let loadDataAction = self.loadDataSubject.filter { $0 != nil }.map { string -> String in
    guard let cursor = string else { fatalError("") }
    return cursor
}

修改为:

let loadDataAction = loadDataSubject.compactMap { $0 }

compactMap 本身就是用来去除 nil 这和 Swift.Collection 功能一致。

# XxxxSubjecterror

在使用 RxSwift 中一定要十分注意 不要 轻易使用 Observer.error(xxx),特别是在 flatMapOperator 中。例如原文 ListViewModel 中:

loadDataAction.filter { $0 == "0" }.map { cursor -> DynamicListParam in
        return DynamicListParam(cursor: cursor)
    }.flatMap { param -> Single<XTListResultModel> in
        // 注释1
        return DynamicNetworkService.list(param: param.toJsonDict())
        .request()
        .map(XTListResultModel.self)
}

注释1 处如果网络出现波动导致 Rx + Moya(下文统称 RxMoya) 中抛出 error 事件,而我们直接将自己的 loadDataSubject 转换了成了 RxMoya 生成的 Single<T: Codable>。因此一旦有 error事件,就是导致 loadDataSubject 终止事件流的传递,不在发送新的元素。所以这里需要对 RxMoya 进行一次 catch error 处理,而后续的组合中我们又需要这个 error 信息,因此就要对结果使用 Result<T,Error> 进行一次包裹,代码如下(存在省略写法):

let dynamycData = loadDataAction.filter { $0 != "0" }.map { cursor -> DynamicListParam in
    DynamicListParam(cursor: cursor)
}.flatMap { param -> Observable<Result<XTListResultModel, Error>> in
    let result = DynamicNetworkService.list(param: param.toJsonDict())
        .request()
        .map(XTListResultModel.self)
        .map { model -> Result<XTListResultModel, Error> in
                .success(model)
        }.catch { .just(.failure($0)) }
    
    return result.asObservable()
}

这样 loadDataSubjectRxMoya 出现网络请求、Model Decoder 等错误时也不会被终止。

# Model 的变动

现在 UI 界面需要展示不同类型的 cell,因此我们需要对 DynamicListModel 进行一次包裹:

enum DynamicDisplayType {
    case dynamic(DynamicListModel)
    case topicList([TopicModel])
    case hotList([DynamicListModel])
}

/// 对应 XTListResultModel
struct DynamicDisplayModel {

    var cursor: String? = nil
    var errMsg: String? = nil
    var errNo: Int? = nil
    var displayModels: [DynamicDisplayType] = []
    var hasMore: Bool = false
    var dynamicsCount: Int = 0
    ....
}

# VM中增加数据请求和处理

有了以上调整,我们实现 推荐圈子 功能就可以正式动工了,将请求首页沸点的 Observe 单独定义,如上述处理 error 代码所示,在 ListViewModel 中增加对应的 PublishSubject

// 增加圈子数据请求
private let topicListSubject = PublishSubject<Void>()

func loadFirstPageData() {
    topicListSubject.onNext(())
    loadDataSubject.onNext("0")
}

loadDataSubject.onNext("0") 替换为 loadFirstPageData()

func initializedNewDateSubject() 中增加对 topicListSubjectflatMap {} 操作

let topicListData = topicListSubject.flatMap { _ -> Observable<Result<TopicListModel, Error>> in
    let result = DynamicNetworkService.topicListRecommend
        .memoryCacheIn()
        .request()
        .map(TopicListModel.self)
        .flatMap { model -> Single<Result<TopicListModel, Error>> in
            .just(.success(model))
        }.catch {
            .just(.failure($0))
        }

    return result.asObservable()
}

然后对 topicListDatadynamycData,进行 zip 操作:

let dynamycData = loadDataAction.filter { ... }

let topicListData = topicListSubject.flatMap { ... }

let newDataSubject = Observable.zip(dynamycData, topicListData).map { (dynamicWrapped, topicListWrapped) -> Result<DynamicDisplayModel, Error> in
    var displayModel = DynamicDisplayModel()

    switch dynamicWrapped {
    case .success(let wrapped):
        displayModel = DynamicDisplayModel.init(from: wrapped)
    case .failure(let error):
        return .failure(error)
    }

    switch topicListWrapped {
    case .success(let wrapped):
        if let list = wrapped.data, !list.isEmpty {
            displayModel.displayModels.insert(.topicList(list), at: 0)
        }
    case .failure(let error):
        // FIXED: - 请求或者解析数据失败, 不作任何处理, 界面不展示
        print(error)
    }

    return .success(displayModel)
}

# DataSource 中的调整

至此网络和数据处理部分结束,下面改造 DataSource,这里更简单只需要将 XTListResultModel 替换为 DynamicDisplayModel,将 DynamicListModel 替换为 DynamicDisplayType,然后在 ASTableDatasource 中修改以下代码:

func tableNode(_ tableNode: ASTableNode, nodeForRowAt indexPath: IndexPath) -> ASCellNode {
    let model = commendList[indexPath.row]

    switch model {
    case .dynamic(let dynModel):
        let cellNode = DynamicListCellNode()
        cellNode.configure(with: dynModel)
        return cellNode
    case .topicList(let topic):
        let cellNoed = DynamicTopicWrapperCellNode()
        cellNoed.configure(with: topic)
        return cellNoed
    case .hotList(let list):
        // TODO: - 这里需要替换为 DynamicHotListWrapperCellNode
        let cellNode = DynamicListCellNode()
        cellNode.configure(with: list[0])
        return cellNode
    }
}

所有操作结束,功能实现。

嘿!!!我并没有忘记 ViewController,但这里面确实么得需要修改的地方,最多就是后续添加 DynamicTopicWrapperCellNodedelegate 方法。

# 修补

感谢某大佬的指正,先修复 ViewModel 中使用了 subject(onNext:) 的错误, 代码以 refreshData 为例:

private func createNewDataSubject(with loadDataAction: Observable<String>) -> Observable<DynamicDisplayModel?> {

    let dynamycData = ...
    let topicListData = ...

    let newDataSubject = Observable.zip(dynamycData, topicListData).map { [weak self] (dynamicWrapped, topicListWrapped) -> DynamicDisplayModel? in
        var displayModel = DynamicDisplayModel()

        switch dynamicWrapped {
        case .success(let wrapped):
            displayModel = DynamicDisplayModel.init(from: wrapped)
        case .failure(let error):
            // TODO: - 处理错误
            if let error = error as? MoyaError {
                self?.handleMoyaError(error, fromNewData: true)
            } else {
                print(error)
            }
            return nil
        }

        switch topicListWrapped {
        case .success(let wrapped):
            if let list = wrapped.data, !list.isEmpty {
                displayModel.displayModels.insert(.topicList(list), at: 0)
            }
        case .failure(let error):
            // FIXED: - 请求或者解析数据失败, 不作任何处理, 界面不展示
            print(error)
        }

        defer {
            self?.endRefreshDataSubject.onNext(())
            // 清空当前请求的状态
            self?.loadDataSubject.onNext(nil)
        }

        return displayModel
    }

    return newDataSubject
}

返回 nweData 的地方修更改为:

private lazy var newDataObservable: Observable<DynamicDisplayModel> = {
    let loadDataAction = loadDataSubject.compactMap { $0 }
    let newData = self.createNewDataSubject(with: loadDataAction)
    return newData.compactMap { $0 }
}()

var refreshData: Observable<DynamicDisplayModel> {
    return self.newDataObservable
}

# 补充

  • 对于网络层的封装可参阅对沸点页面仿写的补充-网络层,当然现在的源码中已经包含了此部内容。

  • demo 最低版本要求 iOS 13 这并不是 Rx 等三方库导致,你完全可以调整到 iOS 10,并重新 pod install。使用 iOS 13 仅仅是想在后续替换掉 Rx