嗨,这里有一份RxSwift+MVVM实现的掘金沸点页面

4,442 阅读7分钟

先看下最终的效果吧:

. .
)

当然这里也有一段完整的视频链接 预览视频

  • 关于 MVVM 网上已经有很多资料了,作为第一次实际使用的我不做过的的解释,请各位自行查阅相关文章。这里金记录下我的使用感受,ViweModel 很方便复用,代码分层很清楚。
  • 同样的关于 RxSwift 本人也是前几天从 0 开始学习的,也不会给出过多的介绍。

如果您也想自己实现一次点这里我已经帮你做好了一份基本的框架,当然完整版的也有,在文章最底部。(已经添加了 SwiftLint

# 代码展示

先来看看完成后的 ViewController 的核心实际代码:


class ViewController: UIViewController {

  @IBOutlet weak var tableView: UITableView!
  let disposeBag = DisposeBag()
  let viewModel = XTRecommendViemModel()
  override func viewDidLoad() {
    super.viewDidLoad()
    initialUI()
    bindViewModel()
    viewModel.viewDidload()
  }
}

// MARK: initial UI
extension ViewController {

  private func initialUI() {
    view.backgroundColor = .white
    title = "数据请求"
    initialTableView()
  }

  private func initialTableView() {
    // 预估行高 会造成 cell 的重复创建和销毁 例如 本来应该创建 6个,
    // 预估行高会创建 7 到 8 个然后在你下划或者上划到头后就开始销毁多余的, 再次滑动又会创建新的
    // 测试机为 6, iOS12.4 和  XR iOS13.5
    tableView.rowHeight = UITableView.automaticDimension
    tableView.estimatedRowHeight = 260
  }
}

// MARK: iniatal rx
extension ViewController {

  func bindViewModel() {
    // 配置下拉刷新
    let tableHeader = MJRefreshNormalHeader()
    tableHeader.isAutomaticallyChangeAlpha = true
    tableHeader.lastUpdatedTimeLabel?.isHidden = true
    tableView.mj_header = tableHeader
    tableView.mj_footer = MJRefreshBackNormalFooter()
    // 绑定数据
    tableHeader.rx.refreshing
      .asDriver()
      .drive(onNext: { [weak self] _ in
        self?.viewModel.loadData(true)
      })
      .disposed(by: disposeBag)
    tableView.mj_footer?.rx.refreshing
      .asDriver()
      .drive(onNext: { [weak self] _ in
        self?.viewModel.loadData(false)
      })
      .disposed(by: disposeBag)
    // 刷新状态管理
    viewModel.refreshStatusBind(to: self.tableView).disposed(by: disposeBag)
    let dataSource = self.tableViewCellDataSourc
    // 获取数据
    self.viewModel.outputs.dataSource
      .drive(tableView.rx.items(dataSource: dataSource))
      .disposed(by: disposeBag)
  }
}

/// 配置 tableViewCell 的数据源
extension ViewController {
  var tableViewCellDataSourc: RxTableViewSectionedReloadDataSource<SectionModel<String, UserActivity>> {
    let dataSource = RxTableViewSectionedReloadDataSource<SectionModel<String, UserActivity>>(
    configureCell: { [weak self] (_, tabView, indexPath, model) -> UITableViewCell in
      let sCell = tabView.dequeueReusableCell(withIdentifier: "XTCell", for: indexPath)
      guard let cell = sCell as? XTCell else { return sCell}
      cell.confirgueCell(model: model)
      if !cell.hasBindStream {
        guard let self = self else { return cell }
        cell.updaBindState()
        let imageTapObserver: Binder<ImageViewTapInfo> = Binder(self, scheduler: MainScheduler.instance) { viewController, info in
          viewController.showPhotoBrowser(info: info)
        }
        cell.bindImageTapStream(observer: imageTapObserver)
      }
      return cell
    })
    return dataSource
  }
}

// MARK: 显示相册
private extension ViewController {
  func showPhotoBrowser(info: ImageViewTapInfo) {
   	...
    self.showXTPhotoBrowser(from: info.sourceView, imagesUrl: orgImageUrlArray, selsctIndex: selectIndex)
  }

  func showXTPhotoBrowser(from sourceView: UIImageView, imagesUrl: [String], selsctIndex: Int) {
    guard !imagesUrl.isEmpty else { return }
    let orgImageUrlArray = imagesUrl
    let imageView = sourceView
    let browser = JXPhotoBrowser()
    ...
    browser.show()
  }
}

实际的代码行数为 160 行,具体的可以在完整版中看到

作为 ViewModelXTRecommendViemModel 中的核心代码

/**
 *
 * Inputs 只提供方法
 * Outputs 提供 Observable
 *
 */

protocol XTRecommendViemModelInputs {

  /// 加载数据
  /// - Parameter isRefreshing: 是否为刷新,**true**就加入到头部,**false**加入尾部
  func loadData(_ isRefreshing: Bool)
  /// 界面已经加载
  func viewDidload()
}

protocol XTRecommendViemModelOutPuts {
  /// 数据源数组
  var dataSource: Driver<[SectionModel<String, UserActivity>]> { get }
}

protocol XTRecommendViemModelType {
  var inputs: XTRecommendViemModelInputs { get }
  var outputs: XTRecommendViemModelOutPuts { get }
}

class XTRecommendViemModel: XTRecommendViemModelType, XTRecommendViemModelInputs, XTRecommendViemModelOutPuts {

  let refreshStauts = BehaviorRelay<RefreshStatus>(value: .none)
  // 协议
  var inputs: XTRecommendViemModelInputs { return self }
  var outputs: XTRecommendViemModelOutPuts { return self }
  // inputs
  func loadData(_ isRefreshing: Bool) {
    // 结束上次的刷新状态
    refreshStauts.accept(isRefreshing ? .endFooterRefresh : .endHeaderRefresh)
    loadDataProperty.onNext(isRefreshing)
  }
  func viewDidload() {
    refreshStauts.accept(.hiddendFooter)
    refreshStauts.accept(.begainHeaderRefresh)
  }
  // outputs
  var dataSource: Driver<[SectionModel<String, UserActivity>]> {
    let loadNewCommand = loadDataProperty
      .filter { $0 }
      .asDriver { _ -> Driver<Bool> in
        return Driver.just(true)
      }
      .flatMap { [weak self] _ -> Driver<[UserActivity]> in
        guard let `self` = self else { return Driver<[UserActivity]>.just([]) }
        return self.queryNewData()
      }
      .flatMap { [weak self] items -> Driver<XTRecommendViemModel.EditeDataCommand> in
        self?.refreshStauts.accept(.endHeaderRefresh)
        self?.refreshStauts.accept(.showFooter)
        return Driver.just(EditeDataCommand.loadNewData(items: items))
      }
    let loadMoreCommand = loadDataProperty
      .filter { !$0 }
      .asDriver { _ in Driver.just(true) }
      .flatMap { [weak self] _ -> Driver<[UserActivity]> in
        guard let `self` = self else { return Driver<[UserActivity]>.just([]) }
        return self.queryNextPageData()
      }
      .flatMap { [weak self] items -> Driver<XTRecommendViemModel.EditeDataCommand> in
        // 判断 items 和 noNext 字段
        // 选择 hiddendFooter, endFooterRefresh, endFooterRefreshWithNoData
        self?.refreshStauts.accept(.endFooterRefresh)
        return Driver.just(EditeDataCommand.loadOldData(items: items))
      }
    let initialWrappedModel = RecommendWrappedModel()
    let dataSource = Driver.of(loadNewCommand, loadMoreCommand)
      .merge()
      .scan(initialWrappedModel) { (resultWrapped: RecommendWrappedModel, command: EditeDataCommand) -> RecommendWrappedModel in
        return resultWrapped.execute(command: command)
      }
      .map { wrappedModel -> [SectionModel<String, UserActivity>] in
        return [SectionModel(model: "XTRemSectionModel", items: wrappedModel.items)]
      }
    return dataSource
  }

  // private
  private let loadDataProperty: PublishSubject<Bool> = PublishSubject()
  // 网络请求
  //private lazy var recommendProvider = { MoyaProvider<JueJinGraphqlAPI>() }()
}

/// 这里就是刷新状态控制的协议
extension XTRecommendViemModel: Refreshable {}

// MARK: 真实网路请求
private extension XTRecommendViemModel {
  func queryNewData() -> Driver<[UserActivity]> {
    /* 网络请求被本地数据替换,公开所爬接口是不道德的 */
    let items = readerLoadData()
    let result = Driver<[UserActivity]>.just(items).delay(.milliseconds(1500))
    return result
  }
  func queryNextPageData() -> Driver<[UserActivity]> {
    let items = readerLoadData()
    let result = Driver<[UserActivity]>.just(items).delay(.milliseconds(1500))
    return result
  }
}

// MARK: 生成指定形式的数据
private extension XTRecommendViemModel {
  // 加载本地数据
  func readerLoadData() -> [UserActivity] {
    let model = UserActivity.modelFromLocal()
    let result = model.shuffled().suffix(10)
    return Array(result)
  }
}

// MARK: 定义数据的转换方式
private extension XTRecommendViemModel {
  enum EditeDataCommand {
    /// instert 到头部
    case loadNewData(items: [UserActivity])
    /// append 到尾部
    case loadOldData(items: [UserActivity])
  }

  /// 内部数据存储的数据结构
  struct RecommendWrappedModel {
    fileprivate var items: [UserActivity]
    init(items: [UserActivity] = []) {
      self.items = items
    }
    /// 核心
    func execute(command: EditeDataCommand) -> RecommendWrappedModel {
      switch command {
      case let .loadNewData(insertItems):
        return RecommendWrappedModel(items: insertItems)
      case let .loadOldData(appedItems):
        var tmpArray = self.items
        tmpArray.append(contentsOf: appedItems)
        return RecommendWrappedModel(items: tmpArray)
      }
    }
  }
// The end
}

真实行数为 170 带有注释

# ViewModel 的解析

由于是第一次用,我这注释应该还算详实吧😅。

Input 协议指定了 VC 能够调用的方法,Output 则是在内部处理数据后向 VC 提供数据。然后定义了一个新的协议 XTRecommendViemModelType 内部需要遵守协议者提供 inputsoutputs 属性, 再让 XTRecommendViemModel 同时遵守着三个协议,其 inputsoutputs 都返回 self

class XTRecommendViemModel: XTRecommendViemModelType, XTRecommendViemModelInputs, XTRecommendViemModelOutPuts {
	...
	// 协议
  	var inputs: XTRecommendViemModelInputs { return self }
  	var outputs: XTRecommendViemModelOutPuts { return self }
}

这样外部在使用时需要向 ViewModel 传递信息就使用 viewModel.inputs.xxx , 需要 ViewModel 提供信息就通过 viewModel.outputs.xxx,分离职责,代码逻辑分层。

Inputs 中的

func loadData(_ isRefreshing: Bool)

对应的是 XTRecommendViemModel 中的

private let loadDataProperty: PublishSubject<Bool> = PublishSubject()

VC 每次调用 loadData(:) 就会使 loadDataProperty 发送一次 stream, XTRecommendViemModeldataSource 就通过 flatMap 这个 PublishSubject<Bool> 而生成的。

这里先按下不表,让我们来看看 XTRecommendViemModel 中真正对数据进行处理和存储的类型 struct RecommendWrappedModelenum EditeDataCommand

EditeDataCommand 定义了对数据操作的类型,我在这里仅仅定义了刷新数据的 loadNew 和添加数据的 loadMore ,实际上是可以根据需求拓展出 reloadIndex 等。

enum EditeDataCommand {
  /// instert 到头部
  case loadNewData(items: [UserActivity])
  /// append 到尾部
  case loadOldData(items: [UserActivity])
}

RecommendWrappedModel 内部的数组 var items: [UserActivity] 就是数据存储的位置, 其对外部只提供

func execute(command: EditeDataCommand) -> RecommendWrappedModel {...}

方法用于操作数据

/// 核心
func execute(command: EditeDataCommand) -> RecommendWrappedModel {
  switch command {
  case let .loadNewData(insertItems):
    return RecommendWrappedModel(items: insertItems)
  case let .loadOldData(appedItems):
    var tmpArray = self.items
    tmpArray.append(contentsOf: appedItems)
    return RecommendWrappedModel(items: tmpArray)
  }
}

现在来看看 dataSoure 是如何生成的。

先看 loadNewCommand

let loadNewCommand = loadDataProperty
  .filter { $0 } // true 进入下一步, false 过滤
  .asDriver { _ -> Driver<Bool> in // 当成一次 driver 信号
    return Driver.just(true)
  }
  .flatMap { [weak self] _ -> Driver<[UserActivity]> in // 内部请求网络数据(异步)
    guard let `self` = self else { return Driver<[UserActivity]>.just([]) }
    return self.queryNewData()
  }
  .flatMap { [weak self] items -> Driver<XTRecommendViemModel.EditeDataCommand> in 
  	 // 根据数据的结果发送 刷新状态
    self?.refreshStauts.accept(.endHeaderRefresh)
    self?.refreshStauts.accept(.showFooter)
    // 将结果转换为 command 命令
    return Driver.just(EditeDataCommand.loadNewData(items: items))
  }

同样的 loadMoreCommand:

let loadMoreCommand = loadDataProperty
  .filter { !$0 } // 为 false 表示加载更多
  .asDriver { _ in Driver.just(true) }
  // 内部发送网络请求(异步)
  .flatMap { [weak self] _ -> Driver<[UserActivity]> in
    guard let `self` = self else { return Driver<[UserActivity]>.just([]) }
    // 这里可以发送当前网络请求的状态 类似于 刷新状态的控制
    return self.queryNextPageData()
  }
  .flatMap { [weak self] items -> Driver<XTRecommendViemModel.EditeDataCommand> in
    // 判断 items 和 noNext 字段
    // 选择 hiddendFooter, endFooterRefresh, endFooterRefreshWithNoData
    self?.refreshStauts.accept(.endFooterRefresh)
    // 转换为 command 消息
    return Driver.just(EditeDataCommand.loadOldData(items: items))
  }

然后是把这两个 commandstream 通过 merge 操作符进行合并,再通过 scan 操作符将每次产生的每次产生的 command 通过 initialWrappedModelexecute(command:) 进行数据处理,然后迭代 wrappedModel,最后在通过 map 方法把 wrappedModel 中的 items 包裹为 SectionModel

let dataSource = Driver.of(loadNewCommand, loadMoreCommand)
  .merge()
  .scan(initialWrappedModel) { (resultWrapped: RecommendWrappedModel, command: EditeDataCommand) -> RecommendWrappedModel in
    return resultWrapped.execute(command: command)
  }
  .map { wrappedModel -> [SectionModel<String, UserActivity>] in
    return [SectionModel(model: "XTRemSectionModel", items: wrappedModel.items)]
  }
return dataSource

这样我们就生成了 VC 所需要的数据源。 viewModel 每次调用 loadData(:) 进行一次 input 操作,就可以在内部转换为一次 outputstream

# 对 MJRefresh 的 Rx 拓展

private var kRxRefreshCommentKey: UInt8 = 0

public extension Reactive where Base: MJRefreshComponent {
  var refreshing: ControlEvent<Void> {
    let source: Observable<Void> = lazyInstanceObservable(&kRxRefreshCommentKey) { () -> Observable<()> in
      Observable.create { [weak control = self.base] observer in
        if let control = control {
          control.refreshingBlock = {
            observer.on(.next(()))
          }
        } else {
          observer.on(.completed)
        }
        return Disposables.create()
      }
      .takeUntil(self.deallocated)
      .share(replay: 1)
    }
    return ControlEvent(events: source)
  }
  
  private func lazyInstanceObservable<T: AnyObject>(_ key: UnsafeRawPointer, createCachedObservable: () -> T) -> T {
    if let value = objc_getAssociatedObject(self.base, key) as? T {
      return value
    }
    let observable = createCachedObservable()
    objc_setAssociatedObject(self.base, key, observable, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    return observable
  }
}

这里是模仿 RxCocoaUIBarButton 进行的拓展,目的是防止类似于一个 header.rx 被重复订阅,导致每次都生成一个 Observer 使前面的订阅不起效果(备注: 不推荐这样实现,这种类似于 target-action 的模式本身就应该只对一个订阅者起效果!)。

# 对刷新状态的控制

public enum RefreshStatus {
  case none, begainHeaderRefresh, endHeaderRefresh
  case hiddendFooter, showFooter, endFooterRefresh, endFooterRefreshWithNoData
}

public protocol Refreshable {
  var refreshStauts: BehaviorRelay<RefreshStatus> { get }
}

public extension Refreshable {
  func refreshStatusBind(to scrollView: UIScrollView) -> Disposable {
    return refreshStauts.subscribe(onNext: { [weak scrollV = scrollView] status in
      switch status {
      case .none:
        break
      case .begainHeaderRefresh:
        scrollV?.mj_header?.beginRefreshing()
      case .endHeaderRefresh:
        scrollV?.mj_header?.endRefreshing()
      case .hiddendFooter:
        scrollV?.mj_footer?.isHidden = true
      case .showFooter:
        scrollV?.mj_footer?.isHidden = false
      case .endFooterRefresh:
        scrollV?.mj_footer?.endRefreshing()
      case .endFooterRefreshWithNoData:
        scrollV?.mj_footer?.endRefreshingWithNoMoreData()
      }
    })
  }
}

XTRecommendViemModelOutputs 中作如下修改

protocol XTRecommendViemModelOutPuts: Refreshable {
    ...
}

就可以直接在 VC 中通过如下调用

viewModel.outputs.refreshStatusBind(to: self.tableView).disposed(by: disposeBag)

来控制 tableView刷新状态。

类似的还可以添加空数据展示视图等。

# 总结

demo 还有很多可以改进的地方

  • tableView 的数据源现在存在两份, 一份是 viewModel 中,一份是 RxTableViewReloadDataSource 中,在改的话会将 ViewModel 中混入 View 的管理所以,我在这并没有将 tableViewdataSource 设置为 viewModel
  • cell 没有对用的 ViewModel

最后给出完整版本的链接,接口是不可能暴露出来的👺,用的是本地数据啦,想要真实数据,自己想办法喽,毕竟这只是一次练手项目😶。

demo

最重要的是要附上自己的学习途径,真的对不起呢,现在补充如下: