0°

单向数据流动的函数式 View Controller(下)

内容预览:
  • 我们知道,任何新的状态都是在原有状态的基础上通过一些改变所得到的~
  • 基本上就是将原来 TableViewController 的 Data Source 部分的代码搬过...~
  • 除此之外,如果你愿意,你也可以写出各种状态间的转换,覆盖尽可能多的...~

始发于微信公众号: 程序员大咖

对 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<Result>(_ 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 的架构类似于下图:


单向数据流动的函数式 View Controller(下)

单向数据流动的函数式 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[..<index] + oldTodos[(index + 1)…]), owner: state.dataSource.owner)

    case .loadToDos:

        command = Command.loadToDos { data in

            // 发送额外的 .addToDos

        }

    }

    return (state, command)

}


对于 .updateText,.addToDos 和 .removeToDo,我们都只是根据已有状态衍生出新的状态。唯一值得注意的是 .loadToDos,它将让 reducer 函数返回非空的 Command。


接下来我们需要一个存储状态和响应 Action 的类型,我们将它叫做 Store:


class Store<A: ActionType, S: StateType, C: CommandType> {

    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<Action, State, Command>!

    

    override func viewDidLoad() {

        super.viewDidLoad()

        

        let dataSource = TableViewControllerDataSource(todos: [], owner: self)

        store = Store<Action, State, Command>(reducer: reducer, initialState: State(dataSource: dataSource, text: “”))

        

        // 订阅 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(下)

  • 程序员大咖整理发布,转载请联系作者获得授权。

↙点击“阅读原文”,加入 

『iOS开发』

和大佬一起学习网络安全知识

以上就是:单向数据流动的函数式 View Controller(下) 的全部内容

本站部分内容来源于互联网和用户投稿,如有侵权请联系我们删除,谢谢^^
Email:[email protected]


0 条回复 A 作者 M 管理员
    所有的伟大,都源于一个勇敢的开始!
欢迎您,新朋友,感谢参与互动!欢迎您 {{author}},您在本站有{{commentsCount}}条评论