耦合是每个程序员都必须面对的话题,也是容易被忽视的存在,怎么处理耦合关系到我们最后的代码质量。今天Peak君和大家聊聊耦合这个基本功话题,一起捋一捋iOS代码中处理耦合的种种方式及差异。
简化场景
耦合的话题可大可小,但原理都是相通的。为了方便讨论,我们先将场景进行抽象和简化,只讨论两个类之间的耦合。
假设我们有个类Person,需要喝水,根据职责划分,我们需要另一个类Cup来完成喝水的动作,代码如下:
//Person.h @interface Person : NSObject - (void)drink; @end //Cup.h @interface Cup : NSObject - (id)provideWater; @end
很明显,Person和Cup之间要配合完成喝水的动作,是无论如何都会产生耦合的,我们来看看在Objective C下都有哪些耦合的方式,以及不同耦合方式对以后代码质量变化的影响。
方式一:.m引用
这种方式直接在.m文件中导入Cup.h,同时生成临时的Cup对象来调用Cup中的方法。代码如下:
#import "Person.h" #import "Cup.h" @implementation Person - (void)drink { Cup* c = [Cup new]; id water = [c provideWater]; [self sip:water]; } - (void)sip:(id)water { //sip water } @end
这应该是不少同学会选择的做法,要用到某个类的功能,就import该类,再调用方法,功能完成提交测试一气呵成。
这种方式初看起来没什么毛病,但有个弊端:Person与Cup的耦合被埋进了Person.m文件的方法实现中,而.m文件一般都是业务逻辑代码的重灾区,当Person.m的代码量膨胀之后,如果Person类交由另一位工程师来维护,那这位新接手的同学无法从Person.h中一眼看出Person类和哪些类之间有交互,即使在Person.m中看drink的声明也没有任何线索,要理清楚的话,只能把Person.m文件从头到尾读一遍,对团队效率的影响可想而知。
方式二:.h Property
既然直接在.m中引用会导致耦合不清晰,我们可以将耦合的部分放入Property中,代码如下:
//Person.h @interface Person : NSObject @property (nonatomic, strong) Cup* cup; - (void)drink; @end //Person.m @implementation Person - (void)drink { id water = [self.cup provideWater]; [self sip:water]; } - (void)sip:(id)water { //sip water } @end
这样,我们只需要扫一眼Person.h就能明白,Person类对哪些类产生了依赖,比直接在.m中引用清晰多了。
不知道大家有没有好奇过,为什么在Objective C中会有.h文件的存在,为什么不像Java,Swift一样一个文件代表一个类?使用.h文件有利有弊。
.h文件最大的意义在于将声明和实现相隔离。声明是告诉外部我支持哪些功能,实现是支撑这些功能背后的代码逻辑。在我们阅读一个类的.h文件的时候,它最主要的作用是透露两个信息:
-
我(Person类)依赖了哪些外部元素
-
我(Person类)提供哪些接口供外部调用
所以.h文件应该是我们代码耦合的关键所在,当我们犹豫一个类的Property要不要放到.h文件中去声明时,要思考这个Property是不是必须暴露给外部。一旦暴露到.h文件中,就增加了依赖和耦合的几率。有时候Review代码,只要看.h文件是否清晰,就大概能猜测这个类设计者的水平。
当我们把Cup类做为Person的Property声明时,就表明Person与Cup之间存在必要的依赖,我们把这种依赖放到头文件中来,起到一目了然的效果。这比方式一清晰了不少,但有另一个问题,Cup暴露出去以后,外部元素可以随意修改,当内部执行drink的时候,可能另一个线程将cup置空了,影响正常的业务流程。
方式三:.h ReadOnly Property
方式二中,Person类在对Cup产生依赖的同时,也承担了cup随时被外部修改的风险。当然做直观的做法是将Cup类作为ReadOnly的property,同时提供一个对外的setter:
//Person.h @interface Person : NSObject @property (nonatomic, strong, readonly) Cup* cup; - (void)setPersonCup:(Cup*)cup; - (void)drink; @end
有同学可能会问,这和上面的做法有什么区别,不一样都有读写的接口吗?最大的区别是增加了检查和干扰的入口。
当我Debug的时候,经常需要检查某个Propery到底是被谁修改了,Setter中设置一个断点调试起来方便不少。同时,我们还可以使用Xcode的Caller机制,查看当前Setter都被那些外部类调用了,分析类与类之间的关联是很有帮助。
Person.m中Setter方法还提供了我们拓展功能的入口,比如我们需要在Setter中增加多线程同步Lock,当Person.m中的其他方法在使用Cup时,Setter必须等待完成才能执行。又比如我们可以在Setter中实现Copy On Write机制:
//Person.m - (void)setPersonCup:(Cup*)cup { Cup* anotherCup = [cup copy]; _cup = anotherCup; }
这样,Person类就可以避免和外部类共享同一个Cup,杜绝使用同一个水杯的卫生问题 ;)
总之,单独的Setter方法让我们对代码有更大的掌控能力,也为后续接手维护你代码的同学带来了方便,利己利人。
方式四:init 注入
使用带Setter的Property虽然看上去好了不少,但Setter方法可以被任意外部类随时随刻调用,对于Person.m中使用Cup的方法来说,多少有些不安心,万一用着用着被别人改了呢?
为了避免被随意修改,我们可以采用init注入的方式,Objective C中的designated initializer正是为此而生:
//Person.h @interface Person : NSObject - (instancetype)initWithCup:(Cup*)cup; - (void)drink; @end
去掉Property,将Cup的设置放入init方法中,这样Person类对外就只提供一次机会来设置Cup,init之后,外部类就没有其他机会来修改Cup了。
这是使用最多,也是比较推荐的方式。只在对象被创建的时候,去建立与其他对象的关系,把可变性降低到一定程度。那这种方式是否也有什么缺点呢?
通过init的方式设置cup,杜绝了外部因素的影响,但如果内部持有了cup对象,那么内部的函数调用依然可以通过各种姿势与Cup类产生耦合,比如:
//Person.m @interface Person () @property (nonatomic, strong) Cup* myCup; @end @implementation Person - (instancetype)initWithCup:(Cup*)cup { self = [super init]; if (self) { self.myCup = cup; } return self; } - (void)drinkWater { id water = [self.myCup provideWater]; [self sip:water]; } - (void)drinkMilk { id milk = [self.myCup provideMilk]; [self sip:milk]; } @end
Person内部的方法可以通过Cup所有对外的接口来产生耦合,此时我们对于两个类之间的耦合,就主要靠对Cup.h头文件来解读了。如果Cup类设计合理,头文件结构清晰的话,这其实不算太糟糕的场景。那还有没有其他方式呢?
方式五:parameter 注入
用Property持有的方式,在Person对象的整个生命周期内,耦合的可能性一直存在,原因在于Property对于.m文件来说是全局可见的。我们可以用另一种方式让耦合只发生在单个方法内部,即parameter injection:
//Person.h @interface Person : NSObject - (void)drink:(Cup*)cup; @end //Person.m - (void)drink:(Cup*)cup { id water = [cup provideWater]; [self sip:water]; }
这种方式的好处在于:Person和Cup的耦合只发生在drink函数的内部,一旦函数调用结束,Person和Cup之间就结束了依赖关系。从时间和空间的跨度上来说,这种方式比持有Property风险更小。
可要是在Person中存在多处Cup的依赖,比如有drinkWater,drinkMilk,drinkCoffee等等,反而又不如Property直观方便了。
方式六:单例引用
单例的优劣有很多优秀的技术文章分析过了,Peak君只强调其中一点,也是平时review代码和Debug发现最多的问题缘由:单例中的状态共享。
上面的例子中,我们可以把Cup做成单例,代码如下:
//Person.m - (void)drink { id water = [[Cup sharedInstance] provideWater]; [self sip:water]; }
这种方式产生的耦合不但和方式一同样隐蔽,而且是最容易导致代码降级的,随着版本的不停迭代,我们很有可能会得到下面的一个类关联图:
所有的对象都依赖于同一个对象的状态,所有的对象都对这个对象的状态拥有读写权限,最后的结果很有可能是到处打补丁修Bug,按下葫芦浮起瓢。
使用单例类似的场景很常见,比如我们在单例中持有某个用户的信息,在用户登出之后,忘记清除之前用户的信息就会导致奇怪的bug,而且单例一旦零散的分布在项目的各个角落,要逐一处理十分困难。
方式七:继承
继承是一种强耦合关系,网络上有不少关于继承(inheritance)和组合(compoisition)之间优劣的对比文章了,这里不做赘述。继承确实能在初期很方便的建立清晰的对象模型,重用和多态看着也很美妙,问题在于这种强耦合关系在理解上很容易产生分歧,比如什么样对象之间可以被确立为父子关系,哪些子类的行为可以放到父类中给其他子类使用,在多层继承的时候这些问题会变得更加复杂。所以Peak君建议尽可能的少用继承关系来描述对象,除非是一目了然毫无异议的父子关系。
我就不强行来一波父类定义来举例了,比如什么ObjectWithCup这类。
方式八:runtime依赖
使用runtime来处理耦合是Objective C独特的方式,而且耦合度非常之低,甚至可以说感觉不到耦合的存在,比如:
//Person.m - (void)drink:(id)obj { id water = nil; SEL sel = NSSelectorFromString(@"provideWater"); if ([obj respondsToSelector:sel]) { water = [obj performSelector:sel]; } if (water) { [self sip:water]; } }
既不需要导入Cup的头文件,也不需要知道Cup到底支持哪些方法。这种方式的问题也正是由于耦合度太低了,让开发者感知不到耦合的存在,感知不到类之间的关系。如果哪天有人把provideWater改写成getWater,drink方法如果没有同步到,Xcode编译时不会提示你,runtime也不会crash,但是业务流程却没有正常往下走了。
这也是为什么我们不推荐用Objective-C runtime的黑魔法去做业务,只是在无副作用的场景下去完成一些数据的获取操作,比如使用AOP去log日志。
方式九:protocol依赖
这并不是一种独立的耦合方式,protocol可以结合上述各种耦合方式来进一步降低耦合,也是在复杂类关系设计中推荐的方式,比如我们可以定义这样一个protocol:
@protocol LiquidContainer <NSObject> - (id)provideWater; - (id)provideCoffee; @end //Person.h @interface Person : NSObject - (void)drink:(id<LiquidContainer>)container; @end
上述的方式中,无论是Property持有还是parameter注入,都可以使用protocol来降低依赖,protocol的好处在于他只规定了方法的声明,并不限定具体是那个类来实现它,给后期的维护留下更大的空间和可能性。
更复杂的场景
以上是一些常见的类耦合方式,描述的两个类A,B之间的耦合方式。从上面的描述中,我们可以大致感知到两个类使用不同的方式所导致的耦合的深浅,这种耦合深浅度说白了就是:互相调用函数和访问状态的频次。理解这种耦的深浅可以帮助我们大致去量化两个对象之间的耦合度,从而在更复杂的场景中去分析一个模块或者一种架构方式的耦合度。
在更复杂的场景中,比如A,B,C三个类之间也可以采用类似的方法去分析,A,B,C三者可以是如下关系:
分析三个类或者更多类之间的耦合关系的时候,也是先拆解成若干个两个类分析,比如左边我们分析AB,BC,AC三组耦合,进而去感知ABC作为一个整体的耦合度。很显然,右边的方式看着比左边的好,因为只需要分析AB和BC。在我们选用设计模式重构代码的时候,也可以依照类似的方式来分析,从而选择耦合度最低,最贴合我们业务场景的模式。
我们的原则是:类与类之间调用的方法,依赖的状态要越少越好,在Objective C这门语言环境下,书写分类清晰,接口简洁的头文件非常重要。
良性的耦合
前面的分析重在尝试去量化和感知耦合的深浅,但并不是每一次方法调用都是有风险的,有些耦合可以称作是良性的。
如果将我们的代码进行高度抽象,所有的代码都可以被归为两类:Data和Action。一个Class中的Property是Data,而Class中的函数则是Action,我之前写过的一篇关于函数式的文章中提到过,真正让我们代码变得危险的是状态的变化,即改变Data。如果一个函数是纯函数,既不依赖于外部状态,也不修改外部状态,那么这个函数无论被调用多少次都是安全的。如果两个类,比如上面举例的Person和Cup,二者互相调用的都是纯函数,那么二者之间的耦合可以看做是良性的,并不会导致程序的状态维护混乱,只是会让代码的重构变得困难,毕竟耦合的越深,重构改动的代码就越多。
所以我们在做设计的时候,应该尽可能使不同元素之间的耦合是良性的,这就涉及到状态的维护问题,先看下图中两种不同的设计方式:
图中红色的圆圈代表每个类或者功能单位所持有的状态。依照图中上方的设计方式,每个单位各自处理自己的状态变化,这些状态之间还互相存在依赖的话,耦合越深,开发调试和重构就越难,代码就降级越厉害。如果按照图中下方的方式,将状态变化的部分全部都集中到一起处理,维护起来就轻松很多了,这也是为什么很多App都有model layer这一设计的原因,将App状态(各类model)的变化处理独立出来作为一个layer,上层(业务层)只是作为model layer的展现和交互的外壳。这种设计技巧,大可以应用于一个App架构的处理,小可以到一个小功能模块的设计。
结束语
上面总结了我们常用的一些耦合方式,目的在于分析不同代码的书写方式,对于我们最后耦合所产生的影响。最后值得一提的是,上面有些耦合方式并没有绝对的优劣之分,不同的业务场景下可能选择的方式也不同,比如有些场景确实需要持有Property,有些场景单例更合适,关键在于我们能明白不同方式对于我们代码后期维护所产生的影响,这篇文章有些地方可能比较抽象,其中很多都是个人感悟和总结,或有不妥之处,请阅读之后选择性的吸收,希望能对大家平常写代码处理耦合带来一些帮助。