UIStackView的简单使用与理解

之前一直在吐槽iOS的布局方式(frame和autolayout)相比前端的flex布局方式很落后,也在想有没有其它的方式来改善。最近偶然发现UIStackView的存在(苹果爸爸原谅我😂),了解后发现其中的使用与布局方式类似于flex布局,感觉这就是苹果爸爸借鉴flex布局特点所构造的一种布局实现方式吧。

实现方式

这里我们看一下如何简单的使用stackview来创造一个拥有众多子item的水平视图。代码如下:

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view, typically from a nib.
    
    containerView = [[UIStackView alloc]initWithFrame:CGRectMake(0, 100, CGRectGetWidth(self.view.bounds), 200)];
    //子视图布局方向:水平或垂直
    containerView.axis = UILayoutConstraintAxisHorizontal;//水平布局
    //子控件依据何种规矩布局
    containerView.distribution = UIStackViewDistributionFillEqually;//子控件均分
    //子控件之间的最小间距
    containerView.spacing = 10;
    //子控件的对齐方式
    containerView.alignment = UIStackViewAlignmentFill;
    NSArray *tempArray = @[@"1",@"2",@"3",@"4"];
    for (NSInteger i = 0; i < 4; i++) {
//        UIView *view = [[UIView alloc]init];
        UILabel *label = [[UILabel alloc] init];
        label.textAlignment = NSTextAlignmentCenter;
        label.backgroundColor = [UIColor colorWithRed:random()%256/255.0 green:random()%256/255.0 blue:random()%256/255.0 alpha:1];
        label.numberOfLines = 0;
        label.text = tempArray[i];
        
        [containerView addArrangedSubview:label];
        
    }
    [self.view addSubview:containerView];
}

可以看到stackView的使用和view没有大的区别,使用时根绝需要来设置stackView的axis(布局方向),distribution(子控件依据何种规矩布局),spacing(子控件之间的最小间距),alignment(子控件的对齐方式)等属性。

这里详细说明一下个属性的主要参数:

axis:
子控件的布局方向,水平(UILayoutConstraintAxisHorizontal)或垂直(UILayoutConstraintAxisVertical), 这个不用过多解释了

UIStackViewDistribution:

UIStackViewDistributionFill :它就是将 arrangedSubviews 填充满整个 StackView ,如果设置了spacing,那么这些 arrangedSubviews 之间的间距就是spacing。如果减去所有的spacing,所有的 arrangedSubview 的固有尺寸( intrinsicContentSize )不能填满或者超出 StackView 的尺寸,那就会按照 Hugging 或者 CompressionResistance 的优先级来拉伸或压缩一些 arrangedSubview 。如果出现优先级相同的情况,就按排列顺序来拉伸或压缩。

UIStackViewDistributionFillEqually :这种就是 StackView 的尺寸减去所有的spacing之后均分给 arrangedSubviews ,每个 arrangedSubview 的尺寸是相同的。

UIStackViewDistributionFillProportionally :这种跟FillEqually差不多,只不过这个不是讲尺寸均分给 arrangedSubviews ,而是根据 arrangedSubviews 的 intrinsicContentSize 按比例分配。

UIStackViewDistributionEqualSpacing :这种是使 arrangedSubview 之间的spacing相等,但是这个spacing是有可能大于 StackView 所设置的spacing,但是绝对不会小于。这个类型的布局可以这样理解,先按所有的 arrangedSubview 的 intrinsicContentSize 布局,然后余下的空间均分为spacing,如果大约 StackView 设置的spacing那这样就OK了,如果小于就按照 StackView 设置的spacing,然后按照 CompressionResistance 的优先级来压缩一个 arrangedSubview 。

UIStackViewDistributionEqualCentering :这种是使 arrangedSubview 的中心点之间的距离相等,这样没两个 arrangedSubview 之间的spacing就有可能不是相等的,但是这个spacing仍然是大于等于 StackView 设置的spacing的,不会是小于。这个类型布局仍然是如果 StackView 有多余的空间会均分给 arrangedSubviews 之间的spacing,如果空间不够那就按照 CompressionResistance 的优先级压缩 arrangedSubview 。

alignment:

UIStackViewAlignmentFill = 默认方式, 如果子控件水平布局, 则指子控件的垂直方向填充满stackView. 反之亦然

UIStackViewAlignmentLeading = 如果子控件竖直布局, 则指子控件左边对齐stackView左边. 反之亦然, 即 UIStackViewAlignmentTop = UIStackViewAlignmentLeading。

UIStackViewAlignmentTop = UIStackViewAlignmentLeading,

UIStackViewAlignmentFirstBaseline = 根据上方基线布局所有子视图的 y 值(适用于 Horizontal 模式)

UIStackViewAlignmentLastBaseline = 根据下方基线布局所有子视图的 y 值(适用于 Horizontal 模式)

UIStackViewAlignmentCenter = 中心对齐

UIStackViewAlignmentTrailing = 如果子控件竖直布局, 则指子控件左边对齐stackView右边. 反之亦然, 即UIStackViewAlignmentBottom = UIStackViewAlignmentTrailing

UIStackViewAlignmentBottom = UIStackViewAlignmentTrailing

这里还要说明几个方法:addArrangedSubviewremoveArrangedSubviewinsertArrangedSubview,日常view的添加和子视图从复视图删除使用的是addSubviewremoveFromSuperview

其中完整方法如下:

初始化数组:
- (instancetype)initWithArrangedSubviews:(NSArray *)views;
添加子视图: 
- (void)addArrangedSubview:(UIView *)view;
移除子视图:
- (void)removeArrangedSubview:(UIView *)view;
根据下标插入视图:
- (void)insertArrangedSubview:(UIView *)viewatIndex:(NSUInteger)stackIndex;

注意: addArrangedSubview 和 insertArrangedSubview, 会把子控件加到arrangedSubviews数组的同时添加到StackView的subView数组中,但是removeArrangedSubview, 只会把子控件从arrangedSubviews数组中移除,不会从subviews中移除,如果需要调用removeFromSuperview

若我们需要删除stackView中subView数组的最后一个视图,可以用如下方式:

//removeArrangedSubview, 只会把子控件从arrangedSubviews数组中移除,
//不会从subviews中移除,如果需要可调用removeFromSuperview
UIView *view = [_containerView.subviews lastObject];
[_containerView removeArrangedSubview:view];
[view removeFromSuperview];

到此stackView的一个简单使用方式就知道了。

2018/10/10 posted in  iOS

KVO自己的理解

  1. KVO利用runtime,生成了一个对象的子类,并生成子类对象替换原来对象的isa指针,重写了set方法。
  2. KVO是基于KVC的,可以明显的发现在改变容器的时候,通过KVC改变改变容器中的值或者使用set方法时,会触发KVO通知函数,而简单的使用addObject:方法时却没有触发,这是因为KVO只响应set方法。可以说kvc是kvo的入口
  3. 直接使用KVO在项目中不是很好用,代码结构比较松散,需要自己封装或使用其它三方框架。
2018/1/8 posted in  iOS

AFNetworking遇到的问题

由于AFNetworking运用了官方NSURLSession所以其中有一些坑

1.苹果运用NSJSONSerialization解析,出现数字类型精度问题

当服务器给我传回来一个3.0的数字类型时,在安卓端是没有问题的,但是在iOS这里会出现2.99999这样的问题。

出现这个问题的原因是:苹果在json解析时,默认为双精度的double类型。

我们相处的解决方案有两种:
1. 跟后台协商将数字型的值改为`字符型`
2. 使用第三方的json解析。

最后我们使用的是第一种方法,因为第二种势必要修改了AFNetwork的源码,开发与维护成本相比较来说要大。

2.json解析失败

由于AF默认的解析方式为0(返回的对象是不可变的,NSDictionaryNSArray):

我们来看一下都有什么选项:

所以当我们服务器返回的json数据是碎片化的(最外层既不是NSArray也不是NSDictionary),那么解析的时候就会出错了。

解决方法是:


在0后添加|字符,增加这种情况,允许碎片化数据。

3. 请求后response的状态码范围问题

正常项目中正常请求成功会返回200,但是服务器若是给你返回了500(我不知道后台为啥会返回这个码),问题就出现了:

这是由于下面的原因:

会发现上图中可接受状态码的范围是200-300;

具体解决方法就不说了。。。

2018/1/5 posted in  iOS

AFNetworking的简单使用

最近看学习一些项目代码到了使用AFNetworking的项目,所以去学习了一下,这里简单的总结一下,AFNetworking的使用方法。

AFNetworking简介

AFNetworking是一个很受大众欢迎的网络框架,可以帮助管理和处理网络任务请求,包括下载、上传、getpost请求等。

安装

AFNetworking的安装可以使用CocoaPods在文件中加入:

pod 'AFNetworking'

并执行 pod install就可以了。有一点要注意的是最新版本为3.1这个版本删除了基于NSURLConnectionOperationAFHTTPRequestOperationManager的支持。转而使用基于NSURLSession封装的AFHTTPSessionManager

网络监听

AFNetworking提供了一个监听网络状态的方法,来实时的判断当前网络是否良好。具体代码如下:

// 如果要检测网络状态的变化,必须用检测管理器的单例的startMonitoring
    /**
     AFNetworkReachabilityStatusUnknown          = -1,  // 未知
     AFNetworkReachabilityStatusNotReachable     = 0,   // 无连接
     AFNetworkReachabilityStatusReachableViaWWAN = 1,   // 3G 花钱
     AFNetworkReachabilityStatusReachableViaWiFi = 2,   // 局域网络,不花钱
     */
    [[AFNetworkReachabilityManager sharedManager] startMonitoring];
    [[AFNetworkReachabilityManager sharedManager] setReachabilityStatusChangeBlock:^(AFNetworkReachabilityStatus status) {
        debugLog(@"%ld",(long)status);
    }];

根据当前网络状态会输出所对应的状态数值。

下载

    //session的默认配置
    NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
    //根据配置创建管理者
    AFURLSessionManager *manager = [[AFURLSessionManager alloc] initWithSessionConfiguration:configuration];
    NSURL *url = [NSURL URLWithString:@"http://smartdsp.xmu.edu.cn/memberpdf/fuxueyang/cvpr2017/cvpr2017.pdf"];
    //根据url创建请求对象
    NSURLRequest *request = [NSURLRequest requestWithURL:url];
    //创建下载任务
    NSURLSessionDownloadTask *downloadTask = [manager downloadTaskWithRequest:request progress:nil destination:^NSURL * _Nonnull(NSURL * _Nonnull targetPath, NSURLResponse * _Nonnull response) {
        //设置下载路径
        NSURL *documentsDirectoryURL = [[NSFileManager defaultManager] URLForDirectory:NSDocumentDirectory inDomain:NSUserDomainMask appropriateForURL:nil create:NO error:nil];
        //返回文件存放在本地的地址
        return [documentsDirectoryURL URLByAppendingPathComponent:[response suggestedFilename]];
    } completionHandler:^(NSURLResponse * _Nonnull response, NSURL * _Nullable filePath, NSError * _Nullable error) {
        //下载完成后调用方法
        debugLog(@"File download to:%@ , %@",filePath,error);
    }];
    //开始下载任务
    [downloadTask resume];

get请求

    NSString *urlString = @"https://www.weifar.com/api/ExamQuestion/id";
    NSDictionary *parameters = @{@"id":@6};
    AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
    //根据上述参数和请求地址来发送请求
    [manager GET:urlString parameters:parameters progress:^(NSProgress * _Nonnull downloadProgress) {
        debugLog(@"%@",downloadProgress);
    } success:^(NSURLSessionDataTask * _Nonnull task, id  _Nullable responseObject) {
        //成功获取数据后,进行处理
        if (responseObject) {
            NSArray *a = responseObject[@"Questions"];
            NSDictionary *dic = a[0];
            NSString *str = dic[@"BlockDescription"];
            debugLog(@"%@",str);
        }
    } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
        //请求失败,打印错误
        debugLog(@"%@",error);
    }];

总结

以上就是AFNetworking的基本使用,还有其他的一些功能由于没有合适的接口可以进行操作,暂且先搁置下。具体的操作可以参考官方的AFNetworkingAPI文档,

2017/11/18 posted in  iOS

FMDB的使用方法

  • 总结

    最近再看其它大牛写的项目代码,发现许多用到了FMDB,所以去了解了一下。

    FMDB简介

    FMDB是一个第三方的开源库,我们可以通过cocopods搜索并整合到项目里面,FMDB其实就是对SQLiteAPI进行了封装,加上了面向对象的思想,让我们不必使用繁琐的C语言API函数,比起直接操作SQLite更加方便。

    并且FMDB 同时兼容 ARC 和非 ARC 工程,会自动根据工程配置来调整相关的内存管理代码。

    使用方法

    本文使用方法,均参考FMDBgithub项目文档https://github.com/ccgus/fmdb

    引入相关文件

    因为是对sqlite的封装所以我们在项目中需要引入它的库。

    之后在文件中导入它的头文件:

    #import "FMDB.h"
    

    建立数据库

    建立数据库只有简单的一句代码,如果当前路径不存在所需的数据库则会自动创建,若存在则会获取到。当路径为字符(@“”)时,一个空的数据库将被创建在临时的位置,数据库关闭时候将被自动删除。路径为NULL时空数据库会被放在内存中,关闭时也将自动被删除。具体信息可以参见:http://www.sqlite.org/inmemorydb.html

    #define PATH_OF_DOCUMENT    [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) objectAtIndex:0]
    
    _path = [PATH_OF_DOCUMENT stringByAppendingPathComponent:@"test.db"];
    //创建数据库
    FMDatabase *db = [FMDatabase databaseWithPath:_path];
    

    打开数据库

    在对数据库进行交互时,必须要先打开它。如果打开失败,可能是权限不足或者资源不足。

    if (![db open]) {
        // [db release];   // uncomment this line in manual referencing code; in ARC, this is not necessary/permitted
        db = nil;
        return;
    }
    

    执行更新(update)操作

    FMDB中除了select为查询(query)以为都为更新操作。

    例如我们执行一个插入操作的完整步骤为:

        static int idx = 1;
        FMDatabase *db = [FMDatabase databaseWithPath:_path];
        if ([db open]) {
            NSString * sql = @"insert into User (name, password) values(?, ?) ";
            NSString *name = [NSString stringWithFormat:@"lzh%d",idx++];
            BOOL result = [db executeUpdate:sql,name,@"op"];
            if (!result) {
                debugLog(@"error to insert data");
            }else{
                debugLog(@"succ to insert data");
            }
            [db close];
        }
    

    查询操作:

        FMDatabase *db = [FMDatabase databaseWithPath:_path];
        if ([db open]) {
            NSString *sql =@"select * from User";
            FMResultSet *result = [db executeQuery:sql];
            while ([result next]) {
                int userId = [result intForColumn:@"id"];
                NSString *name = [result stringForColumn:@"name"];
                NSString *pass = [result stringForColumn:@"password"];
                debugLog(@"user id = %d, name = %@, pass = %@", userId, name, pass);
            }
            [db close];
        }
    

    删除操作:

        static int idx = 1;
        FMDatabase *db =[FMDatabase databaseWithPath:_path];
        if ([db open]) {
            NSString *sql = @"delete from User where id = ?";
            BOOL result = [db executeUpdate:sql , @(idx++)];
            if (!result) {
                debugLog(@"error to delete db data");
            } else {
                debugLog(@"succ to deleta db data");
            }
            [db close];
        }
    

    我们可以看到执行sql语句的时候用的都是executeUpdate:方法。

    执行查询操作

    查询操作与上面的有点区别,我们需要用FMResultSet来存储我们的查询结果,并调用它的next:方法来对数据进行逐行操作:

        FMDatabase *db = [FMDatabase databaseWithPath:_path];
        if ([db open]) {
            NSString *sql =@"select * from User";
            FMResultSet *result = [db executeQuery:sql];
            while ([result next]) {
                int userId = [result intForColumn:@"id"];
                NSString *name = [result stringForColumn:@"name"];
                NSString *pass = [result stringForColumn:@"password"];
                debugLog(@"user id = %d, name = %@, pass = %@", userId, name, pass);
            }
            [db close];
        }
    

    上面代码可以发现执行sql语句变为executeQuery:方法,该方法会将结果返回为FMResultSet类型,之后我们需要调用stringForColumn:对结果进行解析。
    FMDB提供如下多个方法来获取不同类型的数据:

    intForColumn:
    longForColumn:
    longLongIntForColumn:
    boolForColumn:
    doubleForColumn:
    stringForColumn:
    dateForColumn:
    dataForColumn:
    dataNoCopyForColumn:
    UTF8StringForColumn:
    objectForColumn:
    

    也可以按照列的索引对数据进行获取,{type}ForColumnIndex:

    数据参数

    我们可以在sql语句中,用表示执行语句的参数,然后在 executeUpdate:方法来将?所指代的具体参数传入,例如上面的代码:

        NSString * sql = @"insert into User (name, password) values(?, ?) ";
        NSString *name = [NSString stringWithFormat:@"lzh%d",idx++];
        BOOL result = [db executeUpdate:sql,name,@"op"];
    

    线程安全

    FMDatabase这个类是线程不安全的,如果在多个线程同时使用一个FMDatabase实例,会造成数据混乱问题。所以,提供了一个FMDatabaseQueue并且使用它来对多个线程间进行交互,FMDatabaseQueue对象将通过接入多个线程进行同步和整合。

    使用的方法也很简单:

    首先创建一个数据库path来初始化FMDatabaseQueue,然后就可以将一个闭包 (block) 传入 inDatabase 方法中。

    
    FMDatabaseQueue *queue = [FMDatabaseQueue databaseQueueWithPath:aPath];
    
    [queue inDatabase:^(FMDatabase *db) {
        [db executeUpdate:@"INSERT INTO myTable VALUES (?)", @1];
        [db executeUpdate:@"INSERT INTO myTable VALUES (?)", @2];
        [db executeUpdate:@"INSERT INTO myTable VALUES (?)", @3];
    
        FMResultSet *rs = [db executeQuery:@"select * from foo"];
        while ([rs next]) {
            …
        }
    }];
    

    按照上面的方法我们可以创建多个线程来异步的对数据库进行操作:

    FMDatabaseQueue *queue = [FMDatabaseQueue databaseQueueWithPath:_path];
        dispatch_queue_t q1 = dispatch_queue_create("queue1", NULL);
        dispatch_queue_t q2 = dispatch_queue_create("queue2", NULL);
        
        dispatch_async(q1, ^{
            for (int i =1; i<100; ++i) {
                [queue inDatabase:^(FMDatabase *db){
                    NSString *sql = @"insert into User (name, password) values(?, ?)";
                    NSString *name = [NSString stringWithFormat:@"queue1 %d", i];
                    BOOL result = [db executeUpdate:sql,name,@"opop"];
                    if (!result) {
                        debugLog(@"error to add db data: %@", name);
                    } else {
                        debugLog(@"succ to add db data: %@", name);
                    }
                }];
            }
        });
        dispatch_async(q2,^{
            for (int i = 0; i < 100; ++i) {
                [queue inDatabase:^(FMDatabase *db) {
                    NSString * sql = @"insert into user (name, password) values(?, ?) ";
                    NSString * name = [NSString stringWithFormat:@"queue2 %d", i];
                    BOOL result = [db executeUpdate:sql, name, @"opop22"];
                    if (!result) {
                        debugLog(@"error to add db data: %@", name);
                    } else {
                        debugLog(@"succ to add db data: %@", name);
                    }
                }];
            }
        });
    

    执行后可以发现数据库中的部分表数据如下:

    两个线程可以异步执行互不干扰。

    上面数据库的显示 使用的是Navicat,也有其它的数据库管理软件可以显示。

    总结

    FMDB是一个在iOS上简化sqlite API的第三方库,对sqlite进行了很有好的封装,便于维护与增加效率。

  • 2017/11/17 posted in  iOS

    使用RAC的基本操作

    作为一个iOS开发者,你写的每一行代码几乎都是在相应某个事件,例如按钮的点击,收到网络消息,属性的变化(通过KVO)或者用户位置的变化(通过CoreLocation)。但是这些事件都用不同的方式来处理,比如action、delegate、KVO、callback等。ReactiveCocoa为事件定义了一个标准接口,从而可以使用一些基本工具来更容易的连接、过滤和组合。

    这里我们来创建一个简单的登录功能界面,当输入的namepassword符合我们的预期就输出success

    开始

    首先我们创建一个工程起名为FirstRAC,并在里面创建podfile文件:

    source 'https://github.com/CocoaPods/Specs.git'
    
    platform :ios, ‘8.0’
    use_frameworks!
    
    target 'FirstRAC’ do
    
        pod 'Masonry'
        pod 'ReactiveObjC', '~> 3.0.0'
    
    end
    

    首先我们创建一个button,两个LabeltextField如下图所示:

    这里我们的界面布局使用Masonry来进行约束。

    界面元素的属性声明为:

    @property(nonatomic,strong) UIView *login;
    @property(nonatomic,strong) UILabel *nameLabel;
    @property(nonatomic,strong) UILabel *passLabel;
    @property(nonatomic,strong) UITextField *name;
    @property(nonatomic,strong) UITextField *pass;
    @property(nonatomic,strong) UIButton *sign;
    

    元素的约束以及创建为:

    self.login = [[UIView alloc] initWithFrame:CGRectZero];
        
        _name = [[UITextField alloc] init];
        _name.borderStyle = UIFontWeightBold;
        _name.font = [UIFont systemFontOfSize:15];
        _name.placeholder = @"Enter Name";
        [_login addSubview:_name];
        
        _pass = [[UITextField alloc] init];
        _pass.borderStyle = UIFontWeightBold;
        _pass.font = [UIFont systemFontOfSize:15];
        _pass.placeholder = @"Enter Pass";
        [_login addSubview:_pass];
        
        _nameLabel = [[UILabel alloc] init];
        _nameLabel.backgroundColor = UIColor.whiteColor;
        _nameLabel.font = [UIFont systemFontOfSize:14.0];
        _nameLabel.lineBreakMode = NSLineBreakByTruncatingTail;
        _nameLabel.text = @"Name";
        [_login addSubview:_nameLabel];
        
        _passLabel = [[UILabel alloc] init];
        _passLabel.backgroundColor = UIColor.whiteColor;
        _passLabel.font = [UIFont systemFontOfSize:14.0];
        _passLabel.lineBreakMode = NSLineBreakByTruncatingTail;
        _passLabel.text = @"Pass";
        [_login addSubview:_passLabel];
        
        _sign = [UIButton buttonWithType:UIButtonTypeRoundedRect];
        _sign.layer.borderWidth = 3;
    //    _sign.layer.borderColor = [UIColor blueColor].CGColor;
        _sign.titleLabel.textColor = [UIColor redColor];
    //    _sign.backgroundColor = [UIColor greenColor];
        [_sign setTitle:@"Sign" forState:UIControlStateNormal];
        [_sign setTitle:@"Push" forState:UIControlStateHighlighted];
        [_sign setTitleColor:UIColor.blackColor forState:UIControlStateNormal];
        _sign.showsTouchWhenHighlighted = YES;
    //    [_sign addTarget:self action:@selector(buttonClick:) forControlEvents:UIControlEventTouchUpInside];
        [_login addSubview:_sign];
        [self.view addSubview:_login];
    
        [_login mas_makeConstraints:^(MASConstraintMaker *make){
            make.left.right.and.bottom.equalTo(self.view);
            make.top.equalTo(self.mas_topLayoutGuide).offset(@50);
        }];
        [_nameLabel mas_makeConstraints:^(MASConstraintMaker *make){
            make.top.equalTo(_login.top);
            make.height.equalTo(@20);
            make.left.equalTo(_login.left).with.offset(20);
        }];
        [_passLabel mas_makeConstraints:^(MASConstraintMaker *make){
            make.top.equalTo(_nameLabel.bottom).with.offset(10);
            make.centerX.equalTo(_nameLabel.centerX);
            make.height.equalTo(@20);
        }];
        [_name mas_makeConstraints:^(MASConstraintMaker *make){
            make.left.equalTo(_nameLabel.right).with.offset(5);
            make.centerY.equalTo(_nameLabel.centerY);
            make.height.equalTo(@20);
        }];
        [_pass mas_makeConstraints:^(MASConstraintMaker *make){
            make.left.equalTo(_passLabel.right).with.offset(5);
            make.centerY.equalTo(_passLabel.centerY);
            make.height.equalTo(@20);
        }];
        [_sign mas_makeConstraints:^(MASConstraintMaker *make){
            make.height.equalTo(@50);
            make.width.equalTo(@100);
            make.top.equalTo(_pass.bottom).offset(@50);
            make.centerX.equalTo(self.view.centerX);
        }];
    
    

    之后我们运用RAC为界面的元素添加各种事件,首先我们可以简单的为nametextField来订阅事件:

    [[self.name.rac_textSignal
         filter:^BOOL(id value){
             NSString *text = value;
             return text.length>3;
         }]
         subscribeNext:^(id x){
            NSLog(@"%@",x);
        }];
    

    该事件对输入的文本进行过滤,将输入文本的长度大于3的文本转化成信号,传递给下一个订阅事件;下一个订阅者输出文本内容。

    RACSignal有很多方法可以来订阅不同的事件类型。每个方法都需要至少一个block,当事件发生时就会执行block中的逻辑。在上面的例子中可以看到每次next事件发生时,
    subscribeNext:方法提供的block都会执行。

    ReactiveCocoa框架使用category来为很多基本UIKit控件添加signal。这样你就能给控件添加订阅了,text field的rac_textSignal就是这么来的。

    类型转换

    我们运行下面代码就会发现:

    [[[self.pass.rac_textSignal
           map:^id(NSString *text){
               return @(text.length);
           }]
          filter:^BOOL(NSNumber *length){
              return [length integerValue]>3;
          }]
         subscribeNext:^(id x){
             NSLog(@"%@",x);
         }];
    

    编译运行,你会发现log输出变成了文本的长度而不是内容。这是因为:新加的map操作通过block改变了事件的数据。map从上一个next事件接收数据,通过执行block把返回值传给下一个next事件。在上面的代码中,map以NSString为输入,取字符串的长度,返回一个NSNumber。

    如下图所示:

    能看到map操作之后的步骤收到的都是NSNumber实例。你可以使用map操作来把接收的数据转换成想要的类型,只要它是个对象。

    创建有效的状态信号

    首先我们创建两个信号;

    RACSignal *validUsernameSignal =[self.name.rac_textSignal
                                         map:^id(NSString *text){
                                             return @([self isValidUsername:text]);
                                         }];
    RACSignal *validPasswordSignal = [self.pass.rac_textSignal
                                          map:^id(NSString *text){
                                              return @([self isValidPassword:text]);
                                          }];
    
    - (BOOL)isValidUsername:(NSString *)username {
        return username.length > 3;
    }
    
    - (BOOL)isValidPassword:(NSString *)password {
        return password.length > 3;
    }
    

    可以看到,上面的代码对每个输入框的rac_textSignal应用了一个map转换。输出是一个用NSNumber封装的布尔值。

    下一步是转换这些信号,从而能为输入框设置不同的背景颜色。基本上就是,你订阅这些信号,然后用接收到的值来更新输入框的背景颜色。下面有一种方法很方便:

        RAC(self.name , backgroundColor) = [validUsernameSignal
                                            map:^id(NSNumber *nameValid){
                                                return [nameValid boolValue] ? [UIColor clearColor]:[UIColor yellowColor];
                                            }];
        RAC(self.pass , backgroundColor) = [validPasswordSignal
                                            map:^id(NSNumber *nameValid){
                                                return [nameValid boolValue] ? [UIColor clearColor]:[UIColor yellowColor];
                                            }];
    

    RAC宏允许直接把信号的输出应用到对象的属性上。RAC宏有两个参数,第一个是需要设置属性值的对象,第二个是属性名。每次信号产生一个next事件,传递过来的值都会应用到该属性上。

    聚合信号

    目前在应用中,登录按钮只有当用户名和密码输入框的输入都有效时才工作。现在要把这里改成响应式的。

    现在的代码中已经有可以产生用户名和密码输入框是否有效的信号了——validUsernameSignalvalidPasswordSignal了。现在需要做的就是聚合这两个信号来决定登录按钮是否可用。

    把下面的代码添加到viewDidLoad的末尾:

    RACSignal *signUpActiveSignal = [RACSignal combineLatest:@[validUsernameSignal,validPasswordSignal] reduce:^id(NSNumber *usernameValid, NSNumber *passwordValid){
            return @([usernameValid boolValue]&&[passwordValid boolValue]);
        }];
    

    上面的代码使用combineLatest:reduce:方法把validUsernameSignal和validPasswordSignal产生的最新的值聚合在一起,并生成一个新的信号。每次这两个源信号的任何一个产生新值时,reduce block都会执行,block的返回值会发给下一个信号。

    注意:RACsignal的这个方法可以聚合任意数量的信号,reduce block的参数和每个源信号相关。ReactiveCocoa有一个工具类RACBlockTrampoline,它在内部处理reduce block的可变参数。实际上在ReactiveCocoa的实现中有很多隐藏的技巧,值得你去看看。

    现在我们的逻辑变为:

    上图展示了一些重要的概念,你可以使用ReactiveCocoa来完成一些重量级的任务。

    • 分割——信号可以有很多subscriber,也就是作为很多后续步骤的源。注意上图中那个用来表示用户名和密码有效性的布尔信号,它被分割成多个,用于不同的地方。

    • 聚合——多个信号可以聚合成一个新的信号,在上面的例子中,两个布尔信号聚合成了一个。实际上你可以聚合并产生任何类型的信号。

    这些改动的结果就是,代码中没有用来表示两个输入框有效状态的私有属性了。这就是用响应式编程的一个关键区别,你不需要使用实例变量来追踪瞬时状态。

    响应式的登录

    应用目前使用上面图中展示的响应式管道来管理输入框和按钮的状态。但是按钮按下的处理用的还是action,所以下一步就是把剩下的逻辑都替换成响应式的。

    你已经知道了ReactiveCocoa框架是如何给基本UIKit控件添加属性和方法的了。目前你已经使用了rac_textSignal,它会在文本发生变化时产生信号。为了处理按钮的事件,现在需要用到ReactiveCocoaUIKit添加的另一个方法,rac_signalForControlEvents

    现在回到ViewController.m中,把下面的代码添加到viewDidLoad的末尾:

    [[self.signInButton
       rac_signalForControlEvents:UIControlEventTouchUpInside]
       subscribeNext:^(id x) {
         NSLog(@"button clicked");
       }];
    

    上面的代码从按钮的UIControlEventTouchUpInside事件创建了一个信号,然后添加了一个订阅,在每次事件发生时都会输出log
    你就会发现每次点击按钮后,就会响应输出事件,输出button clicked

    现在按钮有了点击事件的信号,下一步就是把它和登录流程连接起来。。那么问题就来了,打开RWSignInService.h,看一下接口:

    //RWSignInService.h
    #import <Foundation/Foundation.h>
    
    typedef void (^RWSignInResponse)(BOOL);
    
    @interface RWSignInService : NSObject
    - (void)signInWithUsername:(NSString *)username
                     password:(NSString *)password
                     complete:(RWSignInResponse)completeBlock;
    @end
    
    //RWSignInService.m
    #import "RWSignInService.h"
    
    @implementation RWSignInService
    
    - (void)signInWithUsername:(NSString *)username password:(NSString *)password complete:(RWSignInResponse)completeBlock {
        
        [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:YES];
        double delayInSeconds = 2.0;
        dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC));
        dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
            [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:NO];
            BOOL success = [username isEqualToString:@"user"] && [password isEqualToString:@"password"];
            completeBlock(success);
        });
    }
    
    @end
    

    这个service有3个参数,用户名、密码和一个完成回调block。这个block会在登录成功或失败时执行。你可以在按钮点击事件的subscribeNext: blcok里直接调用这个方法。

    注意:本教程为了简便使用了一个假的service,所以它不依赖任何外部API。但你现在的确遇到了一个问题,如何使用这些不是用信号表示的API呢?

    创建信号

    幸运的是,把已有的异步API用信号的方式来表示相当简单。,还是在ViewController.m中,添加下面的方法:

    -(RACSignal *)signInSignal{
        return [RACSignal createSignal:^RACDisposable *(id subscriber){
            [self.signInService signInWithUsername:self.name.text password:self.pass.text complete:^(BOOL success){
                [subscriber sendNext:@(success)];
                [subscriber sendCompleted];
            }];
            return nil;
        }];
    }
    

    上面的方法创建了一个信号,使用用户名和密码登录。现在分解来看一下。

    上面的代码使用RACSignalcreateSignal:方法来创建信号。方法的入参是一个block,这个block描述了这个信号。当这个信号有subscriber时,block里的代码就会执行。

    block的入参是一个subscriber实例,它遵循RACSubscriber协议,协议里有一些方法来产生事件,你可以发送任意数量的next事件,或者用error\complete事件来终止。本例中,信号发送了一个next事件来表示登录是否成功,随后是一个complete事件。

    这个block的返回值是一个RACDisposable对象,它允许你在一个订阅被取消时执行一些清理工作。当前的信号不需要执行清理操作,所以返回nil就可以了。

    可以看到,把一个异步API用信号封装是多简单!

    现在就来使用这个新的信号。把之前添加在viewDidLoad中的代码更新成下面这样的:

    [[[self.signInButton
       rac_signalForControlEvents:UIControlEventTouchUpInside]
       map:^id(id x){
         return[self signInSignal];
       }]
       subscribeNext:^(id x){
         NSLog(@"Sign in result: %@", x);
       }];
    
    

    上面的代码使用map方法,把按钮点击信号转换成了登录信号。subscriber输出log.

    编译运行,点击登录按钮,查看Xcode的控制台后发现输出的是一个信号实例,没错,你已经给subscribeNext:block传入了一个信号,但传入的不是登录结果的信号。

    当点击按钮时,rac_signalForControlEvents发送了一个next事件(事件的data是UIButton)。map操作创建并返回了登录信号,这意味着后续步骤都会收到一个RACSignal。这就是你在subscribeNext:这步看到的。

    上面问题的解决方法,有时候叫做信号中的信号,换句话说就是一个外部信号里面还有一个内部信号。你可以在外部信号的subscribeNext:block里订阅内部信号。不过这样嵌套太混乱啦,还好ReactiveCocoa已经解决了这个问题。

    解决的方法很简单,只需要把map操作改成flattenMap就可以了:

    [[[self.signInButton
       rac_signalForControlEvents:UIControlEventTouchUpInside]
       flattenMap:^id(id x){
         return[self signInSignal];
       }]
       subscribeNext:^(id x){
         NSLog(@"Sign in result: %@", x);
       }];
    

    这个操作把按钮点击事件转换为登录信号,同时还从内部信号发送事件到外部信号。

    到这里我们的大部分内容就结束了。最后就是在subscribeNext步骤里添加登录成功后的逻辑。把代码更新成下面的:

    [[[self.sign rac_signalForControlEvents:UIControlEventTouchUpInside]
         flattenMap:^id(id x){
             return [self signInSignal];
         }]
         subscribeNext:^(NSNumber*signedIn){
             BOOL success =[signedIn boolValue];
             if(success){
                 NSLog(@"Success!!!");
             }
         }];
    

    但是,你注意到这个应用现在有一些用户体验上的小问题了吗?当登录service正在校验用户名和密码时,登录按钮应该是不可点击的。这会防止用户多次执行登录操作。

    这个逻辑应该怎么添加呢?改变按钮的可用状态并不是转换(map)、过滤(filter)或者其他已经学过的概念。其实这个就叫做“副作用”,换句话说就是在一个next事件发生时执行的逻辑,而该逻辑并不改变事件本身。

    添加附加操作(Adding side-effects)

    把代码更新成下面的:

    [[[[self.sign rac_signalForControlEvents:UIControlEventTouchUpInside]
           doNext:^(id x){
               self.sign.enabled = NO;
           }]
         flattenMap:^id(id x){
             return [self signInSignal];
         }]
         subscribeNext:^(NSNumber*signedIn){
             BOOL success =[signedIn boolValue];
             if(success){
                 NSLog(@"Success!!!");
             }
         }];
    

    你可以看到doNext:是直接跟在按钮点击事件的后面。而且doNext: block并没有返回值。因为它是附加操作,并不改变事件本身。

    上面的doNext: block把按钮置为不可点击,隐藏登录失败提示。然后在subscribeNext: block里重新把按钮置为可点击,并根据登录结果来决定是否显示失败提示。

    之前的管道图就更新成下面这样的:

    现在所有的工作都已经完成了,这个应用已经是响应式的啦!!

    2017/10/19 posted in  iOS