专栏名称: Cocoa开发者社区
CocoaChina苹果开发中文社区官方微信,提供教程资源、app推广营销、招聘、外包及培训信息、各类沙龙交流活动以及更多开发者服务。
目录
相关文章推荐
51好读  ›  专栏  ›  Cocoa开发者社区

对象间交互模式

Cocoa开发者社区  · 公众号  · ios  · 2017-06-22 11:32

正文

推荐人:feizhu422


前言

直白的对象间交互

轻度抽象的对象间交互

使用Target-Action淡化接口概念的对象间交互

响应式的对象间交互

基于闭包的对象间交互

推导过程

总结


前言


闭包在什么场景下才是最优选择?理由?补充说明一下:很多场景都适用闭包,但不是所有场景下闭包都是最优选择。


这篇文章主要就是回答这个问题的。


但是在直接切入闭包之前,我们需要有一个推导的过程,在推导过程之前,我们还需要梳理一下现有的交互模式。所以,关于这篇文章的内容,更确切地说应该是讨论了对象间各种交互方式。


这个主题其实很早之前业界也讨论过,objc.io很早就有《Communication Patterns》这篇文章详细讲过这个问题。这篇文章在很多地方说得都是对的,但这篇文章在block的讨论上不够全面。国内也有很多人关于对象间交互的问题写过文章,但这些文章相比《Communication Patterns》来说,就已经错得十分离谱了。


本文是从另一个角度讨论了同样的问题,文末给的决策图可以作为《Communication Patterns》的补充去做参考。


直白的对象间交互


解决对象间交互问题的最直白朴素的做法就是直接传值,直接调用。


适用这种做法的前提是你非常确认A对象的信息来源只可能是B对象,而且在未来这个前提也是不会产生任何改变。这个前提在某些场景下是成立的,例如你要取timestamp,那么只可能从NSDate中取。你要操作字符串,也只可能使用NSString。我这里说的是通常的环境,当然,针对NSDate或NSString这类对象做二次封装导致不直接依赖NSDate或NSString的场景也是有的,其本质依旧不变。


这种做法的好处在于可以限制实现手段的多样性,在维护代码的时候工程师能够更加容易地去聚焦问题所在。


存在限制不一定是坏的,灵活度高也不一定是好的。当一个工程中为了解决同一个问题而存在多种方案的时候,存在多种方案往往会给工程维护带来麻烦。尤其是业务上下文对灵活性不存在任何要求的时候,此时引入灵活性带来的权衡就是代码维护困难,于是修复Bug的成本就会随之增加。


这种做法的坏处也是显而易见,当业务上下文对灵活性有要求时,也就是A对象的信息来源只可能是B对象这个前提不满足时,这种做法就会导致业务工程师不得不深入了解实现细节,然后修改,才能不断地去迎合业务需求的变化。一般来说,这种深入修改的成本很大,引发其它bug的几率也会变大。


轻度抽象的对象间交互


为了能够迎合业务需求的变化,我们就需要针对之前最直白朴素的对象间交互方式做一次抽象提炼。


有两种方式可以做抽象,一种是使用多态的方式,把需要调度的方法或数据写在基类中,让调度者声明基类。然后响应者派生自基类,重载基类相关方法,从而达到调度者在无需知道响应者具体类型的情况下完成调度的目的。


另外一种做法是使用接口/协议(interface/protocol,后面我就都用接口来表述了)的方式,让调度者声明一个接口,然后响应者实现这个接口。这样也能同样做到调度者在无需知道响应者具体类型的情况下完成调度的任务。


单就多态和接口两种方法来看,也是各有千秋。


多态方案的优点在于基类可以提供默认的实现。也就是说,即使响应者不重载基类,也能给调度者提供默认实现。在这一点上,接口方案就不够好注1。在支持多继承的语言中(例如C++),多态方案下的一个响应者可以同时继承多个不同领域的基类,这也意味着这个对象可以同时扮演多个调度者的响应者。但是现在绝大多数语言都摒弃了多重继承,主要是多重继承的引入会带来更多额外的问题,例如菱形继承(也叫钻石继承,因为钻石是菱形的)导致的对象父子关系复杂、同名父类方法导致的二义性等。所以,在不支持多继承的语言中,多态方案的缺点就变成了响应者只能够被一个调度者去调用,这就给基于这种方案的对象间交互模式提出了一个前提:一个对象只能作为一类调度者的响应者。然而事实上满足这种前提的场景并不多。


接口方案就没有这样的问题,只要一个对象实现了多个接口,那么这个对象就能够被多个不同的调度者去调度。而且即使多个接口中定义了同名函数,也不会产生二义性,因为一个对象不可能实现同名的两个函数(此处的同名表示函数名和参数列表都相同)。唯一不足的地方就是前面已经提到的:接口方案无法给调度者提供默认实现,这就导致了调度者每次都要确保某个接口方法确实被实现了,才能走下一步的调度操作。


另外补充说明一下,多个接口中定义同名函数的做法在某种程度上也是合理的。尤其是在基于鸭子模型的实现场景下,多个接口中定义同名函数是很普遍的状况。这能够更近一步地淡化对象类型,可以为业务实现提供更高的抽象度。Go在这一方面就做得非常好。


在实际场景中,我更多的是倾向于使用接口方案,即使一个对象只能作为一类调度者的响应者这个前提成立,我也不会选择使用多态方案。虽然使用接口方案时,我必须要确保响应者实现了对应的接口方法,但接口方案的侵入性更加少,它不会为原来的对象额外引入其它我不需要的实现,同时,组件化推进过程中抽离一个接口声明要比抽离一个基类实现要容易得多,因为你不会知道这个基类还依赖了哪些其它乱七八糟的东西。


使用Target-Action淡化接口概念的对象间交互


使用接口方案还会有另外一个问题,就是一个接口的定义很少情况下只有一个方法声明。往往是你引入了一个接口,就相当于引入了这个接口的全家桶。虽然可以使用required关键字来指定某些方法是否必须实现,但如果遇到我们需要的方法不是必须实现的,而必须实现的方法都是我们不需要的情况的时候,就很蛋疼了。


另一方面,有的时候一个接口定义并不足以完全表达响应者应该做的事情。例如页面上控件的事件响应,事件来源可以是手势、可以是按钮等,且手势和按钮在不同的页面上数量都是不同的。这种情况就很难定义一个所谓的EventResponse这样的接口去给响应者。


为了解决全家桶问题和无法完整定义所有事件的问题,我们就可以采用Target-Action的方式来解决。事实上Target-Action就是把每个调用从全家桶里面拿出来了,一些比较不复杂的回调,使用Target-Action就可以了。


一方面Target-Action可以在某种程度上理解成简化版的接口,另一方面也可以借助runtime的特性(也就是说前提是语言支持),Target-Action可以做到完全解耦,同时无视命名域是否完整。因为无论是之前讨论的多态方案还是接口方案,都需要针对对象或接口提供声明。只有命名域中包含这些声明的内容,代码才能够使用得了相关的对象和接口。而Target-Action方案下,Target和Action都无需额外的类型声明,只要手上有Target指针,有Action描述即可。


在Target-Action结合runtime的场景下,命名域无需覆盖到相关的声明,就也能够完成调用。然而其随之而来的权衡就是失去了编译器检查。在这种场景下使用Target-Action是对应用场合十分挑剔的,必须是不经常变动的业务和代码才适用这种方案。


响应式的对象间交互


前面说的都是基于命令式的思路去做的对象交互方案,基于响应式的思路也是可以设计对象交互方案的。


这里先来说一下响应式,响应式和函数式往往会混为一谈,但实际上两者只是互相利用的关系,并不是同一个概念。响应式对应于命令式的差别就在于主动性的差别,举一个日常生活的例子会更容易理解:


假设你现在是主人,你肚子饿了。你需要让你的仆人们给你做饭,端过来给你吃。


在命令式思路的方案下,若要完成这个任务,你需要这么做:


1. 对仆人中负责买菜的人吩咐你去买菜带给你

2. 你拿着仆人买来的菜,给到负责做菜的仆人,吩咐他去做菜

3. 厨师做好菜端给你,你就可以吃了


在响应式思路的方案下,若要完成这个任务,你需要这么做:


你喊一声:我肚子饿了!

1. 负责买菜的仆人听到你喊的这一声,就去买菜了,买好菜就放到仓库里,然后喊一声:"我买好菜了!"

2. 负责做菜的仆人听到买菜的仆人喊的这一声,就去仓库里拿东西做菜,做好了端给你。


这里可以看出响应式和命令式的一个最重要区别:在命令式场景中,仆人是被动的,需要你去吩咐。在响应式的场景中,仆人是主动的,听到一声喊就知道该去做什么事情。而仆人做事情的方法,往往是通过函数式的方式由你"教会"的(当然,仆人也可以天生就会,不需要函数式去辅助。),所以说函数式和响应式不应该混为一谈,他们事实上是互相利用的关系。思路拉回来,响应式和命令式在主动性上的区别就带来了一个这样的结果:响应式中主人无需认识仆人,只需要喊一声就可以了。


在最初的时候,响应式的思路是为了解决C10K问题而诞生的。在大规模服务器集群中,由于集群中的服务器是动态上线、动态扩容的,所以调度者不可能一个一个地去主动调用集群中的单个服务器,因为它没办法认识每一台服务器,所以只好发一个信号出去让关心这个信号的服务器去响应信号,也就是喊一声,然后最终完成任务。而且由于调用方自己会喊一声,那就避免了集群中的服务器一个一个去轮询的问题,轮询带来的性能消耗对于服务端来说是很吃不消的。


响应式思路发展到客户端,更多的是利用了响应式思路中主人无需认识仆人的这个特点,使得工程能够在较低耦合的情况下完成原来的任务。当然,模块的动态挂载也是比较常见的情况,例如页面出现时响应通知,页面消失时不响应通知也是很常见的情况。但这只是业务角度的特征,从架构角度去思考问题的话,我们更加侧重于响应式带来的低耦合的特点。然后只有从性能角度去讲的时候,我们才会去关心避免轮询的特点。如果前面的例子中,仆人闲着没事就来问你一句你饿不饿,你是不是得烦死?


顺便扯一句,如果下一次有人问你delegate和notification有什么区别的时候,说一对一和一对多的区别就很low了,delegate模式也是能够实现出一对多的功能的(例如XMPP的multi delegate)。他们之间的本质区别就在于命令式和响应式。而且他们俩在避免轮询的角度上讲,是一模一样的,没有差别。


另外,响应式的本质其实就是Observer模式的应用。因此,Notification/Signal/Key-Value Observe就都可以被归类到响应式中。iOS场景中的KVO虽然也是属于响应式,但它的设计其实更多的是为了解决轮询问题,对于架构而言帮助不大。


前面我们说到响应式能在较低耦合的情况下完成对象间交互,这个较低耦合在两个方面体现:


  1. 相对于最原始的交互方式,响应式对命名域的要求更少,只需要有String即可。另外,在对命名域的要求上看,它是跟接口方案对命名域的要求是一样的,不需要引入完整的一个对象。所以从命名域的角度讲,它比多态方案要好,跟接口方案一样好,比Target-Action方案差。

  2. 相对于前面所有的方案而言,响应式方案是不需要知道响应对象地址的,否则就无法完成调用。在响应式中,调用者虽然已经不需要知道响应对象的实例以及命名域了,但响应对象仍旧需要知道调用者相关的命名域,否则就无法响应调用者释放的信号。


基于闭包的对象间交互


闭包和其他所有方案比起来,最大的区别在于它能够抓取当前上下文的命名域,这也意味着:使用闭包的调用者完全不需要知道响应者的上下文,因为闭包已经把相关上下文都抓取了。响应者在闭包的创建和传递过程中,也可以做到完全不需要知道调用者的命名域。


举一个面包机的例子:


  • 你是调用者,你想吃面包,虽然你不会做面包,但你会给电器插电源。

  • 厨师是响应者,他把面粉、鸡蛋、牛奶、糖都放进面包机里,并且预先设置好程序,确保插上电源就能做面包。然后把面包机丢给你。

  • 你想吃面包时,你拿到一个不知道是什么机器的东西,但因为你会插电源,所以你把电源插上,面包机程序跑完之后,你就有面包吃了。

  • 在这整个过程中,你既不知道是谁给你的面包机,也不知道面包机里面都装了些什么。你就是个傻子,只知道拿上这个玩意儿插上电源等一会儿就有面包吃。

  • 面粉、鸡蛋、牛奶、糖就是制作面包时候所需要的上下文,但你不用管这些,这都是已经抓好了的。



鉴于闭包能够抓取命名域的特性,就使得闭包可以在两个对象间命名域不完整的情况下,完成对象间交互。例如在跨组件调用中,调用者和响应者都互相不知道对方的命名域,于是在互相都不认识的情况下,他们俩之间的事件传递就只能通过闭包进行了。对于数据传递来说,这种场合下也可以使用NSDictionary,因为NSDictionary肯定是完整覆盖命名域的。也可以通过闭包参数传递过去,具体选择哪种还是要看具体业务了。


对于闭包的应用来说,又分两个场景:响应者使用闭包、调用者使用闭包。


在前面面包机的例子中,就是调用者使用闭包的情况,此时闭包抓取的是响应者的上下文。也就是鸡蛋面粉那些。


在组件间调用的时候,其实是响应者在使用闭包,因为在调用者希望响应者做的事情里,会涉及调用者的命名域。而响应者没办法获取调用者的命名域。


推导过程


我们从最原始直白的对象间交互开始推导。


由于这种最原始直白的交互方案要求对象必须要互相知道对方的具体类型。在有些场合中,这种前提是不满足的。所以为了解决这个问题,就有了基于多态的方案和基于接口的方案。基于多态的方案使得调用者只要知道基类的声明,基于接口的方案只要知道接口的声明即可,而无需知道具体响应者的类型,这就带来了一定程度的灵活性,使得响应者不再受类型的限制。


但即便是多态方案和接口方案,调用者仍旧需要知道响应者的命名域。为了能够让调用者不必知道响应者的命名域,我们就可以用Target-Action的方式,拿到响应者的实例而不必管具体的类型是什么,也无需引入冗余的接口的声明或基类。需要注意的是,Target-Action其本质仍然是基于多态的方案,只不过通常情况下我们的子类采用的是父类的默认实现而已。此时就谈不上多态了,就只是调用父类方法而已。


在Target-Action方案中,仍然有一个小遗憾。那就是调用者虽然可以不必了解响应者是什么对象,但调用者必须要拿到响应者实例。就相当于你要找人办事,你并不知道这个人的名字,甚至也不知道这个人是男是女,但你手上必须要有这个人,你才能办事。为了解决这样的问题,就有了基于响应式的对象间交互方案。


在响应式对象间交互方案下,你再要找人办事,就已经不需要手上要有这个人了。你只需要吼一声,说你要办什么事。只要有人在听,自然就会把事情去办掉了。但即使是在这种情况下,仍然还可以在业务场景中找得到缺陷:响应者必须要知道该听什么指令,而且响应者做的事情必须不能脱离命名域。缺陷就在于这个指令,指令可以体现为NotificationName,也可以体现为Signal。总之,这个指令必须是调用者知道,且响应者也能识别的。响应式往往可以达到模块间跨层级的调度,正是因为指令的命名域覆盖了所有层级。那么,当命名域是残缺不全的,无法覆盖所有层级时,且响应者要做的事情所需要的上下文正好又没有被命名域覆盖到,这时候就蛋疼了。


正因为闭包可以抓取上下文,跨越命名域传递,从而使得在命名域残缺不全的情况下完成对象间交互成为可能。当然,这里的前提是闭包的参数列表里面的参数不要受命名域的影响。所以很明显,闭包的最佳使用场景就是命名域残缺不全。


讲到这里,你是不是会觉得:卧槽,看来闭包是最强大的对象间交互方案了,以后我所有的对象间交互就都用闭包去完成!


千万不要有这种想法,接下来我们再从基于闭包的对象间交互方案来推导,你就会明白这个道理:一定要在合适的场景选择合适的方案。


闭包方案在命名域残缺不全的同时,就带来了一个这样的限制:发起调用的地方和提供回调的地方必须要在同一上下文。这个限制具有两面性,很多人只看到了发起调用和提供回调的地方在同一上下文时的便利,因为哪儿调用的你就在哪儿能够看到回调。但没有意识到这对于程序结构其实是一种限制,在很多场合,我们是不希望发起调用和提供回调是同一个地方的。举个例子就是UITableView的DataSource,正因为DataSource的设计采用了delegate模式,才使得DataSource可以被独立成为一个对象,从而降低ViewController的代码量。还有一个例子就是之前我给出的网络层架构方案,因为很多场合下,发起API调用的人其实并不关心API回调的数据,真正关心API回调数据的有可能是View也有可能是ViewModel或其它的一些对象(例如数据同步对象)。所以此时使用闭包就对这一场景产生了限制。


注意,上面举例的更多是delegate模式,并不意味着只有delegate模式才能做到对象剥离这一点。在除了闭包方案的其它所有方案中,它们都不强制要求发起调用的地方和提供回调的地方必须要在同一上下文。同时还要注意的是,并不是说不用闭包之后,剩下的方案就可以随意选择了。现在我们就从响应式对象交互方案开始推起。


响应式对象交互方案要求响应者必须注册通知或监听信号,随之而来的就是响应者取消注册和取消监听的时机也需要进行管理。这件事情要是做得不干净,是会导致crash的。不过这只是操作上的问题,细心点就能解决,这一点在架构上并不是问题。在架构上真正成为问题的地方在于,响应式方案有可能会造成跨层数据传递,进而导致数据流的动向产生混乱。当底层对数据流有控制的要求的时候,我们是不希望上层(或者底层的其他组件)也能插手数据流的管理的。否则这就是开了天窗,在未来重构的时候就很难整理数据流的流向了。这就相当于你吼了一声想要吃饭,结果不光你屋子里的仆人听见了,别的屋子里的仆人也听见了(比如有另一个人想在你吃饭的时候也一起吃饭,他自己不喊,让他的仆人听你的喊话),这就造成了别的屋子里的仆人也对你产生了依赖,后面如果要分家,就很难理清楚到底应该让哪些仆人听你的,哪些仆人不听你的。实际场合中这样的例子很多,如果你监听了UITextField的通知,你就必须要区分喊话的这个UITextField是不是你关心的那个人。再比如你监听了Application生命周期相关的通知,如果将来要对这部分进行整理,就是一件比较麻烦的事情。所以响应式方案更加适用于对数据流的管理没那么多要求的场合,反正喊了话出去之后我才不管你们怎么干呢,这种场景就比较适合响应式。


所以当你对数据流有比较强的管理的要求的时候,使用响应式方案去处理对象间交互就会比较蛋疼。这时候我们就需要对响应者有更严格的限制,才能保证对数据流的管理。于是我们就只剩下Target-Action、多态方案、接口方案、原始方案可选了。


Target-Action方案和其它方案的辨析其实上面已经说过了,当我们无法提供一个完整的能够cover住所有事件的声明(基类声明或接口声明)时,就适用Target-Action方案。但如果我们能够提供声明,那么就更加适合使用多态方案或接口方案,因为他们能够给我们带来编译期检查。但编译期检查这一特征放在架构角度去考虑的话,其实并不能算是优势,因为这是两个维度的事情,不能放在一起讨论。多态方案和接口方案在架构角度提供的优势是,它能够很清晰地告知一个响应者应该如何被实现,调用者针对响应者的要求能够以声明的方式暴露出去,从而提供了响应者的管理手段:管理声明即可。在未来的迭代或重构中,通过声明的修改再结合编译期检查的功能,就能够快速响应迭代的变化,提高了架构方案的适应性。


在多态方案和接口方案两者之间,也是需要做辩证的。这个辩证的关键点在于响应者角色。如果某一个调用所有的响应者角色在业务场景下都是固定的,那么就可以采用多态方案。如果这个角色是不固定的,那就必须采用接口方案。这很明显是因为接口方案不会限制对象类型,多态方案会对响应者对象类型产生限制造成的。但多态方案也并非一无是处,在多态方案下,基类可以为optional的方法提供默认实现,接口方案就无法做到这一点,默认实现只能写在调用者中,或者索性就不实现了。在optional的方法不需要响应者提供实现的场合下,你还是得用多态方案。


在多态方案和接口方案下,它们都抽象了响应者的类型。在原始方案中,响应者的类型是完整且清晰的,也就是具象的。当我们十分确定响应者的身份时,我们就没必要使用多态方案和接口方案了。原始方案虽然限制最多,但是其适用场景其实是最普遍的,因为绝大多数场景下我们是能够保证响应者的唯一性。例如你使用SDWebImage去加载图片,使用AFNetworking去发请求,使用NSString来表达字符串,这些其实都是原始方案下的对象间交互。除非你有SDWebImage加载不了的图片,AFNetworking发送不了的请求,或者你需要频繁改底层,你才需要使用更加抽象的其它方案。


总结


我也画了一幅图,可能不完整,但应该能够大致表达出意思。这幅图背后的思想的切入点是跟《Communication Patterns》不一样的,所以考虑的条件也不会一样。但两者在本质上不存在冲突,大家可以拼在一起看。



注:

注1:在swift语言的实现中,接口是可以提供默认实现的。对于这种情况我还没想明白应该是属于哪种定位,所以在本文中难以讨论,因为这种实现两边都讨好。在swift这种方式的实际使用场景中,多态方案的适用面就变更小了,在做决策的时候,侧重点就更加偏向于对象的角色定位。


评论系统我用的是Disqus,不定期被墙。所以如果你看到文章下面没有加载出评论列表,翻个墙就有了。



本文遵守CC-BY。 请保持转载后文章内容的完整,以及文章出处。本人保留所有版权相关权利。