行為驅(qū)動開發(fā)iOS

Designer News.png

前段時間在design+code購買了一個學習iOS設計和編碼在線課程,使用Sketch設計App屉符,然后使用Swift語言實現(xiàn)Designer News客戶端。作者Meng To已經(jīng)開源到Github:MengTo/DesignerNewsApp · GitHub壳炎。雖然實現(xiàn)整個Designer News客戶端基本功能贬蛙,但是采用臃腫MVC(Model-View-Controller)架構(gòu),不易于代碼的測試和復用锰提,于是使用ReactiveCocoa實現(xiàn)MVVM(Model-View-View Model)架構(gòu)曙痘,加上一個用Objective-C實現(xiàn)的BDD測試框架Kiwi來單元測試,就可以行為驅(qū)動開發(fā)iOS App立肘。

ReactiveCocoa

ReactiveCocoa是一個用Objective-C編寫边坤,具有函數(shù)式和響應式特性的編程框架。大多數(shù)的開發(fā)者他們解決問題的思考方式都是如何完成任務赛不,通常的做法就是編寫很多指令惩嘉,然后修改重要數(shù)據(jù)結(jié)構(gòu)的狀態(tài),這種編程范式叫做命令式編程(Imperative Programming)踢故。與命令式編程不同的是函數(shù)式編程(Functional Programming)文黎,思考問題的方式是完成什么任務,怎樣描述這個任務殿较。關(guān)于對函數(shù)式編程入門概念的理解耸峭,可以參考酷殼《函數(shù)式編程》這篇文章,深入淺出對函數(shù)式編程的思考方式淋纲、特性和技術(shù)通過一些示例來講解劳闹。

ReactiveCocoa解決哪些問題?

  • 對象之間狀態(tài)與狀態(tài)的依賴過多問題
    借用ReactiveCocoa中一個例子來說明:用戶在登錄界面時洽瞬,有一個用戶名輸入框和密碼輸入框本涕,還有一個登錄按鈕。登錄交互要求如下:
  1. 當用戶名和密碼符合驗證格式伙窃,并且之前還沒登錄時菩颖,登錄按鈕才能點擊。
  2. 當點擊登錄成功登錄后为障,設置已登錄狀態(tài)晦闰。

傳統(tǒng)的做法代碼如下:

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統(tǒng)一處理的話,代碼更加簡潔和易讀声滥。使用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];
}
  • 傳統(tǒng)MVC架構(gòu)中眉撵,由于Controller承擔數(shù)據(jù)驗證、映射數(shù)據(jù)模型到View和操作View層次結(jié)構(gòu)等多個責任醒串,導致Controller過于臃腫执桌,不利于代碼的復用和測試。
    在傳統(tǒng)的MVC架構(gòu)中芜赌,主要有Model, View和Controller三部分組成仰挣。Model主要是保存數(shù)據(jù)和處理業(yè)務邏輯,View將數(shù)據(jù)顯示缠沈,而Controller調(diào)解關(guān)于Model和View之間的所有交互膘壶。
    當數(shù)據(jù)到達時,Model通過Key-Value Observation來通知View Controller, 然后View Controller更新View洲愤。當View與用戶交互后颓芭,View Controller更新Model。
Typical MVC paradigm.png

正如你所見柬赐,View Controller隱式承擔很多責任:數(shù)據(jù)驗證亡问、映射數(shù)據(jù)模型到View和操作View層次結(jié)構(gòu)。MVVM將很多邏輯從View Controller移走到View-Model肛宋,等介紹完ReactiveCocoa后會介紹MVVM架構(gòu)州藕。還有一些關(guān)于如何減負View Controller好文章請參閱objc中國更輕量的View Controllers系列:

  • 更輕量的 View Controllers

  • 整潔的 Table View 代碼

  • 測試 View Controllers

  • 使用Signal來代替KVO、Notification酝陈、Delegate和Target-Action等傳遞消息
    iOS開發(fā)中有多種消息傳遞方式床玻,KVO、Notification沉帮、Delegate锈死、Block和Target-Action,對于它們之間有什么差異以及如何選擇請參考《消息傳遞機制》穆壕。但RAC提供RACSignal來統(tǒng)一消息傳遞機制待牵,不再為如何選擇何種傳遞消息方式而煩惱。

    RAC對常用UI控件事件進行封裝成一個RACSignal對象喇勋,以便對發(fā)生的各種事件進行監(jiān)聽洲敢。
    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訪問服務器后對返回數(shù)據(jù)自創(chuàng)建一個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核心類設計時询一,你就會了解它們之間的關(guān)系和如何選擇。

ReactiveCocoa核心類設計

關(guān)于RAC核心類設計癌椿,官方文檔有詳細的解釋:Framework Overview

Sequence和Signal基本操作

了解完整個RAC核心類設計之后健蕊,要學會對Sequence和Signal基本操作,比如:用signal執(zhí)行side effects踢俄,轉(zhuǎn)換streams, 合并stream和合并signal缩功。詳情請查閱官方文檔:Basic Operators

MVVM架構(gòu)

MVVM high level.png

在MVVM架構(gòu)中,通常都將view和view controller看做一個整體都办。相對于之前MVC架構(gòu)中view controller執(zhí)行很多在view和model之間數(shù)據(jù)映射和交互的工作嫡锌,現(xiàn)在將它交給view model去做。
至于選擇哪種機制來更新view model或view是沒有強制的琳钉,但通常我們都選擇ReactiveCocoa势木。ReactiveCocoa會監(jiān)聽model的改變?nèi)缓髮⑦@些改變映射到view model的屬性中,并且可以執(zhí)行一些業(yè)務邏輯歌懒。
舉個例子來說啦桌,有一個model包含一個dateAdded的屬性,我想監(jiān)聽它的變化然后更新view model的dateAdded屬性及皂。但model的dateAdded屬性的數(shù)據(jù)類型是NSDate甫男,而view model的數(shù)據(jù)類型是NSString,所以在view model的init方法中進行數(shù)據(jù)綁定躲庄,但需要數(shù)據(jù)類型轉(zhuǎn)換查剖。示例代碼如下:

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

ViewModel調(diào)用dateFormatter進行數(shù)據(jù)轉(zhuǎn)換,且方法dateFormatter可以復用到其他地方噪窘。然后view controller監(jiān)聽view model的dateAdded屬性且綁定到label的text屬性笋庄。

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

現(xiàn)在我們抽象出日期轉(zhuǎn)換到字符串的邏輯到view model,使得代碼可以測試復用倔监,并且?guī)蛌iew controller瘦身直砂。

Kiwi

Kiwi是一個iOS行為驅(qū)動開發(fā)(Behavior Driven Development)的庫。相比于Xcode提供單元測試的XCTest是從測試的角度思考問題浩习,而Kiwi是從行為的角度思考問題静暂,測試用例都遵循三段式Given-When-Then的描述,清晰地表達測試用例是測試什么樣的對象或數(shù)據(jù)結(jié)構(gòu)谱秽,在基于什么上下文或情景洽蛀,然后做出什么響應摹迷。

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];
        });
    });
});

我們很容易根據(jù)上下文將其提取為Given..When..Then的三段式自然語言

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

用Xcode自帶的XCTest測試框架寫過測試代碼的朋友可能體會到,以上代碼更加易于閱讀和理解郊供。就算以后有新的開發(fā)者加入或修護代碼時峡碉,不需要太大的成本去閱讀和理解代碼。具體如何使用Kiwi驮审,請參考兩篇文章:

Designer News UI

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

Designer News Design.png

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

登陸界面

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

Login.png

登陸交互

登陸界面有Email輸入框和密碼輸入框费就,當用戶選中其他一個輸入框時诉瓦,左邊對應的圖標變成藍色,同時會有pop動畫表示用戶準備要輸入內(nèi)容力细。
當用戶沒有輸入有效的Email或密碼格式時睬澡,用戶是不能點擊登陸按鈕,只有當用戶輸入有效的郵件和密碼格式時眠蚂,才能點擊登陸按鈕煞聪。


Login.gif

我們可以使用RAC通過監(jiān)聽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"];
    }];
}

當點擊登陸按鈕后逝慧,客戶端向服務端發(fā)送驗證請求昔脯,服務端驗證完賬戶和密碼后,用戶便可以成功登陸笛臣。所以云稚,接下來要了解RESTful API的基本概念和Designer News提供的RESTful API。

Designer News API

RESTful API基本概念和設計

REST全稱是Representational State Transfer沈堡,翻譯過來就是表現(xiàn)層狀態(tài)轉(zhuǎn)化静陈。要想真正理解它的含義,從幾個關(guān)鍵字入手:Resource, Representation, State Transfer

  • Resource(資源)

資源就是網(wǎng)絡上的實體诞丽,它可以是文字鲸拥、圖片矢洲、聲音贷掖、視頻或一種服務瓢省。但網(wǎng)絡有這么多資源叉抡,該如何標識它們呢?你可以用URL(統(tǒng)一資源定位符)來唯一標識和定位它們角撞。只要獲得資源對應的URL,你就可以訪問它們勃痴。

  • Representation(表現(xiàn)層)

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

  • State Transfer(狀態(tài)轉(zhuǎn)換)

客戶端訪問服務端尖淘,服務端處理完后返回客戶端,在這個過程中著觉,一般都會引起數(shù)據(jù)狀態(tài)的改變或轉(zhuǎn)換村生。
客戶端操作服務端,都是通過HTTP協(xié)議饼丘,而在這個HTTP協(xié)議中趁桃,有幾個動詞: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

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

{
  "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": []
        },
  • 協(xié)議(protocol)
    用戶與API通信采用HTTPs協(xié)議
  • 域名(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具體網(wǎng)址,代表網(wǎng)絡一種資源典徘,所以不能有動詞蟀苛,只有使用名詞來表示。
  • HTTP動詞
    動詞GET逮诲,表示從服務端獲取Stories資源
  • 過濾信息(Filtering)
    ?client_id=91a5fed537b58c60f36be1sdf71ed1320e9e4af2bda4366f7dn3d79e63835278指定client_id的Stories資源
  • 狀態(tài)碼(Status Codes)
    服務器向客戶端返回表示成功或失敗的狀態(tài)碼帜平,狀態(tài)碼列表請參考Status Code Definitions
  • 錯誤處理(Error handling)
    服務端處理用戶請求失敗后,一般都返回error字段來表示錯誤信息
{
    error: "Invalid client id"
}

Designer News提供API

Designer News API Reference提供基于HTTP協(xié)議遵循RESTful設計的API汛骂,并且允許應用程序通過 oAuth 2授權(quán)協(xié)議來獲取授權(quán)權(quán)限來訪問用戶信息罕模。

訪問API工具

一般來說,在寫訪問服務端代碼之前帘瞭,我都會用Paw(下載地址)工具來測試API是否可行淑掌;另一方面,用JSON文件保存服務端返回的數(shù)據(jù)蝶念,用于moco模擬服務端的服務抛腕。至于為什么需要moco模擬服務端芋绸,后面會講解,現(xiàn)在通過用戶登錄Designer News這個例子介紹如何使用Paw來測試API担敌。
我們先看看Designer News提供訪問用戶登錄的API

Designer News Login API.png

根據(jù)以上提供的信息摔敛,API的路徑是https://api-news.layervault.com/oauth/token,參數(shù)有grant_type全封,username马昙,passwordclient_secret刹悴。其中usernamepasswordDesigner News注冊才能獲取行楞,而client_idclient_secret需要發(fā)送email到news@layervault.com申請。使用Paw發(fā)送請求和服務端返回結(jié)果如下:

New Send Request.png

Moco模擬服務端

Moco是一個可以輕松搭建測試服務器的工具土匀。

為什么需要模擬服務端

作為一個移動開發(fā)人員子房,有時由于服務端開發(fā)進度慢,空有一個iPhone應用但發(fā)揮不出作用就轧。幸好有了Moco证杭,只需配置一下請求和返回數(shù)據(jù),很快就可以搭建一個模擬服務妒御,無需等待服務端開發(fā)完成才能繼續(xù)開發(fā)解愤。當服務端完成后,修改訪問地址即可携丁。

有時服務端API應該是什么樣子都還沒清楚琢歇,由于有了moco模擬服務,在開發(fā)過程中梦鉴,可以不斷調(diào)整API設計李茫,搞清楚真正自己想要的API是什么樣子的。就這樣肥橙,在服務端代碼還沒真正動手之前魄宏,已經(jīng)提供一份真正滿足自己需要的API文檔,剩下的就交給服務端照著API去實現(xiàn)就行了存筏。

還有一種情況就是宠互,服務端已經(jīng)寫好了,剩下客戶端還沒完成椭坚。由于moco是本地服務予跌,訪問速度比較快,所以通過使用moco來模擬服務端善茎,這樣不僅可以提高客戶端的訪問速度券册,還提高網(wǎng)絡層測試代碼訪問速度的穩(wěn)定性,Designer News就是這樣情況。

如何使用Moco模擬服務

安裝

如果你是使用Mac或Linux烁焙,可以嘗試一下步驟:

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

現(xiàn)在你可以運行一下命令測試安裝是否成功

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

由于有時候服務端返回的數(shù)據(jù)比較多骄蝇,所以將服務端響應的數(shù)據(jù)獨立在一個JSON文件中膳殷。以登陸為例,將數(shù)據(jù)存放在login_response.json

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

而將請求uri路徑九火,方法(method)和參數(shù)(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赚窃,因為協(xié)議默認是http,而且通常運行在本機localhost岔激,所以在啟動模擬服務時只需指定端口12306就行考榨。想更加詳細了解如何配置,請查閱官網(wǎng)的HTTP(s) APIs
還有一個需要配置地方就是鹦倚,由于實際開發(fā)中肯定不止一個客戶端請求,所以還需要一個配置文件settings.json來包含很有的請求冀惭。

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

將路徑跳轉(zhuǎn)到DesignerNewsForObjc/DesignerNewsForObjcTests/JSON目錄震叙,找到settings.json文件,使用命令行來啟動服務:
moco start -p 12306 -g settings.json

使用Paw驗證是否配置成功
Send request to Local Server.png

行為驅(qū)動開發(fā)(BDD)

為什么需要BDD

不知道各位在編寫測試的時候散休,有沒有思考過一個問題:我應該測試什么媒楼?要回答這個問題并不是那么簡單,在沒得到答案之前戚丸,你還是繼續(xù)按照你的想法編寫測試划址。
-(void)testValidateEmail;
像這樣的測試,存在一個根本問題限府。它不會告訴你應該會發(fā)生什么夺颤,也不會預期實際會發(fā)生什么。還有胁勺,當它發(fā)生錯誤時世澜,不會提示你在哪里發(fā)生錯誤,錯誤的原因是什么署穗,因此你需要深入代碼才能知道失敗的原因寥裂。這樣就需要大量額外和不必要的認知負荷。
這時BDD出現(xiàn)了案疲,幫助開發(fā)者確定應該測試什么封恰,它提供DSL(Domain-specific language, 域特定語言),測試用例都遵循三段式Given-When-Then的描述褐啡,清晰地表達測試用例是測試什么樣的對象或數(shù)據(jù)結(jié)構(gòu)诺舔,在基于什么上下文或情景,然后做出什么響應。
所以混萝,我們應該關(guān)注行為遗遵,而不是測試。那行為具體是什么逸嘀?當你設計app里面的其中對象時车要,它的接口定義方法及其依賴關(guān)系,這些方法和依賴關(guān)系決定了你的對象如何與其他對象交互崭倘,以及它的功能是什么翼岁,定義你的對象的行為

BDD過程

行為驅(qū)動開發(fā)大概三個步驟:

  1. 選擇最重要的行為司光,并編寫行為的測試文件琅坡。此時,由于測試對象的類還沒編寫残家,所以編譯失敗榆俺。創(chuàng)建測試對象的類并編寫類的偽實現(xiàn),讓編譯通過坞淮。
  2. 實現(xiàn)被測試類的行為茴晋,讓測試通過。
  3. 如果發(fā)現(xiàn)代碼中有重復代碼回窘,重構(gòu)被測試類來消除重復

如果暫時不理解其中步驟細節(jié)诺擅,沒有關(guān)系,繼續(xù)向下閱讀啡直,后面有例子介紹來幫助你理解三個步驟的含義烁涌。

登陸驗證

網(wǎng)絡訪問層

DesignerNewsURL

DesignerNewsURL類封裝網(wǎng)絡訪問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實現(xiàn)文件有個條件編譯,判斷是在測試環(huán)境還是產(chǎn)品環(huán)境來決定baseURL的值酒觅,可以很方便在測試環(huán)境與產(chǎn)品環(huán)境互相切換撮执。

#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";
行為驅(qū)動開發(fā)LoginClient

在編寫代碼之前,我們應該先想想如何設計LoginClient類舷丹。首先根據(jù)Single responsibility principle(責任單一原則)二打,LoginClient主要負責用戶登錄的網(wǎng)絡訪問。需要提供一個接口掂榔,只要給定用戶名(username)和密碼(password)继效,用戶就能登錄,由于我是使用RAC來處理返回結(jié)果装获,所以這個接口返回RACSignal對象瑞信。

  • 創(chuàng)建一個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

根據(jù)三段式Given-When-Then描述凡简,上面代碼我們可以理解為:在給定LoginClient對象逼友,當用戶輸入正確的用戶名和密碼時,應該登錄成功秤涩。
這時帜乞,由于還沒創(chuàng)建LoginClient類,所以會不通過編譯筐眷,創(chuàng)建LoginClient類黎烈,并編寫它的偽實現(xiàn),讓LoginClientSpec.m通過編譯匀谣。

LoginClient.h.png
LoginClient.m.png

運行測試照棋,測試失敗。

LoginClient Failed.png
  • 實現(xiàn)LoginClient武翎,通過其測試
LoginClient.m .png
LoginClient Pass Test.png
  • 由于無冗余代碼烈炭,無需重構(gòu)

Model層

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

Controller與ViewModel層

controller是處理用戶交互的入口膏执,通常我都會將處理用戶交互的邏輯、數(shù)據(jù)綁定和數(shù)據(jù)校驗都交給ViewModel來精簡controller代碼露久,同時最大程度地復用業(yè)務邏輯的代碼。
我們先回顧用戶登陸時的步驟:1. 用戶先輸入email和密碼欺栗,只有email和密碼符合格式要求時才能點擊按鈕毫痕。2. 用戶成功登陸后,跳轉(zhuǎn)到故事列表主頁迟几。
我們先分析一下如何實現(xiàn)步驟1消请, 想要對email和密碼進行驗證,必須要監(jiān)聽它們兩個值的變化类腮,所以需要對emailTextFieldpasswordTextField使用RAC進行數(shù)據(jù)綁定臊泰。

創(chuàng)建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

這里有兩個關(guān)鍵點蚜枢,一個是從Storyboard中加載controller缸逃,否則不能獲取emailTextField和password,如果采用手寫UI代碼就不需要了厂抽。另一個就是emailTextField或passwordTextField必須調(diào)用sendActionsForControlEvents:UIControlEventEditingChanged方法需频,才能觸發(fā)textField的text屬性改變。

編譯失敗后筷凤,在LoginViewController.m編寫- (void)bindViewModel方法通過測試

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

實現(xiàn)完數(shù)據(jù)綁定行為后昭殉,接下來要數(shù)據(jù)校驗苞七,交給LoginViewModel來處理。創(chuàng)建LoginViewModelSpec.m文件挪丢,提供emailpassword屬性給LoginViewModel蹂风,返回驗證結(jié)果的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

編譯失敗后(已經(jīng)創(chuàng)建LoginViewModel類)乾蓬,添加- (RACSignal*)checkEmailPasswordSignal并實現(xiàn)驗證數(shù)據(jù)惠啄,通過測試

- (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創(chuàng)建屬性為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;
}

通過測試礁阁,完成登陸基本流程,至于登陸成功后如何返回故事列表頁面族奢,這里不詳細介紹姥闭,各位可以通過閱讀工程代碼便可以得到答案。

總結(jié)

最近一段時間都再看關(guān)于敏捷開發(fā)的書籍(用戶故事與敏捷方法越走,硝煙中的Scrum和XP, 解析極限編程)棚品,對敏捷開發(fā)很感興趣,但發(fā)覺很少公司或博客介紹如何實踐敏捷開發(fā)iOS廊敌,所以在網(wǎng)上搜集一些資料铜跑,發(fā)現(xiàn)有很多優(yōu)秀的實踐(測試驅(qū)動開發(fā),重構(gòu)骡澈,持續(xù)集成測試锅纺,增量設計,增量計劃)值得去學習肋殴,通過自己對敏捷開發(fā)中各種實踐的理解來重寫這個Designer News囤锉,這個Designer News功能還沒全部完成,希望各位看完這篇文章嘗試以這樣方式來完成整個app护锤。如果我有些觀點或?qū)嵺`理解有誤官地,請各位多多指點。

擴展閱讀

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末亏较,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子掩缓,更是在濱河造成了極大的恐慌宴杀,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,324評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件拾因,死亡現(xiàn)場離奇詭異旺罢,居然都是意外死亡旷余,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,356評論 3 392
  • 文/潘曉璐 我一進店門扁达,熙熙樓的掌柜王于貴愁眉苦臉地迎上來正卧,“玉大人,你說我怎么就攤上這事跪解÷酰” “怎么了?”我有些...
    開封第一講書人閱讀 162,328評論 0 353
  • 文/不壞的土叔 我叫張陵叉讥,是天一觀的道長窘行。 經(jīng)常有香客問我,道長图仓,這世上最難降的妖魔是什么罐盔? 我笑而不...
    開封第一講書人閱讀 58,147評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮救崔,結(jié)果婚禮上惶看,老公的妹妹穿的比我還像新娘。我一直安慰自己六孵,他們只是感情好纬黎,可當我...
    茶點故事閱讀 67,160評論 6 388
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著劫窒,像睡著了一般本今。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上主巍,一...
    開封第一講書人閱讀 51,115評論 1 296
  • 那天冠息,我揣著相機與錄音,去河邊找鬼煤禽。 笑死,一個胖子當著我的面吹牛岖赋,可吹牛的內(nèi)容都是我干的檬果。 我是一名探鬼主播,決...
    沈念sama閱讀 40,025評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼唐断,長吁一口氣:“原來是場噩夢啊……” “哼选脊!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起脸甘,我...
    開封第一講書人閱讀 38,867評論 0 274
  • 序言:老撾萬榮一對情侶失蹤恳啥,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后丹诀,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體钝的,經(jīng)...
    沈念sama閱讀 45,307評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡翁垂,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,528評論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了硝桩。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片沿猜。...
    茶點故事閱讀 39,688評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖碗脊,靈堂內(nèi)的尸體忽然破棺而出啼肩,到底是詐尸還是另有隱情,我是刑警寧澤衙伶,帶...
    沈念sama閱讀 35,409評論 5 343
  • 正文 年R本政府宣布祈坠,位于F島的核電站,受9級特大地震影響矢劲,放射性物質(zhì)發(fā)生泄漏赦拘。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,001評論 3 325
  • 文/蒙蒙 一卧须、第九天 我趴在偏房一處隱蔽的房頂上張望另绩。 院中可真熱鬧,春花似錦花嘶、人聲如沸笋籽。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,657評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽车海。三九已至,卻和暖如春隘击,著一層夾襖步出監(jiān)牢的瞬間侍芝,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,811評論 1 268
  • 我被黑心中介騙來泰國打工埋同, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留州叠,地道東北人。 一個月前我還...
    沈念sama閱讀 47,685評論 2 368
  • 正文 我出身青樓凶赁,卻偏偏與公主長得像咧栗,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子虱肄,可洞房花燭夜當晚...
    茶點故事閱讀 44,573評論 2 353

推薦閱讀更多精彩內(nèi)容