你好,歡迎來到IOS教程網

 Ios教程網 >> IOS編程開發 >> IOS開發基礎 >> 行為驅動開發iOS

行為驅動開發iOS

編輯:IOS開發基礎

0.jpg

前段時間在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。

ReactiveCocoa

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

ReactiveCocoa解決哪些問題?

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

    1. 當用戶名和密碼符合驗證格式,並且之前還沒登錄時,登錄按鈕才能點擊。

    2. 當點擊登錄成功登錄後,設置已登錄狀態。

傳統的做法代碼如下:

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後代碼如下:

- (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。

blob.png

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

  • 更輕量的 View Controllers

  • 整潔的 Table View 代碼

  • 測試 View Controllers

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

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

// 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 示例代碼如下:

// 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架構

blob.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,請參考兩篇文章:

  • TDD的iOS開發初步以及Kiwi 使用入門

  • Kiwi 使用進階 Mock, Stub, 參數捕獲和異步測試

Designer News UI

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

1431913741665470.png

如果將所有的頁面都逐個說明如何編寫,會比較耗時間,所以只拿登陸頁面來說明我是如何行為驅動開發iOS,但我會將整個項目的代碼上傳到github。

登陸界面

由於這個項目簡單並且只有一個人開發(多人開發的話,采用Storyboard不易於代碼合並),加上Storyboard可以可視化的添加UI組件和Auto Layout的約束,並且可以同時預覽多個不同分辨率iPhone的效果,極大地提高開發界面效率。

1431913777288438.png

登陸交互

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

1.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(資源)

資源就是網絡上的實體,它可以是文字、圖片、聲音、視頻或一種服務。但網絡有這麼多資源,該如何標識它們呢?你可以用URI(統一資源定位符)來唯一標識和定位它們。只要獲得資源對應的URI,你就可以訪問它們。

  • Representation(表現層)

資源是一種信息實體,它有多種表示方式。比如,文本可以用.txt格式表示,也可以用xml、json或html格式表示。

  • State Transfer(狀態轉換)

客戶端訪問服務端,服務端處理完後返回客戶端,在這個過程中,一般都會引起數據狀態的改變或轉換。
客戶端操作服務端,都是通過HTTP協議,而在這個HTTP協議中,有幾個動詞:GET,POSTDELETEUPDATE

    • 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": "
Had no idea it had those customization settings — finally making the switch.
\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

2.jpg

根據以上提供的信息,API的路徑是https://api-news.layervault.com/oauth/token,參數有grant_type,username,password,client_secret。其中username和password在Designer News注冊才能獲取,而client_id和client_secret需要發送email到[email protected]申請。使用Paw發送請求和服務端返回結果如下:

1431914534857946.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" : "[email protected]",
            "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驗證是否配置成功

3.jpg

行為驅動開發(BDD)

為什麼需要BDD

不知道各位在編寫測試的時候,有沒有思考過一個問題:我應該測試什麼?要回答這個問題並不是那麼簡單,在沒得到答案之前,你還是繼續按照你的想法編寫測試。

-(void)testValidateEmail;

像這樣的測試,存在一個根本問題。它不會告訴你應該會發生什麼,也不會預期實際會發生什麼。還有,當它發生錯誤時,不會提示你在哪裡發生錯誤,錯誤的原因是什麼,因此你需要深入代碼才能知道失敗的原因。這樣就需要大量額外和不必要的認知負荷。
這時BDD出現了,幫助開發者確定應該測試什麼,它提供DSL(Domain-specific language, 域特定語言),測試用例都遵循三段式Given-When-Then的描述,清晰地表達測試用例是測試什麼樣的對象或數據結構,在基於什麼上下文或情景,然後做出什麼響應。
所以,我們應該關注行為,而不是測試。那行為具體是什麼?當你設計app裡面的其中對象時,它的接口定義方法及其依賴關系,這些方法和依賴關系決定了你的對象如何與其他對象交互,以及它的功能是什麼,定義你的對象的行為

BDD 過程

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

  1. 選擇最重要的行為,並編寫行為的測試文件。此時,由於測試對象的類還沒編寫,所以編譯失敗。創建測試對象的類並編寫類的偽實現,讓編譯通過。

  2. 實現被測試類的行為,讓測試通過。

  3. 如果發現代碼中有重復代碼,重構被測試類來消除重復

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

登陸驗證

網絡訪問層

DesignerNewsURL

DesignerNewsURL類封裝網絡訪問URL

#import
 
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文件,編寫對應行為。

4.jpg

5.jpg

  SPEC_BEGIN(LoginClientSpec)
 
  describe(@"LoginClient", ^{
 
      context(@"when user input correct username and password", ^{
        __block RACSignal *loginSignal;
 
        beforeEach(^{
          NSString *username = @"[email protected]";
            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通過編譯。

1431915048130161.png

1431915060757701.png

運行測試,測試失敗。

6.jpg

  • 實現LoginClient,通過其測試

7.jpg

8.jpg


  • 由於無冗余代碼,無需重構

Model

由於這次登陸請求服務端返回數據比較簡單,只是獲取access_token字段數據,所以不需要model來映射和存儲數據。不過在獲取多個Stories時,就會使用到model來處理。

Controller與ViewModel

controller 是處理用戶交互的入口,通常我都會將處理用戶交互的邏輯、數據綁定和數據校驗都交給ViewModel來精簡controller代碼,同時最大程度地復用業務邏輯的代碼。
我們先回顧用戶登陸時的步驟:1. 用戶先輸入email和密碼,只有email和密碼符合格式要求時才能點擊按鈕。2. 用戶成功登陸後,跳轉到故事列表主頁。
我們先分析一下如何實現步驟1, 想要對email和密碼進行驗證,必須要監聽它們兩個值的變化,所以需要對emailTextField和passwordTextField使用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文件,提供email和password屬性給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 = @"[email protected]";
            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 = @"[email protected]";
            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創建屬性為loginButtonCommand的RACCommand來處理點擊登陸按鈕的交互。在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。如果我有些觀點或實踐理解有誤,請各位多多指點。

擴展閱讀

  • ReactiveCocoa
    ReactiveCocoa - iOS 開發的新框架
    ReactiveCocoa2實戰
    ReactiveCocoa Essentials: Understanding and Using RACCommand

  • Kiwi
    TDD的iOS開發初步以及Kiwi使用入門
    Kiwi 使用進階 Mock, Stub, 參數捕獲和異步測試

  • RESTful API
    理解RESTful架構
    RESTful API 設計指南
    理解OAuth 2.0
    SSL/TLS協議運行機制的概述

  • Moco
    Moco能集成測試,還能移動開發;能前端開發,還能模擬Web服務器!

  • 測試
    行為驅動開發
    XCTest 測試實戰
    依賴注入
    糟糕的測試
    置換測試: Mock, Stub 和其他

  1. 上一頁:
  2. 下一頁:
蘋果刷機越獄教程| IOS教程問題解答| IOS技巧綜合| IOS7技巧| IOS8教程
Copyright © Ios教程網 All Rights Reserved