ReactiveCocoa實戰: 模仿 "花瓣",重寫 LeanCloud Rest Api的iOS REST Client.

效果圖

這一次咱們將要討論的是移動開發中比較重要的一環--網絡請求的封裝.鑑於我的經驗有限,本文將在必定程度上參考 基於AFNetworking2.0和ReactiveCocoa2.1的iOS REST Client,來以LeanCloudRest Api來練手.前兩節的示例,咱們都是使用自定義的PHP接口來做爲測試服務器,可是真實的服務器接口是涉及到許多細節的,好比一個基本的權限控制機制,用戶登陸登出等.爲了能更真實快速的開始網絡請求類的重構,本節選取一個國內較爲經常使用的後端開發平臺LeanCloud. 本文將實現一個擁有真實數據的博客App的Demo,數據源取自博客主站:ios122.com.html

完整代碼示例下載: githubios

將WP導出的XML數據轉換成JSON文件,導入LeanCloud.

首先,你是確定要先去它們官網註冊一個帳號,而後添加一個應用.這是我是添加了應用iOS122.而後新建一個名爲Post的Class,字段信息以下:git

iOS122是一個wordpress搭建的博客站點,導出的文章爲xml格式,須要處理成 LeanCloud 須要的JSON格式才能導入,主站文章很少,幾十篇,一個一個手動輸,也是能夠的.我將試着寫一小段代碼,來自動解析wp導出的文件,並根據須要生成對應的 JSON 文件.感興趣的,能夠本身試着弄下!github

/* 要實現的邏輯很簡單: 
 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控制檯顯示是這樣的:api

LeanCloud後臺

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

接下來的文字,思路上將在很大程度上參考 @limboy的文章,可是會相對更加完整.另外,其實 LeanCloud 實際上是有本身的iOS API的,可是是一個抽象的封裝,和實際應用中使用的網絡請求API有很大不一樣.兩種方式的差異,有點相似因而使用 字典等基本類型存儲數據,仍是使用 自定義的Model來存儲數據.兩種方式,不過多置評,我的傾向於後一種,方便後續的代碼重構.緩存

// TODO:Models Group包含了全部跟服務端API對應的Model,好比HBPCommentruby

基本結構

使用時,直接引用 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,也就是要實現<NSCoding>和<NSCopying>協議,這兩個協議的內容其實就是對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, 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];
}];

小結與預告

由於咱們的服務器,是傳統的PHP服務器,因此本文對LeanCloud的分析,僅供你們做爲技術實現上的一個參考.具體到本身的業務細節,可能有些地方,須要特殊處理.關於以上技術討論的問題,歡迎跟帖討論!

下一篇主題,會對單元測試的一些細節作一分析.邊摸索邊學習,總算真到了一個合適的重構咱們已有工程的策略了.重構量不小,最核心的一點是必須保證原有的代碼不受影響.也就是說,接下來兩週我要邊寫單元測試用例,邊重構代碼.期間遇到的關於測試的問題與坑,會及時記錄下來,彙總交流.

相關文章
相關標籤/搜索