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文件從頭至尾讀一遍,對團隊效率的影響可想而知。ide

方式二:.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中引用清晰多了。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置空了,影響正常的業務流程。

方式三:.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,有些場景單例更合適,關鍵在於咱們能明白不一樣方式對於咱們代碼後期維護所產生的影響,這篇文章有些地方可能比較抽象,其中不少都是我的感悟和總結,或有不妥之處,請閱讀以後選擇性的吸取,但願能對你們日常寫代碼處理耦合帶來一些幫助。

相關文章
相關標籤/搜索