Swift, ReactiveCocoa第一個app

引言

這篇文章將紀錄用Swift語言配合ReactiveCocoa寫一個僞搜索引擎app的歷程。
大量參考了RayWenderlich.com上的文章(原文連接1 原文連接2)。原文是針對Objective C的,可是如今Swift都已經更新到了3.0(雖然由於做者沒有developer id,用的仍是2.2),ReactiveCocoa也更新到了4.2,原來的大多數技術都已經不能直接使用了(ReactiveCocoa的開發者甚至說在將來的5.0版本中要移除Objective C的支持)。做者爲此也走過很多彎路,參考了多方資料,最終完成了這個sample app,但願可讓別人少走一點彎路。
做者推薦新接觸iOS的開發者在學完基礎的swift以後直接使用ReactiveCocoa+Swift來寫代碼,這將節省大量的精力。react

ReactiveCocoa簡介(翻譯自RayWenderlich.com,有改動)

做爲iOS開發者,咱們寫的每一行代碼幾乎都是在和「事件」打交道,例如用戶點擊了一個按鈕,網絡上發來一條信息,一個屬性值的變化(Key Value Observation),或者是用戶的位置改變了。可是,CocoaTouch把這些事件以不一樣的格式封裝在了一塊兒,例如target-action, delegate, KVO, 回調之類的。這就給開發帶來了很大的麻煩,下降了代碼的可讀性,隨之而來的就是更多的維護成本和更多的bug。ReactiveCocoa把這一切封裝到了一個標準的接口中,這樣它們就能夠很容易地被組合、過濾。git

ReactiveCocoa把函數式編程和響應式編程組合在了一塊兒。github

函數式編程:這種編程方式使用了高階函數,也就是把其餘函數做爲參數的函數(做者認爲RayWenderlich.com的解釋不太好。做者認爲函數式編程就是一個不一樣的計算機架構方式。它注重數學在計算機科學中發揮的做用,把函數理解成真正的數學上的函數。其核心就是沒有Side Effect,也沒有變量。這很是好的避免了併發編程中的不少問題,於是在這兩年逐漸流行。Swift中有良好的函數式編程支持,其語法對著名而常用的Monad結構的支持甚至比經典的Haskell語言還好)編程

響應式編程:這種編程方式注重數據流的傳播和管道式的程序結構。這種結構也是爲了複雜的併發程序而生的,先天具備簡潔、安全的特色。swift

由於這個緣由,ReactiveCocoa也被稱爲是一個函數響應式編程框架。設計模式

這裏就再也不在學術上深究了,打開Xcode吧!xcode

程序結構設計

程序最終運行的效果以下:
效果圖安全

當用戶在文本框中輸入長度大於4的文本時,下方的列表就會顯示和用戶輸入字符長度相同的「搜索結果」(爲了保持簡單,這裏就直接生成了一些字符串,而不是去調用搜索引擎的API)。而且只有當用戶的輸入在0.5秒中沒有變化時,動做纔會被觸發。因爲很是簡單,不考慮錯誤處理。因爲真正的Web API須要訪問網絡,引起異步事件,這個程序若是使用普通的方法將具備至關的複雜性。網絡

程序內部將採用管道式,數據流通過管道以後最終將被一個UITableView顯示。因爲Swift中變量綁定的問題,程序並未採用MVVM設計模式,代碼中的ViewModel只是一個保存數據的容器。(將來將會改進爲MVVM模式)閉包

那麼就 開始吧!

第一步

創建一個iOS工程,類型是Single View Application,設備選擇iPhone(方便UI設計),語言選擇swift

安裝ReactiveCocoa

ReactiveCocoa官方推薦使用Carthage安裝。(固然CocoaPod用不了,緣由你懂的)Carthage安裝外部庫的操做很是簡單:

  1. 打開終端,定位到工程的根目錄下(即*.xcodeproj所在的地方),使用文本編輯器創建一個Cartfile
    在終端輸入nano Cartfile 在新創建的文件中加入一行github "ReactiveCocoa/ReactiveCocoa" Control-O保存,Control-X離開 回到終端,輸入命令carthage update 等待Carthage下載並編譯框架 (若是還沒有安裝Carthage,能夠到官網下載二進制文件,或者用Homebrew:brew install carthage)

  2. 打開工程文件,在General選項卡下加入剛剛編譯出的framework文件(注意到還有一個Result.framework):最終

  3. 在Build Phases選項卡下加入一個新的Run Script,並添加文件,最終看起來應該是這樣(命令須要手工輸入 若是不說Homebrew安裝的Carthage,路徑可能不同):最終

  4. 如今框架已經引入完了,能夠試驗一下,到ViewController.swift中輸入import R,Xcode的自動補全應當在這時給出ReactiveCocoa的提示,那就說明安裝完成了

設計UI,編寫Table View的代碼

UITableView是UIKit中操做較爲複雜的一個,但這個特性也讓它能夠不須要綁定就直接使用ReactiveCocoa的特性,所以在這裏選用它來作介紹。
不瞭解UITableView的操做並不影響接下去的閱讀,由於全部操做都被說明了

首先打開Main.storyboard,向場景中拖入一個Text Field。設置AutoLayout:左20,右20,上0。再拖入一個Table View,放在Text Field下方,設置AutoLayout:左20,右20,下20,上8。再拖入一個Table View Cell放在Table View裏面,拖入一個Label放在Cell裏面,設置AutoLayout:豎直居中,左50。整個場景看上去應該像這樣:
UI設計

以後,在StoryBoard中設置Cell的identifier爲ResultCell。咱們將在以後編寫完Cell的代碼以後改變這個Cell的其餘屬性。如圖:設置屬性

接下來咱們就來編寫Table View的代碼。

首先是Cell:

  1. 新建一個swift文件,命名爲TableViewCell.swift

  2. 定義一個類:TableViewCell,聲明爲UITableViewCell的子類,而且用Interface Builder鏈接以前在storyboard裏面建立的Cell中的Label。加入一個字符串常量,和以前輸入的identifier同樣。最終看起來應該是這樣(做者使用了本身的類前綴LF): Cell文件

  3. 在storyboard中更改Cell的類型:更改類型

這樣,Cell的就定義好了。這能夠被很容易地改成更復雜的情形。

以後是Table View自己的代碼。UITableView採用了Data Source - Update的模式。這種模式的實現須要一個Data Source。
在做者的實現中,須要先有一個ViewModel來封裝數據。爲此,創建一個swift文件,名爲MainViewModel.swift,並加入如下代碼:

struct MainViewModel {
    var resultCount: Int!
    var results: [String]!
    
    static func isValidSearchString(text text: String) -> Bool {
        return text.characters.count > 4
    }
    
    static func produceSearchResult(text text: String) -> [String] {
        return (1...text.characters.count).map {
            i in
            return "somebody \(i)"
        }
    }
}

這裏定義了封裝數據的格式,並提供了兩個輔助函數。
在這個例子中這兩個函數至關簡單,可是隨着代碼變得複雜,把操做聚合起來是頗有利的。

以後新建一個swift文件,命名爲ResultViewController.swift,加入如下代碼:

import UIKit

class LFResultViewController: NSObject, UITableViewDataSource {
    var viewModel = MainViewModel()
    
    @objc func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.resultCount
    }
    
    func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        var cellRetired = tableView.dequeueReusableCellWithIdentifier(LFTableViewCell.identifier) as? LFTableViewCell
        if cellRetired == nil {
            cellRetired = LFTableViewCell(style: .Default, reuseIdentifier: LFTableViewCell.identifier)
            cellRetired?.outputLabel = UILabel()
        }
        cellRetired?.outputLabel.text = self.viewModel.results[indexPath.row]
        return cellRetired!
    }
}

這裏實現了Table View更新時須要的數據的方法。具體的關於UITableViewDataSource協議的內容能夠查看Apple的官方文檔.
這個模式看起來比較複雜,不像其餘控件那樣直截了當的就是賦值。但這種模式對響應式編程和MVVM模式特別有利,做者在將來可能會爲整個UIKit開發這樣的接口來避免使用keypath。

構建管道

這裏先貼出ViewController.swift中其餘部分代碼:

import UIKit
import ReactiveCocoa

class ViewController: UIViewController {
    @IBOutlet weak var outputTableView: UITableView!

    @IBOutlet weak var searchTextInput: UITextField!
    
    var resultViewController = LFResultViewController()
    
    override func viewDidLoad() {
        super.viewDidLoad()

        self.resultViewController.viewModel.resultCount = 0
        self.resultViewController.viewModel.results = []
        self.outputTableView.dataSource = self.resultViewController
        self.outputTableView.reloadData()
    }

}

最關鍵的部分開始了!
RayWenderlich.com上的教程中使用了Signal,可是在swift中,ReactiveCocoa的開發者並無提供泛型的Signal,而使用AnyObject來和Objective C兼容。但這個嚴重損傷了代碼的可讀性,也讓bug有了遁身之處。所以在這裏,咱們將使用SignalProducer,這樣可使用swift的類型檢查來杜絕bug。
viewDidLoad中加入如下代碼:

let searchText = searchTextInput.rac_textSignal()
            .toSignalProducer()
            .map {text in text as! String}

map函數把一個集合類型(Array例如)中的元素逐一操做,並返回新的元素構成的集合類型。用Array舉例能夠清楚地解釋這一切。(不恰當地說,你能夠把SignalProducer當成一個Array,startWithNext就至關於for-in)

[1, 2, 3, 4].map {$0 * 2} //[2, 4, 6, 8]

這裏把一個RACSignal轉化成了一個SignalProducer,而且這個是有類型檢查的。爲了更好地使用類型檢查,咱們把本來的SignalProducer<AnyObject, NSError>轉化成了一個SignalProducer<String, NSError>。在這裏,做者想說,Xcode編輯器的類型檢查能夠很好的幫助咱們避免一些問題。尤爲是在接下來構造Monad結構時,經常能夠三指點按(即LookUp手勢)來查看構造出來的對象的類型。這能夠幫助理清楚Monad每一步的流程。
接下來,咱們有了用來搜索的文本,咱們要先執行一些過濾。在viewDidLoad中加入如下代碼:

searchText.filter(MainViewModel.isValidSearchString)

仍是用Array舉例說明:

[1, 2, 3, 4].filter {$0 % 2 == 0} //[2, 4]

這樣,咱們就用以前定義的過濾函數來檢查字符串是不是合法的搜索字符串。這個方法輸出的仍是一個SignalProducer<String, NSError>
咱們還須要作一點過濾,也就是說,只有當用戶輸入在500毫秒內沒有變化時,更新纔會被觸發。爲此咱們須要throttle函數。在以前那句話下面加上

.throttle(0.5, onScheduler: QueueScheduler.mainQueueScheduler)

注意前面的點。咱們事實上是在調用上一步結果的一個方法。這就是Monad的特色:流暢接口。每一步都會構造一個對象,下一步調用它的方法。Xcode彷佛並不喜歡Monad結構,縮進作得不好。Xcode 8和Xcode Extension或許能夠解決這個問題,可是如今還得手工格式化。還有swift編譯器在處理鏈式調用時會出現一些問題,最多見的是報錯:Expression too complex to be resolved in reasonable time. 這種時候只須要在鏈式調用的每一個點以前換一行就能夠了。這也是推薦的用法。

接下來是傳統方法最費力一部分了:異步請求。(Accept the fact that we are living in a asynchronous world)所幸ReactiveCocoa提供了一個良好的方法來解決這個問題:把它們包裝成SignalProducer。可是咱們在這裏遇到一個問題:若是用map而且返回一個SignalProducer,咱們將會在下一步獲得一個SignalProducer<SignalProducer<([String], Int), NoError>, NoError>。這顯然不是咱們想要的。這裏就要介紹flatMap函數了。先看Array的舉例:

[1, 2, 3, 4].map {[$0]} //[[1], [2], [3], [4]]
[1, 2, 3, 4].flatMap {[$0]} //[1, 2, 3, 4]

也就是說,flatMap方法會自動「剝掉一層」。加上代碼:

.flatMap(.Latest) {
                (text: String) in
                return SignalProducer {
                    (o: Observer<([String], Int), NoError>, c: CompositeDisposable) in
                    let rst = MainViewModel.produceSearchResult(text: text)
                    let cnt = rst.count
                    o.sendNext((rst, cnt))
                    o.sendCompleted()
                }
            }

經過這個flatMap,咱們把原來的SignalProducer<String, NSError>轉化成了SignalProducer<([String], Int), NoError>。(注意使用NoError類型須要包含Result框架)

如今,咱們有了「搜索結果」,能夠去顯示了。
加上代碼:

.observeOn(UIScheduler())
            .startWithNext {
                [weak self] (x: ([String], Int)) in
                if let strong = self {
                    strong.resultViewController.viewModel.resultCount = x.1
                    strong.resultViewController.viewModel.results = x.0
                    strong.outputTableView.reloadData()
                }
            }

這裏有兩個要說明的地方:一是在iOS上只有主線程能夠更新UI,所以咱們須要藉助UIScheduler來把工做轉移到主線程。還有爲了不循環引用,咱們須要聲明一個[weak self] 來告訴編譯器咱們不但願閉包持有對self的引用。詳細說明
最終,viewDidLoad函數應該看起來像這樣:

override func viewDidLoad() {
        super.viewDidLoad()
        self.resultViewController.viewModel.resultCount = 0
        self.resultViewController.viewModel.results = []
        self.outputTableView.dataSource = self.resultViewController
        self.outputTableView.reloadData()
        
        let searchText = searchTextInput.rac_textSignal()
            .toSignalProducer()
            .map {text in text as! String}
        searchText.filter(MainViewModel.isValidSearchString)
            .throttle(0.5, onScheduler: QueueScheduler.mainQueueScheduler)
            .flatMap(.Latest) {
                (text: String) in
                return SignalProducer {
                    (o: Observer<([String], Int), NoError>, c: CompositeDisposable) in
                    let rst = MainViewModel.produceSearchResult(text: text)
                    let cnt = rst.count
                    o.sendNext((rst, cnt))
                    o.sendCompleted()
                }
            }
            .observeOn(UIScheduler())
            .startWithNext {
                [weak self] (x: ([String], Int)) in
                if let strong = self {
                    strong.resultViewController.viewModel.resultCount = x.1
                    strong.resultViewController.viewModel.results = x.0
                    strong.outputTableView.reloadData()
                }
            }
    }

編譯運行,程序如預期執行。

寫在後面

ReactiveCocoa表明了一種全新的方式。它的核心就在於:「高聚合 低耦合」 同時具備強大的異步處理能力。Forget dispatch_async, let's startWithNext.

相關文章
相關標籤/搜索