iOS 应用架构谈(三):网络层设计方案 (上)

阅读数:9838 2015 年 7 月 20 日 06:14

编者按:iOS 客户端应用架构看似简单,但实际上要考虑的事情不少。本文作者将以系列文章的形式来讨论 iOS 应用架构中的种种问题,本文是其中的第三篇,主要讲网络层设计以及安全机制和优化方案。

前言

网络层在一个 App 中也是一个不可缺少的部分,工程师们在网络层能够发挥的空间也比较大。另外,苹果对网络请求部分已经做了很好的封装,业界的 AFNetworking 也被广泛使用。其它的 ASIHttpRequest,MKNetworkKit 啥的其实也都还不错,但前者已经弃坑,后者也在弃坑的边缘。在实际的 App 开发中,Afnetworking 已经成为了事实上各大 App 的标准配置。

网络层在一个 App 中承载了 API 调用,用户操作日志记录,甚至是即时通讯等任务。我接触过一些 App(开源的和不开源的)的代码,在看到网络层这一块时,尤其是在看到各位架构师各显神通展示了各种技巧,我非常为之感到兴奋。但有的时候,往往也对于其中的一些缺陷感到失望。

关于网络层的设计方案会有很多,需要权衡的地方也会有很多,甚至于争议的地方都会有很多。但无论如何,我都不会对这些问题做出任何逃避,我会在这篇文章中给出我对它们的看法和解决方案,观点绝不中立,不会跟大家打太极。

这篇文章就主要会讲这些方面:

  1. 网络层跟业务对接部分的设计
  2. 网络层的安全机制实现
  3. 网络层的优化方案

网络层跟业务对接部分的设计

在安居客 App 的架构更新换代的时候,我深深地感觉到网络层跟业务对接部分的设计有多么重要,因此我对它做的最大改变就是针对网络层跟业务对接部分的改变。网络层跟业务层对接部分设计的好坏,会直接影响到业务工程师实现功能时的心情。

在正式开始讲设计之前,我们要先讨论几个问题:

  1. 使用哪种交互模式来跟业务层做对接?
  2. 是否有必要将 API 返回的数据封装成对象然后再交付给业务层?
  3. 使用集约化调用方式还是离散型调用方式去调用 API?

这些问题讨论完毕之后,我会给出一个完整的设计方案来给大家做参考,设计方案是鱼,讨论的这些问题是渔,我什么都授了,大家各取所需。

使用哪种交互模式来跟业务层做对接?

这里其实有两个问题:一,以什么方式将数据交付给业务层?二,交付什么样的数据给业务层?

以什么方式将数据交付给业务层?

iOS 开发领域有很多对象间数据的传递方式,我看到的大多数 App 在网络层所采用的方案主要集中于这三种:Delegate,Notification,Block。KVO 和 Target-Action 我目前还没有看到有使用的。

目前我知道边锋主要是采用的 block,大智慧主要采用的是 Notification,安居客早期以 Block 为主,后面改成了以 Delegate 为主,阿里没发现有通过 Notification 来做数据传递的地方(可能有),Delegate、Block 以及 target-action 都有,阿里 iOS App 网络层的作者说这是为了方便业务层选择自己合适的方法去使用。这里大家都是各显神通,每次我看到这部分的时候,我都喜欢问作者为什么采用这种交互方案,但很少有作者能够说出个条条框框来。

然而在我这边,我的意见是以 Delegate 为主,Notification 为辅。原因如下:

  • 尽可能减少跨层数据交流的可能,限制耦合
  • 统一回调方法,便于调试和维护
  • 在跟业务层对接的部分只采用一种对接手段(在我这儿就是只采用 delegate 这一个手段)限制灵活性,以此来交换应用的可维护性

尽可能减少跨层数据交流的可能,限制耦合

什么叫跨层数据交流?就是某一层(或模块)跟另外的与之没有直接对接关系的层(或模块)产生了数据交换。为什么这种情况不好?严格来说应该是大部分情况都不好,有的时候跨层数据交流确实也是一种需求。之所以说不好的地方在于,它会导致代码混乱,破坏模块的封装性。我们在做分层架构的目的其中之一就在于下层对上层有一次抽象,让上层可以不必关心下层细节而执行自己的业务。

所以,如果下层细节被跨层暴露,一方面你很容易因此失去邻层对这个暴露细节的保护;另一方面,你又不可能不去处理这个细节,所以处理细节的相关代码就会散落各地,最终难以维护。

说得具象一点就是,我们考虑这样一种情况:A<-B<-C。当 C 有什么事件,通过某种方式告知 B,然后 B 执行相应的逻辑。一旦告知方式不合理,让 A 有了跨层知道 C 的事件的可能,你 就很难保证 A 层业务工程师在将来不会对这个细节作处理。一旦业务工程师在 A 层产生处理操作,有可能是补充逻辑,也有可能是执行业务,那么这个细节的相关处理代码就会有一部分散落在 A 层。然而前者是不应该散落在 A 层的,后者有可能是需求。另外,因为 B 层是对 A 层抽象的,执行补充逻辑的时候,有可能和 B 层针对这个事件的处理逻辑产生冲突,这是我们很不希望看到的。

那么什么情况跨层数据交流会成为需求?在网络层这边,信号从 2G 变成 3G 变成 4G 变成 Wi-Fi,这个是跨层数据交流的其中一个需求。不过其他的跨层数据交流需求我暂时也想不到了,哈哈,应该也就这一个吧。

严格来说,使用 Notification 来进行网络层和业务层之间数据的交换,并不代表这一定就是跨层数据交流,但是使用 Notification 给跨层数据交流开了一道口子,因为 Notification 的影响面不可控制,只要存在实例就存在被影响的可能。另外,这也会导致谁都不能保证相关处理代码就在唯一的那个地方,进而带来维护灾难。作为架构师,在这里给业务工程师限制其操作的灵活性是必要的。另外,Notification 也支持一对多的情况,这也给代码散落提供了条件。同时,Notification 所对应的响应方法很难在编译层面作限制,不同的业务工程师会给他取不同的名字,这也会给代码的可维护性带来灾难。

手机淘宝架构组的侠武同学曾经给我分享过一个问题,在这里我也分享给大家:曾经有一个工程师在监听 Notification 之后,没有写释放监听的代码,当然,找到这个原因又是很漫长的一段故事,现在找到原因了,然而监听这个 Notification 的对象有那么多,不知道具体是哪个 Notificaiton,也不知道那个没释放监听的对象是谁。后来折腾了很久大家都没办法的时候,有一个经验丰富的工程师提出用 hook(Method Swizzling)的方式,最终找到了那个没释放监听的对象,bug 修复了。

我分享这个问题的目的并不是想强调 Notification 多么多么不好,Notification 本身就是一种设计模式,在属于它的问题领域内,Notification 是非常好的一种解决方案。但我想强调的是,对于网络层这个问题领域内来看,架构师首先一定要限制代码的影响范围,在能用影响范围小的方案的时候就尽量采用这种小的方案,否则将来要是有什么奇怪需求或者出了什么小问题,维护起来就非常麻烦。因此 Notification 这个方案不能作为首选方案,只能作为备选。

那么 Notification 也不是完全不能使用,当需求要求跨层时,我们就可以使用 Notification,比如前面提到的网络条件切换,而且这个需求也是需要满足一对多的。

所以,为了符合前面所说的这些要求,使用 Delegate 能够很好地避免跨层访问,同时限制了响应代码的形式,相比 Notification 而言有更好的可维护性。

然后我们顺便来说说为什么尽量不要用 block

1. block 很难追踪,难以维护

我们在调试的时候经常会单步追踪到某一个地方之后,发现尼玛这里有个 block,如果想知道这个 block 里面都做了些什么事情,这时候就比较蛋疼了。

- (void)someFunctionWithBlock:(SomeBlock *)block
{
    ... ...
 -> block();  // 当你单步走到这儿的时候,要想知道 block 里面都做了哪些事情的话,就很麻烦。
    ... ...
}

2. block 会延长相关对象的生命周期

block 会给内部所有的对象引用计数加一,这一方面会带来潜在的 retain cycle,不过我们可以通过 Weak Self 的手段解决。另一方面比较重要就是,它会延长对象的生命周期。

在网络回调中使用 block,是 block 导致对象生命周期被延长的其中一个场合,当 ViewController 从 window 中卸下时,如果尚有请求带着 block 在外面飞,然后 block 里面引用了 ViewController(这种场合非常常见),那么 ViewController 是不能被及时回收的,即便你已经取消了请求,那也还是必须得等到请求着陆之后才能被回收。

然而使用 delegate 就不会有这样的问题,delegate 是弱引用,哪怕请求仍然在外面飞,,ViewController 还是能够及时被回收的,回收之后指针自动被置为了 nil,无伤大雅。

所以平时尽量不要滥用 block,尤其是在网络层这里。

3. 统一回调方法,便于调试和维护

前面讲的是跨层问题,区分了 Delegate 和 Notification,顺带谈了一下 Block。然后现在谈到的这个情况,就是另一个采用 Block 方案不是很合适的情况。首先,Block 本身无好坏对错之分,只有合适不合适。在这一节要讲的情况里,Block 无法做到回调方法的统一,调试和维护的时候也很难在调用栈上显示出来,找的时候会很蛋疼。

在网络请求和网络层接受请求的地方时,使用 Block 没问题。但是在获得数据交给业务方时,最好还是通过 Delegate 去通知到业务方。因为 Block 所包含的回调代码跟调用逻辑放在同一个地方,会导致那部分代码变得很长,因为这里面包括了调用前和调用后的逻辑。从另一个角度说,这在一定程度上违背了 single function,single task 的原则,在需要调用 API 的地方,就只要写 API 调用相关的代码,在回调的地方,写回调的代码。

然后我看到大部分 App 里,当业务工程师写代码写到这边的时候,也意识到了这个问题。因此他们会在 block 里面写个一句话的方法接收参数,然后做转发,然后就可以把这个方法放在其他地方了,绕过了 Block 的回调着陆点不统一的情况。比如这样:

  [API callApiWithParam:param successed:^(Response *response){
        [self successedWithResponse:response];
    } failed:^(Request *request, NSError *error){
        [self failedWithRequest:request error:error];
    }];

这实质上跟使用 Delegate 的手段没有什么区别,只是绕了一下,不过还是没有解决统一回调方法的问题,因为 block 里面写的方法名字可能在不同的 ViewController 对象中都会不一样,毕竟业务工程师也是很多人,各人有各人的想法。所以架构师在这边不要贪图方便,还是使用 delegate 的手段吧,业务工程师那边就能不用那么绕了。Block 是目前大部分第三方网络库都采用的方式,因为在发送请求的那一部分,使用 Block 能够比较简洁,因此在请求那一层是没有问题的,只是在交换数据之后,还是转变成 delegate 比较好,比如 AFNetworking 里面:

  [AFNetworkingAPI callApiWithParam:self.param successed:^(Response *response){
        if ([self.delegate respondsToSelector:@selector(successWithResponse:)]) {
            [self.delegate successedWithResponse:response];
        }
    } failed:^(Request *request, NSError *error){
        if ([self.delegate respondsToSelector:@selector(failedWithResponse:)]) {
            [self failedWithRequest:request error:error];
        }
    }];

这样在业务方这边回调函数就能够比较统一,便于维护。

综上,对于以什么方式将数据交付给业务层?这个问题的回答是这样:

尽可能通过 Delegate 的回调方式交付数据,这样可以避免不必要的跨层访问。当出现跨层访问的需求时(比如信号类型切换),通过 Notification 的方式交付数据。正常情况下应该是避免使用 Block 的。

交付什么样的数据给业务层?

我见过非常多的 App 的网络层在拿到 JSON 数据之后,会将数据转变成对应的对象原型。注意,我这里指的不是 NSDictionary,而是类似 Item 这样的对象。这种做法是能够提高后续操作代码的可读性的。在比较直觉的思路里面,是需要这部分转化过程的,但这部分转化过程的成本是很大的,主要成本在于:

  1. 数组内容的转化成本较高:数组里面每项都要转化成 Item 对象,如果 Item 对象中还有类似数组,就很头疼。
  2. 转化之后的数据在大部分情况是不能直接被展示的,为了能够被展示,还需要第二次转化。
  3. 只有在 API 返回的数据高度标准化时,这些对象原型(Item)的可复用程度才高,否则容易出现类型爆炸,提高维护成本。
  4. 调试时通过对象原型查看数据内容不如直接通过 NSDictionary/NSArray 直观。
  5. 同一 API 的数据被不同 View 展示时,难以控制数据转化的代码,它们有可能会散落在任何需要的地方。

其实我们的理想情况是希望 API 的数据下发之后就能够直接被 View 所展示。首先要说的是,这种情况非常少。另外,这种做法使得 View 和 API 联系紧密,也是我们不希望发生的。

在设计安居客的网络层数据交付这部分时,我添加了 reformer(名字而已,叫什么都好)这个对象用于封装数据转化的逻辑,这个对象是一个独立对象,事实上,它是作为 Adaptor 模式存在的。我们可以这么理解:想象一下我们洗澡时候使用的莲蓬头,水管里出来的水是 API 下发的原始数据。reformer 就是莲蓬头上的不同水流挡板,需要什么模式,就拨到什么模式。

在实际使用时,代码观感是这样的:

先定义一个 protocol:
@protocol ReformerProtocol 
- (NSDictionary)reformDataWithManager:(APIManager *)manager;
@end
在 Controller 里是这样:
@property (nonatomic, strong) id<ReformerProtocol> XXXReformer;
@property (nonatomic, strong) id<ReformerProtocol> YYYReformer;
#pragma mark - APIManagerDelegate
- (void)apiManagerDidSuccess:(APIManager *)manager
{
    NSDictionary *reformedXXXData = [manager fetchDataWithReformer:self.XXXReformer];
    [self.XXXView configWithData:reformedXXXData];
    NSDictionary *reformedYYYData = [manager fetchDataWithReformer:self.YYYReformer];
    [self.YYYView configWithData:reformedYYYData];
}
在 APIManager 里面,fetchDataWithReformer 是这样:
- (NSDictionary)fetchDataWithReformer:(id)reformer
{
    if (reformer == nil) {
        return self.rawData;
    } else {
        return [reformer reformDataWithManager:self];
    }
}
  • 要点 1:reformer 是一个符合 ReformerProtocol 的对象,它提供了通用的方法供 Manager 使用。
  • 要点 2:API 的原始数据(JSON 对象)由 Manager 实例保管,reformer 方法里面取 Manager 的原始数据 (manager.rawData) 做转换,然后交付出去。莲蓬头的水管部分是 Manager,负责提供原始水流(数据流),reformer 就是不同的模式,换什么 reformer 就能出来什么水流。
  • 要点 3:例子中举的场景是一个 API 数据被多个 View 使用的情况,体现了 reformer 的一个特点:可以根据需要改变同一数据来源的展示方式。比如 API 数据展示的是“附近的小区”,那么这个数据可以被列表(XXXView)和地图(YYYView)共用,不同的 view 使用的数据的转化方式不一样,这就通过不同的 reformer 解决了。
  • 要点 4:在一个 view 用来同一展示不同 API 数据的情况,reformer 是绝佳利器。比如安居客的列表 view 的数据来源可能有三个:二手房列表 API,租房列表 API,新房列表 API。这些 API 返回来的数据的 value 可能一致,但是 key 都是不一致的。这时候就可以通过同一个 reformer 来做数据的标准化输出,这样就使得 view 代码复用成为可能。这体现了 reformer 另外一个特点:同一个 reformer 出来的数据是高度标准化的。形象点说就是:只要莲蓬头不换,哪怕水管的水变成海水或者污水了,也依旧能够输出符合洗澡要求的淡水水流。举个例子:
- (void)apiManagerDidSuccess:(APIManager *)manager
{
    // 这个回调方法有可能是来自二手房列表 APIManager 的回调,也有可能是 
租房,也有可能是新房。但是在 Controller 层面我们不需要对它做额外区分,
只要是同一个 reformer 出来的数据,我们就能保证是一定能被 self.XXXView 使 
用的。这样的保证由 reformer 的实现者来提供。
    NSDictionary *reformedXXXData = [manager 
fetchDataWithReformer:self.XXXReformer];
    [self.XXXView configWithData:reformedXXXData];
}
  • 要点 5:有没有发现,使用 reformer 之后,Controller 的代码简洁了很多?而且,数据原型在这种情况下就没有必要存在了,随之而来的成本也就被我们绕过了。

reformer 本质上就是一个符合某个 protocol 的对象,在 controller 需要从 api manager 中获得数据的时候,顺便把 reformer 传进去,于是就能获得经过 reformer 重新洗过的数据,然后就可以直接使用了。

更抽象地说,reformer 其实是对数据转化逻辑的一个封装。在 controller 从 manager 中取数据之后,并且把数据交给 view 之前,这期间或多或少都是要做一次数据转化的,有的时候不同的 view,对应的转化逻辑还不一样,但是展示的数据是一样的。而且往往这一部分代码都非常复杂,且跟业务强相关,直接上代码,将来就会很难维护。所以我们可以考虑采用不同的 reformer 封装不同的转化逻辑,然后让 controller 根据需要选择一个合适的 reformer 装上,就像洗澡的莲蓬头,需要什么样的水流(数据的表现形式)就换什么样的头,然而水(数据)都是一样的。这种做法能够大大提高代码的可维护性,以及减少 ViewController 的体积。

总结一下,reformer 事实上是把转化的代码封装之后再从主体业务中拆分了出来,拆分出来之后不光降低了原有业务的复杂度,更重要的是,它提高了数据交付的灵活性。另外,由于 Controller 负责调度 Manager 和 View,因此它是知道 Manager 和 View 之间的关系的,Controller 知道了这个关系之后,就有了充要条件来为不同的 View 选择不同的 Reformer,并用这个 Reformer 去改造 Mananger 的数据,然后 ViewController 获得了经过 reformer 处理过的数据之后,就可以直接交付给 view 去使用。Controller 因此得到瘦身,负责业务数据转化的这部分代码也不用写在 Controller 里面,提高了可维护性。

所以 reformer 机制能够带来以下好处:

  • 好处 1:绕开了 API 数据原型的转换,避免了相关成本。
  • 好处 2:在处理单 View 对多 API,以及在单 API 对多 View 的情况时,reformer 提供了非常优雅的手段来响应这种需求,隔离了转化逻辑和主体业务逻辑,避免了维护灾难。
  • 好处 3:转化逻辑集中,且将转化次数转为只有一次。使用数据原型的转化逻辑至少有两次,第一次是把 JSON 映射成对应的原型,第二次是把原型转变成能被 View 处理的数据。reformer 一步到位。另外,转化逻辑在 reformer 里面,将来如果 API 数据有变,就只要去找到对应 reformer 然后改掉就好了。
  • 好处 4:Controller 因此可以省去非常多的代码,降低了代码复杂度,同时提高了灵活性,任何时候切换 reformer 而不必切换业务逻辑就可以应对不同 View 对数据的需要。
  • 好处 5:业务数据和业务有了适当的隔离。这么做的话,将来如果业务逻辑有修改,换一个 reformer 就好了。如果其他业务也有相同的数据转化逻辑,其他业务直接拿这个 reformer 就可以用了,不用重写。另外,如果 controller 有修改(比如 UI 交互方式改变),可以放心换 controller,完全不用担心业务数据的处理。

在不使用特定对象表征数据的情况下,如何保持数据可读性?

不使用对象来表征数据的时候,事实上就是使用 NSDictionary 的时候。事实上,这个问题就是,如何在 NSDictionary 表征数据的情况下保持良好的可读性?

苹果已经给出了非常好的做法,用固定字符串做 key,比如你在接收到 KeyBoardWillShow 的 Notification 时,带了一个 userInfo,他的 key 就都是类似 UIKeyboardAnimationCurveUserInfoKey 这样的,所以我们采用这样的方案来维持可读性。下面我举一个例子:

PropertyListReformerKeys.h
extern NSString * const kPropertyListDataKeyID;
extern NSString * const kPropertyListDataKeyName;
extern NSString * const kPropertyListDataKeyTitle;
extern NSString * const kPropertyListDataKeyImage;
PropertyListReformer.h
#import "PropertyListReformerKeys.h"
... ...
PropertyListReformer.m
NSString * const kPropertyListDataKeyID = @"kPropertyListDataKeyID";
NSString * const kPropertyListDataKeyName = @"kPropertyListDataKeyName";
NSString * const kPropertyListDataKeyTitle = @"kPropertyListDataKeyTitle";
NSString * const kPropertyListDataKeyImage = @"kPropertyListDataKeyImage";
- (NSDictionary *)reformData:(NSDictionary *)originData fromManager:(APIManager *)manager
{
    ... ...
    ... ...
    NSDictionary *resultData = nil;
    if ([manager isKindOfClass:[ZuFangListAPIManager class]]) {
        resultData = @{
            kPropertyListDataKeyID:originData[@"id"],
            kPropertyListDataKeyName:originData[@"name"],
            kPropertyListDataKeyTitle:originData[@"title"],
            kPropertyListDataKeyImage:[UIImage imageWithUrlString:originData[@"imageUrl"]]
        };
    }
    if ([manager isKindOfClass:[XinFangListAPIManager class]]) {
        resultData = @{
            kPropertyListDataKeyID:originData[@"xinfang_id"],
            kPropertyListDataKeyName:originData[@"xinfang_name"],
            kPropertyListDataKeyTitle:originData[@"xinfang_title"],
            kPropertyListDataKeyImage:[UIImage imageWithUrlString:originData[@"xinfang_imageUrl"]]
        };
    }
    if ([manager isKindOfClass:[ErShouFangListAPIManager class]]) {
        resultData = @{
            kPropertyListDataKeyID:originData[@"esf_id"],
            kPropertyListDataKeyName:originData[@"esf_name"],
            kPropertyListDataKeyTitle:originData[@"esf_title"],
            kPropertyListDataKeyImage:[UIImage imageWithUrlString:originData[@"esf_imageUrl"]]
        };
    }
    return resultData;
}
PropertListCell.m
#import "PropertyListReformerKeys.h"
- (void)configWithData:(NSDictionary *)data
{
    self.imageView.image = data[kPropertyListDataKeyImage];
    self.idLabel.text = data[kPropertyListDataKeyID];
    self.nameLabel.text = data[kPropertyListDataKeyName];
    self.titleLabel.text = data[kPropertyListDataKeyTitle];
}

这一大段代码看下来,我如果不说一下要点,那基本上就白写了哈:

我们先看一下结构:

使用 Const 字符串来表征 Key,字符串的定义跟着 reformer 的实现文件走,字符串的 extern 声明放在独立的头文件内。

这样 reformer 生成的数据的 key 都使用 Const 字符串来表示,然后每次别的地方需要使用相关数据的时候,把 PropertyListReformerKeys.h 这个头文件 import 进去就好了。

另外要注意的一点是,如果一个 OriginData 可能会被多个 Reformer 去处理的话,Key 的命名规范需要能够表征出其对应的 reformer 名字。如果 reformer 是 PropertyListReformer,那么 Key 的名字就是 PropertyListKeyXXXX。

这么做的好处就是,将来迁移的时候相当方便,只要扔头文件就可以了,只扔头文件是不会导致拔出萝卜带出泥的情况的。而且也避免了自定义对象带来的额外代码体积。

另外,关于交付的 NSDictionary,其实具体还是看 view 的需求,reformer 的设计初衷是:通过 reformer 转化出来的可以直接是 View,或者是 view 直接可以使用的对象(包括 NSDictionary)。比如地图标点列表 API 的数据,通过 reformer 转化之后就可以直接变成 MKAnnotation,然后 MKMapView 就可以直接使用了。这里说的只是当你的需求是交付 NSDictionary 时,如何保证可读性的情况,再强调一下哈,reformer 交付的是 view 直接可以使用的对象,交付出去的可以是 NSDictionary,也可以是 UIView,跟 DataSource 结合之后交付的甚至可以是 UITableViewCell/UICollectionViewCell。不要被 NSDictionary 或所谓的转化成 model 再交付的思想局限。

综上,我对交付什么样的数据给业务层?这个问题的回答就是这样:

对于业务层而言,由 Controller 根据 View 和 APIManager 之间的关系,选择合适的 reformer 将 View 可以直接使用的数据(甚至 reformer 可以用来直接生成 view)转化好之后交付给 View。对于网络层而言,只需要保持住原始数据即可,不需要主动转化成数据原型。然后数据采用 NSDictionary 加 Const 字符串 key 来表征,避免了使用对象来表征带来的迁移困难,同时不失去可读性。

集约型 API 调用方式和离散型 API 调用方式的选择?

集约型 API 调用其实就是所有 API 的调用只有一个类,然后这个类接收 API 名字,API 参数,以及回调着陆点(可以是 target-action,或者 block,或者 delegate 等各种模式的着陆点)作为参数。然后执行类似 startRequest 这样的方法,它就会去根据这些参数起飞去调用 API 了,然后获得 API 数据之后再根据指定的着陆点去着陆。比如这样:

集约型 API 调用方式:

[APIRequest startRequestWithApiName:@"itemList.v1" params:params
 success:@selector(success:) fail:@selector(fail:) target:self];

离散型 API 调用是这样的,一个 API 对应于一个 APIManager,然后这个 APIManager 只需要提供参数就能起飞,API 名字、着陆方式都已经集成入 APIManager 中。比如这样:

离散型 API 调用方式:

@property (nonatomic, strong) ItemListAPIManager *itemListAPIManager;

// getter 
- (ItemListAPIManager *)itemListAPIManager 
{ 
if (_itemListAPIManager == nil) { 
_itemListAPIManager = [[ItemListAPIManager alloc] init]; 
_itemListAPIManager.delegate = self; 
}

return _itemListAPIManager;
}

// 使用的时候就这么写: 
[self.itemListAPIManager loadDataWithParams:params];

集约型 API 调用和离散型 API 调用这两者实现方案不是互斥的,单看下层,大家都是集约型。因为发起一个 API 请求之后,除去业务相关的部分(比如参数和 API 名字等),剩下的都是要统一处理的:加密,URL 拼接,API 请求的起飞和着陆,这些处理如果不用集约化的方式来实现,作者非癫即痴。然而对于整个网络层来说,尤其是业务方使用的那部分,我倾向于提供离散型的 API 调用方式,并不建议在业务层的代码直接使用集约型的 API 调用方式。原因如下:

原因 1:当前请求正在外面飞着的时候,根据不同的业务需求存在两种不同的请求起飞策略:一个是取消新发起的请求,等待外面飞着的请求着陆。另一个是取消外面飞着的请求,让新发起的请求起飞。集约化的 API 调用方式如果要满足这样的需求,那么每次要调用的时候都要多写一部分判断和取消的代码,手段就做不到很干净。

前者的业务场景举个例子就是刷新页面的请求,刷新详情,刷新列表等。后者的业务场景举个例子是列表多维度筛选,比如你先筛选了商品类型,然后筛选了价格区间。当然,后者的情况不一定每次筛选都要调用 API,我们先假设这种筛选每次都必须要通过调用 API 才能获得数据。

如果是离散型的 API 调用,在编写不同的 APIManager 时候就可以针对不同的 API 设置不同的起飞策略,在实际使用的时候,就可以不必关心起飞策略了,因为 APIMananger 里面已经写好了。

原因 2:便于针对某个 API 请求来进行 AOP。在集约型的 API 调用方式下,如果要针对某个 API 请求的起飞和着陆过程进行 AOP,这代码得写成什么样。。。噢,尼玛这画面太美别说看了,我都不敢想。

原因 3:当 API 请求的着陆点消失时,离散型的 API 调用方式能够更加透明地处理这种情况。

当一个页面的请求正在天上飞的时候,用户等了好久不耐烦了,小手点了个 back,然后 ViewController 被 pop 被回收。此时请求的着陆点就没了。这是很危险的情况,着陆点要是没了,就很容易 crash 的。一般来说处理这个情况都是在 dealloc 的时候取消当前页面所有的请求。如果是集约型的 API 调用,这个代码就要写到 ViewController 的 dealloc 里面,但如果是离散型的 API 调用,这个代码写到 APIManager 里面就可以了,然后随着 ViewController 的回收进程,APIManager 也会被跟着回收,这部分代码就得到了调用的机会。这样业务方在使用的时候就可以不必关心着陆点消失的情况了,从而更加关注业务。

原因 4:离散型的 API 调用方式能够最大程度地给业务方提供灵活性,比如 reformer 机制就是基于离散型的 API 调用方式的。另外,如果是针对提供翻页机制的 API,APIManager 就能简单地提供 loadNextPage 方法去加载下一页,页码的管理就不用业务方去管理了。还有就是,如果要针对业务请求参数进行验证,比如用户填写注册信息,在离散型的 APIManager 里面实现就会非常轻松。

综上,关于集约型的 API 调用和离散型的 API 调用,我倾向于这样:对外提供一个 BaseAPIManager 来给业务方做派生,在 BaseManager 里面采用集约化的手段组装请求,放飞请求,然而业务方调用 API 的时候,则是以离散的 API 调用方式来调用。如果你的 App 只提供了集约化的方式,而没有离散方式的通道,那么我建议你再封装一层,便于业务方使用离散的 API 调用方式来放飞请求。

怎么做 APIManager 的继承?

如果要做成离散型的 API 调用,那么使用继承是逃不掉的。BaseAPIManager 里面负责集约化的部分,外部派生的 XXXAPIManager 负责离散的部分,对于 BaseAPIManager 来说,离散的部分有一些是必要的,比如 API 名字等,而我们派生的目的,也是为了提供这些数据。

我在这篇文章里面列举了种种继承的坏处,呼吁大家尽量不要使用继承。但是现在到了不得不用继承的时候,所以我得提醒一下大家别把继承用坏了。

在 APIManager 的情况下,我们最直觉的思路是 BaseAPIManager 提供一些空方法来给子类做重载,比如 apiMethodName 这样的函数,然而我的建议是,不要这么做。我们可以用 IOP 的方式来限制派生类的重载。

大概就是长这样:

BaseAPIManager 的 init 方法里这么写:

// 注意是 weak。 
@property (nonatomic, weak) id child;

(instancetype)init 
{ 
self = [super init]; 
if ([self confirmsToProtocol:@protocol(APIManager)]) { 
self.child = (id)self; 
} else { 
// 不遵守这个 protocol 的就让他 crash,防止派生类乱来。 
NSAssert(NO, "子类必须要实现 APIManager 这个 protocol。"); 
} 
return self; 
}

protocol 这么写,把原本要重载的函数都定义在这个 protocol 里面,就不用在父类里面写空方法了:

@protocol APIManager

@required 
- (NSString *)apiMethodName; 
...

@end

然后在父类里面如果要使用的话,就这么写:

[self requestWithAPIName:[self.child apiMethodName] ......];

简单说就是在 init 的时候检查自己是否符合预先设计的子类的 protocol,这就要求所有子类必须遵守这个 protocol,所有针对父类的重载、覆盖也都以这个 protocol 为准,protocol 以外的方法不允许重载、覆盖。而在父类的代码里,可以不必遵守这个 protocol,保持了未来维护的灵活性。

这么做的好处就是避免了父类写空方法,同时也给子类带上了紧箍咒:要想当我的孩子,就要遵守这些规矩,不能乱来。业务方在实现子类的时候,就可以根据 protocol 中的方法去一一实现,然后约定就比较好做了:不允许重载父类方法,只允许选择实现或不实现 protocol 中的方法。

关于这个的具体的论述在这篇文章里面有,感兴趣的话可以看看。

网络层与业务层对接部分的小总结

这一节主要是讲了以下这些点:

  1. 使用 delegate 来做数据对接,仅在必要时采用 Notification 来做跨层访问
  2. 交付 NSDictionary 给业务层,使用 Const 字符串作为 Key 来保持可读性
  3. 提供 reformer 机制来处理网络层反馈的数据,这个机制很重要,好处极多
  4. 网络层上部分使用离散型设计,下部分使用集约型设计
  5. 设计合理的继承机制,让派生出来的 APIManager 受到限制,避免混乱

    ...

编后语

为了更好地向读者输出更优质的内容,InfoQ 将精选来自国内外的优秀文章,经过整理审校后,发布到网站。本篇文章作者为田伟宇,原文链接为 Casa Taloyum 。本文已由原作者授权 InfoQ 中文站转载。

评论

发布