对 View Controller 的进一步改造  在着手大幅调整代码之前,我想先介绍一些基本概念。  什么是纯函数  纯函数 (Pure Function) 是指一个函数如果有相同的输入,则它产生相同的输出。换言之,也就是一个函数的动作不依赖于外部变量之类的状态,一旦输入给定,那么输出则唯一确定。对于 app 而言,我们总是会和一定的用户输入打交道,也必然会需要按照用户的输入和已知状态来更新 UI 作为“输出”。所以在 app 中,特别是 View Controller 中操作 UI 的部分,我会倾向于将“纯函数”定义为:在确定的输入下,某个函数给出确定的 UI。  上面的 State 为我们打造一个纯函数的 View Controller 提供了坚实的一步,但是它还并不是纯函数。对于任意的新的 state,输出的 UI 在一定程度上还是依赖于原来的 state。不过我们可以通过将原来的 state 提取出来,换成一个用于更新 UI 的纯函数,即可解决这个问题。新的函数签名看起来大概会是这样:  func updateViews(state: State, previousState: State?)  这样,当我们给定原状态和现状态时,将得到确定的 UI,我们稍后会来看看这个方法的具体实现。  单向数据流  我们想要对 State View Controller 做的另一个改进是简化和统一状态维护的相关工作。我们知道,任何新的状态都是在原有状态的基础上通过一些改变所得到的。举例来说,在待办事项的 demo 中,新加一个待办意味着在原状态的 state.todos 的基础上,接收到用户的添加的行为,然后在数组中加上待办事项,并输出新的状态:  if userWantToAddItem {  state.todos = state.todos + [item]  }  其他的操作也皆是如此。将这个过成进行一些抽象,我们可以得到这样一个公式:  新状态 = f(旧状态, 用户行为)  或者用 Swift 的语言,就是:  func reducer(state: State, userAction: Action) -> State  如果你对函数式编程有所了解,应该很容易看出,这其实就是 reduce 函数的 transformer,它接受一个已有状态 State 和一个输入 Action,将 Action 作用于 state,并给出新的 State。结合 Swift 标准库中的 reduce 的函数签名,我们可以轻而易举地看到两者的关联:  func reduce(_ initialResult: Result,  _ nextPartialResult: (Result, Element) throws -> Result) rethrows -> Result  其中 reducer 对应的正是 reduce 中的 nextPartialResult 部分,这也是我们将它称为 reducer 的原因。  有了 reducer(state: State, userAction: Action) -> State,接下来我们就可以将用户操作抽象为 Action,并将所有的状态更新集中处理了。为了让这个过程一般化,我们会统一使用一个 Store 类型来存储状态,并通过向 Store 发送 Action 来更新其中的状态。而希望接收到状态更新的对象 (这个例子中是 TableViewController 实例) 可以订阅状态变化,以更新 UI。订阅者不参与直接改变状态,而只是发送可能改变状态的行为,然后接受状态变化并更新 UI,以此形成单向的数据流动。而因为更新 UI 的代码将会是纯函数的,所以 View Controller 的 UI 也将是可预期及可测试的。  异步状态  对于像 ToDoStore.shared.getToDoItems 这样的异步操作,我们也希望能够纳入到 Action 和 reducer 的体系中。异步操作对于状态的立即改变 (比如设置 state.loading 并显示一个 Loading Indicator),我们可以通过向 State 中添加成员来达到。要触发这个异步操作,我们可以为它添加一个新的 Action,相对于普通 Action 仅仅只是改变 state,我们希望它还能有一定“副作用”,也就是在订阅者中能实际触发这个异步操作。这需要我们稍微更新一下 reducer 的定义,除了返回新的 State 以外,我们还希望对异步操作返回一个额外的 Command:  func reducer(state: State, userAction: Action) -> (State, Command?)  Command 只是触发异步操作的手段,它不应该和状态变化有关,所以它没有出现在 reducer 的输入一侧。如果你现在不太理解的话也没有关系,先只需要记住这个函数签名,我们会在之后的例子中详细地看到这部分的工作方式。  将这些结合起来,我们将要实现的 View Controller 的架构类似于下图:

  使用单向数据流和 reducer 改进 View Controller  准备工作够多了,让我们来在 State View Controller 的基础上进行改进吧。  为了能够尽量通用,我们先来定义几个协议:  protocol ActionType {}  protocol StateType {}  protocol CommandType {}  除了限制协议类型以外,上面这几个 protocol 并没有其他特别的意义。接下来,我们在 TableViewController 中定义对应的 Action,State 和 Command:  class TableViewController: UITableViewController {  struct State: StateType {  var dataSource = TableViewControllerDataSource(todos: [], owner: nil)  var text: String = ""  }  enum Action: ActionType {  case updateText(text: String)  case addToDos(items: [String])  case removeToDo(index: Int)  case loadToDos  }  enum Command: CommandType {  case loadToDos(completion: ([String]) -> Void )  }  //...  }  为了将 dataSource 提取出来,我们在 State 中把原来的 todos 换成了整个的 dataSource。TableViewControllerDataSource 就是标准的 UITableViewDataSource,它包含 todos 和用来作为 inputCell 设定 delegate 的 owner。基本上就是将原来 TableViewController 的 Data Source 部分的代码搬过去,部分关键代码如下:  class TableViewControllerDataSource: NSObject, UITableViewDataSource {  var todos: [String]  weak var owner: TableViewController?  init(todos: [String], owner: TableViewController?) {  self.todos = todos  self.owner = owner  }  //...  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {  //...  let cell = tableView.dequeueReusableCell(withIdentifier: inputCellReuseId, for: indexPath) as! TableViewInputCell  cell.delegate = owner  return cell  }  }  这是基本的将 Data Source 分离出 View Controller 的方法,本身很简单,也不是本文的重点。  注意 Command 中包含的 loadToDos 成员,它关联了一个方法作为结束时的回调,我们稍后会在这个方法里向 store 发送 .addToDos 的 Action。  准备好必要的类型后,我们就可以实现核心的 reducer 了:  func reducer(state: State, action: Action) -> (state: State, command: Command?) {  var state = state  var command: Command? = nil  switch action {  case .updateText(let text):  state.text = text  case .addToDos(let items):  state.dataSource = TableViewControllerDataSource(todos: items + state.dataSource.todos, owner: state.dataSource.owner)  case .removeToDo(let index):  let oldTodos = state.dataSource.todos  state.dataSource = TableViewControllerDataSource(todos: Array(oldTodos[..  case .loadToDos:  command = Command.loadToDos { data in  // 发送额外的 .addToDos  }  }  return (state, command)  }  对于 .updateText,.addToDos 和 .removeToDo,我们都只是根据已有状态衍生出新的状态。唯一值得注意的是 .loadToDos,它将让 reducer 函数返回非空的 Command。  接下来我们需要一个存储状态和响应 Action 的类型,我们将它叫做 Store:  class Store {  let reducer: (_ state: S, _ action: A) -> (S, C?)  var subscriber: ((_ state: S, _ previousState: S, _ command: C?) -> Void)?  var state: S  init(reducer: @escaping (S, A) -> (S, C?), initialState: S) {  self.reducer = reducer  self.state = initialState  }  func dispatch(_ action: A) {  let previousState = state  let (nextState, command) = reducer(state, action)  state = nextState  subscriber?(state, previousState, command)  }  func subscribe(_ handler: @escaping (S, S, C?) -> Void) {  self.subscriber = handler  }  func unsubscribe() {  self.subscriber = nil  }  }  千万不要被这些泛型吓到,它们都非常简单。这个 Store 接受一个 reducer 和一个初始状态 initialState 作为输入。它提供了 dispatch 方法,持有该 store 的类型可以通过 dispatch 向其发送 Action,store 将根据 reducer 提供的方式生成新的 state 和必要的 command,然后通知它的订阅者。  在 TableViewController 中增加一个 store 变量,并在 viewDidLoad 中初始化它:  class TableViewController: UITableViewController {  var store: Store  override func viewDidLoad() {  super.viewDidLoad()  let dataSource = TableViewControllerDataSource(todos: [], owner: self)  store = Store  // 订阅 store  store.subscribe { [weak self] state, previousState, command in  self?.stateDidChanged(state: state, previousState: previousState, command: command)  }  // 初始化 UI  stateDidChanged(state: store.state, previousState: nil, command: nil)  // 开始异步加载 ToDos  store.dispatch(.loadToDos)  }  //...  }  将 stateDidChanged 添加到 store.subscribe 后,每次 store 状态改变时,stateDidChanged 都将被调用。现在我们还没有实现这个方法,它的具体内容如下:  func stateDidChanged(state: State, previousState: State?, command: Command?) {  if let command = command {  switch command {  case .loadToDos(let handler):  ToDoStore.shared.getToDoItems(completionHandler: handler)  }  }  guard let previousState = previousState else { return }  if previousState.dataSource.todos != state.dataSource.todos {  let dataSource = state.dataSource  tableView.dataSource = dataSource  tableView.reloadData()  title = "TODO - ((dataSource.todos.count))"  }  if (previousState.text != state.text) {  let isItemLengthEnough = state.text.count >= 3  navigationItem.rightBarButtonItem?.isEnabled = isItemLengthEnough  let inputIndexPath = IndexPath(row: 0, section: TableViewControllerDataSource.Section.input.rawValue)  let inputCell = tableView.cellForRow(at: inputIndexPath) as? TableViewInputCell  inputCell?.textField.text = state.text  }  }  同时,我们就可以把之前 Command.loadTodos 的回调补全了:  func reducer(state: State, action: Action) -> (state: State, command: Command?) {  var state = state  var command: Command? = nil  switch action {  // ...  case .loadToDos:  command = Command.loadToDos { data in  // 发送额外的 .addToDos  self.store.dispatch(.addToDos(items: data))  }  }  return (state, command)  }  stateDidChanged 现在是一个纯函数式的 UI 更新方法,它的输出 (UI) 只取决于输入的 state 和 previousState。另一个输入 Command 负责触发一些不影响输出的“副作用”,在实践中,除了发送请求这样的异步操作外,View Controller 的转换,弹窗之类的交互都可以通过 Command 来进行。Command 本身不应该影响 State 的转换,它需要通过再次发送 Action 来改变状态,以此才能影响 UI。  到这里,我们基本上拥有所有的部件了。最后的收尾工作相当容易,把之前的直接的状态变更代码换成事件发送即可:  override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {  guard indexPath.section == TableViewControllerDataSource.Section.todos.rawValue else { return }  store.dispatch(.removeToDo(index: indexPath.row))  }  @IBAction func addButtonPressed(_ sender: Any) {  store.dispatch(.addToDos(items: [store.state.text]))  store.dispatch(.updateText(text: ""))  }  func inputChanged(cell: TableViewInputCell, text: String) {  store.dispatch(.updateText(text: text))  }  测试纯函数式 View Controller  折腾了这么半天,归根结底,其实我们想要的是一个高度可测试的 View Controller。基于高度可测试性,我们就能拥有高度的可维护性。stateDidChanged 现在是一个纯函数,与 controller 的当前状态无关,测试它将非常容易:  func testUpdateView() {  let state1 = TableViewController.State(  dataSource:TableViewControllerDataSource(todos: [], owner: nil),  text: ""  )  // 从 nil 状态转换为 state1  controller.stateDidChanged(state: state1, previousState: nil, command: nil)  XCTAssertEqual(controller.title, "TODO - (0)")  XCTAssertEqual(controller.tableView.numberOfRows(inSection: TableViewControllerDataSource.Section.todos.rawValue), 0)  XCTAssertFalse(controller.navigationItem.rightBarButtonItem!.isEnabled)  let state2 = TableViewController.State(  dataSource:TableViewControllerDataSource(todos: ["1", "3"], owner: nil),  text: "Hello"  )  // 从 state1 状态转换为 state2  controller.stateDidChanged(state: state2, previousState: state1, command: nil)  XCTAssertEqual(controller.title, "TODO - (2)")  XCTAssertEqual(controller.tableView.numberOfRows(inSection: TableViewControllerDataSource.Section.todos.rawValue), 2)  XCTAssertEqual(controller.tableView.cellForRow(at: todoItemIndexPath(row: 1))?.textLabel?.text, "3")  XCTAssertTrue(controller.navigationItem.rightBarButtonItem!.isEnabled)  }  作为单元测试,能覆盖产品代码就意味着覆盖了绝大多数使用情况。除此之外,如果你愿意,你也可以写出各种状态间的转换,覆盖尽可能多的边界情况。这可以保证你的代码不会因为新的修改发生退化。  虽然我们没有明说,但是 TableViewController 中的另一个重要的函数 reducer 也是纯函数。对它的测试同样简单,比如:  func testReducerUpdateTextFromEmpty() {  let initState = TableViewController.State()  let state = controller.reducer(state: initState, action: .updateText(text: "123")).state  XCTAssertEqual(state.text, "123")  }  输出的 state 只与输入的 initState 和 action 有关,它与 View Controller 的状态完全无关。reducer 中的其他方法的测试如出一辙,在此不再赘言。  最后,让我们来看看 State View Controller 中没有被测试的加载部分的内容。由于现在加载新的待办事项也是由一个 Action 来触发的,我们可以通过检查 reducer 返回的 Command 来确认加载的结果:  func testLoadToDos() {  let initState = TableViewController.State()  let (_, command) = controller.reducer(state: initState, action: .loadToDos)  XCTAssertNotNil(command)  switch command! {  case .loadToDos(let handler):  handler(["2", "3"])  XCTAssertEqual(controller.store.state.dataSource.todos, ["2", "3"])  // 现在 Command 只有 .loadToDos 一个命令。如果存在多个 Command,可以去下面的注释,  // 这样在命令不符时可以让测试失败  // default:  // XCTFail("The command should be .loadToDos")  }  }  可能有同学会有疑问,认为这里没有测试 ToDoStore.shared.getToDoItems。但是记住,我们这里要测试的是 View Controller,而不是网络层。对于 ToDoStore 的测试应该放在单独的地方进行。  你可以在 GitHub repo 的 reducer 分支中找到对应这部分的代码。  总结  可能你已经见过类似的单向数据流的方式了,比如 Redux,或者更古老一些的 Flux。甚至在 Swift 中,也有 ReSwift 实现了类似的想法。在这篇文章中,我们保持了基本的 MVC 架构,而使用了这种方法改进了 View Controller 的设计。  在例子中,我们的 Store 位于 View Controller 中。其实只要存在状态变化,这套方式可以在任何地方适用。你完全可以在其他的层级中引入 Store。只要能保证数据的单向流动,以及完整的状态变更覆盖测试,这套方式就具有良好的扩展性。  相对于大刀阔斧地改造,或者使用全新的设计模式,这种稍微小一些改进更容易在日常中进行探索和实践,它不存在什么外部依赖,可以被直接用在新建的 View Controller 中,你也可以逐步将已有类进行改造。毕竟绝大多数 iOS 开发者可能都会把大量时间花在 View Controller 上,所以能否写出易于测试,易于维护的 View Controller,多少将决定一个 iOS 开发者的幸福程度。所以花一些时间琢磨如何写好 View Controller,应该是每个 iOSer 的必修课。  一些推荐的参考资料

  如果你对函数式编程的一些概念感兴趣,不妨看看我和一些同仁翻译的《函数式 Swift》一书,里面对像是值类型、纯函数、引用透明等特性进行了详细的阐述。如果你想更多接触一些类似的架构方法,我个人推荐研读一下 React 的资料,特别是如何以 React 的思想思考的相关内容。如果你还有余力,即使你日常每天还是做 CocoaTouch 的 native 开发,也不妨尝试用 React Native 来构建一些项目。相信你会在这个过程中开阔眼界,得到新的领悟。

\

本文链接:单向数据流动的函数式 View Controller(下)

您可能也会喜欢

友情链接:

经文 心经唱诵 大悲咒注音