行事使得开发iOS

Designer News.png


前段时间在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
),思考问题的模式是瓜熟蒂落什么任务,咋样描述那个职责。关于对函数式编程入门概念的了然,可以参见酷壳《函数式编程》这篇随笔,深刻浅出对函数式编程的想想格局、特性和技能通过有些示范来讲学。

Base64编码

据我说知,苹果并没有提供API来是实现Base64编码,所以需要看官在网上寻找验证,还好,这并不难

感谢Lonely__angelababa的唤起,苹果是有Base64的API,截图如下:

苹果提供Base64API.png

Base64编码的思想是是行使64个基本的ASCII码字符对数据进行重新编码。它将急需编码的数量拆分成字节数组。以3个字节为一组。按顺序排列24 位数据,再把这24位数据分为4组,即每组6位。再在每组的的最高位前补两个0凑足一个字节。这样就把一个3字节为一组的数据重新编码成了4个字节。当所要编码的数量的字节数不是3的整倍数,也就是说在分组时末了一组不够3个字节。这时在最后一组填充1到2个0字节。并在终极编码完成后在终极添加1到2个
“=”。

例:将对ABC进行BASE64编码:

1、首先取ABC对应的ASCII码值。A(65)B(66)C(67);

2、再取二进制值A(01000001)B(01000010)C(01000011);

3、然后把这三个字节的二进制码接起来(010000010100001001000011);

4、
再以6位为单位分为4个数据块,并在最高位填充四个0后形成4个字节的编码后的值,(00010000)(00010100)(00001001)(00000011),其中加色部分为真正数据;

5、再把那六个字节数据转化成10进制数得(16)(20)(9)(3);

6、最终遵照BASE64给出的64个基本字符表,查出对应的ASCII码字符(Q)(U)(J)(D),这里的值实际就是数额在字符表中的索引。

Base64编码表

解码过程就是把4个字节再还原成3个字节再按照不同的数据格局把字节数组重新整理成多少。

Base64很直观的目标就是让二进制文件转发为64个主导的ASCII码字符。

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。

Typical MVC paradigm.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要旨类设计时,你就会询问它们中间的涉及和怎么抉择。

AES

系统也并不曾一向提供诸如DES、AES的API,可是提供了加密解密的相关操作CommonCrypto,DES或者AES的贯彻,需要我们团结一心包装一下。

加密是由算法/模式/填充构成的,算法是DES,AES等,
情势是EBC,CBC等,iOS和Android的填充是不一致的:

mac支持:

NoPadding (NoPadding就是不填充,相当于自定义填充)

PKCS7Padding

而java支持:

NoPadding

ISO10126Padding

OAEPPadding, OAEPWith<digest>And<mgf>Padding

PKCS1Padding

PKCS5Padding

SSL3Padding

接下去我们引入一些背景知识:

在密码学中,分组加密(Block
cipher,又称分块加密),是一种对称密钥算法。它将公开分成三个等长的模块(block),使用规定的算法和对称密钥对每组分别加密解密。分组加密是极其首要的加密协议组成,其中优秀的如DES和AES作为美利哥政坛表决的正统加密算法,应用领域从电子邮件加密到银行贸易转帐,异常普遍。

密码学中的工作模式:

最早出现的干活形式,ECB,CBC,OFB和CFB可以追溯到1981年。2001年,NIST修订了其原首发布的做事情势工作列表,参预了AES,并进入了CTR情势。最终,在二零一零年二月,NIST参与了XTS-AES,而任何的可信形式并不曾为NIST所认证。例如CTS是一种密文窃取的格局,许多广大的密码学运行库提供了这种情势。

密码学中,块密码的劳作格局允许拔取同一个块密码密钥对多于一块的数目举行加密,并保管其安全性。块密码自身只好加密长度等于密码块长度的单块数据,若要加密变长数据,则数据必须先被细分为部分单身的密码块。通常而言,最后一块数据也亟需动用合适填充方式将数据扩充到适合密码块大小的长短。一种工作形式描述了加密每一数据块的长河,并时时使用基于一个常备称为初始化向量的附加输入值以举办随机化,以保证安全。

初始化向量

初叶化向量(IV,Initialization
Vector)是累累做事形式中用来随机化加密的一块数据,由此得以由同样的通晓,相同的密钥暴发不同的密文,而无需重新爆发密钥,制止了普通很是复杂的这一过程。

初叶化向量与密钥比较有两样的安全性要求,由此IV平常并非保密,不过在大部分情形中,不应有在利用相同密钥的事态下两遍采用同一个IV。对于CBC和CFB,重用IV会导致泄露明文第一个块的少数信息,亦包括五个例外信息中相同的前缀。对于OFB和CTR而言,重用IV会导致全盘失去安全性。另外,在CBC格局中,IV在加密时务必是不可能揣测的;特此外,在许多实现中应用的发生IV的主意,例如SSL2.0应用的,即采纳上一个信息的结尾一块密文作为下一个信息的IV,是不安全的。

专注:ECB情势不需要起始化向量,之所以提一句,是因为我用的ECB情势。

填充

块密码只可以对规定长度的数量块举办处理,而音讯的尺寸一般是可变的。因而有的情势(即ECB和CBC)需要最终一块在加密前开展填空。有数种填充方法,其中最简便易行的一种是在平文的末段填充空字符以使其尺寸为块长度的平头倍,但必须保证可以苏醒平文的本来长度;例如,若平文是C语言风格的字符串,则唯有串尾会有空字符。稍微复杂一点的艺术则是原本的DES使用的艺术,即在数据后添加一个1位,再添加丰裕的0位直到满足块长度的渴求;若新闻长度刚好符合块长度,则增长一个填充块。最复杂的则是本着CBC的法子,例如密文窃取,残块终结等,不会生出额外的密文,但会扩大一些复杂度。布鲁斯(布鲁斯)·施奈尔和Neil斯·弗格森(Ferguson)指出了二种简单的可能性:添加一个值为128的字节(十六进制的80),再以0字节填满最终一个块;或向最后一个块填充n个值均为n的字节。

CFB,OFB和CTR情势不需要对长度不为密码块大小整数倍的音讯进行特其余处理。因为这几个情势是经过对块密码的输出与平文举行异或工作的。末了一个平文块(可能是不完全的)与密钥流块的前多少个字节异或后,发生了与该平文块大小相同的密文块。流密码的这些特性使得它们得以利用在需要密文和平文数据长度严谨相等的场面,也可以接纳在以流情势传输数据而不便利进行填写的场地。

专注:ECB格局是索要填写的。

ECB:
最简便的加密格局即为电子密码本(Electronic
codebook,ECB)形式。需要加密的信息按照块密码的块大小被分成数个块,并对各种块举办单独加密。

ECB加密

ECB解密

本办法的败笔在于同样的平文块会被加密成相同的密文块;由此,它无法很好的隐蔽数据格局。在少数场面,这种模式不可能提供严峻的数码保密性,因此并不引进用于密码协议中。下面的例子呈现了ECB在密文中体现平文的格局的程度:该图像的一个位图版本(上图)通过ECB形式也许会被加密成中图,而非ECB情势经常会将其加密成最下图。

原图

运用ECB情势加密

提供了伪随机性的非ECB格局

原图是选择CBC,CTR或其他另外的更安全的格局加密最下图可能产生的结果——与随机噪声无异。注意最下图看起来的随机性并无法表示图像已经被安全的加密;许多不安全的加密法也恐怕发生这种“随机的”输出。

ECB情势也会造成使用它的磋商无法提供数据完整性珍重,易境遇重播攻击的影响,因而每个块是以完全相同的艺术解密的。例如,“梦幻之星在线:青色脉冲”在线电子游戏采取ECB情势的Blowfish密码。在密钥交换系统被破解而暴发更简单的破解模式前,作弊者重复通过发送加密的“杀死怪物”音讯包以非官方的立即扩充阅历值。

其它情势在此就不开展了,详情请转块密码的做事形式
,进一步询问CBC、CFB、OFB、CTR等形式。

把最要害的函数摘出来解释一下:

/*!
    @function   CCCrypt
    @abstract   Stateless, one-shot encrypt or decrypt operation.
                This basically performs a sequence of CCCrytorCreate(),
                CCCryptorUpdate(), CCCryptorFinal(), and CCCryptorRelease().

    @param      alg             Defines the encryption algorithm.


    @param      op              Defines the basic operation: kCCEncrypt or
                    kCCDecrypt.

    @param      options         A word of flags defining options. See discussion
                                for the CCOptions type.

    @param      key             Raw key material, length keyLength bytes. 

    @param      keyLength       Length of key material. Must be appropriate 
                                for the select algorithm. Some algorithms may 
                                provide for varying key lengths.

    @param      iv              Initialization vector, optional. Used for 
                                Cipher Block Chaining (CBC) mode. If present, 
                                must be the same length as the selected 
                                algorithm's block size. If CBC mode is
                                selected (by the absence of any mode bits in 
                                the options flags) and no IV is present, a 
                                NULL (all zeroes) IV will be used. This is 
                                ignored if ECB mode is used or if a stream 
                                cipher algorithm is selected. 

    @param      dataIn          Data to encrypt or decrypt, length dataInLength 
                                bytes. 

    @param      dataInLength    Length of data to encrypt or decrypt.

    @param      dataOut         Result is written here. Allocated by caller. 
                                Encryption and decryption can be performed
                                "in-place", with the same buffer used for 
                                input and output. 

    @param      dataOutAvailable The size of the dataOut buffer in bytes.  

    @param      dataOutMoved    On successful return, the number of bytes
                    written to dataOut. If kCCBufferTooSmall is
                returned as a result of insufficient buffer
                space being provided, the required buffer space
                is returned here. 

    @result     kCCBufferTooSmall indicates insufficent space in the dataOut
                                buffer. In this case, the *dataOutMoved 
                                parameter will indicate the size of the buffer
                                needed to complete the operation. The 
                                operation can be retried with minimal runtime 
                                penalty. 
                kCCAlignmentError indicates that dataInLength was not properly 
                                aligned. This can only be returned for block 
                                ciphers, and then only when decrypting or when 
                                encrypting with block with padding disabled. 
                kCCDecodeError  Indicates improperly formatted ciphertext or
                                a "wrong key" error; occurs only during decrypt
                                operations. 
 */  

CCCryptorStatus CCCrypt(
    CCOperation op,         /* 枚举值,确认是加密操作,还是解密操作 */
    CCAlgorithm alg,        /* 枚举值,确认加解密的算法,如kCCAlgorithmAES128、kCCAlgorithmDES */
    CCOptions options,      /* 枚举值,kCCOptionPKCS7Padding | kCCOptionECBMode,经我调查,这样就是ECB模式,并以PKCS7来填充*/
    const void *key,
    size_t keyLength,
    const void *iv,         /* 初始化向量(NULLoptional initialization vector),ECB模式写NULL就行 */
    const void *dataIn,     /* optional per op and alg */
    size_t dataInLength,
    void *dataOut,          /* data RETURNED here */
    size_t dataOutAvailable,
    size_t *dataOutMoved)  

下面说到,iOS和Android填充是不同等的,这如何是好?据说,PKCS7Padding是兼容PKCS5Padding的,我在与安卓联合测试中,确实尚未问题。

把自家用的AES加密摘出来吧:

我用的是一个NSData类目NSData+AES,密钥是128位的,即16个字节,加密解密方法的贯彻如下(记得引#import <CommonCrypto/CommonCryptor.h>):

加密:

- (NSData *)AES128EncryptWithKey:(NSString *)key
{
    // 'key' should be 32 bytes for AES256, will be null-padded otherwise
    char keyPtr[kCCKeySizeAES128+1]; // room for terminator (unused)
    bzero(keyPtr, sizeof(keyPtr)); // fill with zeroes (for padding)

    // fetch key data
    [key getCString:keyPtr maxLength:sizeof(keyPtr) encoding:NSUTF8StringEncoding];

    NSUInteger dataLength = [self length];

    //See the doc: For block ciphers, the output size will always be less than or
    //equal to the input size plus the size of one block.
    //That's why we need to add the size of one block here
    size_t bufferSize = dataLength + kCCBlockSizeAES128;
    void *buffer = malloc(bufferSize);

    size_t numBytesEncrypted = 0;
    CCCryptorStatus cryptStatus = CCCrypt(kCCEncrypt, kCCAlgorithmAES128, kCCOptionPKCS7Padding | kCCOptionECBMode,
                                          keyPtr, kCCKeySizeAES128,
                                          NULL /* initialization vector (optional) */,
                                          [self bytes], dataLength, /* input */
                                          buffer, bufferSize, /* output */
                                          &numBytesEncrypted);
    if (cryptStatus == kCCSuccess) {
        //the returned NSData takes ownership of the buffer and will free it on deallocation
        return [NSData dataWithBytesNoCopy:buffer length:numBytesEncrypted];
    }

    free(buffer); //free the buffer;
    return nil;
}  

解密:

- (NSData *)AES128DecryptWithKey:(NSString *)key {
    // 'key' should be 32 bytes for AES256, will be null-padded otherwise
    char keyPtr[kCCKeySizeAES128+1]; // room for terminator (unused)
    bzero(keyPtr, sizeof(keyPtr)); // fill with zeroes (for padding)

    // fetch key data
    [key getCString:keyPtr maxLength:sizeof(keyPtr) encoding:NSUTF8StringEncoding];

    NSUInteger dataLength = [self length];

    //See the doc: For block ciphers, the output size will always be less than or
    //equal to the input size plus the size of one block.
    //That's why we need to add the size of one block here
    size_t bufferSize = dataLength + kCCBlockSizeAES128;
    void *buffer = malloc(bufferSize);

    size_t numBytesDecrypted = 0;
    CCCryptorStatus cryptStatus = CCCrypt(kCCDecrypt, kCCAlgorithmAES128, kCCOptionPKCS7Padding| kCCOptionECBMode,
                                          keyPtr, kCCKeySizeAES128,
                                          NULL /* initialization vector (optional) */,
                                          [self bytes], dataLength, /* input */
                                          buffer, bufferSize, /* output */
                                          &numBytesDecrypted);

    if (cryptStatus == kCCSuccess) {
        //the returned NSData takes ownership of the buffer and will free it on deallocation
        return [NSData dataWithBytesNoCopy:buffer length:numBytesDecrypted];
    }

    free(buffer); //free the buffer;
    return nil;
}  

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的羁绊,并且可以同时预览七个不同分辨率摩托罗拉的听从,极大地提升开发界面效能。

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是一个足以轻松搭建测试服务器的工具。

为啥需要效法服务端

作为一个移动开发人士,有时出于服务端开发进度慢,空有一个Samsung应用但公布不出效率。幸好有了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"
          }
      }
]
  1. 葡京在线开户,运行Moco HTTP服务器
    moco start -p 12306 -c foo.json
  2. 开辟浏览器访问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,否则无法博得emailText菲尔德(Field)(Field)和password,假诺利用手写UI代码就不需要了。另一个就是emailTextField(Field)或passwordTextField必须调用sendActionsForControlEvents:UIControlEventEditingChanged办法,才能触发text菲尔德(Field)的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。如若本身有点意见或实施精通有误,请各位多多指引。

扩展阅读