编者按:iOS 客户端应用架构看似简单,但实际上要考虑的事情不少。本文作者将以系列文章的形式来讨论 iOS 应用架构中的种种问题,本文是其中的第三篇,主要讲网络层设计以及安全机制和优化方案。
前言
网络层在一个 App 中也是一个不可缺少的部分,工程师们在网络层能够发挥的空间也比较大。另外,苹果对网络请求部分已经做了很好的封装,业界的 AFNetworking 也被广泛使用。其它的 ASIHttpRequest,MKNetworkKit 啥的其实也都还不错,但前者已经弃坑,后者也在弃坑的边缘。在实际的 App 开发中,Afnetworking 已经成为了事实上各大 App 的标准配置。
网络层在一个 App 中承载了 API 调用,用户操作日志记录,甚至是即时通讯等任务。我接触过一些 App(开源的和不开源的)的代码,在看到网络层这一块时,尤其是在看到各位架构师各显神通展示了各种技巧,我非常为之感到兴奋。但有的时候,往往也对于其中的一些缺陷感到失望。
关于网络层的设计方案会有很多,需要权衡的地方也会有很多,甚至于争议的地方都会有很多。但无论如何,我都不会对这些问题做出任何逃避,我会在这篇文章中给出我对它们的看法和解决方案,观点绝不中立,不会跟大家打太极。
这篇文章就主要会讲这些方面:
- 网络层跟业务对接部分的设计
- 网络层的安全机制实现
- 网络层的优化方案
网络层跟业务对接部分的设计
在安居客 App 的架构更新换代的时候,我深深地感觉到网络层跟业务对接部分的设计有多么重要,因此我对它做的最大改变就是针对网络层跟业务对接部分的改变。网络层跟业务层对接部分设计的好坏,会直接影响到业务工程师实现功能时的心情。
在正式开始讲设计之前,我们要先讨论几个问题:
- 使用哪种交互模式来跟业务层做对接?
- 是否有必要将 API 返回的数据封装成对象然后再交付给业务层?
- 使用集约化调用方式还是离散型调用方式去调用 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 这样的对象。这种做法是能够提高后续操作代码的可读性的。在比较直觉的思路里面,是需要这部分转化过程的,但这部分转化过程的成本是很大的,主要成本在于:
- 数组内容的转化成本较高:数组里面每项都要转化成 Item 对象,如果 Item 对象中还有类似数组,就很头疼。
- 转化之后的数据在大部分情况是不能直接被展示的,为了能够被展示,还需要第二次转化。
- 只有在 API 返回的数据高度标准化时,这些对象原型(Item)的可复用程度才高,否则容易出现类型爆炸,提高维护成本。
- 调试时通过对象原型查看数据内容不如直接通过 NSDictionary/NSArray 直观。
- 同一 API 的数据被不同 View 展示时,难以控制数据转化的代码,它们有可能会散落在任何需要的地方。
其实我们的理想情况是希望 API 的数据下发之后就能够直接被 View 所展示。首先要说的是,这种情况非常少。另外,这种做法使得 View 和 API 联系紧密,也是我们不希望发生的。
在设计安居客的网络层数据交付这部分时,我添加了 reformer(名字而已,叫什么都好)这个对象用于封装数据转化的逻辑,这个对象是一个独立对象,事实上,它是作为 Adaptor 模式存在的。我们可以这么理解:想象一下我们洗澡时候使用的莲蓬头,水管里出来的水是 API 下发的原始数据。reformer 就是莲蓬头上的不同水流挡板,需要什么模式,就拨到什么模式。
在实际使用时,代码观感是这样的:
先定义一个 protocol: @protocol ReformerProtocol <nsobject> - (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<reformerprotocol>)reformer { if (reformer == nil) { return self.rawData; } else { return [reformer reformDataWithManager:self]; } }</reformerprotocol></nsobject>
- 要点 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 中的方法。
关于这个的具体的论述在这篇文章里面有,感兴趣的话可以看看。
网络层与业务层对接部分的小总结
这一节主要是讲了以下这些点:
- 使用 delegate 来做数据对接,仅在必要时采用 Notification 来做跨层访问
- 交付 NSDictionary 给业务层,使用 Const 字符串作为 Key 来保持可读性
- 提供 reformer 机制来处理网络层反馈的数据,这个机制很重要,好处极多
- 网络层上部分使用离散型设计,下部分使用集约型设计
- 设计合理的继承机制,让派生出来的 APIManager 受到限制,避免混乱
…
编后语
为了更好地向读者输出更优质的内容,InfoQ 将精选来自国内外的优秀文章,经过整理审校后,发布到网站。本篇文章作者为田伟宇,原文链接为 Casa Taloyum 。本文已由原作者授权 InfoQ 中文站转载。
评论