看了 Limboy(文章1 文章2) 和 Casa (文章) 對 iOS 元件化方案的討論,寫篇文章梳理下思路。
首先我覺得”元件”在這裡不太合適,因為按我理解元件是指比較小的功能塊,這些元件不需要多少元件間通訊,沒什麼依賴,也就不需要做什麼其他處理,物件導向就能搞定。而這裡提到的是較大粒度的業務功能,我們習慣稱為”模組”。為了方便表述,下面模組和元件代表同一個意思,都是指較大粒度的業務模組。
一個 APP 有多個模組,模組之間會通訊,互相呼叫,例如微信讀書有 書籍詳情 想法列表 閱讀器 發現卡片 等等模組,這些模組會互相呼叫,例如 書籍詳情要調起閱讀器和想法列表,閱讀器要調起想法列表和書籍詳情,等等,一般我們是怎樣呼叫呢,以閱讀器為例,會這樣寫:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#import "WRBookDetailViewController.h" #import "WRReviewViewController.h" @implementation WRReadingViewController + (void)gotoDetail { WRBookDetailViewController *detailVC = [[WRBookDetailViewController alloc] initWithBookId:self.bookId]; [self.navigationController.pushViewController:detailVC animated:YES]; } + (void)gotoReview { WRReviewViewController *reviewVC = [[WRReviewViewController alloc] initWithBookId:self.bookId reviewType:1]; [self.navigationController.pushViewController:reviewVC animated:YES]; } @end |
看起來挺好,這樣做簡單明瞭,沒有多餘的東西,專案初期推薦這樣快速開發,但到了專案越來越龐大,這種方式會有什麼問題呢?顯而易見,每個模組都離不開其他模組,互相依賴粘在一起成為一坨:
這樣揉成一坨對測試/編譯/開發效率/後續擴充套件都有一些壞處,那怎麼解開這一坨呢。很簡單,按軟體工程的思路,下意識就會加一箇中間層:
叫他 Mediator Manager Router 什麼都行,反正就是負責轉發資訊的中間層,暫且叫他 Mediator。
看起來順眼多了,但這裡有幾個問題:
- Mediator 怎麼去轉發元件間呼叫?
- 一個模組只跟 Mediator 通訊,怎麼知道另一個模組提供了什麼介面?
- 按上圖的畫法,模組和 Mediator 間互相依賴,怎樣破除這個依賴?
方案1
對於前兩個問題,最直接的反應就是在 Mediator 直接提供介面,呼叫對應模組的方法:
1 2 3 4 5 6 7 8 9 10 11 |
//Mediator.m #import "BookDetailComponent.h" #import "ReviewComponent.h" @implementation Mediator + (UIViewController *)BookDetailComponent_viewController:(NSString *)bookId { return [BookDetailComponent detailViewController:bookId]; } + (UIViewController *)ReviewComponent_viewController:(NSString *)bookId reviewType:(NSInteger)type { return [ReviewComponent reviewViewController:bookId type:type]; } @end |
1 2 3 4 5 6 7 8 9 |
//BookDetailComponent 元件 #import "Mediator.h" #import "WRBookDetailViewController.h" @implementation BookDetailComponent + (UIViewController *)detailViewController:(NSString *)bookId { WRBookDetailViewController *detailVC = [[WRBookDetailViewController alloc] initWithBookId:bookId]; return detailVC; } @end |
1 2 3 4 5 6 7 8 9 |
//ReviewComponent 元件 #import "Mediator.h" #import "WRReviewViewController.h" @implementation ReviewComponent + (UIViewController *)reviewViewController:(NSString *)bookId type:(NSInteger)type { UIViewController *reviewVC = [[WRReviewViewController alloc] initWithBookId:bookId type:type]; return reviewVC; } @end |
然後在閱讀模組裡:
1 2 3 4 5 6 7 8 9 10 11 |
//WRReadingViewController.m #import "Mediator.h" @implementation WRReadingViewController + (void)gotoDetail:(NSString *)bookId { UIViewController *detailVC = [Mediator BookDetailComponent_viewControllerForDetail:bookId]; [self.navigationController pushViewController:detailVC]; UIViewController *reviewVC = [Mediator ReviewComponent_viewController:bookId type:1]; [self.navigationController pushViewController:reviewVC]; } @end |
這就是一開始架構圖的實現,看起來顯然這樣做並沒有什麼好處,依賴關係並沒有解除,Mediator 依賴了所有模組,而呼叫者又依賴 Mediator,最後還是一坨互相依賴,跟原來沒有 Mediator 的方案相比除了更麻煩點其他沒區別。
那怎麼辦呢。
怎樣讓Mediator解除對各個元件的依賴,同時又能調到各個元件暴露出來的方法?對於OC有一個法寶可以做到,就是runtime反射呼叫:
1 2 3 4 5 6 7 8 9 10 11 |
//Mediator.m @implementation Mediator + (UIViewController *)BookDetailComponent_viewController:(NSString *)bookId { Class cls = NSClassFromString(@"BookDetailComponent"); return [cls performSelector:NSSelectorFromString(@"detailViewController:") withObject:@{@"bookId":bookId}]; } + (UIViewController *)ReviewComponent_viewController:(NSString *)bookId type:(NSInteger)type { Class cls = NSClassFromString(@"ReviewComponent"); return [cls performSelector:NSSelectorFromString(@"reviewViewController:") withObject:@{@"bookId":bookId, @"type": @(type)}]; } @end |
這下 Mediator 沒有再對各個元件有依賴了,你看已經不需要 #import 什麼東西了,對應的架構圖就變成:
只有呼叫其他元件介面時才需要依賴 Mediator,元件開發者不需要知道 Mediator 的存在。
等等,既然用runtime就可以解耦取消依賴,那還要Mediator做什麼?元件間呼叫時直接用runtime介面調不就行了,這樣就可以沒有任何依賴就完成呼叫:
1 2 3 4 5 6 7 8 |
//WRReadingViewController.m @implementation WRReadingViewController + (void)gotoReview:(NSString *)bookId { Class cls = NSClassFromString(@"BookDetailComponent"); UIViewController *reviewVC = [cls performSelector:NSSelectorFromString(@"reviewViewController:") withObject:@{@"bookId":bookId, @"type": @(1)}]; [self.navigationController pushViewController:reviewVC]; } @end |
這樣就完全解耦了,但這樣做的問題是:
- 呼叫者寫起來很噁心,程式碼提示都沒有,每次呼叫寫一坨。
- runtime方法的引數個數和型別限制,導致只能每個介面都統一傳一個 NSDictionary。這個 NSDictionary裡的key value是什麼不明確,需要找個地方寫文件說明和檢視。
- 編譯器層面不依賴其他元件,實際上還是依賴了,直接在這裡呼叫,沒有引入呼叫的元件時就掛了
把它移到Mediator後:
- 呼叫者寫起來不噁心,程式碼提示也有了。
- 引數型別和個數無限制,由 Mediator 去轉就行了,元件提供的還是一個 NSDictionary 引數的介面,但在Mediator 裡可以提供任意型別和個數的引數,像上面的例子顯式要求引數 NSString *bookId 和 NSInteger type。
- Mediator可以做統一處理,呼叫某個元件方法時如果某個元件不存在,可以做相應操作,讓呼叫者與元件間沒有耦合。
到這裡,基本上能解決我們的問題:各元件互不依賴,元件間呼叫只依賴中介軟體Mediator,Mediator不依賴其他元件。接下來就是優化這套寫法,有兩個優化點:
- Mediator 每一個方法裡都要寫 runtime 方法,格式是確定的,這是可以抽取出來的。
- 每個元件對外方法都要在 Mediator 寫一遍,元件一多 Mediator 類的長度是恐怖的。
優化後就成了 casa 的方案,target-action 對應第一點,target就是class,action就是selector,通過一些規則簡化動態呼叫。Category 對應第二點,每個元件寫一個 Mediator 的 Category,讓 Mediator 不至於太長。這裡有個demo
總結起來就是,元件通過中介軟體通訊,中介軟體通過 runtime 介面解耦,通過 target-action 簡化寫法,通過 category 感官上分離元件介面程式碼。
方案2
回到 Mediator 最初的三個問題,蘑菇街用的是另一種方式解決:登錄檔的方式,用URL表示介面,在模組啟動時註冊模組提供的介面,一個簡化的實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
//Mediator.m 中介軟體 @implementation Mediator typedef void (^componentBlock) (id param); @property (nonatomic, storng) NSMutableDictionary *cache - (void)registerURLPattern:(NSString *)urlPattern toHandler:(componentBlock)blk { [cache setObject:blk forKey:urlPattern]; } - (void)openURL:(NSString *)url withParam:(id)param { componentBlock blk = [cache objectForKey:url]; if (bulk) blk(param); } @end |
1 2 3 4 5 6 7 8 9 |
//BookDetailComponent 元件 #import "Mediator.h" #import "WRBookDetailViewController.h" + (void)initComponent { [[Mediator sharedInstance] registerURLPattern:@"weread://bookDetail" toHandler:^(NSDictionary *param) { WRBookDetailViewController *detailVC = [[WRBookDetailViewController alloc] initWithBookId:param[@"bookId"]]; [self.navigationController.pushViewController:detailVC animated:YES]; }]; } |
1 2 3 4 5 6 7 |
//WRReadingViewController.m 呼叫者 //ReadingViewController.m #import "Mediator.h" + (void)gotoDetail:(NSString *)bookId { [[Mediator sharedInstance] openURL:@"weread://bookDetail" withParam:@{@"bookId": bookId}]; } |
這樣同樣做到每個模組間沒有依賴,Mediator 也不依賴其他元件,不過這裡不一樣的一點是元件本身和呼叫者都依賴了Mediator,不過這不是重點,架構圖還是跟方案1一樣。
各個元件初始化時向 Mediator 註冊對外提供的介面,Mediator 通過儲存在記憶體的表去知道有哪些模組哪些介面,介面的形式是 URL->block。
這裡拋開URL的遠端呼叫和本地呼叫混在一起導致的問題,先說只用於本地呼叫的情況,對於本地呼叫,URL只是一個表示元件的key,沒有其他作用,這樣做有三個問題:
- 需要有個地方列出各個元件裡有什麼 URL 介面可供呼叫。蘑菇街做了個後臺專門管理。
- 每個元件都需要初始化,記憶體裡需要儲存一份表,元件多了會有記憶體問題。
- 引數的格式不明確,是個靈活的 dictionary,也需要有個地方可以查引數格式。
第二點沒法解決,第一點和第三點可以跟前面那個方案一樣,在 Mediator 每個元件暴露方法的轉介面,然後使用起來就跟前面那種方式一樣了。
拋開URL不說,這種方案跟方案1的共同思路就是:Mediator 不能直接去呼叫元件的方法,因為這樣會產生依賴,那我就要通過其他方法去呼叫,也就是通過 字串->方法 的對映去呼叫。runtime 介面的 className + selectorName -> IMP 是一種,登錄檔的 key -> block 是一種,而前一種是 OC 自帶的特性,後一種需要記憶體維持一份登錄檔,這是不必要的。
現在說回 URL,元件化是不應該跟 URL 扯上關係的,因為元件對外提供的介面主要是模組間程式碼層面上的呼叫,我們先稱為本地呼叫,而 URL 主要用於 APP 間通訊,姑且稱為遠端呼叫。按常規思路者應該是對於遠端呼叫,再加個中間層轉發到本地呼叫,讓這兩者分開。那這裡這兩者混在一起有什麼問題呢?
如果是 URL 的形式,那元件對外提供介面時就要同時考慮本地呼叫和遠端呼叫兩種情況,而遠端呼叫有個限制,傳遞的引數型別有限制,只能傳能被字串化的資料,或者說只能傳能被轉成 json 的資料,像 UIImage 這類物件是不行的,所以如果元件介面要考慮遠端呼叫,這裡的引數就不能是這類非常規物件,介面的定義就受限了。
用理論的話來說就是,遠端呼叫是本地呼叫的子集,這裡混在一起導致元件只能提供子集功能,無法提供像方案1那樣提供全集功能。所以這個方案是天生有缺陷的,對於遺漏的這部分功能,蘑菇街使用了另一種方案補全,請看方案3。
方案3
蘑菇街為了補全本地呼叫的功能,為元件多加了另一種方案,就是通過 protocol-class 登錄檔的方式。首先有一個新的中介軟體:
1 2 3 4 5 6 7 8 9 10 11 12 |
//ProtocolMediator.m 新中介軟體 @implementation ProtocolMediator @property (nonatomic, storng) NSMutableDictionary *protocolCache - (void)registerProtocol:(Protocol *)proto forClass:(Class)cls { NSMutableDictionary *protocolCache; [protocolCache setObject:cls forKey:NSStringFromProtocol(proto)]; } - (Class)classForProtocol:(Protocol *)proto { return protocolCache[NSStringFromProtocol(proto)]; } @end |
然後有一個公共Protocol檔案,定義了每一個元件對外提供的介面:
1 2 3 4 5 6 7 8 9 |
//ComponentProtocol.h @protocol BookDetailComponentProtocol - (UIViewController *)bookDetailController:(NSString *)bookId; - (UIImage *)coverImageWithBookId:(NSString *)bookId; @end @protocol ReviewComponentProtocol - (UIViewController *)ReviewController:(NSString *)bookId; @end |
再在模組裡實現這些介面,並在初始化時呼叫 registerProtocol 註冊。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
//BookDetailComponent 元件 #import "ProtocolMediator.h" #import "ComponentProtocol.h" #import "WRBookDetailViewController.h" + (void)initComponent { [[ProtocolMediator sharedInstance] registerProtocol:@protocol(BookDetailComponentProtocol) forClass:[self class]; } - (UIViewController *)bookDetailController:(NSString *)bookId { WRBookDetailViewController *detailVC = [[WRBookDetailViewController alloc] initWithBookId:param[@"bookId"]]; return detailVC; } - (UIImage *)coverImageWithBookId:(NSString *)bookId { …. } |
最後呼叫者通過 protocol 從 ProtocolMediator 拿到提供這些方法的 Class,再進行呼叫:
1 2 3 4 5 6 7 8 9 10 |
//WRReadingViewController.m 呼叫者 //ReadingViewController.m #import "ProtocolMediator.h" #import "ComponentProtocol.h" + (void)gotoDetail:(NSString *)bookId { Class cls = [[ProtocolMediator sharedInstance] classForProtocol:BookDetailComponentProtocol]; id bookDetailComponent = [[cls alloc] init]; UIViewController *vc = [bookDetailComponent bookDetailController:bookId]; [self.navigationController pushViewController:vc animated:YES]; } |
這種思路有點繞,這個方案跟剛才兩個最大的不同就是,它不是直接通過 Mediator 呼叫元件方法,而是通過 Mediator 拿到元件物件,再自行去呼叫元件方法。
結果就是元件方法的呼叫是分散在各地的,沒有統一的入口,也就沒法做元件不存在時的統一處理。元件1呼叫了元件2的方法,如果用前面兩種方式,元件間是沒有依賴的,元件1+Mediator可以單獨抽離出來,只需要在Mediator裡做好呼叫元件2方法時的異常處理就行。而這種方法元件1對元件2的呼叫分散在各個地方,沒法做這些處理,在不修改元件1程式碼的情況下,元件1和元件2是分不開的。
當然你也可以在這上面跟方案1一樣在 Mediator 對每一個元件介面 wrapper 一層,那這樣這種方案跟方案1比除了更復雜點,其他沒什麼區別。
在 protocol-class 這個方案上,主要存在的問題就是分散呼叫導致耦合,另外實現上會有一些繞,其他就沒什麼了。casa 說的 “protocol對業務產生了侵入,且不符合黑盒模型。” 其實並沒有這麼誇張,實際上 protocol 對外提供元件方法,跟方案1在 Mediator wrapper 對外提供元件方法是差不多的。
最後
蘑菇街在一個專案裡同時用了方案2和方案3兩種方式,會讓寫元件的人不知所措,新增一個介面時不知道該用方案2的方式還是方案3的方式,可能這個在蘑菇街內部會通過一些文件規則去規範,但其實是沒有必要的。可能是蘑菇街作為電商平臺一開始就注重APP頁面間跳轉的概念,每個模組已經有一個對應的URL,於是元件化時自然想到通過URL的方式表示元件,後續發現URL方式的限制,於是加上方案3的方式,這也是正常的探索過程。
上面論述下方案1確實比方案2+方案3簡單明瞭,沒有 登錄檔常駐記憶體/引數傳遞限制/呼叫分散 這些缺點,方案1多做的一步是需要對所有元件方法進行一層 wrapper,但若想要明確提供元件的方法和引數型別,解耦統一處理,方案2和方案3同樣需要多加這層。
實際上我沒有元件化相關的實踐,這裡僅從 limboy 和 casa 提供的這幾個方案對比分析,我還對元件化帶來的收益是否大於元件化增加的成本這點存疑,相信真正實踐起來還會碰到很多坑,繼續探索中。