快速架構單向數據流的 Realm 應用

maosanwan 8年前發布 | 25K 次閱讀 iOS開發 Objective-C開發

來自: https://realm.io/cn/news/unidirectional-data-flow-in-swift/

你聽說過單向數據流嗎?但是你不知道它具體如何實現。或者你曾經嘗試過一些單向數據流的框架,但是它們都太復雜了?

本教程會教會你在不依賴現有的單向數據流的框架下,采用簡單的單向數據流風格來架構出一個基于Realm的應用。

概述

MVC 設計模式一直以來都是 Cocoa 開發的基石(也是一般 UI 開發的基石),但是近年來在網頁開發社區流行的其他一些可選項也層出不窮。其中一個就是單向數據流模式,正如 非死book 開發中使用的 React 和 Flux 模式一樣,單向數據流模式解決了復雜應用中雙向綁定的問題。

單向數據流模式也開始用于原生移動開發,它可以非常有效地簡化原來有著許多任務調度或者嚴重回調機制的代碼,而且可以避免許多因為維護視圖控制器中互斥的狀態變量而帶來的軟件缺陷。

作為一個例子,我們準備實現一個簡單的時間跟蹤應用。它會支持:

  • 創建一個新的項目
  • 開始和結束的操作
  • 可以看到每個項目的消耗時間
  • 刪除一個項目

單向數據流介紹

快速架構單向數據流的 Realm 應用

這個風格的關鍵因素:

  • 把你的應用狀態封裝到單一的數據結構中 - ‘真理的唯一來源’(存儲)。
  • 確保所有狀態的改變(操作)都直接操作該數據結構,然后廣播通知。
  • 確保視圖只在收到狀態改變的時候更新。

更多詳情,請看 Benjamin Encz’s單向數據流入門

讓我們開始吧,但是如果你想直接看最后的代碼,你可以看 GitHub 這里 .

教程

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

用你喜歡的依賴解決的方式加入 Realm 和 RealmSwift 框架(關于 CocoaPods, Carthage,和 binary 安裝的步驟請看這里)。

加入一個新的 Swift 的文件叫做 ‘Store.swift’,然后創建 Project 和 Activity 的 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
    }
}

接下來我們要創建存儲。好消息是 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 上把它們和 nameLabel , elapsedTimeLabel 和 activityButton 的 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 視圖層
 本文由用戶 maosanwan 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!