快速架构单向数据流的 Realm 应用

你听说过单向数据流吗?但是你不知道它具体如何实现。或者你曾经尝试过一些单向数据流的框架,但是它们都太复杂了?

本教程会教会你在不依赖现有的单向数据流的框架下,采用简单的单向数据流风格来架构出一个基于Realm的应用。

概述

MVC 设计模式一直以来都是 Cocoa 开发的基石(也是一般 UI 开发的基石),但是近年来在网页开发社区流行的其他一些可选项也层出不穷。其中一个就是单向数据流模式,正如 Facebook 开发中使用的 React 和 Flux 模式一样,单向数据流模式解决了复杂应用中双向绑定的问题。

单向数据流模式也开始用于原生移动开发,它可以非常有效地简化原来有着许多任务调度或者严重回调机制的代码,而且可以避免许多因为维护视图控制器中互斥的状态变量而带来的软件缺陷。

作为一个例子,我们准备实现一个简单的时间跟踪应用。它会支持:

  • 创建一个新的项目
  • 开始和结束的操作
  • 可以看到每个项目的消耗时间
  • 删除一个项目

单向数据流介绍

Unidirectional Data Flow

这个风格的关键因素:

  • 把你的应用状态封装到单一的数据结构中 - ‘真理的唯一来源’(存储)。
  • 确保所有状态的改变(操作)都直接操作该数据结构,然后广播通知。
  • 确保视图只在收到状态改变的时候更新。

更多详情,请看 Benjamin Encz’s 单向数据流入门

让我们开始吧,但是如果你想直接看最后的代码,你可以看 GitHub 这里.

教程

创建一个新的 Xcode 的工程,使用 “Single View Application” 模板。确定 “Language” 是 Swift,然后取消 “Use Core Data”。

用你喜欢的依赖解决的方式加入 Realm 和 RealmSwift 框架(关于 CocoaPods, Carthage,和 binary 安装的步骤请看 这里)。

加入一个新的 Swift 的文件叫做 ‘Store.swift’,然后创建 ProjectActivity 的 Realm 对象 - 这些会用来记录应用的状态。

import RealmSwift

class Project: Object {
    dynamic var name: String = ""
    let activities = List<Activity>()
}

class Activity: Object {
    dynamic var startDate: NSDate?
    dynamic var endDate: NSDate?
}

我们也就这个机会给 Project 类加上计算的属性,这会简化我们之后的编码。

extension Project {
    var elapsedTime: NSTimeInterval {
        return activities.reduce(0) { time, activity in
            guard let start = activity.startDate,
                let end = activity.endDate else { return time }
            return time + end.timeIntervalSinceDate(start)
        }
    }
    
    var currentActivity: Activity? {
        return activities.filter("endDate == nil").first
    }
}

Receive news and updates from Realm straight to your inbox

接下来我们要创建存储。好消息是 Realm 已经非常契合单向数据流存储的要求了,我们不需要再写许多模板代码来实现它了。

我们使用内嵌的 Realm 的变化通知机制来触发视图更新 - Realm 的后台线程会自动感知和触发更新通知。

首先,我们给 Realm 扩展些计算属性,它们会返回当前应用的状态 - 在我们应用里,是所有项目的一个列表。

// MARK: Application/View state
extension Realm {
    var projects: Results<Project> {
        return objects(Project.self)
    }	
}

接下来,我们创建一些操作,当然也是通过扩展 Realm。操作是唯一能够修改 Realm 中数据模型的方法,而且它们不可以有返回值 - 所有对模型的改变都会通过通知的方式广播给视图。这可以保证每次状态更新的时候视图都能一致地重绘,无论更新来自何处。

// MARK: Actions
extension Realm {
    func addProject(name: String) {
        do {
            try write {
                let project = Project()
                project.name = name
                add(project)
            }
        } catch {
            print("Add Project action failed: \(error)")
        }
    }
    
    func deleteProject(project: Project) {
        do {
            try write {
                delete(project.activities)
                delete(project)
            }
        } catch {
            print("Delete Project action failed: \(error)")
        }
    }
    
    func startActivity(project: Project, startDate: NSDate) {
        do {
            try write {
                let act = Activity()
                act.startDate = startDate
                project.activities.append(act)
            }
        } catch {
            print("Start Activity action failed: \(error)")
        }
    }
    
    func endActivity(project: Project, endDate: NSDate) {
        guard let activity = project.currentActivity else { return }
        
        do {
            try write {
                activity.endDate = endDate
            }
        } catch {
            print("End Activity action failed: \(error)")
        }
     }

}

在文件末尾,创建一个 Store 的实例。

let store = try! Realm()

现在让我们来实现视图层。打开你的 ViewController.swift 文件然后把它从 UIViewController 重命名为 UITableViewController 的子类。增加一个 projects 的属性并且重载 UITableViewDataSource 方法。我们也会增加一个 UITableViewCell 子类 - 注意在这里无论何时 project 属性变化了,每一个子视图的属性都会被重置;再强调一次,当数据模型变化时,需要确保每一个视图都是一致的更新,这点非常重要。

class ViewController: UITableViewController {

    let projects = store.projects
    	
    override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
        return 1
    }
    
    override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return projects.count
    }
    
    override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCellWithIdentifier("ProjectCell") as! ProjectCell
        cell.project = projects[indexPath.row]
        return cell
    }
}

class ProjectCell: UITableViewCell {
    @IBOutlet var nameLabel: UILabel!
    @IBOutlet var elapsedTimeLabel: UILabel!
    @IBOutlet var activityButton: UIButton!
    
    var project: Project? {
        didSet {
            guard let project = project else { return }
            nameLabel.text = project.name
            if project.currentActivity != nil {
                elapsedTimeLabel.text = "??"
                activityButton.setTitle("Stop", forState: .Normal)
            } else {
                elapsedTimeLabel.text = NSDateComponentsFormatter().stringFromTimeInterval(project.elapsedTime)
                activityButton.setTitle("Start", forState: .Normal)
            }
        }
    }
    
    @IBAction func activityButtonTapped() {
        guard let project = project else { return }
        if project.currentActivity == nil {
            // TODO: start a new activity
        } else {
            // TODO: complete the activity
        }
    }
}

下面我们会把视图控制器注册成为存储的观察者,这样我们就能在状态改变的时候重载视图了。在 ViewController 上实现 Realm 通知如下:


var notificationToken: NotificationToken?

override func viewDidLoad() {
    super.viewDidLoad()
    
    updateView()
    notificationToken = store.addNotificationBlock { [weak self] (_) in
        self?.updateView()
    }
}

func updateView() {
    tableView.reloadData()
}

现在我们用 Interface Builder 把所有的组件联系起来。打开 Main.storyboard,按如下步骤操作:

  • 删除当前的 View Controller界面
  • 从 Object Library 拖拽出 Navigation Controller。这会创建一个 UITableViewController 视图作为根导航。
  • 在 Navigation Controller 中选中 Is Initial View Controller
  • 选择根视图控制器然后把 Identity Inspector 上的定制类改为 ViewController
  • 选择 Table View 单元,然后把 Identity Inspector 上的定制类改为 ProjectCell。并且在 Attributes Inspector 上重用标示为 ProjectCell
  • 在 cell prototype 上放置两个 UILabel 和一个 UIButton,然后设置为 autolayout。在 ProjectCell 上把它们和 nameLabelelapsedTimeLabelactivityButton 的 outlets 连接起来。当你做完这些,请把 Activity 的按钮的 TouchUpInside 和 activityButtonTapped 操作连接起来。
  • 选择导航组件,然后把它的标题改成比 Root View Controller 更合适的名字。

这样,所有视图控制器的代码都完成了 - 无论何时状态改变,列表都会自动更新了。你现在可以编译和运行你的应用了,虽然没有那么激动人心,因为现在没有任何项目(而且没有办法添加任何项目)!

所以让我们来增加些操作来看看状态更新是如何工作的 - 我们开始增加一个新的项目。因为项目只需要一个名字,最容易的方法就是把 ‘add project’ 放到列表的表头上。

在 storyboard 里, 按照如下步骤创建 ‘add project’ 元素:

  • 在 Object Library 中拖拽出一个 Bar Button 到 navigation bar 的右边。Xcode 这里会有点不好用 - 我发现有时候在 Document Outline 里面拖拽到导航栏里会容易些(左手 Interface Builder pane)。
  • 在 Attributes Inspector 里,把 bar button 的 system item 设为 ‘Add’
  • 在 Table View 上再添加一个 View,然后增加一个 Text Field 和一个 button,然后配置成为 autolayout。
  • 把 button 的 title 改成 ‘Add’。
  • 打开 Assistant Editor 然后确保它显示的是文件 ‘ViewController.swift’。
  • Control-drag text field 到 ViewController.swift 源代码然后创建一个叫做 newProjectTextField 的 outlet
  • 在列表表头 Control-drag ‘Add’ 按钮到 ViewController.swift 然后创建一个新的 action 叫做 addButtonTapped。不要忘记把 drop-down 改为 ‘action’!
  • Control-drag ‘+’ bar 按钮到 ViewController.swift 然后创建一个新的 action 叫做 showNewProjectView。再强调一次,如果 Xcode 不好使用的话,使用 Document Outline。

隐藏 Assistant View 然后转到 ‘ViewController.swift’。 增加显示和隐藏表头的代码,在 addButtonTapped 方法里面调用存储的 addProject 方法。你也需要增加一个 hideNewProjectView() 方法来调用 stateDidUpdate

func updateView() {
    tableView.reloadData()
    hideNewProjectView()
}

@IBAction func showNewProjectView(sender: AnyObject) {
    tableView.tableHeaderView?.frame = CGRect(origin: CGPointZero, size: CGSize(width: view.frame.size.width, height: 44))
    tableView.tableHeaderView?.hidden = false
    tableView.tableHeaderView = tableView.tableHeaderView // tableHeaderView needs to be reassigned to recognize new height
    newProjectTextField.becomeFirstResponder()
}

func hideNewProjectView() {
    tableView.tableHeaderView?.frame = CGRect(origin: CGPointZero, size: CGSize(width: view.frame.size.width, height: 0))
    tableView.tableHeaderView?.hidden = true
    tableView.tableHeaderView = tableView.tableHeaderView
    newProjectTextField.endEditing(true)
    newProjectTextField.text = nil
}

@IBAction func addButtonTapped() {
    guard let name = newProjectTextField.text else { return }
    store.addProject(name)
}

如果你现在运行你的应用,你应该能增加新的项目了 - 太棒了!当 addProject 被调用的时候,列表自动更新了,尽管我们在 addButtonTapped 里面没有一行 UI 更新的代码 - 应用状态的改变会自动的影响到视图。这就是单向数据流的操作。

剩下的操作就非常直接了 - 我们可以在 ProjectCell.activityButtonTapped 里面增加开始和停止的逻辑:

@IBAction func activityButtonTapped() {
    guard let project = project else { return }
    if project.currentActivity == nil {
        store.startActivity(project, startDate: NSDate())
    } else {
        store.endActivity(project, endDate: NSDate())
    }
}

然后在 ViewController 里面重载合适的 UITableViewController 的方法来实现 swipe-to-delete:

override func tableView(tableView: UITableView, canEditRowAtIndexPath indexPath: NSIndexPath) -> Bool {
    return true
}

override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
    store.deleteProject(projects[indexPath.row])
}

就这么简答!编译然后运行你的超级有用的时间跟踪应用,拍拍自己的脑袋,你已经会实现单向数据流模式了。

小结

在本次教程里面,我们介绍了单向数据流的概念和如何使用内嵌的 Realm 通知功能来实现一个采纳单向数据流模式的应用。特别的,我们采用了这些原则:

  • 把所有的应用状态都存储在一个‘唯一真理来源’(Realm)里面
  • 只通过扩展 Realm 的操作方法来实现状态的改变
  • 保证所有的视图都在 Realm 更新通知到来的时候更新自己

聊聊未来

我们的应用有很多地方都可以改进,这些都是些非常值得考虑的地方,如果你在你自己的应用里面也采用这些技术的话。

  1. 响应式更新: 这个模式需要你使用一个 Functional Reactive 编程库来在你的应用里面分发状态更新。可以尝试尝试这些库 ReactiveCocoaRxSwift, 或者 ReactKit
  2. 本地状态: 你会注意到如果你在增加一个项目的过程中开始或者暂停另一个项目,你的项目的文字和按钮会消失。这是因为我们在每次应用状态更新的时候都重置了整个视图。如果你有些诸如此类的状态和视图相关而和应用状态无关,存在视图控制器里面是可以的。然后最好的实践是定义一个结构存储所有的状态,然后用一个单一的互斥属性。使用 Reactive 库会帮助你把本地更新时间和应用状态在一个句柄里实现。
  3. 计时器: 为了使事情更简单,我们在运行的活动中只显示了一个手表的表情。然而每秒更新 label 的显示更合理些。虽然在数据层做这个计算然后每秒都广播更新事件是可以实现的,但是这样系统负担太重了而且这也和应用状态无关(它仅仅是显示)。更好的方案是,采用一个定制的 iOS 类似的 WKInterfaceTimer 然后让 label 自己处理显示时间的问题。

现在你已经是单向数据流的专家了,看看当前已有的框架是非常值得的,看看能不能帮到你自己。请看:

  • ReSwift - 从 ReSwift 发展而来的 ReduxKit & Swift-Flow - 这有一个 router 项目来帮你开始。
  • SwiftFlux - 一个 Flux 的 Swift 实现
  • Few.swift - 用 Switf 尝试 React-style declarative 视图层

About the content

This content has been published here with the express permission of the author.


Realm Team

At Realm, our mission is to help developers build better apps faster. We provide a unique set of tools and platform technologies designed to make it easy for developers to build apps with sophisticated, powerful features — things like realtime collaboration, augmented reality, live data synchronization, offline experiences, messaging, and more.

Everything we build is developed with an eye toward enabling developers for what we believe the mobile internet evolves into — an open network of billions of users and trillions of devices, and realtime interactivity across them all.

4 design patterns for a RESTless mobile integration »

close