iOS代码耦合的处理

耦合是每个程序员都必须面对的话题,也是容易被忽视的存在,怎么处理耦合关系到我们最后的代码质量。今天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的好处在于他只规定了方法的声明,并不限定具体是那个类来实现它,给后期的维护留下更大的空间和可能性。有关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,有些场景单例更合适,关键在于我们能明白不同方式对于我们代码后期维护所产生的影响,这篇文章有些地方可能比较抽象,其中很多都是个人感悟和总结,或有不妥之处,请阅读之后选择性的吸收,希望能对大家平常写代码处理耦合带来一些帮助。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,530评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,403评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,120评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,770评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,758评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,649评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,021评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,675评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,931评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,751评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,410评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,004评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,969评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,042评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,493评论 2 343

推荐阅读更多精彩内容