swift 组件化_打造完备的iOS组件化方案:如何面向接口进行模块解耦?
作者 | 黑超熊貓zuik,一個修行中的 iOS 開發,喜歡搞點別人沒搞過的東西,鉆研過逆向工程、VIPER 架構和組件化。
關于組件化的探討已經有不少了,在之前的文章?iOS VIPER架構實踐(三):面向接口的路由設計[1]?中,綜合比較了各種方案后,我傾向于使用面向接口的方式進行組件化。
這是一篇從代碼層面講解模塊解耦的文章,會全方位地展示如何實踐面向接口的思想,盡量全面地探討在模塊管理和解耦的過程中,需要考慮到的各種問題,并且給出實際的解決方案,以及對應的模塊管理開源工具:ZIKRouter[2]。你也可以根據本文的內容改造自己現有的方案,即使你的項目不進行組件化,也可以參考本文進行代碼解耦。
文章主要內容:
? 如何衡量模塊解耦的程度
? 對比不同方案的優劣
? 在編譯時進行靜態路由檢查,避免使用不存在的模塊
? 如何進行模塊解耦,包括模塊重用、模塊適配、模塊間通信、子模塊交互
? 模塊的接口和依賴管理
? 管理界面跳轉邏輯
什么是組件化
將模塊單獨抽離、分層,并制定模塊間通信的方式,從而實現解耦,以及適應團隊開發。
為什么需要組件化
主要有4個原因:
? 模塊間解耦
? 模塊重用
? 提高團隊協作開發效率
? 單元測試
當項目越來越大的時候,各個模塊之間如果是直接互相引用,就會產生許多耦合,導致接口濫用,當某天需要進行修改時,就會牽一發而動全身,難以維護。
問題主要體現在:
? 修改某個模塊的功能時,需要修改許多其他模塊的代碼,因為這個模塊被其他模塊引用
? 模塊對外的接口不明確,外部甚至會調用不應暴露的私有接口,修改時會耗費大量時間
? 修改的模塊涉及范圍較廣,很容易影響其他團隊成員的開發,產生代碼沖突
? 當需要抽離模塊到其他地方重用時,會發現耦合導致根本無法單獨抽離
? 模塊間的耦合導致接口和依賴混亂,難以編寫單元測試
所以需要減少模塊之間的耦合,用更規范的方式進行模塊間交互。這就是組件化,也可以叫做模塊化。
你的項目是否需要組件化
組件化也不是必須的,有些情況下并不需要組件化:
? 項目較小,模塊間交互簡單,耦合少
? 模塊沒有被多個外部模塊引用,只是一個單獨的小模塊
? 模塊不需要重用,代碼也很少被修改
? 團隊規模很小
? 不需要編寫單元測試
組件化也是有一定成本的,你需要花時間設計接口,分離代碼,所以并不是所有的模塊都需要組件化。
不過,當你發現這幾個跡象時,就需要考慮組件化了:
? 模塊邏輯復雜,多個模塊間頻繁互相引用
? 項目規模逐漸變大,修改代碼變得越來越困難
? 團隊人數變多,提交的代碼經常和其他成員沖突
? 項目編譯耗時較大
? 模塊的單元測試經常由于其他模塊的修改而失敗
組件化方案的8條指標
決定了要開始組件化之路后,就需要思考我們的目標了。一個組件化方案需要達到怎樣的效果呢?我在這里給出8個理想情況下的指標:
1) 模塊間沒有直接耦合,一個模塊內部的修改不會影響到另一個模塊
2) 模塊可以被單獨編譯
3) 模塊間能夠清晰地進行數據傳遞
4) 模塊可以隨時被另一個提供了相同功能的模塊替換
5) 模塊的對外接口容易查找和維護
6) 當模塊的接口改變時,使用此模塊的外部代碼能夠被高效地重構
7) 盡量用最少的修改和代碼,讓現有的項目實現模塊化
8) 支持 Objective-C 和 Swift,以及混編
前4條用于衡量一個模塊是否真正解耦,后4條用于衡量在項目實踐中的易用程度。最后一條必須支持 Swift,是因為 Swift 是一個必然的趨勢,如果你的方案不支持 Swift,說明這個方案在將來的某個時刻必定要改進改變,而到時候所有基于這個方案實現的模塊都會受到影響。
基于這8個指標,我們就能在一定程度上對我們的方案做出衡量了。
方案對比
現在主要有3種組件化方案:URL 路由、target-action、protocol 匹配。
接下來我們就比較一下這幾種組件化方案,看看它們各有什么優缺點。這部分在之前的文章中已經探討過,這里再重新比較一次,補充一些細節。必須要先說明的是,沒有一個完美的方案能滿足所有場景下的需求,需要根據每個項目的需求選擇最適合的方案。
URL 路由
目前 iOS 上絕大部分的路由工具都是基于 URL 匹配的,或者是根據命名約定,用 runtime 方法進行動態調用。
這些動態化的方案的優點是實現簡單,缺點是需要維護字符串表,或者依賴于命名約定,無法在編譯時暴露出所有問題,需要在運行時才能發現錯誤。
代碼示例:
[URLRouter registerURL:@"app://editor" handler:^(NSDictionary *userInfo) {
UIViewController *editorViewController = [[EditorViewController alloc] initWithParam:userInfo];
return editorViewController;
}];
// 調用路由
[URLRouter openURL:@"app://editor/?debug=true" completion:^(NSDictionary *info) {
}];
URL router 的優點:
? 極高的動態性,適合經常開展運營活動的 app,例如電商
? 方便地統一管理多平臺的路由規則
? 易于適配 URL Scheme
URL router 的缺點:
? 傳參方式有限,并且無法利用編譯器進行參數類型檢查,因此所有的參數都只能從字符串中轉換而來
? 只適用于界面模塊,不適用于通用模塊
? 不能使用 designated initializer 聲明必需參數
? 要讓 view controller 支持 url,需要為其新增初始化方法,因此需要對模塊做出修改
? 不支持 storyboard
? 無法明確聲明模塊提供的接口,只能依賴于接口文檔,重構時無法確保修改正確
? 依賴于字符串硬編碼,難以管理
? 無法保證所使用的模塊一定存在
? 解耦能力有限,url 的"注冊"、"實現"、"使用"必須用相同的字符規則,一旦任何一方做出修改都會導致其他方的代碼失效,并且重構難度大
字符串解耦的問題
如果用上面的8個指標來衡量,URL 路由只能滿足"支持模塊單獨編譯"、"支持 OC 和 Swift"兩條。它的解耦程度非常一般。
所有基于字符串的解耦方案其實都可以說是偽解耦,它們只是放棄了編譯依賴,但是當代碼變化之后,即便能夠編譯運行,邏輯仍然是錯誤的。
例如修改了模塊定義時的 URL:
// 注冊某個URL[URLRouter registerURL:@"app://editorView" handler:^(NSDictionary *userInfo) {
...
}];
那么調用者的 URL 也必須修改,代碼仍然是有耦合的,只不過此時編譯器無法檢查而已。這會導致維護更加困難,一旦 URL 中的參數有了增減,或者決定替換為另一個模塊,參數命名有了變化,幾乎沒有高效的方式來重構代碼。可以使用宏定義來管理字符串,不過這要求所有模塊都使用同一個頭文件,并且也無法解決參數類型和數量變化的問題。
URL 路由適合用來做遠程模塊的網絡協議交互,而在管理本地模塊時,最大的甚至是唯一的優勢,就是適合經常跨多端運營活動的 app,因為可以由運營人員統一管理多平臺的路由規則。
代表框架
? routable-ios
? JLRoutes
? MGJRouter
? HHRouter
改進:避免字符串管理
改進 URL 路由的方式,就是避免使用字符串,通過接口管理模塊。
參數可以通過 protocol 直接傳遞,能夠利用編譯器檢查參數類型,并且在 ZIKRouter 中,能通過路由聲明和編譯檢查,保證所使用的模塊一定存在。在為模塊創建路由時,也無需修改模塊的代碼。
但是必須要承認的是,盡管 URL 路由缺點多多,但它在跨平臺路由管理上的確是最適合的方案。因此 ZIKRouter 也對 URL 路由做出了支持,在用 protocol 管理的同時,可以通過字符串匹配 router,也能和其他 URL router 框架對接。
Target-Action 方案
有一些模塊管理工具基于 Objective-C 的 runtime、category 特性動態獲取模塊。例如通過NSClassFromString獲取類并創建實例,通過performSelector: NSInvocation動態調用方法。
例如基于 target-action 模式的設計,大致是利用 category 為路由工具添加新接口,在接口中通過字符串獲取對應的類,再用 runtime 創建實例,動態調用實例的方法。
示例代碼:
// 模塊管理者,提供了動態調用 target-action 的基本功能@interface Mediator : NSObject
+ (instancetype)sharedInstance;
- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params;
@end
// 在 category 中定義新接口
@interface Mediator (ModuleActions)
- (UIViewController *)Mediator_editorViewController;
@end
@implementation Mediator (ModuleActions)
- (UIViewController *)Mediator_editorViewController {
// 使用字符串硬編碼,通過 runtime 動態創建 Target_Editor,并調用 Action_viewController:
UIViewController *viewController = [self performTarget:@"Editor" action:@"viewController" params:@{@"key":@"value"}];
return viewController;
}
@end
// 調用者通過 Mediator 的接口調用模塊
UIViewController *editor = [[Mediator sharedInstance] Mediator_editorViewController];
// 模塊提供者提供 target-action 的調用方式
@interface Target_Editor : NSObject
- (UIViewController *)Action_viewController:(NSDictionary *)params;
@end
@implementation Target_Editor
- (UIViewController *)Action_viewController:(NSDictionary *)params {
// 參數通過字典傳遞,無法保證類型安全
EditorViewController *viewController = [[EditorViewController alloc] init];
viewController.valueLabel.text = params[@"key"];
return viewController;
}
@end
優點:
? 利用 category 可以明確聲明接口,進行編譯檢查
? 實現方式輕量
缺點:
? 需要在 mediator 和 target 中重新添加每一個接口,模塊化時代碼較為繁瑣
? 在 category 中仍然引入了字符串硬編碼,內部使用字典傳參,一定程度上也存在和 URL 路由相同的問題
? 無法保證所使用的模塊一定存在,target 模塊在修改后,使用者只有在運行時才能發現錯誤
? 過于依賴 runtime 特性,無法應用到純 Swift 上。在 Swift 中擴展 mediator 時,無法使用純 Swift 類型的參數
? 可能會創建過多的 target 類
使用 runtime 相關的接口調用任意類的任意方法,需要注意別被蘋果的審核誤傷。參考:Are performSelector and respondsToSelector banned by App Store?[3]
字典傳參的問題
字典傳參時無法保證參數的數量和類型,只能依賴調用約定,就和字符串傳參一樣,一旦某一方做出修改,另一方也必須修改。
相比于 URL 路由,target-action 通過 category 的接口把字符串管理的問題縮小到了 mediator 內部,不過并沒有完全消除,而且在其他方面仍然有很多改進空間。上面的8個指標中其實只能滿足第2個"支持模塊單獨編譯",另外在和接口相關的第3、5、6點上,比 URL 路由要有改善。
代表框架
? CTMediator
改進:避免字典傳參
Target-Action 方案最大的優點就是整個方案實現輕量,并且也一定程度上明確了模塊的接口。只是這些接口都需要通過 Target-Action 封裝一次,并且每個模塊都要創建一個 target 類,既然如此,直接用 protocol 進行接口管理會更加簡單。
ZIKRouter 避免使用 runtime 獲取和調用模塊,因此可以適配 OC 和 swift。同時,基于 protocol 匹配的方式,避免引入字符串硬編碼,能夠更好地管理模塊,也避免了字典傳參。
基于 protocol 匹配的方案
有一些模塊管理工具或者依賴注入工具,也實現了基于接口的管理方式。實現思路是將 protocol 和對應的類進行字典匹配,之后就可以用 protocol 獲取 class,再動態創建實例。
BeeHive 示例代碼:
// 注冊模塊 (protocol-class 匹配)[[BeeHive shareInstance] registerService:@protocol(EditorViewProtocol) service:[EditorViewController class]];// 獲取模塊 (用 runtime 創建 EditorViewController 實例)
id editor = [[BeeHive shareInstance] createService:@protocol(EditorViewProtocol)];
優點:
? 利用接口調用,實現了參數傳遞時的類型安全
? 直接使用模塊的 protocol 接口,無需再重復封裝
缺點:
? 由框架來創建所有對象,創建方式有限,例如不支持外部傳入參數,再調用自定義初始化方法
? 用 OC runtime 創建對象,不支持 Swift
? 只做了 protocol 和 class 的匹配,不支持更復雜的創建方式和依賴注入
? 無法保證所使用的 protocol 一定存在對應的模塊,也無法直接判斷某個 protocol 是否能用于獲取模塊
相比直接 protocol-class 匹配的方式,protocol-block 的方式更加易用。例如 Swinject。
Swinject 示例代碼:
let container = Container()// 注冊模塊
container.register(EditorViewProtocol.self) { _ in
return EditorViewController()
}
// 獲取模塊
let editor = container.resolve(EditorViewProtocol.self)!
代表框架
? BeeHive
? Swinject
改進:離散式管理
BeeHive 這種方式和 ZIKRouter 的思路類似,但是所有的模塊在注冊后,都是由 BeeHive 單例來創建,使用場景十分有限,例如不支持純 Swift 類型,不支持使用自定義初始化方法以及額外的依賴注入。
ZIKRouter 進行了進一步的改進,并不是直接對 protocol 和 class 進行匹配,而是將 protocol 和 router 子類或者 router 對象進行匹配,在 router 子類中再提供創建模塊的實例的方式。這時,模塊的創建職責就從 BeeHive 單例上轉到了每個單獨的 router 上,從集約型變成了離散型,擴展性進一步提升。
Protocol-Router 匹配方案
變成 protocol-router 匹配后,代碼將會變成這樣:
一個 router 父類提供基礎的方法:
class ZIKViewRouter: NSObject {...
// 獲取模塊
public class func makeDestination -> Any? {
let router = self.init(with: ViewRouteConfig())
return router.destination(with: router.configuration)
}
// 讓子類重寫
public func destination(with configuration: ViewRouteConfig) -> Any? {
return nil
}
}
每個模塊各自編寫自己的 router 子類:
// editor 模塊的 routerclass EditorViewRouter: ZIKViewRouter {
// 子類重寫,創建模塊
override func destination(with configuration: ViewRouteConfig) -> Any? {
let destination = EditorViewController()
return destination
}
}
把 protocol 和 router 類進行注冊綁定:
EditorViewRouter.register(RoutableView())然后就可以用 protocol 獲取 router 類,再進一步獲取模塊:
// 獲取模塊的 router 類let routerClass = Router.to(RoutableView())// 獲取 EditorViewProtocol 模塊let destination = routerClass?.makeDestination()
加了一層 router 中間層之后,解耦能力一下子就增強了:
? 可以在 router 上添加許多通用的擴展接口,例如創建模塊、依賴注入、界面跳轉、界面移除,甚至增加 URL 路由支持
? 在每個 router 子類中可以進行更詳細的依賴注入和自定義操作
? 可以自定義創建對象的方式,例如自定義初始化方法、工廠方法,在重構時可以直接搬運現有的創建代碼,無需在原來的類上增加或修改接口,減少模塊化過程中的工作量
? 可以讓多個 protocol 和同一個模塊進行匹配
? 可以讓模塊進行接口適配,允許外部做完適配后,為 router 添加新的 protocol,解決編譯依賴的問題
? 返回的對象只需符合 protocol,不再和某個單一的類綁定。因此可以根據條件,返回不同的對象,例如適配不同系統版本時,返回不同的控件,讓外部只關注接口
動態化的風險
大部分組件化方案都會帶來一個問題,就是減弱甚至拋棄編譯檢查,因為模塊已經變得高度動態化了。
當調用一個模塊時,怎么能保證這個模塊一定存在?直接引用類時,如果類不存在,編譯器會給出引用錯誤,但是動態組件就無法在靜態時檢查了。
例如 URL 地址變化了,但是代碼中的某些 URL 沒有及時更新;使用 protocol 獲取模塊時,protocol 并沒有注冊對應的模塊。這些問題都只能在運行時才能發現。
那么有沒有一種方式,可以讓模塊既高度解耦,又能在編譯時保證調用的模塊一定存在呢?
答案是 YES。
靜態路由檢查
ZIKRouter 最特別的功能,就是能夠保證所使用的 protocol 一定存在,在編譯階段就能防止使用不存在的模塊。這個功能可以讓你更安全、更簡單地管理所使用的路由接口,不必再用其他復雜的方式進行檢查和維護。
當使用了錯誤的 protocol 時,會產生編譯錯誤。
Swift 中使用未聲明的 protocol:
Objective-C 中使用未聲明的 protocol:
這個特性通過兩個機制來實現:
? 只有被聲明為可路由的 protocol 才能用于路由,否則會產生編譯錯誤
? 可路由的 protocol 必定有一個對應的模塊存在
下面就一步步講解,怎么在保持動態解耦特性的同時,實現一套完備的靜態類型檢查的機制。
路由聲明
怎么才能聲明一個 protocol 是可以用于路由的呢?
要實現第一個機制,關鍵就是要為 protocol 添加特殊的屬性或者類型,使用時,如果 protocol 不符合特定類型,就產生編譯錯誤。
原生 Xcode 并不支持這樣的靜態檢查,這時候就要考驗我們的創造力了。
Objective-C:protocol 繼承鏈
在 Objective-C 中,可以要求 protocol 必須繼承自某個特定的父 protocol,并且通過宏定義 + protocol 限定,對 protocol 的父 protocol 繼承鏈進行靜態檢查。
例如 ZIKRouter 中獲取 router 類的方法是這樣的:
@protocol ZIKViewRoutable@end
@interface ZIKViewRouter()
@property (nonatomic, class, readonly) ZIKViewRouterType *(^toView)(Protocol *viewProtocol);@end
toView用類屬性的方式提供,以方便鏈式調用,這個 block 接收一個Protocol?*類型的 protocol,返回對應的 router 類。
Protocol?*表示這個 protocol 必須繼承自ZIKViewRoutable。普通 protocol 的類型是Protocol *,所以如果傳入@protocol(EditorViewProtocol)就會產生編譯警告。
而如果用宏定義再給 protocol 變量加上一個 protocol 限定,進行一次類型轉換,就可以利用編譯器檢查 protocol 的繼承鏈:
// 聲明時繼承自 ZIKViewRoutable@protocol EditorViewProtocol @end// 宏定義,為 protocol 變量添加 protocol 限定
#define ZIKRoutable(RoutableProtocol) (Protocol*)@protocol(RoutableProtocol)// 用 protocol 獲取 router
ZIKViewRouter.toView(ZIKRoutable(EditorViewProtocol))
ZIKRoutable(EditorViewProtocol)展開后是(Protocol *)@protocol(EditorViewProtocol),類型為Protocol *。在 Objective-C 中Protocol *是Protocol *的子類型,編譯器將不會有警告。
但是當傳入的 protocol 沒有繼承自ZIKViewRoutable時,例如ZIKRoutable(UndeclaredProtocol)的類型是Protocol *,編譯器在檢查 protocol 的繼承鏈時,由于UndeclaredProtocol沒有繼承自ZIKViewRoutable,因此Protocol *不是Protocol *的子類型,編譯器會給出類型錯誤的警告。在Build Settings中可以把incompatible pointer types警告變成編譯錯誤。
最后,把ZIKViewRouter.toView(ZIKRoutable(EditorViewProtocol))用宏定義簡化一下,變成ZIKViewRouterToView(EditorViewProtocol),就能在獲取 router 的時候方便地靜態檢查 protocol 的類型了。
Swift:條件擴展
Swift 中不支持宏定義,也不能隨意進行類型轉換,因此需要換一種方式來進行編譯檢查。
可以用 struct 的泛型傳遞 protocol,然后用條件擴展為特定泛型的 struct 添加初始化方法,從而讓沒有聲明過的泛型類型不能直接創建 struct。
例如:
// 用 RoutableView 的泛型來傳遞 protocolstruct RoutableView<Protocol> {
// 禁止默認的初始化方法
@available(*, unavailable, message: "Protocol is not declared as routable")
public init() { }
}
// 泛型為 EditorViewProtocol 的擴展
extension RoutableView where Protocol == EditorViewProtocol {
// 允許初始化
init() { }
}
// 泛型為 EditorViewProtocol 時可以初始化
RoutableView()// 沒有聲明過的泛型無法初始化,會產生編譯錯誤
RoutableView()
此時 Xcode 還可以給出自動補全,列出所有聲明過的 protocol:
路由檢查
通過路由聲明,我們做到了在編譯時對所使用的 protocol 做出限制。下一步就是保證聲明過的 protocol 必定有對應的模塊,類似于程序在 link 階段,會檢查頭文件中聲明過的類必定有對應的實現。
這一步是無法直接在編譯階段實現的,不過可以參考 iOS 在啟動時檢查動態庫的方式,我們可以在啟動階段實現這個功能。
Objective-C: protocol 遍歷
在 app 以 DEBUG 模式啟動時,我們可以遍歷所有繼承自 ZIKViewRoutable 的 protocol,在注冊表中檢查是否有對應的 router,如果沒有,就給出斷言錯誤。
另外,還可以讓 router 同時注冊創建模塊時用到類:
EditorViewRouter.registerView(EditorViewController.self)從而進一步檢查 router 中的 class 是否遵守對應的 protocol。這時整個類型檢查過程就完整了。
Swift: 符號遍歷
但是 Swift 中的 protocol 是靜態類型,并不能通過 OC runtime 直接遍歷。是不是就無法動態檢查了呢?其實只要發揮創造力,一樣能做到。
Swift 的泛型名會在符號名中體現出來。例如上面聲明的 init 方法:
// MyApp 中,泛型為 EditorViewProtocol 的擴展extension RoutableView where Protocol == EditorViewProtocol {
// 允許初始化
init() { }
}
在還原符號后就是(extension in MyApp):ZRouter.RoutableView.init() -> ZRouter.RoutableView。
此時我們可以遍歷 app 的符號表,來查找 RoutableView 的所有擴展,從而提取出所有聲明過的 protocol 類型,再去檢查是否有對應的 router。
Swift Runtime 和 ABI
但是如果要進一步檢查 router 中的 class 是否遵守 router 中的 protocol,就會遇到問題了。在 Swift 中怎么檢查某個任意的 class 遵守某個 Swift protocol ?
Swift 中沒有直接提供class_conformsToProtocol這樣的函數,不過我們可以通過 Swift Runtime 提供的標準函數和 Swift ABI 中定義的內存結構,完成同樣的功能。
這部分的實現可以參考代碼:_swift_typeIsTargetType[4]。之后我會寫幾篇文章詳細講解 Swift ABI 的底層內容。
路由檢查這部分只在 DEBUG 模式下進行,因此可以放開折騰。
自動推斷返回值類型
還有最后一個問題,在 BeeHive 中使用[[BeeHive shareInstance] createService:@protocol(EditorViewProtocol)]獲取模塊時,返回值是一個id類型,使用者需要手動指定返回變量的類型,在 Swift 中更是需要手動類型轉換,而這一步是可能出錯的,并且編譯器無法檢查。要實現最完備的類型檢查,就不能忽視這個問題。
有沒有一種方式能讓返回值的類型和 protocol 的類型對應呢?OC 中的泛型在這時候就發揮作用了。
可以在 router 上聲明模塊的泛型:
@interface ZIKViewRouter<__covariant destination __covariant>RouteConfig: ZIKViewRouteConfiguration *> : NSObject@end這里使用了兩個泛型參數 Destination 和 RouteConfig,分別表示此 router 所管理的模塊類型和路由 config 的類型。__covariant則表示這個泛型支持協變,也就是子類型可以和父類型一樣使用。
聲明了泛型參數后,我們可以在方法中的參數聲明中使用泛型:
@interface ZIKViewRouter<__covariant destination __covariant>RouteConfig: ZIKViewRouteConfiguration *> : NSObject- (nullable Destination)makeDestination;
- (nullable Destination)destinationWithConfiguration:(RouteConfig)configuration;@end
此時在獲取 router 時,就可以把 protocol 的類型作為 router 的泛型參數:
#define ZIKRouterToView(ViewProtocol) [ZIKViewRouter,ZIKViewRouteConfiguration *> toView](ZIKRoutable(ViewProtocol))使用ZIKRouterToView(EditorViewProtocol)獲取的 router 類型就是ZIKViewRouter,ZIKViewRouteConfiguration *>。在這個 router 上調用makeDestination時,返回值的類型就是id,從而實現了完整的類型傳遞。
而在 Swift 中,直接用函數泛型就能實現:
class Router {static func to(_ routableView: RoutableView<Protocol>) -> ViewRouter<Protocol, ViewRouteConfig>?
}
使用Router.to(RoutableView())時,獲得的 router 類型就是ViewRouter?,在調用makeDestination時,返回值類型就是EditorViewProtocol,無需手動類型轉換。
如果你使用協議組合,還能同時指明多個類型:
typealias EditorViewProtocol = UIViewController & EditorViewInput并且在 router 子類中重寫對應方法時,也能用泛型進一步確保類型正確:
class EditorViewRouter: ZIKViewRouter<EditorViewProtocol, ZIKViewRouteConfiguration> {override func destination(with configuration: ZIKViewRouteConfiguration) -> EditorViewProtocol? {
// 函數重寫時,參數類型會和泛型一致,實現時能確保返回值的類型是正確的
return EditorViewController()
}
}
現在我們完成了一套完備的類型檢查機制,而且這套檢查同時支持 OC 和 Swift。
至此,一個基于接口的、類型安全的模塊管理工具就完成了。使用 makeDestination 創建模塊只是最基本的功能,我們可以在父類 router 中進行許多有用的功能擴展,例如依賴注入、界面跳轉、接口適配,來更好地進行面向接口的開發。
模塊解耦
那么在面向接口編程時,我們還需要哪些功能呢?在擴展之前,我們先來討論一下如何使用接口進行模塊解耦,首先從理論層面梳理,再把理論轉化為工具。
模塊分類
不同模塊對解耦的要求是不同的。模塊從層級上可以從低到高分類:
? 底層功能模塊,功能單一,有一定通用性,例如各種功能組件(日志、數據庫)。底層模塊的主要目的是復用
? 中間層的通用業務模塊,可以在不同項目中通用。會引用各種底層模塊,以及和其他業務模塊通信
? 中間層的特殊功能模塊,提供了獨特的功能,沒有通用性,可能會引用一些底層模塊,例如性能監控模塊。這種模塊可以被其他模塊直接引用,不用太多考慮模塊間解耦的問題
? 上層的專有業務模塊,屬于某個項目中獨有的業務。會引用各種底層模塊,以及和其他業務模塊通信,和中間層的差別就是上層的解耦要求沒有中間層那么高
什么是解耦
首先明確一下什么才是解耦,梳理這個問題能夠幫助我們明確目標。
解耦的目的基本上就是兩個:提高代碼的可維護性、模塊重用。指導思想就是面向對象的設計原則。
解耦也有不同的程度,從低到高,差不多可以分為3層:
1) 模塊間使用抽象接口交互,沒有直接類型耦合,一個模塊內部的修改不會影響到另一個模塊 (單一職責、依賴倒置)
2) 模塊可重用,可以被單獨編譯 (接口隔離、依賴倒置、控制反轉)
3) 模塊可以隨時被另一個提供了相同功能的模塊替換 (開閉原則、依賴倒置、控制反轉)
第一層:抽象接口,提取依賴關系
第一層解耦,是為了減少不同代碼間的依賴關系,讓代碼更容易維護。例如把類替換為 protocol,隔絕模塊的私有接口,把依賴關系最小化。
解耦的整個過程,就是梳理和管理依賴的過程。因此模塊的內聚性越高越好,外部依賴越少越好,這樣維護起來才更簡單。
如果模塊不需要重用,那在這一層基本上就夠了。
第二層:模塊重用,管理模塊間通信
第二層解耦,是把代碼單獨抽離,做到了模塊重用,可以交給不同的成員維護,對模塊間通信提出了更高的要求。模塊需要在接口中聲明外部依賴,去除對特定類型的耦合。
此時影響最大的地方就是模塊間通信的方式,有時候即便是能夠單獨編譯了,也不意味著解耦。例如 URL 路由,只是放棄了編譯檢查,耦合關系還是存在于 URL 字符串中,一方的 URL 改變,其他方的代碼邏輯就會出錯,所以邏輯上仍然是耦合的。因此所有基于某種隱式調用約定的方案(例如字符串匹配),都只是解除編譯檢查,而不是真正的解耦。
有人說使用 protocol 進行模塊間通信,會導致模塊和 protocol 耦合。這個觀點是錯誤的。protocol 恰恰是把模塊的依賴明確地提取出來,是一種更高效的方法。否則完全用隱式約定來進行通信,沒有編譯器的輔助,一旦模塊的接口名、參數類型、參數數量需要更新,將會非常難以維護。
而且,通過設計模式,是可以解除對特定 protocol 的依賴的,下文將會對此進行講解。
第三層:去除隱式約定
第三層解耦,模塊間做到了真正的解耦,只要兩個模塊提供了相同的功能,就可以無縫替換,并且調用方無需任何修改。被替換的模塊只需要提供相同功能的接口,通過適配器對接即可,沒有其他任何限制,不存在任何其他的隱式調用約定。
一般有這種解耦要求的,都是那些跨項目的通用模塊,而項目內專有的業務模塊則沒有這么高的要求。不過那些跨多端的模塊和遠程模塊無法做到這樣的解耦,因為跨多端時沒有統一的定義接口的方式,因此只能通過隱式約定或者網絡協議定義接口,例如 URL 路由。
總的來說,解耦的過程就是職責分離、依賴管理(依賴聲明和注入)、模塊通信 這三大部分。
模塊重用
要做到模塊重用,模塊需要盡量減少外部依賴,并且把依賴提取出來,體現到模塊的接口上,讓調用者主動注入。同時,把模塊的各種事件也提取出來,讓調用者進行處理。
這樣一來,模塊就只需要負責自身的邏輯,不需要關心調用者如何使用模塊。那些每個應用各自專有的應用層邏輯也就從模塊中分離出來了。
因此,要想做好模塊解耦,管理好依賴是非常重要的。而 protocol 接口就是管理依賴的最高效的方式。
依賴管理
依賴,就是模塊中用到的外部數據和外部模塊。接下來討論如何使用 protocol 管理依賴,并且演示如何用 router 實現。
依賴注入
先來復習一下依賴注入的概念。依賴注入和依賴查找是實現控制反轉思想的具體方式。
控制反轉是將對象依賴的獲取從主動變為被動,從對象內部直接引用并獲取依賴,變為由外部向對象提供對象所要求的依賴,把不屬于自己的職責移交出去,從而讓對象和其依賴解耦。此時控制流的主動權從內部轉移到了外部,因此稱為控制反轉。
依賴注入就是指外部向對象傳入依賴。
一個類 A 在接口中體現出內部需要用到的一些依賴(例如內部需要用到類B的實例),從而讓使用者從外部注入這些依賴,而不是在類內部直接引用依賴并創建類 B。依賴可以用 protocol 的方式聲明,這樣就可以使類 A 和所使用的依賴類 B 進行解耦。
分離模塊創建和配置
那么如何用 router 進行依賴注入呢?
模塊創建了實例后,經常還需要進行一些配置。模塊管理工具應該從設計上提供配置功能。
最簡單的方式,就是在destinationWithConfiguration:中創建 destination 時進行配置。但是我們還可以更進一步,把 destination 的創建和配置分離開。分離之后,router 就可以單獨提供配置功能,去配置那些不是由 router 創建的 destination,例如 storyboard 中創建的 view、各種接口回調中返回的實例對象。這樣就可以覆蓋更多現存的使用場景,減少代碼修改。
Prepare Destination
可以在 router 子類中的prepareDestination:configuration:中進行模塊配置,也就是依賴注入,而模塊的調用者無需關心這部分依賴是如何配置的:
// router 父類class ZIKViewRouter<Destination, RouteConfig>: NSObject {
...
public class func makeDestination -> Destination? {
let router = self.init(with: ViewRouteConfig())
let destination = router.destination(with: router.configuration)
if let destination = destination {
// router 父類中調用模塊配置方法
router.prepareDestination(destination, configuration: router.configuration)
}
return destination
}
// 模塊創建,讓子類重寫
public func destination(with configuration: ViewRouteConfig) -> Destination? {
return nil
}
// 模塊配置,讓子類重寫
func prepareDestination(_ destination: Destination, configuration: RouteConfig) {
}
}
// editor 模塊的 router
class EditorViewRouter: ZIKViewRouter<EditorViewController, ViewRouteConfig> {
override func destination(with configuration: ViewRouteConfig) -> EditorViewController? {
let destination = EditorViewController()
return destination
}
// 配置模塊,注入靜態依賴
override func prepareDestination(_ destination: EditorViewController, configuration: ViewRouteConfig) {
// 注入 service 依賴
destination.storageService = Router.makeDestination(to: RoutableService<EditorStorageServiceInput>())
// 其他配置
destination.title = "默認標題"
}
}
此時調用者中如果有某些對象不是創建自 router的,就可以直接用對應的 router 進行配置,執行依賴注入:
var destination: EditorViewProtocol = ...Router.to(RoutableView())?.prepare(destination: destination, configuring: { (config, _) in
})
獨立的配置功能在某些場景下是非常有用的,尤其是在重構現有代碼的時候。有一些系統接口的設計就是在接口中返回對象,但是這些對象是由系統自動創建的,而不是通過 router 創建的,因此需要通過 router 對其進行配置,例如 storyboard 中創建的 view controller。此時將 view controller 模塊化后,依然可以保持現有代碼,只需要調用一句prepareDestination:configuration:配置即可,模塊化的過程中就能讓代碼的修改最小化。
可選依賴:屬性注入和方法注入
當依賴是可選的,并不是創建對象所必需的,可以用屬性注入和方法注入。
屬性注入是指外部設置對象的屬性。方法注入是指外部調用對象的方法,從而傳入依賴。
protocol PersonType {var wife: Person? { get set } // 可選的屬性依賴
func addChild(_ child: Person) -> Void // 可選的方法注入
}
protocol Child {
var parent: Person { get }
}
class Person: PersonType {
var wife: Person? = nil
var childs: Set<Child> = []
func addChild(_ child: Child) {
childs.insert(child)
}
}
在 router 里,可以注入一些默認的依賴:
class PersonRouter: ZIKServiceRouter<Person, PerformRouteConfig> {...
override func destination(with configuration: PerformRouteConfig) -> Person? {
let person = Person()
return person
}
// 配置模塊,注入靜態依賴
override func prepareDestination(_ destination: Person, configuration: PerformRouteConfig) {
if destination.wife != nil {
return
}
//設置默認值
let wife: Person = ...
person.wife = wife
}
}
模塊間參數傳遞
在執行路由操作的同時,調用者也可以用PersonType動態地注入依賴,也就是向模塊傳參。
configuration 就是用來進行各種功能擴展的。Router 可以在 configuration 上提供prepareDestination,讓調用者設置,就能讓調用者配置 destination。
let wife: Person = ...let child: Child = ...
let person = Router.makeDestination(to: RoutableService(), configuring: { (config, _) in// 獲取模塊的同時進行配置
config.prepareDestination = { destination in
destination.wife = wife
destination.addChild(child)
}
})
封裝一下就能變成更簡單的接口:
let wife: Person = ...let child: Child = ...
let person = Router.makeDestination(to: RoutableService(), preparation: { destination in
destination.wife = wife
destination.addChild(child)
})
必需依賴:工廠方法
有一些參數是在 destination 類創建前就需要傳入的必需參數,例如初始化方法中的參數,就是必需依賴。
class Person: PersonType {let name: String
// 初始化方法,需要必需參數
init(name: String) {
self.name = name
}
}
這些必需參數有時候是由調用者提供的。在 URL 路由中,這種"必需"特性就無法體現出來,而用接口的方式就能簡單地實現。
傳遞必需依賴需要用工廠模式,在工廠方法上聲明必需參數和模塊接口。
protocol PersonTypeFactory {// 工廠方法,聲明了必需參數 name,返回 PersonType 類型的 destination
func makeDestinationWith(_ name: String) -> PersonType?
}
那么如何用 router 傳遞必需參數呢?
Router 的 configuration 可以用來進行自定義參數擴展。可以把必需參數保存到 configuration 上,或者更直接點,由 configuration 來提供工廠方法,然后使用工廠方法的 protocol 來獲取模塊:
// 通用 configuration,可以提供自定義工廠方法class PersonModuleConfiguration: PerformRouteConfig, PersonTypeFactory {
// 工廠方法
public func makeDestinationWith(_ name: String) -> PersonType? {
self.makedDestination = Person(name: name)
return self.makedDestination
}
// 由工廠方法創建的 destination,提供給 router
public var makedDestination: Destination?
}
在 router 中使用自定義 configuration:
class PersonRouter: ZIKServiceRouter<Person, PersonModuleConfiguration> {// 重寫 defaultRouteConfiguration,使用自定義 configuration
override class func defaultRouteConfiguration() -> PersonModuleConfiguration {
return PersonModuleConfiguration()
}
override func destination(with configuration: PersonModuleConfiguration) -> Person? {
// 使用工廠方法創建的 destination
return config.makedDestination
}
}
然后把PersonTypeFactory協議和 router 進行注冊:
PersonRouter.register(RoutableServiceModule())就可以用PersonTypeFactory獲取模塊了:
let name: String = ...Router.makeDestination(to: RoutableServiceModule(), configuring: { (config, _) in// config 遵守 PersonTypeFactory
config.makeDestinationWith(name)
})
用泛型代替 configuration 子類
如果你不需要在 configuration 上保存其他自定義參數,也不想創建過多的 configuration 子類,可以用一個通用的泛型類來實現子類重寫的效果。
泛型可以自定義參數類型,此時可以直接把工廠方法用 block 保存在 configuration 的屬性上。
// 通用 configuration,可以提供自定義工廠方法class ServiceMakeableConfigurationConstructor>: PerformRouteConfig {
public var makeDestinationWith: Constructor
public var makedDestination: Destination?
}
在 router 中使用自定義 configuration:
class PersonRouter: ZIKServiceRouter<Person, PerformRouteConfig> {// 重寫 defaultRouteConfiguration,使用自定義 configuration
override class func defaultRouteConfiguration() -> PerformRouteConfig {
let config = ServiceMakeableConfiguration<PersonType, (String) -> PersonType>({ _ in})
// 設置工廠方法,讓調用者使用
config.makeDestinationWith = { [unowned config] name in
config.makedDestination = Person(name: name)
return config.makedDestination
}
return config
}
override func destination(with configuration: PerformRouteConfig) -> Person? {
if let config = configuration as? ServiceMakeableConfiguration<PersonType, (String) -> PersonType> {
// 使用工廠方法創建的 destination
return config.makedDestination
}
return nil
}
}
// 讓對應泛型的 configuration 遵守 PersonTypeFactory
extension ServiceMakeableConfiguration: PersonTypeFactory where Destination == PersonType, Constructor == (String) -> PersonType {
}
避免接口污染
除了必需依賴,還有一些參數是不屬于 destination 類的,而是屬于模塊內其他組件的,也不能通過 destination 的接口來傳遞。例如 MVVM 和 VIPER 架構中,model 參數不能傳給 view,而是應該交給 view model 或者 interactor。此時可以使用相同的模式。
protocol EditorViewModuleInput {// 工廠方法,聲明了參數 note,返回 EditorViewInput 類型的 destination
func makeDestinationWith(_ note: Note) -> EditorViewInput?
}
class EditorViewRouter: ZIKViewRouter<EditorViewInput, ViewRouteConfig> {
// 重寫 defaultRouteConfiguration,使用自定義 configuration
override class func defaultRouteConfiguration() -> ViewRouteConfig {
let config = ViewMakeableConfiguration<EditorViewInput, (Note) -> EditorViewInput>({ _ in})
// 設置工廠方法,讓調用者使用
config.makeDestinationWith = { [unowned config] note in
config.makedDestination = self.makeDestinationWith(note: note)
return config.makedDestination
}
return config
}
class func makeDestinationWith(note: Note) -> EditorViewInput {
let view = EditorViewController()
let presenter = EditorViewPresenter(view)
let interactor = EditorInteractor(Presenter)
// 把 model 傳遞給數據管理者,view 不接觸 model
interactor.note = note
return view
}
override func destination(with configuration: ViewRouteConfig) -> EditorViewInput? {
if let config = configuration as? ViewMakeableConfiguration<EditorViewInput, (Note) -> EditorViewInput> {
// 使用工廠方法創建的 destination
return config.makedDestination
}
return nil
}
}
就可以用EditorViewModuleInput獲取模塊了:
let note: Note = ...Router.makeDestination(to: RoutableViewModule(), configuring: { (config, _) in// config 遵守 EditorViewModuleInput
config.makeDestinationWith(note)
})
依賴查找
當模塊的必需依賴很多時,如果把依賴都放在初始化接口中,就會出現一個非常長的方法。
除了讓模塊把依賴聲明在接口中,模塊內部也可以用模塊管理工具動態查找依賴,例如用 router 查找 protocol 對應的模塊。如果要使用這種模式,那么所有模塊都需要統一使用相同的模塊管理工具。
代碼如下:
class EditorViewController: UIViewController {lazy var storageService: EditorStorageServiceInput {
return Router.makeDestination(to: RoutableService())!
}
}
循環依賴
使用依賴注入時,有些特殊情況需要處理,例如循環依賴的無限遞歸問題。
循環依賴是指兩個對象互相依賴。
在 router 內部動態注入依賴時,如果注入的依賴同時依賴于被注入的對象,則必須在 protocol 中聲明。
protocol Parent {// Parent 依賴 Child
var child: Child { get set }
}
protocol Child {
// Child 依賴 Parent
var parent: Parent { get set }
}
class ParentObject: Parent {
var child: Child!
}
class ChildObject: Child {
var parent: Parent!
}
class ParentRouter: ZIKServiceRouter<ParentObject, PerformRouteConfig> {
override func destination(with configuration: PerformRouteConfig) -> ParentObject? {
return ParentObject()
}
override func prepareDestination(_ destination: ParentObject, configuration: PerformRouteConfig) {
guard destination.child == nil else {
return
}
// 只有在外部沒有設置 child 時,才去主動尋找依賴
let child = Router.makeDestination(to RoutableService<Child>(), preparation { child in
// 設置 child 的依賴,防止 child 內部再去尋找 parent 依賴,導致循環
child.parent = destination
})
destination.child = child
}
}
class ChildRouter: ZIKServiceRouter<ChildObject, PerformRouteConfig> {
override func destination(with configuration: PerformRouteConfig) -> ChildObject? {
return ChildObject()
}
override func prepareDestination(_ destination: ChildObject, configuration: PerformRouteConfig) {
guard destination.parent == nil else {
return
}
// 只有在外部沒有設置 parent 時,才去主動尋找依賴
let parent = Router.makeDestination(to RoutableService<Parent>(), preparation { parent in
// 設置 parent 的依賴,防止 parent 內部再去尋找 child 依賴,導致循環
parent.child = destination
})
destination.parent = parent
}
}
這樣就能避免循環依賴導致的無限遞歸問題。
模塊適配器
當使用 protocol 管理模塊時,protocol 必定會出現在多個模塊中。那么此時如何讓每個模塊單獨編譯呢?
一個方式是把 protocol 在每個用到的模塊里復制一份,而且無需修改 protocol 名,Xcode 不會報錯。
另一個方式是使用適配器模式,可以讓不同模塊使用各自不同的 protocol 和同一個模塊交互。
required protocol 和 provided protocol
你可以為同一個 router 注冊多個 protocol。
根據依賴關系,接口可以分為required protocol和provided protocol。模塊本身提供的接口是provided protocol,模塊的調用者需要使用的接口是required protocol。
required protocol是provided protocol的子集,調用者只需要聲明自己用到的那些接口,不必引入整個provided protocol,這樣可以讓模塊間的耦合進一步減少。
在 UML 的組件圖中,就很明確地表現出了這兩者的概念。下圖中的半圓就是Required Interface,框外的圓圈就是Provided Interface:
那么如何實施Required Interface和Provided Interface?從架構分層上看,所有的模塊都是依附于一個更上層的宿主 app 環境存在的,應該由使用這些模塊的宿主 app 在一個 adapter 里進行接口適配,從而使得調用者可以繼續在內部使用required protocol,adapter 負責把required protocol和修改后的provided protocol進行適配。整個過程模塊都無感知。
這時候,調用者中定義的required protocol就相當于是在聲明自己所依賴的外部模塊。
為provided模塊添加required protocol
模塊適配的工作全部由模塊的使用和裝配者 App Context 完成,最少時只需要兩行代碼。
例如,某個模塊需要展示一個登陸界面,而且這個登陸界面可以顯示一段自定義的提示語。
調用者模塊示例:
// 調用者中聲明的依賴接口,表明自身依賴一個登陸界面protocol RequiredLoginViewInput {
var message: String? { get set } //顯示在登陸界面上的自定義提示語
}
// 調用者中調用 login 模塊
Router.makeDestination(to: RoutableView<RequiredLoginViewInput>(), preparation: {
destination.message = "請登錄"
})
實際登陸界面提供的接口則是ProvidedLoginViewInput:
// 實際登陸界面提供的接口protocol ProvidedLoginViewInput {
var message: String? { get set }
}
適配的代碼由宿主 app 實現,讓登陸界面支持?
RequiredLoginViewInput:// 讓模塊支持 required protocol,只需要添加一個 protocol 擴展即可
extension LoginViewController: RequiredLoginViewInput {
}
并且讓登陸界面的 router 也支持 RequiredLoginViewInput:
// 如果可以獲取到 router 類,可以直接為 router 添加 RequiredLoginViewInputLoginViewRouter.register(RoutableView())
// 如果不能得到對應模塊的 router,可以用 adapter 進行轉發
ZIKViewRouteAdapter.register(adapter: RoutableView(), forAdaptee: RoutableView())
適配之后,RequiredLoginViewInput就能和ProvidedLoginViewInput一樣使用,獲取到同一個模塊了:
調用者模塊示例:
Router.makeDestination(to: RoutableView(), preparation: {destination.message = "請登錄"
})// ProvidedLoginViewInput 和 RequiredLoginViewInput 能獲取到同一個 router
Router.makeDestination(to: RoutableView(), preparation: {
destination.message = "請登錄"
})
接口適配
有時候ProvidedLoginViewInput和RequiredLoginViewInput的接口名可能會稍有不同,此時需要用 category、extension、子類、proxy 類等方式進行接口適配。
protocol ProvidedLoginViewInput {var notifyString: String? { get set } // 接口名不同
}
適配時需要進行接口轉發,讓登陸界面支持 RequiredLoginViewInput:
extension LoginViewController: RequiredLoginViewInput {var message: String? {
get {
return notifyString
}
set {
notifyString = newValue
}
}
}
用中介者轉發接口
如果不能直接為模塊添加required protocol,比如 protocol 里的一些 delegate 需要兼容:
protocol RequiredLoginViewDelegate {func didFinishLogin() -> Void
}
protocol RequiredLoginViewInput {
var message: String? { get set }
var delegate: RequiredLoginViewDelegate { get set }
}
而模塊里的 delegate 接口不一樣:
protocol ProvidedLoginViewDelegate {func didLogin() -> Void
}
protocol ProvidedLoginViewInput {
var notifyString: String? { get set }
var delegate: ProvidedLoginViewDelegate { get set }
}
相同方法有不同參數類型時,可以用一個新的 router 代替真正的 router,在新的 router 里插入一個中介者,負責轉發接口:
class ReqiredLoginViewRouter: ProvidedLoginViewRouter {override func destination(with configuration: ZIKViewRouteConfiguration) -> RequiredLoginViewInput? {
let realDestination: ProvidedLoginViewInput = super.destination(with configuration)
// proxy 負責把 RequiredLoginViewInput 轉發為 ProvidedLoginViewInput
let proxy: RequiredLoginViewInput = ProxyForDestination(realDestination)
return proxy
}
}
對于普通OC類,proxy 可以用 NSProxy 來實現。對于 UIKit 中的那些復雜的 UI 類,或者 Swift 類,可以用子類,然后在子類中重寫方法,進行模塊適配。
聲明式依賴
利用之前的靜態路由檢查機制,模塊只需要聲明 required 接口,就能保證對應的模塊必定存在。
模塊無需在自己的接口里聲明依賴,如果模塊需要新增依賴,只需要創建新的 required 接口即可,無需修改接口本身。這樣也能避免依賴變動導致的接口變化,減少接口維護的成本。
模塊提供默認的依賴配置
每次引入模塊,宿主 app 都需要寫一份適配代碼,雖然大多數情況下只有兩行,但是我們想盡量減少宿主 app 的維護職責。
此時,可以讓模塊提供一份默認的依賴,用宏定義包裹,繞過編譯檢查。
#if USE_DEFAULT_DEPENDENCYimport ProvidedLoginModule
public func registerDefaultDependency() {
ZIKViewRouteAdapter.register(adapter: RoutableView<RequiredLoginViewInput>(), forAdaptee: RoutableView<ProvidedLoginViewInput>())
}
extension ProvidedLoginViewController: RequiredLoginViewInput {
}
#endif
如果宿主 app 要使用默認依賴,就在.xcconfig里設置Preprocessor Macros,開啟宏定義:
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) USE_DEFAULT_DEPENDENCY=1如果是 Swift 模塊,需要在模塊的 target 里設置Active Compilation Conditions,添加編譯宏USE_DEFAULT_DEPENDENCY。
宿主 app 直接調用默認的適配代碼即可,不用再負責維護:
public func registerAdapters() {// 注冊默認的依賴
registerDefaultDependency()
...
}
如果宿主 app 需要替換使用另一個 provided 模塊,可以關閉宏定義,再寫一份另外的適配代碼,即可替換依賴。
模塊化
區分了required protocol和provided protocol后,就可以實現真正的模塊化。在調用者聲明了所需要的required protocol后,被調用模塊就可以隨時被替換成另一個相同功能的模塊。
參考 demo 中的ZIKLoginModule示例模塊,登錄模塊依賴于一個彈窗模塊,而這個彈窗模塊在ZIKRouterDemo和ZIKRouterDemo-macOS中是不同的,而在切換彈窗模塊時,登錄模塊中的代碼不需要做任何改變。
使用 adapter 的規范
一般來說,并不需要立即把所有的 protocol 都分離為required protocol和provided protocol。調用模塊和目的模塊可以暫時共用 protocol,或者只是簡單地改個名字,讓required protocol作為provided protocol的子集,在第一次需要替換模塊的時候再用 category、extension、proxy、subclass 等技術進行接口適配。
接口適配也不能濫用,因為成本比較高,而且并非所有的接口都能適配,例如同步接口和異步接口就難以適配。
對于模塊間耦合的處理,有這么幾條建議:
? 如果依賴的是提供特定功能的模塊,沒有通用性,直接引用類即可
? 如果是依賴某些簡單的通用模塊(例如日志模塊),可以在模塊的接口上把依賴交給外部來設置,例如 block 的形式
? 大部分需要解耦的模塊都是需要重用的業務模塊,如果你的模塊不需要重用,并且也不需要分工開發,直接引用對應類即可
? 大部分情況下建議共用 protocol,或者讓required protocol作為provided protocol的子集,接口名保持一致
? 只有在你的業務模塊的確允許使用者使用不同的依賴模塊時,才進行多個接口間的適配。例如需要跨平臺的模塊,例如登錄界面模塊允許不同的 app 使用不同的登陸 service 模塊
通過required protocol和provided protocol,我們就實現了模塊間的完全解耦。
模塊間通信
模塊間通信有多種方式,解耦程度也各有不同。這里只討論接口交互的方式。
控制流 input 和 output
模塊的對外接口可以分為 input 和 output。兩者的區別主要是控制流的主動權歸屬不同。
Input 是由外部主動調用的接口,控制流的發起者在外部,例如外部調用 view 的 UI 修改接口。
Output 是模塊內部主動調用外部實現的接口,控制流的發起者在內部,需要外部實現 output 所要求的方法。例如輸出 UI 事件、事件回調、獲取外部的 dataSource。iOS 中常用的 delegate 模式,也是一種 output。
設置 input 和 output
模塊設計好 input 和 output,然后在模塊創建的時候,設置好模塊之間的 input 和 output 關系,即可配置好模塊間通信,同時充分解耦。class NoteListViewController: UIViewController, EditorViewOutput {
func showEditor() {
let destination = Router.makeDestination(to: RoutableView<EditorViewInput>(), preparation: { [weak self] destination in
destination.output = self
})
present(destination, animated: true)
}
}
protocol EditorViewInput {
weak var output: EditorViewOutput? { get set }
}
子模塊
大部分方案都沒有討論子模塊存在的情況。如果使用了 MVVM 或者 VIPER 架構,此時一個 view controller 使用了 child view controller,那多個模塊的 view model 和 interactor 之間如何交互?子模塊由誰初始化、由誰管理?
有些方案是直接在父 view model 里創建和使用子 view model,但是這樣就導致了 view 的實現方式影響了view model 的實現,如果父 view 里替換使用了另一個子 view,那父 view model 里的代碼也需要修改。
子模塊的來源
子模塊的來源有:
? 父 view 引用了一個封裝好的子 view 控件,連帶著引入了子 view 的整個 MVVM 或者 VIPER 模塊
? View model 或者 interactor 里使用了一個 Service
通信方式
子 view 可能是一個 UIView,也可能是一個 Child UIViewController。因此子 view 有可能需要向外部請求數據,也可能獨立完成所有任務,不需要依賴父模塊。
如果子 view 可以獨立,那在子模塊里不會出現和父模塊交互的邏輯,只有把一些事件通過 output 傳遞出去的接口。這時只需要把子 view 的 input 接口封裝在父 view 的 input 接口里即可,父 view model / presenter / interactor 是不知道父 view 提供的這幾個接口是通過子 view 實現的。
如果父模塊需要調用子模塊的業務接口,或接收子模塊的數據或業務事件,并且不想影響 view 的接口,可以把子 view model / presenter / interactor 作為父 view model / presenter / interactor 的一個 service,在引入子模塊時,注入到父 view model / presenter / interactor,從而繞過 view 層。這樣子模塊和父模塊就能通過 service 的形式進行通信了,而這時,父模塊也不知道這個 service 是來自子模塊里的。
在這樣的設計下,子模塊和父模塊是不知道彼此的存在的,只是通過接口進行交互。好處是父 view 如果想要更換為另一個相同功能的子 view 控件,就只需要在父 view 里修改,不會影響其他的 view model / presenter / interactor。
父模塊:
class EditorViewController: UIViewController {var viewModel: EditorViewModel!
func addTextView() {
let textViewController = Router.makeDestination(to: RoutableView<TextViewInput>()) { (destination) in
// 設置模塊間交互
// 原本父 view 是無法接觸到子模塊的 view model / presenter / interactor
// 此時子模塊是把這些內部組件作為業務 input 開放給了外部
self.viewModel.textService = destination.viewModel
destination.viewModel.output = self.viewModel
}
addChildViewController(textViewController)
view.addSubview(textViewController.view)
textViewController.didMove(toParentViewController: self)
}
}
子模塊:
protocol TextViewInput {weak var output: TextViewModuleOutput? { get set }
var viewModel: TextViewModel { get }
}
class TextViewController: UIViewController, TextViewInput {
weak var output: TextViewModuleOutput?
var viewModel: TextViewModel!
}
Output 的適配
在使用 output 時,模塊適配會帶來一定麻煩。
例如這樣一對 required-provided protocol:
protocol RequiredEditorViewInput {weak var output: RequiredEditorViewOutput? { get set }
}
protocol ProvidedEditorViewInput {
weak var output: ProvidedEditorViewOutput? { get set }
}
由于 output 的實現者不是固定的,因此無法讓所有的 output 類都同時適配RequiredEditorViewOutput和ProvidedEditorViewOutput。此時建議直接使用對應的 protocol,不使用 required-provided 模式。
如果你仍然想要使用 required-provided 模式,那就需要用工廠模式來傳遞 output ,在內部用 proxy 進行適配。
實際模塊的 router:
protocol ProvidedEditorViewModuleInput {var makeDestinationWith(_ output: ProvidedEditorViewOutput?) -> ProvidedEditorViewInput? { get set }
}
class ProvidedEditorViewRouter: ZIKViewRouter<EditorViewController, ViewRouteConfig> {
override class func registerRoutableDestination() {
register(RoutableViewModule<ProvidedEditorViewModuleInput>())
}
override class func defaultRouteConfiguration() -> ViewRouteConfig {
let config = ViewMakeableConfiguration<ProvidedViewInput, (ProvidedEditorViewOutput?) -> ProvidedViewInput?>({ _ in})
config.makeDestinationWith = { [unowned config] output in
// 設置 output
let viewModel = EditorViewModel(output: output)
config.makedDestination = EditorViewController(viewModel: viewModel)
return config.makedDestination
}
return config
}
override func destination(with configuration: ViewRouteConfig) -> EditorViewController? {
if let config = configuration as? ViewMakeableConfiguration<ProvidedViewInput, (ProvidedEditorViewOutput?) {
return config.makedDestination
}
return nil
}
}
適配代碼:
protocol RequiredEditorViewModuleInput {var makeDestinationWith(_ output: RequiredEditorViewOutput?) -> RequiredEditorViewInput? { get set }
}
// 用于適配的 required router
class RequiredEditorViewRouter: ProvidedEditorViewRouter {
override class func registerRoutableDestination() {
register(RoutableViewModule<RequiredEditorViewModuleInput>())
}
// 兼容 configuration
override class func defaultRouteConfiguration() -> PerformRouteConfig {
let config = super.defaultRouteConfiguration()
let makeDestinationWith = config.makeDestinationWith
config.makeDestinationWith = { requiredOutput in
// proxy 負責把 RequiredEditorViewOutput 轉為 ProvidedEditorViewOutput
let providedOutput = EditorOutputProxy(forwarding: requiredOutput)
return makeDestinationWith(providedOutput)
}
return config
}
}
class EditorOutputProxy: ProvidedEditorViewOutput {
let forwarding: RequiredEditorViewOutput
// 實現 ProvidedEditorViewOutput,轉發給 forwarding
}
可以看到,output 的適配有些繁瑣。因此除非你的模塊是通用模塊,有實際的解耦需求,否則直接使用 provided protocol 即可。
功能擴展
總結完使用接口進行模塊解耦和依賴管理的方法,我們可以進一步對 router 進行擴展了。上面使用 makeDestination 創建模塊是最基本的功能,使用 router 子類后,我們可以進行許多有用的功能擴展,這里給出一些示范。
自動注冊
編寫 router 代碼時,需要注冊 router 和 protocol 。在 OC 中可以在 +load 方法中注冊,但是 Swift 里已經不能使用 +load 方法,而且分散在 +load 中的注冊代碼也不好管理。BeeHive 中通過宏定義和__attribute((used, section("__DATA,""BeehiveServices"""))),把注冊信息添加到了 mach-O 中的自定義區域,然后在啟動時讀取并自動注冊,可惜這種方式在 Swift 中也無法使用了。
我們可以把注冊代碼寫在 router 的+registerRoutableDestination方法里,然后逐個調用每個 router 類的+registerRoutableDestination方法即可。還可以更進一步,用 runtime 技術遍歷 mach-O 中的__DATA,__objc_classlist區域的類列表,獲取所有的 router 類,自動調用所有的+registerRoutableDestination方法。
把注冊代碼統一管理之后,如果不想使用自動注冊,也能隨時切換為手動注冊。
// editor 模塊的 routerclass EditorViewRouter: ZIKViewRouter {
override class func registerRoutableDestination() {
registerView(EditorViewController.self)
register(RoutableView())
}
}
復制代碼Objective-C Sample
@interface EditorViewRouter : ZIKViewRouter
@end
@implementation EditorViewRouter
+ (void)registerRoutableDestination {[self registerView:[EditorViewController class]];[self registerViewProtocol:ZIKRoutable(EditorViewProtocol)];
}@end
封裝界面跳轉
iOS 中模塊間耦合的原因之一,就是界面跳轉的邏輯是通過 UIViewController 進行的,跳轉功能被限制在了 view controller 上,導致數據流常常都繞不開 view 層。要想更好地管理跳轉邏輯,就需要進行封裝。
封裝界面跳轉可以屏蔽 UIKit 的細節,此時界面跳轉的代碼就可以放在非 view 層(例如 presenter、view model、interactor、service),并且能夠跨平臺,也能輕易地通過配置切換跳轉方式。
如果是普通的模塊,就用ZIKServiceRouter,而如果是界面模塊,例如 UIViewController 和 UIView,就可以用ZIKViewRouter,在其中封裝了界面跳轉功能。
封裝界面跳轉后,使用方式如下:
class TestViewController: UIViewController {//直接跳轉到 editor 界面
func showEditor() {
Router.perform(to: RoutableView<EditorViewProtocol>(), path: .push(from: self))
}
//跳轉到 editor 界面,跳轉前用 protocol 配置界面
func prepareAndShowEditor() {
Router.perform(
to: RoutableView<EditorViewProtocol>(),
path: .push(from: self),
preparation: { destination in
// 跳轉前進行配置
// destination 自動推斷為 EditorViewProtocol
})
}
}
可以用 ViewRoutePath 一鍵切換不同的跳轉方式:
enum ViewRoutePath {case push(from: UIViewController)
case presentModally(from: UIViewController)
case presentAsPopover(from: UIViewController, configure: ZIKViewRoutePopoverConfigure)
case performSegue(from: UIViewController, identifier: String, sender: Any?)
case show(from: UIViewController)
case showDetail(from: UIViewController)
case addAsChildViewController(from: UIViewController, addingChildViewHandler: (UIViewController, @escaping () -> Void) -> Void)
case addAsSubview(from: UIView)
case custom(from: ZIKViewRouteSource?)
case makeDestination
case extensible(path: ZIKViewRoutePath)
}
而且在界面跳轉后,還可以根據跳轉時的跳轉方式,一鍵回退界面,無需再手動區分 dismiss、pop 等各種情況:
class TestViewController: UIViewController {var router: DestinationViewRouter<EditorViewProtocol>?
func showEditor() {
// 持有 router
router = Router.perform(to: RoutableView<EditorViewProtocol>(), path: .push(from: self))
}
// Router 會對 editor view controller 執行 pop 操作,移除界面
func removeEditor() {
guard let router = router, router.canRemove else {
return
}
router.removeRoute()
router = nil
}
}
自定義跳轉
有些界面的跳轉方式很特殊,例如 tabbar 上的界面,需要通過切換 tabbar item 來進行。也有的界面有自定義的跳轉動畫,此時可以在 router 子類中重寫對應方法,進行自定義跳轉。
class EditorViewRouter: ZIKViewRouter<EditorViewController, ViewRouteConfig> {override func destination(with configuration: ViewRouteConfig) -> Any? {
return EditorViewController()
}
override func canPerformCustomRoute() -> Bool {
return true
}
override func performCustomRoute(onDestination destination: EditorViewController, fromSource source: Any?, configuration: ViewRouteConfig) {
beginPerformRoute()
// 自定義跳轉
CustomAnimator.transition(from: source, to: destination) {
self.endPerformRouteWithSuccess()
}
}
override func canRemoveCustomRoute() -> Bool {
return true
}
override func removeCustomRoute(onDestination destination: EditorViewController, fromSource source: Any?, removeConfiguration: ViewRemoveConfig, configuration: ViewRouteConfig) {
beginRemoveRoute(fromSource: source)
// 移除自定義跳轉
CustomAnimator.dismiss(destination) {
self.endRemoveRouteWithSuccess(onDestination: destination, fromSource: source)
}
}
override class func supportedRouteTypes() -> ZIKViewRouteTypeMask {
return [.custom, .viewControllerDefault]
}
}
支持 storyboard
很多項目使用了 storyboard,在進行模塊化時,肯定不能要求所有使用 storyboard 的模塊都改為使用代碼。因此我們可以 hook 一些 storyboard 相關的方法,例如-prepareSegue:sender:,在其中調用prepareDestination:configuring:即可。
URL 路由
雖然之前列出了 URL 路由的許多缺點,但是如果你的模塊需要從 h5 界面調用,例如電商 app 需要實現跨平臺的動態路由規則,那么 URL 路由就是最佳的方案。
但是我們并不想為了實現 URL 路由,使用另一套框架再重新封裝一次模塊。只需要在 router 上擴展 URL 路由的功能,即可同時用接口和 URL 管理模塊。
你可以給 router 注冊 url:
class EditorViewRouter: ZIKViewRouter<EditorViewProtocol, ViewRouteConfig> {override class func registerRoutableDestination() {
// 注冊 url
registerURLPattern("app://editor/:title")
}
}
之后就可以用相應的 url 獲取 router:
ZIKAnyViewRouter.performURL("app://editor/test_note", path: .push(from: self))以及處理 URL Scheme:
public func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool {let urlString = url.absoluteString
if let _ = ZIKAnyViewRouter.performURL(urlString, fromSource: self.rootViewController) {
return true
} else if let _ = ZIKAnyServiceRouter.performURL(urlString) {
return true
}
return false
}
每個 router 子類還能各自對 url 進行進一步處理,例如處理 url 中的參數、通過 url 執行對應方法、執行路由后發送返回值給調用者等。
每個項目對 URL 路由的需求都不一樣,基于 ZIKRouter 強大的可擴展性,你也可以按照項目需求實現自己的 URL 路由規則。
用 router 對象代替 router 子類
除了創建 router 子類,也可以使用通用的 router 實例對象,在每個對象的 block 屬性中提供和 router 子類一樣的功能,因此不必擔心類過多的問題。原理就和用泛型 configuration 代替 configuration 子類一樣。
ZIKViewRoute 對象通過 block 屬性實現子類重寫的效果,代碼可以用鏈式調用:
ZIKViewRoute.make(withDestination: EditorViewController.self, makeDestination: ({ (config, router) -> EditorViewController? inreturn EditorViewController()
}))
.prepareDestination({ (destination, config, router) in
}).didFinishPrepareDestination({ (destination, config, router) in
})
.register(RoutableView())
簡化 router 實現
基于 ZIKViewRoute 對象實現的 router,可以進一步簡化 router 的實現代碼。
如果你的類很簡單,并不需要用到 router 子類,直接一行代碼注冊類即可:
ZIKAnyViewRouter.register(RoutableView(), forMakingView: EditorViewController.self)或者用 block 自定義創建對象的方式:
ZIKAnyViewRouter.register(RoutableView(),forMakingView: EditorViewController.self) { (config, router) -> EditorViewProtocol? inreturn EditorViewController()}
或者指定用 C 函數創建對象:
function makeEditorViewController(config: ViewRouteConfig) -> EditorViewController? {return EditorViewController()
}
ZIKAnyViewRouter.register(RoutableView(),
forMakingView: EditorViewController.self, making: makeEditorViewController)
事件處理
有時候模塊需要處理一些系統事件或者 app 的自定義事件,此時可以讓 router 子類實現,再進行遍歷分發。
class SomeServiceRouter: ZIKServiceRouter {@objc class func applicationDidEnterBackground(_ application: UIApplication) {
// handle applicationDidEnterBackground event
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidEnterBackground(_ application: UIApplication) {
Router.enumerateAllViewRouters { (routerType) in
if routerType.responds(to: #selector(applicationDidEnterBackground(_:))) {
routerType.perform(#selector(applicationDidEnterBackground(_:)), with: application)
}
}
Router.enumerateAllServiceRouters { (routerType) in
if routerType.responds(to: #selector(applicationDidEnterBackground(_:))) {
routerType.perform(#selector(applicationDidEnterBackground(_:)), with: application)
}
}
}
}
單元測試
借助于使用接口管理依賴的方案,我們在對模塊進行單元測試時,可以自由配置 mock 依賴,而且無需 hook 模塊內部的代碼。
例如這樣一個依賴于網絡模塊的登陸模塊:
// 登錄模塊class LoginService {
func login(account: String, password: String, completion: (Result) -> Void) {
// 內部使用 RequiredNetServiceInput 進行網絡訪問
let netService = Router.makeDestination(to: RoutableService<RequiredNetServiceInput
>())
let request = makeLoginRequest(account: account, password: password)
netService?.POST(request: request, completion: completion)
}
}
// 聲明依賴
extension RoutableService where Protocol == RequiredNetServiceInput {
init() {}
}
在編寫單元測試時,不需要引入真實的網絡模塊,可以提供一個自定義的 mock 網絡模塊:
class MockNetService: RequiredNetServiceInput {func POST(request: Request, completion: (Result) {
completion(.success)
}
}
// 注冊 mock 依賴
ZIKAnyServiceRouter.register(RoutableService(),
forMakingService: MockNetService.self) { (config, router) -> EditorViewProtocol? inreturn MockNetService()
}
對于那些沒有接口交互的外部依賴,例如只是簡單的跳轉到對應界面,則只需注冊一個空白的 proxy。
單元測試代碼:
class LoginServiceTests: XCTestCase {func testLoginSuccess() {
let expectation = expectation(description: "end login")
let loginService = LoginService()
loginService.login(account: "account", password: "pwd") { result in
expectation.fulfill()
}
waitForExpectations(timeout: 5, handler: { if let error = $0 {print(error)}})
}
}
使用接口管理依賴,可以更容易 mock,剝除外部依賴對測試的影響,讓單元測試更穩定。
接口版本管理
使用接口管理模塊時,還有一個問題需要注意。接口是會隨著模塊更新而變化的,這個接口已經被很多外部使用了,要如何減少接口變化產生的影響?
此時需要區分新接口和舊接口,區分版本,推出新接口的同時,保留舊接口,并將舊接口標記為廢棄。這樣使用者就可以暫時使用舊接口,漸進式地修改代碼。
這部分可以參考 Swift 和 OC 中的版本管理宏。
接口廢棄,可以暫時使用,建議盡快使用新接口代替:
// Swift@available(iOS, deprecated: 8.0, message: "Use new interface instead")
// Objective-C
API_DEPRECATED_WITH_REPLACEMENT("performPath:configuring:", ios(7.0, 7.0));
接口已經無效:
// Swift@available(iOS, unavailable)
// Objective-C
NS_UNAVAILABLE
最終形態
最后,一個 router 的最終形態就是下面這樣:
// editor 模塊的 routerclass EditorViewRouter: ZIKViewRouter<EditorViewController, ViewRouteConfig> {
override class func registerRoutableDestination() {
registerView(EditorViewController.self)
register(RoutableView<EditorViewProtocol>())
registerURLPattern("app://editor/:title")
}
override func processUserInfo(_ userInfo: [AnyHashable : Any] = [:], from url: URL) {
let title = userInfo["title"]
// 處理 url 中的參數
}
// 子類重寫,創建模塊
override func destination(with configuration: ViewRouteConfig) -> Any? {
let destination = EditorViewController()
return destination
}
// 配置模塊,注入靜態依賴
override func prepareDestination(_ destination: EditorViewController, configuration: ViewRouteConfig) {
// 注入 service 依賴
destination.storageService = Router.makeDestination(to: RoutableService<EditorStorageServiceInput>())
// 其他配置
// 處理來自 url 的參數
if let title = configuration.userInfo["title"] as? String {
destination.title = title
} else {
destination.title = "默認標題"
}
}
// 事件處理
@objc class func applicationDidEnterBackground(_ application: UIApplication) {
// handle applicationDidEnterBackground event
}
}
基于接口進行解耦的優勢
我們可以看到基于接口管理模塊的優勢:
? 依賴編譯檢查,實現嚴格的類型安全
? 依賴編譯檢查,減少重構時的成本
? 通過接口明確聲明模塊所需的依賴,允許外部進行依賴注入
? 保持動態特性的同時,進行路由檢查,避免使用不存在的路由模塊
? 利用接口,區分 required protocol 和 provided protocol,進行明確的模塊適配,實現徹底解耦
回過頭看之前的 8 個解耦指標,ZIKRouter 已經完全滿足。而 router 提供的多種模塊管理方式(makeDestination、prepareDestination、依賴注入、頁面跳轉、storyboard 支持),能夠覆蓋大多數現有的場景,從而實現漸進式的模塊化,減輕重構現有代碼的成本。
參考
[1]https://juejin.im/post/59cb629c5188253cb5016322?
[2]https://github.com/Zuikyo/ZIKRouter?
[3]https://stackoverflow.com/questions/42662028/are-performselector-and-respondstoselector-banned-by-app-store?
[4]https://github.com/Zuikyo/ZIKRouter/blob/acb923bcdd09c65672977b5a20f7c527e459ead5/ZIKRouter/Utilities/ZIKRouterRuntimeDebug.h#L41
推薦閱讀??聊聊AppDelegate解耦??iOS 原生 App 是怎么 deselectRow 的??動手制作一個簡易的iOS動態執行器??iOS 流量監控分析
總結
以上是生活随笔為你收集整理的swift 组件化_打造完备的iOS组件化方案:如何面向接口进行模块解耦?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: python tkinter butto
- 下一篇: 静态方法什么时候执行?_面试官:知道类在