ios 元件化方案
1.
閱讀本篇文章以前,假設你已經瞭解了元件化這個概念。
最近兩年移動端的元件化特別火,但移動端元件化的概念追其溯源應該來自於Server
端,具體來說這種概念應該是由Java
的Spring
框架帶來的。
Spring
最初是想替代笨重的EJB
,在版本演進過程中又提供了諸如AOP
、DI
、IoC
等功能,推動了Java
程式設計師面向介面程式設計,而面向介面程式設計在面向物件的基礎上將物件又抽象了一層,對外提供的服務只提供介面類而不直接提供物件類,這就引出了一個問題,為什麼給外部提供的是一個介面類而不是物件類?
回想下我們在編寫iOS
程式碼的過程中,我們最常採用的程式碼組織方式是MVC
,最常使用的開發思想是面向物件程式設計,假設現在有一個控制器AViewController
UI
由3部分組成,從上至下分別為頂部Banner
,中間是UICollectionView
管理著一些入口,底部是UITableView
管理著商品列表,單一職能原則約束著我們這3部分業務邏輯最好是由3個類去管理,大家通常也是這麼做的,因此現在AViewController
就要對這3個類進行引用(import
),假設中間部分的入口可以跳轉到10個不用的頁面(Controller
),那麼可能就會有人在AViewController
中import
這10個Controller
,此時,耦合的關係就產生了,如果整個專案都按照這個流程開發,最終整個專案類與類之間的耦合關係會複雜到難以想象,當我們需要把一個類或某個功能、某條業務遷移到其他專案時,可能你就會變成這樣what the fuck!
tmd
怎麼這麼多錯誤?
怎麼解決?
1、根據IDE
的錯誤提示慢慢改,缺啥補啥。
2、元件化,一勞永逸。
2.
如何進行元件化,網上已經有了不少文章講解了這方面的經驗,我這裡再簡單說一說,說不全我文章寫不下去。
第一步:規劃專案整體架構
設計專案的整體架構並不是讓你決定使用MVC
還是MVVM
,在我看來,MVC
和MVVM
亦或是MVP
等等等,都屬於程式碼的組織方式,嚴格意義上來說,並不能算是專案架構,專案架構需要你站在更高的緯度去統籌、規劃專案該如何分層,這個時候就需要你根據產品來對專案劃分不通的層次,業務層的程式碼就劃分到業務層,第三方庫都是通用的,就可以把這些第三方庫劃分到通用層,那麼這個層級關係誰在上誰在下?我們可以根據對業務對程式碼的依賴程度來劃分,那麼業務層就應該在最上面,通用層的程式碼在最下面。如圖:
圖中中間又多出來兩層,中間層和通用業務層,通用業務層顧名思義就是可以分別提供給業務ABCD
使用的業務類的程式碼;中間層的作用是協調和解耦的作用,協調元件間的通訊,解除元件間的耦合,它要做的也就是這篇文章的標題所要講的,中間層就是元件通訊方案。
第二步:管理基礎元件
第一類基礎元件:
一個iOS
專案可能會依賴很多第三方開源庫,比如AFNetworking
、SDWebImage
,FMDB
等,這些開源框架服務全球上百萬個專案,它們是對系統API
的封裝,並且不依賴於業務,我們可以將他們歸到基礎元件裡,很多專案使用cocoapod
來管理這些庫,也有直接把庫檔案直接拖到專案裡來的,我這裡假設使用cocoapod
進行管理。
第二類基礎元件:
而在一些比較大的專案裡或要求比較高的公司往往會將這些第三方開源框架進行二次封裝,以滿足一些使用上的需要或彌補一些先天的缺陷,那麼這些進行二次封裝的庫同樣也屬於基礎元件,我們可以將自己二次封裝的庫也放到通用層這一層,那怎麼管理這些二次封裝的庫呢?推薦使用本地的私有庫,利用cocoapod
進行管理。
第三類基礎元件:
在開發業務時,我們也可以從業務程式碼中抽取一些庫出來,比如很多新聞App
首頁的橫向滾動頁面就可以抽取出一套UI
框架,UITabbarController
也可以抽取成一套UI
框架,高效的切一個UI
控制元件的圓角我們也可以抽取成一套小的UI
框架,自定義彈窗、loading
動效等都可以抽取成單獨的框架。
在整理這些基礎元件的同時,勢必要改很多業務層的程式碼,這會讓你感覺很噁心,但做這些事情的同時也是在為我們的業務元件化鋪路,也就是說,抽取基礎元件會推進我們進行業務元件化。
第三步:業務元件化
既然我們封裝的基礎元件可以使用私有pod
進行管理,業務層程式碼可以用私有pod
進行管理嗎?答案是可以,業務元件化也可以通過私有pod
庫來解決。
我們在第一步中劃分好了專案的架構層次,最頂層的是業務層,業務層根據業務屬性劃分好了若干條業務線,那麼每條業務線就對應著一個pod
私有庫,在我們打包私有庫的時候,私有repo
對程式碼的檢查可是相當嚴格的,像引用了一個本repo
中不存在的類,repo
的校驗都是通不過的,所以這就逼你把各業務線的程式碼進行歸類,屬於哪條業務線的程式碼就劃分到相應的業務線中,這樣做下來,各業務線最後只保留了和本業務線相關的程式碼,感覺結構上和程式碼上都清晰了不少。
但還有一個新問題,業務A
的程式碼呼叫業務B
的程式碼怎麼辦?難道要在業務A
的程式碼中import
業務B
的程式碼,那不又耦合了嗎?而且即便可以這樣做,私有pod
也不允許我們這樣做,因為在校驗私有repo
的時候,這樣的做法根本校驗不通過,為了解決這個問題,我們引入了中間層,讓中間層來解決這個問題,有句話說的好:沒有什麼問題是一箇中間件解決不了的,有就用兩個
,這就引出了接下來要講的,元件間的通訊方案。
3.
iOS
端通用的元件間通訊方案有如下3種:
- URL Router
- Target-Action
- 面向介面程式設計(Protocol - Class)
接下來說這3種方案的具體實現原理。
URL Router
在前端,一個url
表示一個web
頁面。
在後端,一個url
表示一個請求介面。
在iOS
,我們要在App
中跳轉到手機系統設定中的某個功能時,方式是通過UIApplication
開啟一個官方提供的url
,相當於一個url
也是一個頁面。
所以,參考以上幾種場景,我們也可以用一個url
表示一個頁面(Controller
),不止可以表示頁面,還可以表示一個檢視(UI
控制元件),甚至是任意一個類的物件。
知道可以這麼做,我們就可以建立一個字典,key
是url
,value
是相應的物件,這個字典由路由類去管理,典型的方案就是MGJRouter
。
這種方案的優點是能解決元件間的依賴,並且方案成熟,有很多知名公司都在用這種方案;缺點是編譯階段無法發現潛在bug
,並且需要去註冊&
維護路由表。
程式碼示例:
註冊路由
[[Router sharedInstance] registerURL:@"myapp://good/detail" with:^UIViewController *{
return [GoodDetailViewController new];
}];
通過url獲取
UIViewController *vc = [[Router sharedInstance] openURL:@"myapp://good/detail"]
Target-Action
Target-Action
可直接譯為目標-行為,在Object-C
中Target
就是訊息接收者物件,Action
就是訊息,比如我們要呼叫Person
物件的play
方法我們會說向Person
物件傳送了一個play
訊息,此時Target
就是person
物件,Action
就是play
這個方法。
到了專案中,如何利用Target-Action
機制進行解耦?別忘了,Object-C
這項高階語言同樣支援反射。
之前我們在AViewController
中push
到BViewController
,需要在AViewController
類檔案中import
進BViewController
,這樣二者就會產生耦合,現在利用Target-Action
機制,我們不再直接import
進BViewController
,而是利用NSClassFromString(<#NSString * _Nonnull aClassName#>)
這個api
將BViewController
這個字串反射成BViewController
這個類,這樣我們就可以根據反射後的類進行例項化,再呼叫例項化物件的各種方法。
利用Target-Action
機制,我們可以實現各種靈活的解耦,將任意類的例項化過程封裝到任意一個Target
類中,同時,相比於URL Router
,Target-Action
也不需要註冊和記憶體佔用,但缺點是,編譯階段無法發現潛在的BUG
,而且,開發者所建立的類和定義的方法必須要遵守Target-Action
的命名規則,呼叫者可能會因為硬編碼問題導致呼叫失敗。
這種方案對應的開源框架是CTMediator
和阿里BeeHive
中的Router
,二者都是通過反射機制拿到最終的目標類和所需要呼叫的方法(對應的api是NSSelectorFromString(<#NSString * _Nonnull aSelectorName#>)
),最終通過runtime
或performSelector:
執行target
的action
,在action
中進行類的例項化操作,根據具體的使用場景來決定是否將例項物件作為當前action
的返回值。
這裡不再列舉demo
,CTMediator
和BeeHive
在github
中都可以搜到。
面向介面程式設計
我們在第1
部分囉嗦了一大堆就是為了給面向介面程式設計這一部分做鋪墊,傳統的MVC
+面向物件程式設計
的程式設計方式引出的問題我們在第1
部分簡單闡述了一些,而除了這些問題之外,還會產生哪些問題?接下來會講述一些例子。
在Java
中,介面是Interface
,在Object-C
中,介面是Protocol
,所以在Object-C
中,面向介面程式設計又被稱為面向協議程式設計,在Swift
中,Apple
強化了面向介面程式設計這一思想,而這一思想,早已稱為其他語言的主流程式設計思想。
什麼是面向介面程式設計?面向介面程式設計強調我們再設計一個方法或函式時,應該更關注介面而不是具體的實現。
舉個具體的業務需求作為例子:
彈窗幾乎在所有App
中都存在,大廠App
中的彈窗相對來說比較剋制,除了升級之外的彈窗幾乎見不到其他型別,中小型App
中的彈窗就比較多,比如升級彈窗、活動彈窗、廣告彈窗等等,當然,需求複雜的時候,產品還會要求彈窗時機以及彈窗的優先順序等條件。
當我們使用面向物件程式設計思想時,解決方案大概是下面這樣的:
PS:以下程式碼示例基於下面兩個條件
1、如果彈窗介面來自於多個Service
。
2、如果專案大,彈窗這個業務需求也可能來自於不同的業務線,有時候你無法強制要求其他業務線的開發人員必須使用你定製好的類進行開發,可能你覺得你定義的類能適用很多場景,但人家未必這樣認為。
- 需求第1期:升級彈窗
資料型別
@interface UpgradePopUps : NSObject
@property(nonatomic, copy) NSString *content; //內容
@property(nonatomic, copy) NSString *url; //AppStore連結
@property(nonatomic, assign) BOOL must; //是否強制升級
@end
升級彈窗
@interface UpgradView : UIView
- (void)pop;
@end
- 需求第2期:廣告彈窗
資料型別
typedef NS_ENUM(NSUInteger, AdType) {
AdTypeImage, //圖片
AdTypeGif, //GIF
AdTypeVideo, //視訊
};
@interface AdPopUps : NSObject
@property(nonatomic, copy) AdType type; //廣告型別
@property(nonatomic, copy) NSString *content; //內容
@property(nonatomic, copy) NSString *url; //路由url(可能是native頁面也可能是H5)
@end
廣告彈窗
@interface AdView : UIView
- (void)pop;
@end
- 需求第3期,彈窗太多了,給加個優先順序,根據優先順序彈窗。
- 需求第4期,加個活動彈窗,定個優先順序。
- 需求第5期,加個XX彈窗,定個優先順序。
估計此刻的你應該是這樣的:
現在使用面向介面程式設計思想對業務進行改造,我們抽象出一個介面如下:
@protocol PopUpsProtocol <NSObject>
//活動型別(識別符號)
@property(nonatomic, copy) NSString *type;
//跳轉url
@property(nonatomic, copy) NSString *url;
//文字內容
@property(nonatomic, copy) NSString *content;
@required
//開啟執行,在這個方法中展示出彈窗
- (void)execute;
@end
一個簡單的介面就抽象完了,下次如果有新的彈窗需要接入,只需要讓新的彈窗類遵守這個PopUpsProtocol
就可以了,例項化一個彈窗物件的方法如下所示:
id<PopUpsProtocol> popUps = [[AdPopUps alloc] init];
popUps.url = @"...";
popUps.content = @"...";
popUps.type = @"...":
//show
[popUps execute];
在AdPopUps
中程式碼如下:
@interface AdPopUps : NSObject <PopUpsProtocol>
@property(nonatomic, copy) NSString *type;
@property(nonatomic, copy) NSString *url;
@property(nonatomic, copy) NSString *content;
@end
@implementation AdPopUps
- (void)execute {
AdView *adView = [AdView alloc] init];
[adView show];
}
@end
現在我們把這些彈窗事件封裝到Task
(任務)物件中,這個自定義物件可以設定優先順序,然後當把這個任務加入到任務佇列後,佇列會根據任務的優先順序進行排序,整個需求就搞定了。下面來看一下Task
類:
typedef NS_ENUM(NSUInteger, PopUpsTaskPriority) {
PopUpsTaskPriorityLow, //低
PopUpsTaskPriorityDefault, //預設
PopUpsTaskPriorityHigh, //高
};
@interface MSPopUpsTask : NSObject
//任務的唯一識別符號
@property(nonatomic, copy) NSString *identifier;
//優先順序
@property(nonatomic, assign) PopUpsTaskPriority priority;
//任務對應的活動
@property(nonatomic, strong) id<PopUpsProtocol> activity;
//初始化方法
- (instancetype)initWithPriority:(PopUpsTaskPriority)priority
activity:(id<PopUpsProtocol>)activity
identifier:(NSString *)identifier;
//執行任務
- (void)handle;
@end
@implementation MSPopUpsTask
- (void)handle {
if ([_activity respondsToSelector:@selector(execute)]) {
[_activity execute];
}
}
@end
大家看到了,Task
沒有直接依賴任何PopUps
類,而是直接依賴介面PopUpsProtocol
。
一個面向介面程式設計的小例子這裡就講述完了,這個例子中的對於介面的使用方法只是其中一種,在實際應用中,還有其他使用方法,大家可自行搜尋。
接下來說採用面向介面程式設計思想輸出的程式碼會帶來的哪些好處?
1.介面比物件更直觀
讓程式設計師看一個介面往往比看一個物件及其屬性要直觀和簡單,抽象介面往往比定義屬性更能描述想做的事情,呼叫者只需要關注介面而不用關注具體實現。
2.依賴介面而不是依賴物件
剛才我們使用面向介面程式設計的方式建立了一個物件:
id<PopUpsProtocol> popUps = [[AdPopUps alloc] init];
現在我們除了要引用AdPopUps
這個類外,還要引用PopUpsProtocol
,一下引用了兩個,好像又把問題複雜化了,所以我們想辦法只引用protocol
而不引用類,這個時候就需要把protcol
及這個protocol
的具體實現類繫結在一起(protocol
-class
),當我們通過protocol
獲取物件的時候,實際上獲取的是遵守了這個protocol
協議的物件,那如果一個protocol
對應多個實現類怎麼辦?別忘了有工廠模式。
所以,我們需要將Protocol
和Class
繫結到一起,程式碼大概是這種形式的:
[self bindBlock:^(id objc){
AdPopUps *ad = [[AdPopUps alloc] init];
ad.url = @"...";
return (id<PopUpsProtocol >)ad;
} toProtocol:@protocol(PopUpsProtocol)];
獲取方式就是這樣的:
id<PopUpsProtocol> popUps = [self getObject:@protocol(PopUpsProtocol)];
呼叫方法:
[popUps execute];
這樣就把問題解決了。
好了,我們就可以將這個彈窗管理系統作為一個元件去釋出了,所以,為了實現基於元件的開發,必須有一種機制能同時滿足下面兩個要求:
(1)解除Task
對具體彈窗類的強依賴(編譯期依賴)。
(2)在執行時為Task
提供正確的彈窗例項,使彈窗管理系統可以正確展示相應的彈窗。
換句話說,就是將Task
和PopUps
的依賴關係從編譯期推遲到了執行時,所以我們需要把這種依賴關係在一個合適的時機(也就是Task
需要用到PopUps
的時候)注入到執行時,這就是依賴注入(DI
)的由來。
需要注意的是,Task
和PopUps
的依賴關係是解除不掉的,他們倆的依賴關係依然存在,所以我們總說,解除的是強依賴,解除強依賴的手段就是將依賴關係從編譯期推遲到執行時。
其實不管是哪種程式設計模式,為了實現鬆耦合(服務呼叫者和提供者之間的或者框架和外掛之間的),都需要在必要的位置實現面向介面程式設計,在此基礎之上,還應該有一種方便的機制實現具體型別之間的執行時繫結,這就是依賴注入(DI
)所要解決的問題。
如何簡單理解依賴注入?
我們可以將執行中的專案當做是主系統,這些介面及其背後的具體實現就是一個個的外掛,主系統並不依賴任何一個外掛,當外掛被主系統載入的時候,主系統就可以準確呼叫適當外掛的功能。
下面,就要開始分享Object-C
對DI
的具體實現了,這裡需要引入一個框架Objection
,github
上可以搜尋到。
4.
DI
往往和IoC
聯絡到一起的,IoC
更多指IoC
容器。
IoC
即控制反轉,該怎麼理解IoC
這個概念?
簡單理解,從前,我們使用一個物件,除了銷燬之外(iOS
有ARC
進行記憶體管理),這個物件的控制權在我們開發人員手裡,這個控制權體現在物件的初始化、對屬性賦值操作等,因為物件的控制權在我們手裡,所以我們可以把這種情況稱為“控制正轉”。
那麼控制反轉就是將控制權交出去,交給IoC
容器,讓IoC
容器去建立物件,給物件的屬性賦值,這個物件的初始化過程是依賴於DI
的,通過DI
(依賴注入)實現IOC
(控制反轉)
DI
提供了幾種注入方式,這裡說幾個最常用的:
- (1)構造器注入
也就是通過我們指定的初始化方法進行注入,比如針對於Task
這個類,它的構造器就是:
- (instancetype)initWithPriority:(PopUpsTaskPriority)priority
activity:(id<PopUpsProtocol>)activity
identifier:(NSString *)identifier;
IoC
容器會根據這個構造器的引數將依賴的屬性注入進來,並完成最終的初始化操作。
(2)屬性注入
也叫setter方法注入,即當前物件只需要為其依賴物件所對應的屬性新增setter方法,IoC
容器通過此setter
方法將相應的依賴物件設定到被注入物件的方式即setter
方法注入。在Java Spring
中,可以在XML
檔案中配置屬性注入的預設值,比如:
<beans>
<bean id="Person" class="com.package.Person">
<property name="name">
<value>張三</value>
</property>
</bean>
</beans>
在iOS
中可以通過plist
檔案來儲存這些預設值。
- (3)介面注入
介面注入和以上兩種注入方式差不多,但首先你要告訴IoC
容器這個介面對應哪個實現類,否則光注入一個介面有什麼用呢?所以我們需要在專案內給每一個介面建立一個實現類,使介面與類是一一對應的關係(protocol-class
)。
在上面的例子中,因為Task
有個屬性實現了這個PopUpsProtocol
介面,所以IoC
注入的是這個介面的實現類,所以從這個角度來說,介面注入實際上與setter
注入是等價的。
在Java Spring
中,介面注入同樣是通過XML
檔案進行配置的,但現在更多的是用註解來替代XML
注入。