行爲驅動開發iOS <收藏>

前段時間在design+code購買了一個學習iOS設計和編碼在線課程,使用Sketch設計App,而後使用Swift語言實現Designer News客戶端。做者Meng To已經開源到Github:MengTo/DesignerNewsApp · GitHub。雖然實現整個Designer News客戶端基本功能,可是採用臃腫MVC(Model-View-Controller)架構,不易於代碼的測試和複用,因而使用ReactiveCocoa實現MVVM(Model-View-View Model)架構,加上一個用Objective-C實現的BDD測試框架Kiwi來單元測試,就能夠行爲驅動開發iOS App。css

ReactiveCocoa

ReactiveCocoa是一個用Objective-C編寫,具備函數式和響應式特性的編程框架。大多數的開發者他們解決問題的思考方式都是如何完成任務,一般的作法就是編寫不少指令,而後修改重要數據結構的狀態,這種編程範式叫作命令式編程(Imperative Programming)。與命令式編程不一樣的是函數式編程(Functional Programming),思考問題的方式是完成什麼任務,怎樣描述這個任務。關於對函數式編程入門概念的理解,能夠參考酷殼《函數式編程》這篇文章,深刻淺出對函數式編程的思考方式、特性和技術經過一些示例來說解。html

ReactiveCocoa解決哪些問題?

  • 對象之間狀態與狀態的依賴過多問題
    借用ReactiveCocoa中一個例子來講明:用戶在登陸界面時,有一個用戶名輸入框和密碼輸入框,還有一個登陸按鈕。登陸交互要求以下:前端

    1. 當用戶名和密碼符合驗證格式,而且以前還沒登陸時,登陸按鈕才能點擊。
    2. 當點擊登陸成功登陸後,設置已登陸狀態。

    傳統的作法代碼以下:react

    static void *ObservationContext = &ObservationContext; - (void)viewDidLoad { [super viewDidLoad]; [LoginManager.sharedManager addObserver:self forKeyPath:@"loggingIn" options:NSKeyValueObservingOptionInitial context:&ObservationContext]; [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(loggedOut:) name:UserDidLogOutNotification object:LoginManager.sharedManager]; [self.usernameTextField addTarget:self action:@selector(updateLogInButton) forControlEvents:UIControlEventEditingChanged]; [self.passwordTextField addTarget:self action:@selector(updateLogInButton) forControlEvents:UIControlEventEditingChanged]; [self.logInButton addTarget:self action:@selector(logInPressed:) forControlEvents:UIControlEventTouchUpInside]; } - (void)dealloc { [LoginManager.sharedManager removeObserver:self forKeyPath:@"loggingIn" context:ObservationContext]; [NSNotificationCenter.defaultCenter removeObserver:self]; } - (void)updateLogInButton { BOOL textFieldsNonEmpty = self.usernameTextField.text.length > 0 && self.passwordTextField.text.length > 0; BOOL readyToLogIn = !LoginManager.sharedManager.isLoggingIn && !self.loggedIn; self.logInButton.enabled = textFieldsNonEmpty && readyToLogIn; } - (IBAction)logInPressed:(UIButton *)sender { [[LoginManager sharedManager] logInWithUsername:self.usernameTextField.text password:self.passwordTextField.text success:^{ self.loggedIn = YES; } failure:^(NSError *error) { [self presentError:error]; }]; } - (void)loggedOut:(NSNotification *)notification { self.loggedIn = NO; } - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if (context == ObservationContext) { [self updateLogInButton]; } else { [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; } }

    以上使用KVO、Notification、Target-Action等處理事件或消息的方式編寫的代碼分散到各個地方,變得雜亂和難以理解;可是使用RACSignal統一處理的話,代碼更加簡潔和易讀。使用RAC後代碼以下:ios

- (void)viewDidLoad { [super viewDidLoad]; @weakify(self); RAC(self.logInButton, enabled) = [RACSignal combineLatest:@[ self.usernameTextField.rac_textSignal, self.passwordTextField.rac_textSignal, RACObserve(LoginManager.sharedManager, loggingIn), RACObserve(self, loggedIn) ] reduce:^(NSString *username, NSString *password, NSNumber *loggingIn, NSNumber *loggedIn) { return @(username.length > 0 && password.length > 0 && !loggingIn.boolValue && !loggedIn.boolValue); }]; [[self.logInButton rac_signalForControlEvents:UIControlEventTouchUpInside] subscribeNext:^(UIButton *sender) { @strongify(self); RACSignal *loginSignal = [LoginManager.sharedManager logInWithUsername:self.usernameTextField.text password:self.passwordTextField.text]; [loginSignal subscribeError:^(NSError *error) { @strongify(self); [self presentError:error]; } completed:^{ @strongify(self); self.loggedIn = YES; }]; }]; RAC(self, loggedIn) = [[NSNotificationCenter.defaultCenter rac_addObserverForName:UserDidLogOutNotification object:nil] mapReplace:@NO]; }
  • 傳統MVC架構中,因爲Controller承擔數據驗證、映射數據模型到View和操做View層次結構等多個責任,致使Controller過於臃腫,不利於代碼的複用和測試。
    在傳統的MVC架構中,主要有Model, View和Controller三部分組成。Model主要是保存數據和處理業務邏輯,View將數據顯示,而Controller調解關於Model和View之間的全部交互。
    當數據到達時,Model經過Key-Value Observation來通知View Controller, 而後View Controller更新View。當View與用戶交互後,View Controller更新Model。

Typical MVC paradigm.png

正如你所見,View Controller隱式承擔不少責任:數據驗證、映射數據模型到View和操做View層次結構。MVVM將不少邏輯從View Controller移走到View-Model,等介紹完ReactiveCocoa後會介紹MVVM架構。還有一些關於如何減負View Controller好文章請參閱objc中國更輕量的View Controllers系列:git

  • 使用Signal來代替KVO、Notification、Delegate和Target-Action等傳遞消息
    iOS開發中有多種消息傳遞方式,KVO、Notification、Delegate、Block和Target-Action,對於它們之間有什麼差別以及如何選擇請參考《消息傳遞機制》。但RAC提供RACSignal來統一消息傳遞機制,再也不爲如何選擇何種傳遞消息方式而煩惱。github

    RAC對經常使用UI控件事件進行封裝成一個RACSignal對象,以便對發生的各類事件進行監聽。
    KVO示例代碼以下:shell

    // When self.username changes, logs the new name to the console. // // RACObserve(self, username) creates a new RACSignal that sends the current // value of self.username, then the new value whenever it changes. // -subscribeNext: will execute the block whenever the signal sends a value. [RACObserve(self, username) subscribeNext:^(NSString *newName) { NSLog(@"%@", newName); }];

    Target-Action示例代碼以下:編程

    // Logs a message whenever the button is pressed. // // RACCommand creates signals to represent UI actions. Each signal can // represent a button press, for example, and have additional work associated // with it. // // -rac_command is an addition to NSButton. The button will send itself on that // command whenever it's pressed. self.button.rac_command = [[RACCommand alloc] initWithSignalBlock:^(id _) { NSLog(@"button was pressed!"); return [RACSignal empty]; }];

    Notification示例代碼以下:json

    // Respond to when email text start and end editing [[[NSNotificationCenter defaultCenter] rac_addObserverForName:UITextFieldTextDidBeginEditingNotification object:self.emailTextField] subscribeNext:^(id x) { [self.emailImageView animate]; self.emailImageView.image = [UIImage imageNamed:@"icon-mail-active"]; self.emailTextField.background = [UIImage imageNamed:@"input-outline-active"]; }]; [[[NSNotificationCenter defaultCenter] rac_addObserverForName:UITextFieldTextDidEndEditingNotification object:self.emailTextField] subscribeNext:^(id x) { self.emailTextField.background = [UIImage imageNamed:@"input-outline"]; self.emailImageView.image = [UIImage imageNamed:@"icon-mail"]; }];

    除此以外,還可使用AFNetworking訪問服務器後對返回數據自建立一個RACSignal。示例代碼以下:

    + (RACSubject*)storiesForSection:(NSString*)section page:(NSInteger)page { RACSubject* signal = [RACSubject subject]; NSDictionary* parameters = @{ @"page" : [NSString stringWithFormat:@"%ld", (long)page], @"client_id" : clientID }; [[AFHTTPSessionManager manager] GET:[DesignerNewsURL stroiesURLString] parameters:parameters success:^(NSURLSessionDataTask* task, id responseObject) { NSLog(@"url string = %@", task.currentRequest.URL); [signal sendNext:responseObject]; [signal sendCompleted]; } failure:^(NSURLSessionDataTask* task, NSError* error) { NSLog(@"url string = %@", task.currentRequest.URL); [signal sendError:error]; }]; return signal; }

    有些朋友能夠感受有點奇怪,上面代碼明明返回的是RACSubject,而不是RACSignal,其實RACSubject是RACSignal的子類,可是RACSubject寫出代碼更加簡潔,因此採用RACSubject(官方不推薦使用)。等下將RAC核心類設計時,你就會了解它們之間的關係和如何選擇。

ReactiveCocoa核心類設計

關於RAC核心類設計,官方文檔有詳細的解釋:Framework Overview

Sequence和Signal基本操做

瞭解完整個RAC核心類設計以後,要學會對Sequence和Signal基本操做,好比:用signal執行side effects,轉換streams, 合併stream和合並signal。詳情請查閱官方文檔:Basic Operators

MVVM架構


MVVM high level.png


在MVVM架構中,一般都將view和view controller看作一個總體。相對於以前MVC架構中view controller執行不少在view和model之間數據映射和交互的工做,如今將它交給view model去作。
至於選擇哪一種機制來更新view model或view是沒有強制的,但一般咱們都選擇ReactiveCocoa。ReactiveCocoa會監聽model的改變而後將這些改變映射到view model的屬性中,而且能夠執行一些業務邏輯。
舉個例子來講,有一個model包含一個dateAdded的屬性,我想監聽它的變化而後更新view model的dateAdded屬性。但model的dateAdded屬性的數據類型是NSDate,而view model的數據類型是NSString,因此在view model的init方法中進行數據綁定,但須要數據類型轉換。示例代碼以下:

RAC(self,dateAdded) = [RACObserve(self.model,dateAdded) map:^(NSDate*date){ return [[ViewModel dateFormatter] stringFromDate:date]; }];

ViewModel調用dateFormatter進行數據轉換,且方法dateFormatter能夠複用到其餘地方。而後view controller監聽view model的dateAdded屬性且綁定到label的text屬性。

RAC(self.label,text) = RACObserve(self.viewModel,dateAdded);

如今咱們抽象出日期轉換到字符串的邏輯到view model,使得代碼能夠測試複用,而且幫view controller瘦身

Kiwi

Kiwi是一個iOS行爲驅動開發(Behavior Driven Development)的庫。相比於Xcode提供單元測試的XCTest是從測試的角度思考問題,而Kiwi是從行爲的角度思考問題,測試用例都遵循三段式Given-When-Then的描述,清晰地表達測試用例是測試什麼樣的對象或數據結構,在基於什麼上下文或情景,而後作出什麼響應。

describe(@"Team", ^{ context(@"when newly created", ^{ it(@"has a name", ^{ id team = [Team team]; [[team.name should] equal:@"Black Hawks"]; }); it(@"has 11 players", ^{ id team = [Team team]; [[[team should] have:11] players]; }); }); });

咱們很容易根據上下文將其提取爲Given..When..Then的三段式天然語言

Given a Team, when be newly created, it should have a name, it should have 11 player

用Xcode自帶的XCTest測試框架寫過測試代碼的朋友可能體會到,以上代碼更加易於閱讀和理解。就算之後有新的開發者加入或修護代碼時,不須要太大的成本去閱讀和理解代碼。具體如何使用Kiwi,請參考兩篇文章:

Designer News UI

在編寫Designer News客戶端代碼以前,首先經過UI來了解整個App的概況。設計Designer News UI的工具是Sketch,想得到Designer News UI,請點擊下載Designer New UI


Designer News Design.png


若是將全部的頁面都逐個說明如何編寫,會比較耗時間,因此只拿登錄頁面來講明我是如何行爲驅動開發iOS,但我會將整個項目的代碼上傳到github

登錄界面

因爲這個項目簡單而且只有一我的開發(多人開發的話,採用Storyboard不易於代碼合併),加上Storyboard能夠可視化的添加UI組件和Auto Layout的約束,而且能夠同時預覽多個不一樣分辨率iPhone的效果,極大地提升開發界面效率。


Login.png

登錄交互

登錄界面有Email輸入框和密碼輸入框,當用戶選中其餘一個輸入框時,左邊對應的圖標變成藍色,同時會有pop動畫表示用戶準備要輸入內容。
當用戶沒有輸入有效的Email或密碼格式時,用戶是不能點擊登錄按鈕,只有當用戶輸入有效的郵件和密碼格式時,才能點擊登錄按鈕。


Login.gif

咱們可使用RAC經過監聽Text Field的UITextFieldTextDidBeginEditingNotificationUITextFieldTextDidEndEditingNotification的通知來處理用戶選中Email輸入框和密碼輸入框時改變圖標和顯示的動畫。

#pragma mark - Text Field notification - (void)textFieldStartEndEditing { // Respond to when email text start and end editing [[[NSNotificationCenter defaultCenter] rac_addObserverForName:UITextFieldTextDidBeginEditingNotification object:self.emailTextField] subscribeNext:^(id x) { [self.emailImageView animate]; self.emailImageView.image = [UIImage imageNamed:@"icon-mail-active"]; self.emailTextField.background = [UIImage imageNamed:@"input-outline-active"]; }]; [[[NSNotificationCenter defaultCenter] rac_addObserverForName:UITextFieldTextDidEndEditingNotification object:self.emailTextField] subscribeNext:^(id x) { self.emailTextField.background = [UIImage imageNamed:@"input-outline"]; self.emailImageView.image = [UIImage imageNamed:@"icon-mail"]; }]; // Respond to when password text start and end editing [[[NSNotificationCenter defaultCenter] rac_addObserverForName:UITextFieldTextDidBeginEditingNotification object:self.passwordTextField] subscribeNext:^(id x) { [self.passwordImageView animate]; self.passwordTextField.background = [UIImage imageNamed:@"input-outline-active"]; self.passwordImageView.image = [UIImage imageNamed:@"icon-password-active"]; }]; [[[NSNotificationCenter defaultCenter] rac_addObserverForName:UITextFieldTextDidEndEditingNotification object:self.passwordTextField] subscribeNext:^(id x) { self.passwordTextField.background = [UIImage imageNamed:@"input-outline"]; self.passwordImageView.image = [UIImage imageNamed:@"icon-password"]; }]; }

當點擊登錄按鈕後,客戶端向服務端發送驗證請求,服務端驗證完帳戶和密碼後,用戶即可以成功登錄。因此,接下來要了解RESTful API的基本概念和Designer News提供的RESTful API。

Designer News API

RESTful API基本概念和設計

REST全稱是Representational State Transfer,翻譯過來就是表現層狀態轉化。要想真正理解它的含義,從幾個關鍵字入手:Resource, Representation, State Transfer

  • Resource(資源)
    資源就是網絡上的實體,它能夠是文字、圖片、聲音、視頻或一種服務。但網絡有這麼多資源,該如何標識它們呢?你能夠用URL(統一資源定位符)來惟一標識和定位它們。只要得到資源對應的URL,你就能夠訪問它們。
  • Representation(表現層)
    資源是一種信息實體,它有多種表示方式。好比,文本能夠用.txt格式表示,也能夠用xml、json或html格式表示。
  • State Transfer(狀態轉換)
    客戶端訪問服務端,服務端處理完後返回客戶端,在這個過程當中,通常都會引發數據狀態的改變或轉換。
    客戶端操做服務端,都是經過HTTP協議,而在這個HTTP協議中,有幾個動詞:GET, POST, DELETEUPDATE
    • GET表示獲取資源
    • POST表示新增資源
    • DELETE表示刪除資源
    • UPDATE表示更新資源

理解RESTful核心概念後,咱們來簡單瞭解RESTful API設計以即可以看懂Designer News提供API。就拿Designer News獲取Stories對應URL的一個例子來講明:
客戶端請求
GET https://api-news.layervault.com/api/v1/stories?client_id=91a5fed537b58c60f36be1sdf71ed1320e9e4af2bda4366f7dn3d79e63835278

服務端返回結果(部分結果)

{
  "stories": [ { "id": 46826, "title": "A Year of DuckDuckGo", "comment": "", "comment_html": null, "comment_count": 4, "vote_count": 17, "created_at": "2015-03-28T14:05:38Z", "pinned_at": null, "url": "https://news.layervault.com/click/stories/46826", "site_url": "https://api-news.layervault.com/stories/46826-a-year-of-duckduckgo", "user_id": 3334, "user_display_name": "Thomas W.", "user_portrait_url": "https://designer-news.s3.amazonaws.com/rendered_portraits/3334/original/portrait-2014-09-16_13_25_43__0000-333420140916-9599-7pse94.png?AWSAccessKeyId=AKIAI4OKHYH7JRMFZMUA&Expires=1459149709&Signature=%2FqqLAgqpOet6fckn4TD7vnJQbGw%3D", "hostname": "designwithtom.com", "user_url": "http://news.layervault.com/u/3334/thomas-wood", "badge": null, "user_job": "Online Designer at IDG UK", "sponsored": false, "comments": [ { "id": 142530, "body": "Had no idea it had those customization settings — finally making the switch.", "body_html": "<p>Had no idea it had those customization settings — finally making the switch.</p>\\n", "created_at": "2015-03-28T18:41:37Z", "depth": 0, "vote_count": 0, "url": "https://api-news.layervault.com/comments/142530", "user_url": "http://news.layervault.com/u/3826/matt-soria", "user_id": 3826, "user_display_name": "Matt S.", "user_portrait_url": "https://designer-news.s3.amazonaws.com/rendered_portraits/3826/original/portrait-2014-04-12_11_08_21__0000-382620140412-5896-1udai4f.png?AWSAccessKeyId=AKIAI4OKHYH7JRMFZMUA&Expires=1459125745&Signature=%2BDdWMtto3Q10dd677sUOjfvQO3g%3D", "user_job": "Web Dood @ mattsoria.com", "comments": [] },
  • 協議(protocol)
    用戶與API通訊採用HTTPs協議
  • 域名(domain name)
    應該儘量部署到專用域名下https://api-news.layervault.com/,但有時會進一步擴展爲https://api-news.layervault.com/api
  • 版本(version)
    應該將API版本號v1放入URL
  • 路徑(Endpoint)
    路徑https://api-news.layervault.com/api/v1/stories表示API具體網址,表明網絡一種資源,因此不能有動詞,只有使用名詞來表示。
  • HTTP動詞
    動詞GET,表示從服務端獲取Stories資源
  • 過濾信息(Filtering)
    ?client_id=91a5fed537b58c60f36be1sdf71ed1320e9e4af2bda4366f7dn3d79e63835278指定client_id的Stories資源
  • 狀態碼(Status Codes)
    服務器向客戶端返回表示成功或失敗的狀態碼,狀態碼列表請參考Status Code Definitions
  • 錯誤處理(Error handling)
    服務端處理用戶請求失敗後,通常都返回error字段來表示錯誤信息
    { error: "Invalid client id" }

Designer News提供API

Designer News API Reference提供基於HTTP協議遵循RESTful設計的API,而且容許應用程序經過 oAuth 2受權協議來獲取受權權限來訪問用戶信息。

訪問API工具

通常來講,在寫訪問服務端代碼以前,我都會用Paw(下載地址)工具來測試API是否可行;另外一方面,用JSON文件保存服務端返回的數據,用於moco模擬服務端的服務。至於爲何須要moco模擬服務端,後面會講解,如今經過用戶登陸Designer News這個例子介紹如何使用Paw來測試API。
咱們先看看Designer News提供訪問用戶登陸的API


Designer News Login API.png

根據以上提供的信息,API的路徑是https://api-news.layervault.com/oauth/token,參數有grant_typeusernamepasswordclient_secret。其中usernamepasswordDesigner News註冊才能獲取,而client_idclient_secret須要發送email到news@layervault.com申請。使用Paw發送請求和服務端返回結果以下:


New Send Request.png

Moco模擬服務端

Moco是一個能夠輕鬆搭建測試服務器的工具。

爲何須要模擬服務端

做爲一個移動開發人員,有時因爲服務端開發進度慢,空有一個iPhone應用但發揮不出做用。幸虧有了Moco,只需配置一下請求和返回數據,很快就能夠搭建一個模擬服務,無需等待服務端開發完成才能繼續開發。當服務端完成後,修改訪問地址便可。

有時服務端API應該是什麼樣子都還沒清楚,因爲有了moco模擬服務,在開發過程當中,能夠不斷調整API設計,搞清楚真正本身想要的API是什麼樣子的。就這樣,在服務端代碼還沒真正動手以前,已經提供一份真正知足本身須要的API文檔,剩下的就交給服務端照着API去實現就好了。

還有一種狀況就是,服務端已經寫好了,剩下客戶端還沒完成。因爲moco是本地服務,訪問速度比較快,因此經過使用moco來模擬服務端,這樣不只能夠提升客戶端的訪問速度,還提升網絡層測試代碼訪問速度的穩定性,Designer News就是這樣狀況。

如何使用Moco模擬服務

安裝

若是你是使用Mac或Linux,能夠嘗試一下步驟:

  1. 肯定你安裝JDK 6以上
  2. 下載腳本
  3. 把它放在你的$PATH路徑
  4. 設置它能夠執行(chmod 755 ~/bin/moco)

如今你能夠運行一下命令測試安裝是否成功

  1. 編寫配置文件foo.json,內容以下:
    [
       {
         "response" : { "text" : "Hello, Moco" } } ]
  2. 運行Moco HTTP服務器
    moco start -p 12306 -c foo.json
  3. 打開瀏覽器訪問http://localhost:12306,你回看見"Hello, Moco"
配置服務

因爲有時候服務端返回的數據比較多,因此將服務端響應的數據獨立在一個JSON文件中。以登錄爲例,將數據存放在login_response.json

{
    "access_token": "4422ea7f05750e93a101cb77ff76dffd3d65d46ebf6ed5b94d211e5d9b3b80bc", "token_type": "bearer", "scope": "user", "created_at": 1428040414 }

而將請求uri路徑,方法(method)和參數(queries)等配置放在login_conf.json文件中

[
  {
    "request" : { "uri" : "/oauth/token", "method" : "post", "queries" : { "grant_type" : "password", "username" : "liuyaozhu13hao@163.com", "password" : "freedom13", "client_secret" : "53e3822c49287190768e009a8f8e55d09041c5bf26d0ef982693f215c72d87da", "client_id" : "750ab22aac78be1c6d4bbe584f0e3477064f646720f327c5464bc127100a1a6d" } }, "response" : { "file" : "./Login/login_response.json" } } ]

不知道有沒有留意到上面uri路徑不是全路徑http://localhost:12306/oauth/token,由於協議默認是http,並且一般運行在本機localhost,因此在啓動模擬服務時只需指定端口12306就行。想更加詳細瞭解如何配置,請查閱官網的HTTP(s) APIs
還有一個須要配置地方就是,因爲實際開發中確定不止一個客戶端請求,因此還須要一個配置文件settings.json來包含頗有的請求。

[
    {
        "include" : "./Story/stories_conf.json" }, { "include" : "./Login/login_conf.json" }, { "include" : "./Story/story_upvote_conf.json" } ]
啓動服務

將路徑跳轉到DesignerNewsForObjc/DesignerNewsForObjcTests/JSON目錄,找到settings.json文件,使用命令行來啓動服務:
moco start -p 12306 -g settings.json

使用Paw驗證是否配置成功

Send request to Local Server.png

行爲驅動開發(BDD)

爲何須要BDD

不知道各位在編寫測試的時候,有沒有思考過一個問題:我應該測試什麼?要回答這個問題並非那麼簡單,在沒獲得答案以前,你仍是繼續按照你的想法編寫測試。
-(void)testValidateEmail;
像這樣的測試,存在一個根本問題。它不會告訴你應該會發生什麼,也不會預期實際會發生什麼。還有,當它發生錯誤時,不會提示你在哪裏發生錯誤,錯誤的緣由是什麼,所以你須要深刻代碼才能知道失敗的緣由。這樣就須要大量額外和沒必要要的認知負荷。
這時BDD出現了,幫助開發者肯定應該測試什麼,它提供DSL(Domain-specific language, 域特定語言),測試用例都遵循三段式Given-When-Then的描述,清晰地表達測試用例是測試什麼樣的對象或數據結構,在基於什麼上下文或情景,而後作出什麼響應。
因此,咱們應該關注行爲,而不是測試。那行爲具體是什麼?當你設計app裏面的其中對象時,它的接口定義方法及其依賴關係,這些方法和依賴關係決定了你的對象如何與其餘對象交互,以及它的功能是什麼,定義你的對象的行爲

BDD過程

行爲驅動開發大概三個步驟:

  1. 選擇最重要的行爲,並編寫行爲的測試文件。此時,因爲測試對象的類還沒編寫,因此編譯失敗。建立測試對象的類並編寫類的僞實現,讓編譯經過。
  2. 實現被測試類的行爲,讓測試經過。
  3. 若是發現代碼中有重複代碼,重構被測試類來消除重複

若是暫時不理解其中步驟細節,沒有關係,繼續向下閱讀,後面有例子介紹來幫助你理解三個步驟的含義。

登錄驗證

網絡訪問層

DesignerNewsURL

DesignerNewsURL類封裝網絡訪問URL

#import <Foundation/Foundation.h> extern NSString* const baseURL; extern NSString* const clientID; extern NSString* const clientSecret; @interface DesignerNewsURL : NSObject + (NSString*)loginURLString; + (NSString*)stroiesURLString; + (NSString*)storyIdURLStringWithId:(NSInteger)storyId; + (NSString*)storyUpvoteWithId:(NSInteger)storyId; + (NSString*)storyReplyWithId:(NSInteger)storyId; + (NSString*)commentUpvoteWithId:(NSInteger)commentId; + (NSString*)commentReplyWithId:(NSInteger)commentId; @end

這裏還有個技巧就是在DesignerNewsURL.m實現文件有個條件編譯,判斷是在測試環境仍是產品環境來決定baseURL的值,能夠很方便在測試環境與產品環境互相切換。

#ifndef TEST NSString* const baseURL = @"https://api-news.layervault.com"; #else NSString* const baseURL = @"http://localhost:12306"; #endif NSString* const clientID = @"750ab22aac78be1c6d4bbe584f0e3477064f646720f327c5464bc127100a1a6d"; NSString* const clientSecret = @"53e3822c49287190768e009a8f8e55d09041c5bf26d0ef982693f215c72d87da";
行爲驅動開發LoginClient

在編寫代碼以前,咱們應該先想一想如何設計LoginClient類。首先根據Single responsibility principle(責任單一原則),LoginClient主要負責用戶登陸的網絡訪問。須要提供一個接口,只要給定用戶名(username)和密碼(password),用戶就能登陸,因爲我是使用RAC來處理返回結果,因此這個接口返回RACSignal對象。

  • 建立一個LoginClientkiwi文件,編寫對應行爲。

Create LoginClient 1.png

Create LoginClient 2.png
SPEC_BEGIN(LoginClientSpec)

  describe(@"LoginClient", ^{ context(@"when user input correct username and password", ^{ __block RACSignal *loginSignal; beforeEach(^{ NSString *username = @"liuyaozhu13hao@163.com"; NSString *password = @"freedom13"; loginSignal = [LoginClient loginWithUsername:username password:password]; }); it(@"should return login signal that can't be nil", ^{ [[loginSignal shouldNot] beNil]; }); it(@"should login successfully", ^{ __block NSString *accessToken = nil; [loginSignal subscribeNext:^(NSString *x) { accessToken = x; NSLog(@"accessToken = %@", accessToken); }error:^(NSError *error) { [[accessToken shouldNot] beNil]; } completed:^{ [[accessToken shouldNot] beNil]; } ]; }); }); }); SPEC_END

根據三段式Given-When-Then描述,上面代碼咱們能夠理解爲:在給定LoginClient對象,當用戶輸入正確的用戶名和密碼時,應該登陸成功。
這時,因爲還沒建立LoginClient類,因此會不經過編譯,建立LoginClient類,並編寫它的僞實現,讓LoginClientSpec.m經過編譯。


LoginClient.h.png

LoginClient.m.png


運行測試,測試失敗。


LoginClient Failed.png
  • 實現LoginClient,經過其測試

LoginClient.m .png

LoginClient Pass Test.png
  • 因爲無冗餘代碼,無需重構

Model層

因爲此次登錄請求服務端返回數據比較簡單,只是獲取access_token字段數據,因此不須要model來映射和存儲數據。不過在獲取多個Stories時,就會使用到model來處理。

Controller與ViewModel層

controller是處理用戶交互的入口,一般我都會將處理用戶交互的邏輯、數據綁定和數據校驗都交給ViewModel來精簡controller代碼,同時最大程度地複用業務邏輯的代碼。
咱們先回顧用戶登錄時的步驟:1. 用戶先輸入email和密碼,只有email和密碼符合格式要求時才能點擊按鈕。2. 用戶成功登錄後,跳轉到故事列表主頁。
咱們先分析一下如何實現步驟1, 想要對email和密碼進行驗證,必需要監聽它們兩個值的變化,因此須要對emailTextFieldpasswordTextField使用RAC進行數據綁定。

建立LoginViewControllerSpeckiwi文件,測試綁定行爲代碼以下:

SPEC_BEGIN(LoginViewControllerSpec)

describe(@"LoginViewController", ^{ __block LoginViewController *controller; beforeEach(^{ controller = [UIViewController loadViewControllerWithIdentifierForMainStoryboard:@"LoginViewController"]; [controller view]; }); afterEach(^{ controller = nil; }); describe(@"Email Text Field", ^{ context(@"when touch text field", ^{ it(@"should not be nil", ^{ [[controller.emailTextField shouldNot] beNil]; }); }); context(@"when text field's text is hello", ^{ it(@"shoud euqal view model's email property", ^{ controller.emailTextField.text = @"hello"; [controller.emailTextField sendActionsForControlEvents:UIControlEventEditingChanged]; [[controller.viewModel.email should] equal:@"hello"]; }); }); }); describe(@"Password Text Field", ^{ context(@"when touch text field", ^{ it(@"should not be nil", ^{ [[controller.passwordTextField shouldNot] beNil]; }); }); context(@"when text field' text is hello", ^{ it(@"should equal view model's password property", ^{ controller.passwordTextField.text = @"hello"; [controller.passwordTextField sendActionsForControlEvents:UIControlEventEditingChanged]; [[controller.viewModel.password should] equal:@"hello"]; }); }); }); }); SPEC_END

這裏有兩個關鍵點,一個是從Storyboard中加載controller,不然不能獲取emailTextField和password,若是採用手寫UI代碼就不須要了。另外一個就是emailTextField或passwordTextField必須調用sendActionsForControlEvents:UIControlEventEditingChanged方法,才能觸發textField的text屬性改變。

編譯失敗後,在LoginViewController.m編寫- (void)bindViewModel方法經過測試

RAC(self.viewModel, email) = self.emailTextField.rac_textSignal; RAC(self.viewModel, password) = self.passwordTextField.rac_textSignal;

實現完數據綁定行爲後,接下來要數據校驗,交給LoginViewModel來處理。建立LoginViewModelSpec.m文件,提供emailpassword屬性給LoginViewModel,返回驗證結果的RACSignal,測試驗證行爲代碼以下:

SPEC_BEGIN(LoginViewModelSpec)

describe(@"LoginViewModel", ^{ // Initialize __block LoginViewModel *viewModel; beforeEach(^{ viewModel = [[LoginViewModel alloc] init]; }); afterEach(^{ viewModel = nil; }); context(@"when email and password is valid", ^{ it(@"should get valid signal", ^{ viewModel.email = @"liuyaozhu13hao@163.com"; viewModel.password = @"123456"; __block BOOL result; [[viewModel checkEmailPasswordSignal] subscribeNext:^(id x) { result = [x boolValue]; } completed:^{ [[theValue(result) should] beYes]; }]; }); }); context(@"when email is valid, but password is invalid", ^{ it(@"should get invalid signal", ^{ viewModel.email = @"liuyaozhu13hao@163.com"; viewModel.password = @"1"; __block BOOL result; [[viewModel checkEmailPasswordSignal] subscribeNext:^(id x) { result = [x boolValue]; } completed:^{ [[theValue(result) shouldNot] beYes]; }]; }); }); context(@"when password is valid, but email is invalid", ^{ it(@"should get invalid signal", ^{ viewModel.email = @"liuyaozhu"; viewModel.password = @"123456"; __block BOOL result; [[viewModel checkEmailPasswordSignal] subscribeNext:^(id x) { result = [x boolValue]; } completed:^{ [[theValue(result) shouldNot] beYes]; }]; }); }); }); SPEC_END

編譯失敗後(已經建立LoginViewModel類),添加- (RACSignal*)checkEmailPasswordSignal並實現驗證數據,經過測試

- (RACSignal*)checkEmailPasswordSignal
{
    RACSignal* emailSignal = RACObserve(self, email); RACSignal* passwordSignal = RACObserve(self, password); return [RACSignal combineLatest:@[ emailSignal, passwordSignal ] reduce:^(NSString* email, NSString* password) { BOOL result = [email isValidEmail] && [password isValidPassword]; return @(result); }]; }

最後須要在LoginViewModel建立屬性爲loginButtonCommandRACCommand來處理點擊登錄按鈕的交互。在LoginViewControllerSpec.m測試loginButton.rac_command不能爲空

describe(@"Login Button", ^{ context(@"when load view", ^{ it(@"should be not nil", ^{ [[controller.loginButton shouldNot] beNil]; }); it(@"should have rac command that not be nil", ^{ [[controller.loginButton.rac_command shouldNot] beNil]; }); }); });

測試失敗,在LoginViewController.m編寫- (void)bindViewModel方法如下代碼片斷

self.loginButton.rac_command = self.viewModel.loginButtonCommand;

LoginViewModel.m延遲初始化loginButtonCommand屬性

#pragma mark - Lazy initialization - (RACCommand*)loginButtonCommand { if (!_loginButtonCommand) { _loginButtonCommand = [[RACCommand alloc] initWithEnabled:[self checkEmailPasswordSignal] signalBlock:^RACSignal * (id input) { self.active = YES; return [[LoginClient loginWithUsername:self.email password:self.password] doNext:^(NSString *token) { self.active = NO; // Save the token [LocalStore saveToken:token]; // Dismiss view controller and fetch data, reload self.dismissBlock(); }]; }]; } return _loginButtonCommand; }

經過測試,完成登錄基本流程,至於登錄成功後如何返回故事列表頁面,這裏不詳細介紹,各位能夠經過閱讀工程代碼即可以獲得答案。

總結

最近一段時間都再看關於敏捷開發的書籍(用戶故事與敏捷方法硝煙中的Scrum和XP, 解析極限編程),對敏捷開發很感興趣,但發覺不多公司或博客介紹如何實踐敏捷開發iOS,因此在網上搜集一些資料,發現有不少優秀的實踐(測試驅動開發,重構,持續集成測試,增量設計,增量計劃)值得去學習,經過本身對敏捷開發中各類實踐的理解來重寫這個Designer News,這個Designer News功能還沒所有完成,但願各位看完這篇文章嘗試以這樣方式來完成整個app。若是我有些觀點或實踐理解有誤,請各位多多指點。

擴展閱讀



文/Sam_Lau(簡書做者) 原文連接:http://www.jianshu.com/p/73f9d719cee4 著做權歸做者全部,轉載請聯繫做者得到受權,並標註「簡書做者」。
相關文章
相關標籤/搜索