【Kickstarter-iOS 源码分析】03 - MVVM 架构

Kickstarter-iOS源码地址 >>

Kickstarter-iOS 把 MVVM 模式贯彻地非常彻底。MVVM 的全称是 Model-View-ViewModel,所以我们可能会觉得要有 View 存在的地方,才可以用 ViewModel。但是 Kickstarter-iOS 在 AppDelegate 中也使用了 ViewModel,把很多在 AppDelegate 处理的逻辑剥离到 AppDelegateViewModelType 中。

这篇文章主要讲一下 Kickstarter-iOS 在使用 MVVM 架构时用到的一些很好的技巧。

所需知识

默认调用 bindViewModel()

在 MVVM 架构中,一般来说 ViewModel 是被 UIViewUIViewController 持有,而持有 ViewModel 的对象就需要绑定到 ViewModel,这样就能响应 ViewModel 中数据的变化,从而更新 UI。一般我们都会在持有 ViewModel 的对象中定义一个方法 bindViewModel(),并且在这个方法里面做绑定。

Kickstarter 分别在 UIViewUIViewController 做了一些处理,让程序在启动的时候就默认在各自内部的方法调用了 bindViewModel(),这样可以避免在很多的 View 和 ViewController 中写重复的代码。

UIView

对于 UIView,Kickstarter 通过扩展重写 awakeFromNib(),在内部调用 bindViewModel()。代码如下:

extension UIView {
  open override func awakeFromNib() {
    super.awakeFromNib()
    self.bindViewModel()
  }

  @objc open func bindViewModel() {
  }
}

因为 Kickstarter 在整个项目中都是通过 xib 来构建 UI 的,所以 UI 在初始化时,awakeFromNib()会被调用,从而 bindViewModel() 也被调用。那么在其他继承自 UIView 的 view 中,只需要重写 bindViewModel(),就能达到绑定 ViewModel 的目的。

UIViewController

UIViewController 中就会稍微复杂一点。Kickstarter 通过 runtime,默认在 viewDidLoad() 中调用 bindViewModel()。那么在其他继承自 UIViewController 的 ViewController 中,只需要重写 bindViewModel(),就能达到绑定 ViewModel 的目的。

UIViewController-Preparation.swift相关代码如下:

private func swizzle(_ vc: UIViewController.Type) {

  [
    (#selector(vc.viewDidLoad), #selector(vc.ksr_viewDidLoad)),
    (#selector(vc.viewWillAppear(_:)), #selector(vc.ksr_viewWillAppear(_:))),
    (#selector(vc.traitCollectionDidChange(_:)), #selector(vc.ksr_traitCollectionDidChange(_:))),
    ].forEach { original, swizzled in

      guard let originalMethod = class_getInstanceMethod(vc, original),
        let swizzledMethod = class_getInstanceMethod(vc, swizzled) else { return }

      let didAddViewDidLoadMethod = class_addMethod(vc,
                                                    original,
                                                    method_getImplementation(swizzledMethod),
                                                    method_getTypeEncoding(swizzledMethod))

      if didAddViewDidLoadMethod {
        class_replaceMethod(vc,
                            swizzled,
                            method_getImplementation(originalMethod),
                            method_getTypeEncoding(originalMethod))
      } else {
        method_exchangeImplementations(originalMethod, swizzledMethod)
      }
  }
}

private var hasSwizzled = false

extension UIViewController {
  final public class func doBadSwizzleStuff() {
    guard !hasSwizzled else { return }

    hasSwizzled = true
    swizzle(self)
  }

  @objc internal func ksr_viewDidLoad() {
    self.ksr_viewDidLoad()
    self.bindViewModel()
  }

  /**
   The entry point to bind all view model outputs. Called just before `viewDidLoad`.
   */
  @objc open func bindViewModel() {
  }
}

然后在 AppDelegate.swift中的 didFinishLaunchingWithOptions调用 doBadSwizzleStuff(),代码如下:

func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

    UIViewController.doBadSwizzleStuff()
}

通过这两个处理,就能避免编写大量的重复代码。

ViewModel

我从项目中找了一个代码量比较少的 ViewModel 文件 HelpWebViewModel.swift,以这个文件为例。具体代码如下:

import Library
import Prelude
import ReactiveSwift
import Result

internal protocol HelpWebViewModelInputs {
  /// Call to configure with HelpType.
  func configureWith(helpType: HelpType)

  /// Call when the view loads.
  func viewDidLoad()
}

internal protocol HelpWebViewModelOutputs {
  /// Emits a request that should be loaded into the webview.
  var webViewLoadRequest: Signal<URLRequest, NoError> { get }
}

internal protocol HelpWebViewModelType {
  var inputs: HelpWebViewModelInputs { get }
  var outputs: HelpWebViewModelOutputs { get }
}

internal final class HelpWebViewModel: HelpWebViewModelType, HelpWebViewModelInputs, HelpWebViewModelOutputs {
  internal init() {
    self.webViewLoadRequest = self.helpTypeProperty.signal.skipNil()
      .takeWhen(self.viewDidLoadProperty.signal)
      .map { urlForHelpType($0, baseUrl: AppEnvironment.current.apiService.serverConfig.webBaseUrl) }
      .skipNil()
      .map { AppEnvironment.current.apiService.preparedRequest(forURL: $0) }
  }

  internal var inputs: HelpWebViewModelInputs { return self }
  internal var outputs: HelpWebViewModelOutputs { return self }

  internal let webViewLoadRequest: Signal<URLRequest, NoError>

  fileprivate let helpTypeProperty = MutableProperty<HelpType?>(nil)
  func configureWith(helpType: HelpType) {
    self.helpTypeProperty.value = helpType
  }
  fileprivate let viewDidLoadProperty = MutableProperty(())
  func viewDidLoad() {
    self.viewDidLoadProperty.value = ()
  }
}

private func urlForHelpType(_ helpType: HelpType, baseUrl: URL) -> URL? {
  switch helpType {
  case .cookie:
    return baseUrl.appendingPathComponent("cookies")
  case .contact:
    return nil
  case .helpCenter:
    return baseUrl.appendingPathComponent("help")
  case .howItWorks:
    return baseUrl.appendingPathComponent("about")
  case .privacy:
    return baseUrl.appendingPathComponent("privacy")
  case .terms:
    return baseUrl.appendingPathComponent("terms-of-use")
  case .trust:
    return baseUrl.appendingPathComponent("trust")
  }
}

对于 ViewModel,我想说两点:1)使用 ReactiveSwift;2)使用 inputs 和 outputs 区分数据的输入和输出。

使用 ReactiveSwift

在文章的开头的 所需知识 部分,我就提到过,响应式编程非常适合 MVVM 架构。在 ViewModel 中,我们通常会使用 ReactiveSwift 或者 RxSwift 去定义一些属性,然后在 UIViewUIViewController中的 bindViewModel() 方法里面订阅那些属性的变化,然后更新 UI。

至于在开发过程中,我们该选择哪一种呢?我个人更偏向于 ReactiveSwift。大家可以看看这篇对比文章 How does ReactiveSwift relate to RxSwift?

使用 inputs 和 outputs 区分数据的输入和输出

在 ViewModel 中,我们需要接受外部的信息输入,并且告诉外部有哪些信息发生了变化。

Kickstarter-iOS 把信息的输入和输出分别用 HelpWebViewModelInputsHelpWebViewModelOutputs 分开,这样在使用 ViewModel 的时候就会非常清晰,不会把 inputs 和 outputs 混在一起。例如,我们在Xcode 中编写 viewModel.outputs. 时,Xcode 只会提示 webViewLoadRequest,而不会把属于 inputsviewDidLoad()也显示给我们。

这在我们使用 ViewModel 的时候带来了极大的便利,并且让看代码的人一目了然,哪些代码处理输入,哪些代码处理输出,非常清晰。

这里有一个讲解 MVVM & TDD 的视频,大家可以去看一下,需要自带梯子。

想及时看到我的新文章的,可以关注我。同时也欢迎加入我管理的Swift开发群:536353151

下一篇文章:【Kickstarter-iOS 源码分析】04 - Environment 和 AppEnvironment

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270