本系列文章是自己在学习相关领域知识时的思考过程的记录,对内容的正确性和严谨程度不做保证,如果发现问题欢迎指出交流。
1. 前言
之前在网上看过一些关于对象之间交互的文章,有我认为比较认同的,也有我感觉描述不够理想的。对于这类有些偏于设计的内容,我认为很多人会有自己的看法,就我个人而言,习惯对一个或一类问题按不同的角度进行分析,这是适合我的思考方式,如果你有兴趣了解,可以往下阅读。
这篇文章是根据不同的角度来描述对象之间事件传递的,个人认为事件传递这个词比较适合我讲的内容,而并没有使用“交互”这个词,因为这个词显得太过宽泛了。
2. 事件传递的分类
承接上面说的,既然是传递,那么我们可以建立问题模型为发送者和接收者,发送者需要将事件或者信息传递给接收者,接收者根据自己的需要来对其做出一定的响应。需要注意的是,我们描述的对象基本上特定于iOS平台,当然大部分都具有通用性。
首先我们讨论如何对事件传递进行分类,分类是需要建立在不同观测角度的,这里我将其分为两个角度:一是其耦合关系,指的是两个对象(类)的边界在建立联系时,是以什么样的方式进行可能产生的关系依赖;二是过程的设计,也就是对象在运行时利用其设想的怎样的过程进行数据的传递, 这里一般都会利用某些直观的依赖或常见的抽象设计。说到这个位置,感觉才可以引出这篇文章的中心,那就是在适当的场景找到适当的事件传递设计。
2.1 耦合关系
两个对象在建立联系时其可能产生耦合,按直观的思想,发送者想要将事件传递给接收者,就简单直接的交付给它即可,这样操作本身是没有问题的,这样的两者之间产生的显式的确定类型耦合。而之所有会设计出其他类型更为松耦合关系,是因为直观简单的方式,虽然简单,但是却不够灵活,而现代软件开发中项目的复杂度和规模都在增大,灵活是影响软件质量和开发效率的重要参考。
-
确定类型
当发送者显式的引用了接收者的类型,产生直接的耦合。
-
抽象类型
抽象类型在大部分面向对象语言中,有两个比较常见的体现,在Objective-C中是Protocol和基类(Objective-C不支持语言层面的抽象类部件)。
抽象类型对于耦合关系的影响是将依赖进行反转,从发送者直接依赖接受者进化为发送者依赖抽象,接收者也依赖抽象。从架构层级关系的角度来看其使高层组件不直接依赖低层组件,从耦合关系来看其有更好的扩展性。
-
类型无关
前面两种是按照对象的类型进行关系区分,还有一些事件传递方式对类型并不关心,通过一种中间层设计将事件进行传递,但是在接收方在对事件响应时,可能需要准确的分辨出事件的类型和参数识别。这种方式解决了编译期间的类型依赖,但是也带入了运行时的维护问题。
对于项目中代码出现耦合可能会让人嗤之以鼻,但是耦合在大部分情况下非但不是问题,而且是合理的,我们应该根据实际的状况分辨出项目代码中应该被合理解耦的部分,比如在一个按照MVC模式开发的项目中,我们在ViewController直接使用View的实际类型进行构建,而尽量保持View对于ViewController是一个松耦合的关系,这种设计大部分情况下并不存在什么问题;而在面向两个需要解耦的业务组件时,前面的这种哪怕是单向依赖也不再适用,这时候就需要利用类似中间层的设计来双向解耦。
2.2 过程设计
事件传递的设计充斥着各种巧妙的对象组合关系,在根据过程这个角度来看待事件传递时,其不同的过程设计往往会有各自适合的场景,这也是这篇文章的中心。前面的耦合关系描述过的各个分类,都会在这一部分得到合适的利用,需要明确的是,在每个方案中我会尽量说明这种方案所带来的耦合性,但是有些方案的实现并没有标准一说,其最重要的是带来的解决问题的思想,它具体会如何耦合其他角色,是开发者根据自己的理解在实现中的权衡。暂时先对各个方案进行罗列,后面将对各个方案分别进行阐述。
- 直接调用
- delegate
- proxy
- block
- kvo & notification
- target-action
- responder chain
3. 过程设计的具体方案
3.1 直接调用
-
描述
直接调用是最为普遍的传递过程,存在于项目的任何位置,在符合这种方案的场景中,发送者对于接收者有着充足的了解,其也会直接耦合接收者,但是如前所说,这种方案本身并不存在什么问题,只是需要开发者自己去思考其适用性。
3.2 delegate
-
描述
在iOS开发中,delegate经常会被翻译为代理,这里我们不讨论这种翻译的正确性,由于在我们的讨论中会产生与proxy的冲突,这里我们按照更符合其意义的表达“委托”。
在现实生活中,委托往往代表一个一个人无法完成某件事,需要将其委托给另一个人帮忙完成。在编程领域中其实也一样:A对象由于无法完成需要的操作,其作为发送者将这个事件发送给接收者B对象,让B对象帮忙执行,甚至还返回结果。在这个简单的说明中,出现的角色只有发送者和接收者,所以理解起来也较为轻松,但是在某些场景下,可能会出现两个以上的角色,这时候对于理解delegate可能会产生一些问题,我们会在后面讲完proxy方案后,对这两种方案进行对比时再阐述。delegate这种方案其实也隐含一个事实,发送方对于给接收方发送事件这种行为是需要发送方自己来保证的:我求你办事,我得亲自去求你,总不能我都没求你,你也不知道我的事就帮我做了。
-
耦合性
发送方对于接收方的依赖是选择性的,既可以对接收方直接依赖,也可以对接收方进行一定的抽象,比如通过Protocol对delegate对象进行注入;而接收方对发送方的依赖需要开发者自己去考虑。
3.2 proxy
-
描述
proxy指的是用一个替身对象来代替实际的对象,供外部访问,在这种场景里实际上有三个角色,除了发送者和接收者,还有一个代理者,发送者本意是想访问接收者,实际上访问的是代理者,这种设计可以控制发送者对于接收者的访问。
-
耦合性
proxy适用的场景中有三个角色,其中发送方对于代理者可以直接依赖,代理者也可以对接收方直接依赖,但是这两个交界处都可以利用抽象将其解耦。因为这种设计本身有比较多的变化性,所以对于其耦合关系很难明确定义。
-
proxy和delegate的异同
proxy和delegate个人认为有相当大的类似,甚至在某些场景下的解决方案,都符合两者的定义。我们以相同和不同两点进行阐述:
-
相同
说到相同点,第一当然是它们作为这篇文章的要点进行说明的主题,也就是事件传递。而更为类似的地方是:
- delegate:自身无法完成,需要他人协助
- proxy的代理者回传事件给接收者的部分:自身无法完成,需要他人协助(特定情况下是这样,后面说不同点时会在细说)
这一点在特定情况下看起来几乎是一模一样,所以也引出前面我在说delegate时没说的情况,比较符合这个情况的例子应该是适配器模式的模型:
将一个类的接口,转换成客户期望的另一个接口。适配器让原本接口不兼容的类可以合作无间。
适配器模式
在这个模型中,Adapter可以认为是Adaptee的代理提供为外部访问,而Adapter将事件传递给
Adaptee的行为又可以认为是委托,更有甚者认为是第一种直接调用(因为Adapter可以直接依依赖Adaptee)。那么如何理解这种一个场景符合多个方案的特点呢,写到这里我要说明一点,文中描述的所有方案,并不代表其所适应的场景是相互独立的,在复杂场景下,其整体和部分本来就可以作为不同切入点进行看待,这也是进行OO设计时的重要原则,而这些方案本身又是独立的,因为其出发点不同。
-
不同
现在说说proxy和delegate的不同,首先它们在语义上就完全不同,前面的相同处也只是proxy在特殊场景下的部分和delegate产生了雷同,前面也点明了这一点。那么proxy的代理者在回传事件给接收者的部分,在非特定情况下一般是什么样呢?proxy的代理者和接收者之间交互方式并没有严格的要求,除了前面所说的类似delegate的委托调用方式,其实双方可以并不互相了解,比如可以通过观察者模式对其进行解耦,甚至代理者可以将收到的事件直接不做任何处理直接返回。
-
3.3 block
-
描述
block是OC中的闭包实现,闭包本身并不是事件传递方式,事件传递是闭包中的作为方法参数传递时函数指针的功劳。这里我们不讨论闭包本身的设计初衷和其他适应场景,只讨论和事件传递相关的部分。网上对于block和delegate的使用争论多年前就有,这里我说下自己的看法,block所承载的事件传递所适应的场景和delegate几乎一致,但是block自身的特点使其能够面向更细化的场景:
-
事件接收方需要捕获作用域中的上下文
能够捕获上下文的好处在这个场景下是可以减少冗余的参数传递。
-
一次性的回调
项目中经常可以遇到一些回调场景,比如一个弹框的点击,这种场景下如果不使用Block而使用Delegate等其他方式,代码会分散在不同的作用域中,一来其导致失去了内聚性,二来在处理点击时,还需要处理具体是哪个弹框实例带来的回调了,第三由于场景本身是将弹框设计为一次性的,而由于在回调方法中需要辨别实例,其导致需要对弹框的引用进行显式的持有,这样和一次性回调的代码风格又显得背道而驰;而Block的特性使这种场景的回调处理显得更加内聚、清晰和符合直觉。
-
-
耦合性
发送方对于接收方的耦合建立在block这种抽象类型中,和delegate类似的耦合度。
3.4 kvo & notification
-
描述
之所以把这两种方式放在一起,是因为kvo和notification都是观察者模式在苹果Foundation框架中的实现。观察者模式的本质是一种能够对交互双方都能够解耦的中间层设计,在这个基础上进行细化的设计,其拥有不同场景的适应性。
kvo是针对于键值变化的观察者模式,发送者的发出的事件是键值出现变化。
notification更为通用,在观察者模式的基础上利用通知名对事件进行分类。
-
耦合
观察者模式对于交互双方都能双向解耦,对于编译期间对这种解耦,大部分细化的观察者模式都能够做到,但是有可能会带来一些编译期间无法检查,只会在运行时出现的问题,比如kvo的keypath、notification的notificaton.name、params.key、params.value.class都带来了维护问题,其出现这类问题我认为是在设计这类框架时考虑到的兼容性,所以在实际使用上,可能会产生针对于这种约定的依赖问题,不过也可以通过自己设计类似机制,但是普适应只针对于自己项目来解决这类问题。
3.5 target-action
-
描述
target-action这种方案,其表达的目的就如何其名字一样,在目标对象上执行操作。这种设计一般要求语言具有一定的反射能力,action需要从target对象上得到响应,如果抛开设计初衷和实际实现,单从抽象角度来看,其非常像是一种特殊的delegate:一般的delegate设计中对接收者对类型进行约束,目的是保证接收者能够响应约定的方法,而target-action将对接收者的约束从发送者内部或接口处移出,由中介者或接收者自己对自己能够完成响应的方法进行保证。当然这也产生了一定的安全问题,因为这种保证在编译时无法被检查,就像前面在描述kvo和notification时一样。
-
耦合
target-action和观察者模式一样,也能够对交互双方双向解耦,也拥有和kvo/notification一样运行时可能出现的问题。
3.5 responder chain
-
描述
resonder chain方案是利用响应者链形成的一种事件传递机制,其本质上是对责任链模式的一种运用。在遇到View层级过深,需要将事件传递给处于同一响应者链中距离很远的对象,常见的delegate和block方式很难解决这种问题,或者能解决却带来不小的工作量,此时reponder chain方案就十分适合。
-
耦合
苹果的UIKit框架中响应者链提供了这种方案的基础支持,另外需要做的就是对事件分发制定一套框架,网上也有许多例子。而发送方和接收方在传递过程中并不会产生直接依赖,它们只对框架和响应者链产生依赖,但是话说回来,如果框架能够提供的事件区别和参数传递机制往往和notification的设计类似,那么就会产生是和它一样的维护问题。
4. 给出的建议
本文是按照耦合关系角度和过程设计角度来描述事件传递这个过程的,而在描述每种过程设计时,对于其适用的场景和一些争论点都做了说明,所以也希望诸位在开发中能够清晰的分辨出应该使用的方式。但是对于项目开发,仅仅知道该如何利用平台提供的方案还是不够的,问题有两点:
- 平台提供的方案并不完美适应你的项目,在面对复杂场景或要针对架构做一些基础设计或某些特殊情况时,需要利用你沉淀的知识和平台提供的能力做出符合需求的自定义设计。
- 很多项目中对于事件传递该使用哪种方式并没有制定规则,导致代理/block/通知满天飞,而有些场景可能多个方式都适合,所以尽量为你的项目做出事件传递的规则来约束,一可以提高开发人员自身对于这些设计的思考能力,发现制定的规则有问题时,可以大家一起讨论而广思集益得出更合理的设计;二是可以提高项目代码的可读性和可维护性,大家在这方面的开发方式都一致,不管是代码评审还是工作交接,都能把思考放在实际逻辑上而提高一定的效率,而这方面不仅仅面向事件传递的规则,也适用于项目中其他方面。
5. 结语
感谢阅读!