ReactiveCocoa实战: 模仿 "花瓣",重写 LeanCloud Rest Api的iOS

这一次我们将要讨论的是移动开发中比较重要的一环--网络请求的封装.鉴于个人经验有限,本文将在一定程度上参考 基于AFNetworking2.0和ReactiveCocoa2.1的iOS REST Client,来以LeanCloudRest Api来练手.前两节的示例,我们都是使用自定义的PHP接口来作为测试服务器,但是真实的服务器接口是涉及到许多细节的,比如一个基本的权限控制机制,用户登录登出等.为了能更真实快速的开始网络请求类的重构,本节选取一个国内较为常用的后端开发平台LeanCloud. 本文将实现一个拥有真实数据的博客App的Demo,数据源取自博客主站:ios122.com.

完整代码示例下载: github

将WP导出的XML数据转换成JSON文件,导入LeanCloud.

首先,你是肯定要先去它们官网注册一个账号,然后添加一个应用.这是我是添加了应用iOS122.然后新建一个名为Post的Class,字段信息如下:

iOS122是一个wordpress搭建的博客站点,导出的文章为xml格式,需要处理成 LeanCloud 需要的JSON格式才能导入,主站文章不多,几十篇,一个一个手动输,也是可以的.我将试着写一小段代码,来自动解析wp导出的文件,并根据需要生成对应的 JSON 文件.感兴趣的,可以自己试着弄下!

/* 要实现的逻辑很简单: 
 1.读取XML文件;
 2.解析为JSON,并显示;
 3.将JSON输出为json文件.*/
    
/* 1.读取并解析XML. */
NSMutableArray * jsonArray = [NSMutableArray arrayWithCapacity: 42];
    
NSString *XMLFilePath = [[NSBundle mainBundle] pathForResource:@"Post" ofType:@"xml"];
ONOXMLDocument *document = [ONOXMLDocument XMLDocumentWithData:[NSData dataWithContentsOfFile:XMLFilePath] error: NULL];
    
NSString *XPath = @"//channel/item";
    
[document enumerateElementsWithXPath:XPath usingBlock:^(ONOXMLElement *element,__unused NSUInteger idx,__unused BOOL *stop) {
    ONOXMLElement * titleElement = [element firstChildWithTag:@"title"];
    ONOXMLElement * descElement = [element firstChildWithTag: @"encoded" inNamespace: @"excerpt"];
    ONOXMLElement * contentElement = [element firstChildWithTag: @"encoded" inNamespace:@"content"];
    
    NSDictionary * jsonDict = @{
                                @"title": [titleElement stringValue],@"desc": [descElement stringValue],@"body": [contentElement stringValue]};
    
    [jsonArray addObject: jsonDict];
}];
    
/* 2.显示JSON字符串. */
NSData * jsonData = [NSJSONSerialization dataWithJSONObject:jsonArray
                                                   options:NSJSONWritingPrettyPrinted
                                                     error:NULL];

NSString * jsonString = [[NSString alloc] initWithData:jsonData
                                             encoding:NSUTF8StringEncoding];
    
self.textView.text = jsonString;
    
/*3.存储到文件中.
 真机下,暂无法找到Documents目录下的东西,可以通过模拟器运行此段代码,并通过finder-->前往文件夹,输入此处jsonPath对应的文件路径来获取 Post.json 文件.
 */
NSArray *paths=NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES);
NSString * path=[paths objectAtIndex:0];
NSString * jsonPath=[path stringByAppendingPathComponent:@"Post.json"];

[jsonData writeToFile: jsonPath atomically:YES];
  • 导入后,LeanCloud控制台显示是这样的:

模仿 "花瓣",重写 LeanCloud Rest Api的iOS REST Client.

接下来的文字,思路上将在很大程度上参考 @limboy的文章,但是会相对更加完整.另外,其实 LeanCloud 其实是有自己的iOS API的,但是是一个抽象的封装,和实际应用中使用的网络请求API有很大不同.两种方式的差别,有点类似于是使用 字典等基本类型存储数据,还是使用 自定义的Model来存储数据.两种方式,不过多置评,个人倾向于后一种,方便后续的代码重构.

// TODO:Models Group包含了所有跟服务端API对应的Model,比如HBPComment

基本结构

使用时,直接引用 YFAPI.h 即可,里面包含了所有的Class:

|- YFAPI.h
|- Classes
    |- YFAPIManager.h
    |- YFAPIManager.m
    |- Models
        |- YFPostModel.h
        |- YFPostModel.h
           ...

YFAPIManager包含了所有的跟服务端通信的方法,通过Category来区分:

//
//  YFAPIManager.h
//  iOS122
//
//  Created by 颜风 on 15/10/28.
//  Copyright © 2015年 iOS122. All rights reserved.
//

#import <Foundation/Foundation.h>
#import <AFNetworking.h>

@class RACSignal,YFUserModel;

@interface YFAPIManager : AFHTTPRequestOperationManager

@property (nonatomic,nonatomic) YFUserModel * user; //!< 当前登录的用户,可能为nil.


/**
 *  一个单例.
 *
 *  @return 共享的实例对象.
 */
+ (instancetype) sharedInstance;

@end

/**
 *  私有扩展,其他网路请求的基础.
 */
@interface YFAPIManager (Private)

/**
 *  内部统一使用这个方法来向服务端发送请求
 *
 *  @param method       请求方式.
 *  @param relativePath 相对路径.
 *  @param parameters   参数.
 *  @param resultClass  从服务端获取到JSON数据后,使用哪个Class来将JSON转换为OC的Model.
 *
 *  @return RACSignal 信号对象.
 */
- (RACSignal *)requestWithMethod:(NSString *)method relativePath:(NSString *)relativePath parameters:(NSDictionary *)parameters resultClass:(Class)resultClass;

@end


/**
 *  用户信息相关的操作.
 */
@interface YFAPIManager (User)


/**
 *  用户登录.
 *
 *  获取到用户数据后,会自动更新User属性,所以仅需要在必要的地方观察user属性即可.
 *
 *  @param username 用户名.
 *  @param password 用户密码.
 *
 *  @return RACSingal对象,sendNext的是此类的的单例实例.
 */
- (RACSignal *)signInUsingUsername:(NSString *)username passowrd:(NSString *)password;

/**
 *  登出.
 *
 *  登出,其实就是把 user 属性设为nil.
 *
 *  @return sendNext为此类的单例实例.
 */
- (RACSignal *) logout;

@end

/**
 *  文章相关操作.
 */
@interface YFAPIManager (Post)
//....

@end

Models Group包含了所有跟服务端API对应的Model,比如 YFPostModel:

//
//  YFPostModel.h
//  iOS122
//
//  Created by 颜风 on 15/10/28.
//  Copyright © 2015年 iOS122. All rights reserved.
//

#import <Foundation/Foundation.h>
#import <Mantle.h>

/**
 *  文章.
 */
@interface YFPostModel : MTLModel <MTLJSONSerializing>

@property (strong,nonatomic) NSString * postId; //!< 文章唯一标识.
@property (copy,nonatomic) NSString * title; //!< 文章标题.
@property (copy,nonatomic) NSString * desc; //!< 文章简介.
@property (copy,nonatomic) NSString * body; //!< 文章详情.

@end
//
//  YFPostModel.m
//  iOS122
//
//  Created by 颜风 on 15/10/28.
//  Copyright © 2015年 iOS122. All rights reserved.
//

#import "YFPostModel.h"

@implementation YFPostModel

/**
 *  用于指定模型属性与JSON数据字段的对应关系.
 *
 *  @return 模型属性与JSON数据字段的对应关系:以模型属性为键,JSON字段为值.
 */

+ (NSDictionary *)JSONKeyPathsByPropertyKey {
    NSDictionary * dictMap = @{
                               @"postId": @"objectId",@"title": @"title",@"desc": @"desc",@"body": @"body"
                               };
    
    return dictMap;
}

@end

可以使用类似下面的语句,来将JSON转换为Model:

YFPostModel * model = [MTLJSONAdapter modelOfClass:[YFPostModel class] fromJSONDictionary:@{@"title": @"标题",@"desc": @"简介",@"body": @"内容",@"objectId": @"id"} error: NULL];

Archive / UnArchive / Copy

每一个Model都要支持Archive / UnArchive / Copy,也就是要实现 协议,这两个协议的内容其实就是对Object的Property做些处理,所以如果可以在基类里把这些事都统一处理,就会方便许多。考虑到设计的稳定性和后期的可扩展性,我们使用比较著名的第三方库-- Mantle 来处理.你可以使用CocoaPods安装这个库,然后引入头文件 #import <Mantle.h> 到自定义的Model中即可.

pod 'Mantle' # JSON <==> Model

用户的登录与登出

先来说说登录,由于使用RAC,在构造API时,就不需要传入Block了,随之而来的一个问题就是需要在注释中说明sendNext时会发送什么内容.LeanCloud用户登录接口会返回完整的用户信息:

+ (RACSignal *)signInUsingUsername:(NSString *)username passowrd:(NSString *)password
{
    NSDictionary *parameters = @{
                                 @"username": username,@"password": password,};
    
    YFAPIManager *manager = [self sharedInstance];

    // 需要配对使用@weakify 与 @strongify 宏,以防止block内的可能的循环引用问题.
    @weakify(manager);
    
    return [[[[manager rac_GET:@"login" parameters:parameters]
               // reduceEach的作用是传入多个参数,返回单个参数,是基于`map`的一种实现
               reduceEach:^id(NSDictionary *response,AFHTTPRequestOperation *operation){
                   @strongify(manager);
                   
                   YFUserModel * user = [MTLJSONAdapter modelOfClass:[YFUserModel class] fromJSONDictionary: response error: NULL];
                   
                   manager.user = user;
                   
                   return manager;
               }]
              // 避免side effect,有点类似于 "懒加载".
              replayLazily]
            setNameWithFormat:@"+signInUsingUsername:%@ password:%@",username,password];
}

用户的登出就简单了,直接设置user为nil就行了:

+ (RACSignal *)logout
{
    YFAPIManager * manager = [YFAPIManager sharedInstance];
    @weakify(manager);
    
    return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
        @strongify(manager);
        
        manager.user = nil;
        
        
        [subscriber sendNext: manager];
        
        [subscriber sendCompleted];
        
        return nil;
    }];
}

设置超时时间和缓存策略

"花瓣"采取的是重新定义 AFHTTPRequestSerializer 子类的方式,但其实用AOP,几行代码就够了:

// 设置超时和缓存策略.
[self.requestSerializer aspect_hookSelector:@selector(requestWithMethod:
                                                      URLString:
                                                      parameters:
                                                      error:) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo>info){
    /* 在方法调用后,来获取返回值,然后更改其属性. */
    // __autoreleasing 关键字是必须的,默认的 __strong,会引起后续代码的野指针崩溃.
    __autoreleasing NSMutableURLRequest *  request = nil;
    
    NSInvocation *invocation = info.originalInvocation;

    [invocation getReturnValue: &request];
    
    if (nil != request) {
        request.timeoutInterval = 30;
        request.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
        
        [invocation setReturnValue: &request];
    }
}error: NULL];

使用了一个AOP库,感兴趣的戳这里: Aspects.

权限验证

这个比较简单些,直接在方法里面加上判断属性self.isAuthenticated 即可:

if (!self.isAuthenticated)
{
  ....
}

其中 isAuthenticated 为基于self.user的推导属性,其实现如下:

RAC(self,isAuthenticated) = [RACSignal combineLatest:@[RACObserve(self,user)] reduce:^id{
    @strongify(self);
    
    BOOL isLogin = YES;
    
    if (nil == self.user || nil == self.user.token) {
        isLogin = NO;
    }
    
    return [NSNumber numberWithBool: isLogin];
}];

实现博客数据的访问.

这里我们要实现访问某个具体的博客数据,以验证上述各种基础构件的可用性.为了使示例更具有典型性,我手动将博客数据设为仅指定测试用户(测试用户可以在LeanCloud后台添加和指定)可以访问:

需要先实现- (RACSignal *)requestWithMethod:(YFAPIManagerMethod)method relativePath:(NSString *)relativePath parameters:(NSDictionary *)parameters resultClass:(Class)resultClass;方法,这是所有网络访问的基础,如下:

/**
 *  内部统一使用这个方法来向服务端发送请求
 *
 *  @param method       请求方式.
 *  @param relativePath 相对路径.
 *  @param parameters   参数.
 *  @param resultClass  从服务端获取到JSON数据后,使用哪个Class来将JSON转换为OC的Model.
 *
 *  @return RACSignal 信号对象.sendNext返回的是转换后的Model.
 */

- (RACSignal *)requestWithMethod:(YFAPIManagerMethod)method relativePath:(NSString *)relativePath parameters:(NSDictionary *)parameters resultClass:(Class)resultClass
{
    RACSignal * signal = nil;
    
    if (method == YFAPIManagerMethodGet) {
        signal = [self rac_GET:relativePath parameters:parameters];
    }
    
    if (method == YFAPIManagerMethodPut) {
        signal = [self rac_PUT:relativePath parameters:parameters];
    }
    
    if (method == YFAPIManagerMethodPost) {
        signal = [self rac_POST:relativePath parameters:parameters];
    }
    
    if (method == YFAPIManagerMethodPatch) {
        signal = [self rac_PATCH:relativePath parameters:parameters];
    }
    
    if (method == YFAPIManagerMethodDelete) {
        signal = [self rac_DELETE:relativePath parameters:parameters];
    }
    
    return [[signal reduceEach:^id(NSDictionary *response){
        id responseModel = [MTLJSONAdapter modelOfClass:resultClass fromJSONDictionary:response error:NULL];
        
        return responseModel;
    }]replayLazily];
}

然后添加一个用户博客详情访问的方法即可:

/**
 *  获取文章详情.
 *
 *  @param postId 文章id.
 *
 *  @return sendNext为获取到的文章数据模型.
 */

- (RACSignal *)fetchPostDetail:(NSString *)postId
{
    return [[self requestWithMethod:YFAPIManagerMethodGet relativePath:[NSString stringWithFormat:@"classes/Post/%@",postId] parameters:nil resultClass: [YFPostModel class]] setNameWithFormat: @"%@ -fetchPostDetail: %@",self.class,postId];
}

然后你就可以用类似下面的代码访问博客详情了:

[[[YFAPIManager sharedInstance] fetchPostDetail: @"56308138e4b0feb4c8ba2a34"] subscribeNext:^(YFPostModel * x) {
    NSLog(@"%@",x.body);
    
    [self.webView loadHTMLString:x.body baseURL:nil];
}];

一些你可能需要知道的技术细节

md5 加密

LeanClodu Rest API 需要在本地对masterKey在本地做一次md5加密,我封装了一个方法,可以直接用:

/**
 *  将字符串md5加密,并返回加密后的结果.
 *
 *  @param originalStr 原始字符串.
 *  @param lower       是否返回小写形式: YES,返回全小写形式;NO,返回全大写形式.
 *
 *  @return md5 加密后的结果.
 */
- (NSString *) md5Str: (NSString *) originalStr isLower: (BOOL) lower
{
    const char *original = [originalStr UTF8String];
    unsigned char result[CC_MD5_DIGEST_LENGTH];
    CC_MD5(original,(CC_LONG)strlen(original),result);
    NSMutableString *hash = [NSMutableString string];
    for (int i = 0; i < 16; i++)
    {
        [hash appendFormat:@"%02X",result[i]];
    }
    
    NSString * md5Result = [hash lowercaseString];
    
    if (NO == lower) {
        md5Result = [md5Result uppercaseString];
    }
    
    return md5Result;
}

动态设置请求头

因为LeanCloud的请求签权和时间戳有挂,所以每次请求都需要重置部分请求头,此处可以每个请求都手动设置,但是我是使用AOP,直接hook了一下(PS:强烈建议不知道AOP为何物的童鞋,学习下,真的很爽用起来):

// 每次发送请求前,都需要更新一下 请求头中的 apiClientSecret,因为它是时间戳相关的.
[self aspect_hookSelector:NSSelectorFromString(@"rac_requestPath:parameters:method:") withOptions:AspectPositionBefore usingBlock:^{
    @strongify(self);
    
    [self.requestSerializer setValue: self.apiClientSecret forHTTPHeaderField: @"X-LC-Sign"];
    
} error:NULL];

token值自动设置

这个其实算是RAC的基础,让token和user的变化绑定起来就行了,如果你想重写user的setter方法,然后出发请求头中token的变化,也是可以的(但我更喜欢RAC的写法了):

// 每次用户数据更新时,都需要重新设置下请求头中的token值.
[RACObserve(self,user) subscribeNext:^(YFUserModel * user) {
    @strongify(self);
    
    [self.requestSerializer setValue:user.token forHTTPHeaderField: @"X-LC-Session"];
}];

"推导属性"的实现

所谓"推导属性",就是那些附属的,是依据其他属性推断出来的属性,本身应该随着核心属性的变化而自动变化.实现方式有很多,可以重写此属性的getter方法,也可以像下面这样:

// 设置isAuthenticated.
RAC(self,user)] reduce:^id{
    @strongify(self);
    
    BOOL isLogin = YES;
    
    if (nil == self.user || nil == self.user.token) {
        isLogin = NO;
    }
    
    return [NSNumber numberWithBool: isLogin];
}];

小结与预告

因为我们的服务器,是传统的PHP服务器,所以本文对LeanCloud的分析,仅供大家作为技术实现上的一个参考.具体到自己的业务细节,可能有些地方,需要特殊处理.关于以上技术讨论的问题,欢迎跟帖讨论!

下一篇主题,会对单元测试的一些细节做一分析.边摸索边学习,总算真到了一个合适的重构我们已有工程的策略了.重构量不小,最核心的一点是必须保证原有的代码不受影响.也就是说,接下来两周我要边写单元测试用例,边重构代码.期间遇到的关于测试的问题与坑,会及时记录下来,汇总交流.

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


react 中的高阶组件主要是对于 hooks 之前的类组件来说的,如果组件之中有复用的代码,需要重新创建一个父类,父类中存储公共代码,返回子类,同时把公用属性...
我们上一节了解了组件的更新机制,但是只是停留在表层上,例如我们的 setState 函数式同步执行的,我们的事件处理直接绑定在了 dom 元素上,这些都跟 re...
我们上一节了解了 react 的虚拟 dom 的格式,如何把虚拟 dom 转为真实 dom 进行挂载。其实函数是组件和类组件也是在这个基础上包裹了一层,一个是调...
react 本身提供了克隆组件的方法,但是平时开发中可能很少使用,可能是不了解。我公司的项目就没有使用,但是在很多三方库中都有使用。本小节我们来学习下如果使用该...
mobx 是一个简单可扩展的状态管理库,中文官网链接。小编在接触 react 就一直使用 mobx 库,上手简单不复杂。
我们在平常的开发中不可避免的会有很多列表渲染逻辑,在 pc 端可以使用分页进行渲染数限制,在移动端可以使用下拉加载更多。但是对于大量的列表渲染,特别像有实时数据...
本小节开始前,我们先答复下一个同学的问题。上一小节发布后,有小伙伴后台来信问到:‘小编你只讲了类组件中怎么使用 ref,那在函数式组件中怎么使用呢?’。确实我们...
上一小节我们了解了固定高度的滚动列表实现,因为是固定高度所以容器总高度和每个元素的 size、offset 很容易得到,这种场景也适合我们常见的大部分场景,例如...
上一小节我们处理了 setState 的批量更新机制,但是我们有两个遗漏点,一个是源码中的 setState 可以传入函数,同时 setState 可以传入第二...
我们知道 react 进行页面渲染或者刷新的时候,会从根节点到子节点全部执行一遍,即使子组件中没有状态的改变,也会执行。这就造成了性能不必要的浪费。之前我们了解...
在平时工作中的某些场景下,你可能想在整个组件树中传递数据,但却不想手动地通过 props 属性在每一层传递属性,contextAPI 应用而生。
楼主最近入职新单位了,恰好新单位使用的技术栈是 react,因为之前一直进行的是 vue2/vue3 和小程序开发,对于这些技术栈实现机制也有一些了解,最少面试...
我们上一节了了解了函数式组件和类组件的处理方式,本质就是处理基于 babel 处理后的 type 类型,最后还是要处理虚拟 dom。本小节我们学习下组件的更新机...
前面几节我们学习了解了 react 的渲染机制和生命周期,本节我们正式进入基本面试必考的核心地带 -- diff 算法,了解如何优化和复用 dom 操作的,还有...
我们在之前已经学习过 react 生命周期,但是在 16 版本中 will 类的生命周期进行了废除,虽然依然可以用,但是需要加上 UNSAFE 开头,表示是不安...
上一小节我们学习了 react 中类组件的优化方式,对于 hooks 为主流的函数式编程,react 也提供了优化方式 memo 方法,本小节我们来了解下它的用...
开源不易,感谢你的支持,❤ star me if you like concent ^_^
hel-micro,模块联邦sdk化,免构建、热更新、工具链无关的微模块方案 ,欢迎关注与了解
本文主题围绕concent的setup和react的五把钩子来展开,既然提到了setup就离不开composition api这个关键词,准确的说setup是由...
ReactsetState的执行是异步还是同步官方文档是这么说的setState()doesnotalwaysimmediatelyupdatethecomponent.Itmaybatchordefertheupdateuntillater.Thismakesreadingthis.staterightaftercallingsetState()apotentialpitfall.Instead,usecom