耦合是每一个程序员都必须面对的话题,也是容易被忽视的存在,怎么处理耦合关系到咱们最后的代码质量。今天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文件中导入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文件从头至尾读一遍,对团队效率的影响可想而知。ide
既然直接在.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中引用清晰多了。post
不知道你们有没有好奇过,为何在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置空了,影响正常的业务流程。
方式二中,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方法让咱们对代码有更大的掌控能力,也为后续接手维护你代码的同窗带来了方便,利己利人。
使用带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类设计合理,头文件结构清晰的话,这其实不算太糟糕的场景。那还有没有其余方式呢?
用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来处理耦合是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 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,有些场景单例更合适,关键在于咱们能明白不一样方式对于咱们代码后期维护所产生的影响,这篇文章有些地方可能比较抽象,其中不少都是我的感悟和总结,或有不妥之处,请阅读以后选择性的吸取,但愿能对你们日常写代码处理耦合带来一些帮助。