iOS混編 模塊化、組件化、經驗指北

1. 開篇

本文的初衷,是爲了給正在作混編或者模塊化的同窗們一個建議和參考。html

由於來餓廠之後作的項目是全公司惟一一個 Swift/OC 混編的 iOS 項目,因此一路上踩坑無數,如今把一些踩坑的過程和經驗總結起來,供你們參考。git

相信在瀏覽本文後,必定會有所收穫。github

我來的時候項目已經開始 Swift 改造了,慢慢的把項目 Swift 化,新代碼都是 Swift 的。shell

先公佈七個月成果,下圖是咱們最終的項目結構:swift

blog_iOS-Modularization-02.png

對於咱們混編的狀況,在五個月前你們就展開了討論。服務器

給咱們的選擇有兩種:網絡

  1. 慢慢將 OC 代碼替換成 Swift
  2. 儘快模塊化,分離兩種語言代碼

一開始咱們是從 選擇1 開始作的,可是很快咱們就發現,對於咱們 74% 都是 OC 代碼的項目來講,太痛了,太漫長了,並且期間迭代的過程當中還在不斷地迭代,不斷的耦合。架構

因此在通過一番利害分析後咱們迅速投入到了 選擇2 中。一方面,模塊化自己就是愈來愈臃腫的項目的最終歸宿,一方面能夠慢慢將兩種語言剝離。app

注:這裏的模塊化,也就是你們說的『組件化』,不是在主工程用文件夾分模塊,而是指將獨立模塊抽調成 CocoaPods 庫、或者其餘形式的庫文件,成爲一個獨立工程。模塊化

2. 模塊劃分

刀怎麼切,是混編模塊化最重要的一步,徹底決定了後續工做的難與否。

不用從業務模塊拆分,相似『實時訂單模塊』、『歷史訂單模塊』、『我的中心』這樣直接拆分,保準你後面哭到沒法自已。

正確的作法應該從底層部分開始抽離,首先能想到的應該是『類擴展 Extension』、『工具類』、『網絡庫』、『DB 管理』(固然這個咱們沒有用到比較重的 DB)。

日常咱們看到一些大型庫,或者一些公司介紹本身產品架構時候都是什麼樣的?是否是下層有 OpenGL ES 和 Core Graphics 纔有上層 Core Animation,再到 UIKit。下層決定上層,只有把複用率高的部分抽出才能逐步構建上層業務。

blog_iOS-Modularization-01.png
[圖片上傳中...(blog_iOS-Modularization-04.png-7534c8-1513047089367-0)]

因此首先咱們作的就是抽工具類和 Extension,諸如:

  1. 各種 Constants 文件
  2. NSTimerNSStringUILabel 等等類的 Extension
  3. RouterHelperJavascripInterface 等等 Utils 和 Helper

這一塊的工做,不只僅能夠抽出 OC 代碼,也同時能夠抽出 Swift 的代碼。咱們將 OC 部分的代碼新建了庫爲 LPDBOCFoundationGarbage,Swift 部分的代碼新建庫爲 LPDBPublicModule

2.1 LPDBOCFoundationGarbage

先說 LPDBOCFoundationGarbage,叫這個名字顯然不只僅會放入上面所提到的文件。LPDBOCFoundationGarbage 還會大量放入長期不跟隨業務變更的 OC 代碼。這是由於,在實踐中,咱們發現老是『理想很美好』,雖然你們都抱有把舊代碼整理一遍的願望,可是實際上,咱們項目的舊代碼已經到了剪不斷理還亂的地步,因此指望一邊整理、一邊分離的想法基本是不可靠的。這時候就要借用 MM 大佬給咱們傳授的一句話『讓噁心的代碼噁心到一塊兒』,LPDBOCFoundationGarbage 正是爲此而建立。

大量放入長期不跟隨業務變更的 OC 代碼包括:

  1. 自定義的 Customer View,諸如:Refresh 控件、Loading 控件、紅點控件等等
  2. 自定義的小型控制器,諸如:TextField 和其五六個過濾器 PhoneNumValidator、IDCardValidator 等等
  3. 不隨業務變更的 Controller,諸如:自定義的 AlertController、自定義的 WebController、自定義的 BaseViewController 等等

最後咱們的一級列表看起來就像這樣:

blog_iOS-Modularization-04.png

關於前綴說兩句。咱們全部抽出的庫都帶有前綴 LPDB,可是針對 Swift 庫和 OC 庫稍有區分的是,OC 庫內的文件也都帶有前綴,而 Swift 庫是去掉了前綴,這也符合兩種語言的規範。

2.2 LPDBPublicModule

LPDBPublicModule 狀況很簡單,主要是新業務迭代時候產生的一些複用性高的代碼,可是這顯然和 OC 那個垃圾桶庫不同,要乾淨整潔的多。主要存放的是:

  1. Swift Extension
  2. Lotusoot 及其餘公開協議

Lotusoot 是個由我開發的模塊化工具和規範,一開始我叫它『路由』,可是隨後發現部門這邊由於叫它『路由庫』而曲解了它的意思,因此後來我就叫『模塊化工具』了。關於 Lotusoot 能夠查看這篇

2.3 LPDBNetwork

這塊毋庸置疑,無論什麼項目都基本有的一塊,基本上咱們項目中網絡相關的舊代碼都是 OC 的,惟一比較麻煩的是,咱們的網絡層,早期人員寫的比較粗糙,甚至和 UI 層代碼有不少耦合,好比網絡請求中和網絡請求失敗有一些 HUD 顯示,轉轉菊花什麼的。因此致使在從主工程抽離的時候有不少噁心的地方。

因此對於這種強耦合,最後解決的方式是分紅了兩遍代碼改造,第一遍先經過反射先將 OC 代碼抽出,保證代碼可用,經過基礎測試。第二遍是經過協議來代替原先的反射。第三遍是使用 Lotusoot 完全規範服務調用。在後面一節『過程當中的一些難點總結』中會介紹

2.4 LPDBUIKit

這塊是 Swift 的 UI 庫,一些比較經常使用到的控件等等。

2.5 LPDBEnvironment

這塊是用於環境控制的,切換要訪問的服務器環境,這塊自己能夠不抽出的,可是因爲有其餘基礎模塊,好比 LPDBNetwork 依賴,並且其中相關代碼比較多,環境相關的代碼也比較獨立,因此單獨抽出。

3. 業務模塊抽離

到這裏爲止,比較底層的代碼就基本抽出結束了,剩下的就能夠較爲輕鬆一些的抽取業務庫了。

抽取業務庫的重點在於:

  1. 抽取的業務庫不會常常改動,以防止在抽取、重構過程當中因爲業務需求發生更動
  2. 抽取的業務庫能夠高度獨立,抽取後應當和積木同樣,如 LPDBLoginModule,抽取後快速被集成在任何模塊,並能保證登陸功能,更好的服務其餘模塊

咱們目前抽出的三個業務模塊分別是: LPDBHistoryModuleLPDBUserCenterModuleLPDBLoginModule

4. 過程當中的一些重難點

剩下的就是,來講一下在這個過程當中的疑難問題。

4.1 處理模塊耦合代碼-反射調用

抽取代碼第一遍使用反射的緣由主要是,一般你在遞歸某個文件的依賴的時候,會遞歸出很是多的東西(尤爲是咱們的蜜汁舊代碼),每每就是 A->B->C->D->F,中間有各類依賴,甚至到最後一層的時候還引用了 Swift 的類。直到最後你看 #import 就想吐。給個圖感覺一下:

blog_iOS-Modularization-05.png

爲何沒有辦法一步到位,經過協議解決耦合?

這主要是由於單個 Pod 庫開發時使用開發模式是很容易調試的,可是兩個 Pod 庫同時在不發版本的狀況下使用開發模式是比較難處理的(能夠參考這篇文章中『使用私有庫』一節)。這種狀況下,反覆操做兩個或者兩個以上的庫是麻煩的,因此優先考慮將代碼儘快分離開來,並能經過基本測試,不影響功能。

因此在這一遍處理結束後,子庫中出現了不少 NSClassFromString 等等。

LPDBLoginMoudle 爲例:

NSString *className = [NSString stringWithFormat:@"%@.`AuthLoginManager", [NSString targetName]];
id authLoginManager = NSClassFromString(className);
if (![authLoginManager conformsToProtocol:@protocol(authLoginSuccess)]) {
    return;
}
[authLoginManager authLoginSuccess];
複製代碼
id delegate = [[UIApplication sharedApplication] delegate];
[delegate jumpToShopListVC:shops];
複製代碼

4.2 處理模塊耦合代碼-協議調用

保持第一遍中充滿 NSClassFromString 是不可取的,由於這類代碼每每屬於硬編碼,不能在類名出現改動、或者方法名出現改動的時候及時在編譯階段拋出 error。

在這裏引出一段討論。

以前跟大神們討論組件化(模塊化)的具體實踐時候,說到了主流的組件化可能都借用了 + (void)load 方法和 rumtime 操做來註冊路由和服務。這時候 casa 大神提出了一種說法『組件化的根本目的是隔離、隔離問題影響域、隔離業務、隔離開發時的依賴。因此讓兩個原本有關係的人變得沒有關係,就須要一箇中間人,若是不用 runtime 能省掉很多事,可是用 URL 是一件相對來講比較多餘的事,一個包含了 target-action 的字符串就足夠了,URL 是字符串的更復雜表徵,target-action 的意義體現的更明顯。同時 URL 應該僅限於 H5 調度和跨 App 的 URL Scheme 調度』。

這裏要向 casa 大神很是很是鄭重的道歉,上面一段,原來在初版的時候是預留修改的片斷,本想再讀一遍大神 《 [iOS應用架構談 組件化方案]》 仔細理解之後再次修改這塊,原本是悄咪咪的發了文章,沒想到被推送出去了,有引導你們曲解大神的願意。很是很是抱歉!如今已經修改。 下面在貼上大佬本身對 URL 的看法:

blog_iOS-Modularization-09.png

那個時候聽了 casa 大神的說法以爲『哎?有道理』,可是在後期的實踐中,我以爲就我我的的代碼習慣,是但願儘量的將問題暴露在編譯階段,能讓它拋出 error 就拋出 error,縱使使用字符串能夠定義常量,但因爲你們不是獨立負責項目,在其餘人看到你的方法參數時,好比:+ (void)callService:(NSString *)sUrl 或者 + (void)openURL:(NSString *)url ,對方發現你的參數是 NSStrring,頗有可能直接出現硬編碼字符串而不去查閱常量列表,這是習慣性編碼很容易出現的問題。但我對 casa 『URL 沒有 target-action 表徵明顯』是很是仍可的,因此 Lotusoot 的重點只在於解耦的服務調用,URL 只是爲了更好的爲 H5 頁面提供外部調用服務,在工程內部大可以使用更加簡潔的方式。

最後一點緣由是,反射或者經過類/方法字符串字典的方式實在太 OC 了,無論怎麼樣咱們是一個儘可能 Swift 化的項目,應該儘可能吸收其優勢,雖然抽出的 OC 庫可使用反射,那 Swift 庫咋辦?目前 Swift3 與 4 都沒有很好的支持反射。

因此,第二遍處理使用協議替換反射是頗有必要的。但實質上,處理的並非很好。大體以下(咱們以 LPDBLoginModule 爲例):

4.2.1 在 LPDBLoginModule 整理用到的服務,歸類整理

如咱們的 LPDBLoginModule 用到了 AppDelegate 中的一些方法,同事用到了 AuthLogin 相關類中的一些方法

4.2.2 在 LPDBLoginModule 中創建相應的協議

即創建 AuthLoginDelegate.hAppDelegateProtocol

大體的代碼以下:

@protocol AppDelegateProtocol <NSObject>

- (void)jumpToHomeVC;
- (void)jumpToShopListVC:(NSArray *)shops;
- (CLLocationCoordinate2D)getCoordinate;

@end
複製代碼
@protocol AuthLoginDelegate <NSObject>[Pods](media/Pods.)
+ (void)authLoginSuccess;
@end
複製代碼

4.2.3 在主工程中去實現協議

AppDelegateProtocol 由 AppDelegate 擴展實現:

@import LPDBLoginModule;
@interface AppDelegate (Protocol) <AppDelegateProtocol>
@end

@implementation AppDelegate (Protocol)
- (CLLocationCoordinate2D)getCoordinate {
    ...
}
- (void)jumpToHomeVC {
    ...
}
- (void)jumpToShopListVC:(NSArray *)shops {
    ...
}
@end
複製代碼

AuthLoginDelegate 由 AuthLoginManager(這個 Manager 在主工程中是 swift 編寫的) 實現:

extension AuthLoginManager: AuthLoginDelegate {
    static func authLoginSuccess() {
        ...
    }
}
複製代碼

4.2.4 在 LPDBLoginModule 調用服務

id delegate = [[UIApplication sharedApplication] delegate];

if (![delegate conformsToProtocol:@protocol(AppDelegateProtocol)]) {
    return;
}
CLLocationCoordinate2D coordinate = [delegate coordinate];
複製代碼
NSString *className = [NSString stringWithFormat:@"%@.AuthLoginManager", [NSString targetName]];
id authLoginManager = NSClassFromString(className);
if (![authLoginManager conformsToProtocol:@protocol(LPDBAuthLoginDelegate)]) {
     return;
}
[authLoginManager authLoginSuccess];
[self jumpToSelectShopView:shops];
複製代碼

通過這些改造以後,模塊間的狀態如圖所示:

可是,能夠很明顯感覺到,此次的改變並不完全:

  1. 仍是存在大量的 ![delegate conformsToProtocol:@protocol(AppDelegateProtocol)] 這樣的判斷,僅僅是起到了容錯,保證不會 crash,可是卻不能將問題暴露在編譯階段。
  2. AppDelegateProtocol 明明是一個公共的,多個模塊使用的協議,卻被定義到了 LPDBLoginModule
  3. 概念顛倒,理想狀態下,應該是各個子模塊提供協議和實現,告知其餘模塊能夠調用該模塊哪些功能。而目前是子模塊告知其餘模塊須要調用哪些方法,由其餘模塊實現。

那麼爲了完全解決問題,咱們引入了 Lotusoot —— 組件通訊和工具

4.3 處理模塊耦合代碼-Lotusoot

Lotusoot 的最初目的就是爲了解決模塊間的耦合,而且同時支持 OC 和 Swift 使用,也是這幾個月中去作的一個比較重要的東西,庫自己小巧靈活,包含的東西也不多,可是起到的規範做用倒是我很是滿意的一點。

Lotusoot 規範的核心思想主要是如下幾步,咱們一樣使用上面的 LPDBLoginModule 爲例

4.3.1 創建共用模塊——LPDBPublicModule

LPDBPublicModule中定義了各個模塊能夠提供的服務,作成協議,稱爲 Lotus,一個 Lotus 協議包含了一個模塊的全部的能調用的方法的列表。舉例以下:

@objc public protocol AppDelegateLotus {
    func jumpToHomeVC()
    func jumpToSelectShopVC(shops: [Any], isNapos: Bool)
    func getCoordinate() -> CLLocationCoordinate2D
}
複製代碼
@objc public protocol MainLotus {
    func authLoginSuccess()
}
複製代碼

4.3.2 各個模塊中,實現 LPDBPublicModule 中對應的 Lotus 協議

實現協議的 Class 稱爲 Lotusoot。舉例以下:

class AppDelegateLotusoot: NSObject, AppDelegateLotus {

    func jumpToHomeVC() {
        ...
    }
    
    func jumpToSelectShopVC(shops: [Any], isNapos: Bool) {
        ...
    }

    func getCoordinate() -> CLLocationCoordinate2D {
        ...
    }
}
複製代碼
class MainLotusoot: NSObject, MainLotus {
    func authLoginSuccess() {
        ...
    }
}
複製代碼

4.3.3 註冊服務

須要着重說明的是,這一步是能夠省略的,經過 Lotusoot 提供的腳本和註解,能夠自動爲全部的路由進行註冊。請移步 Lotusoot參考『3. 註解與規範』部分。

didFinishLaunchingWithOptions 中註冊服務:

[LotusootCoordinator registerWithLotusoot:[AppDelegateLotusoot new] lotusName:@"AppDelegateLotus"];
    [LotusootCoordinator registerWithLotusoot:[MainLotusoot new] lotusName:@"MainLotus"];
複製代碼

4.3.3 在其餘模塊中調用服務

如今只須要 import Lotusootimport ModulePublic

id<MainLotus> mainModule = [LotusootCoordinator lotusootWithLotus:@"MainLotus"];
[mainModule authLoginSuccess];
複製代碼
// 若是使用字符串 @"AppDelegateLotus" 註冊,建議定義在 LPDBPublicModule
// 也可使用 NSStirngFromClass(AppDelegateLotus.class)
id<AppDelegateLotus> appDelegateLotus = [LotusootCoordinator lotusootWithLotus:@"AppDelegateLotus"];
[appDelegateLotus goToHomeVC];
複製代碼

不管 OC 仍是 Swift,均可以順暢調用

// 或者使用相似字符串 "AccountLotus",但須要你管理好 kAccountLotus,儘可能不要硬編碼
let appDelegateLotus = s(AppDelegateLotus.self) 
let appDelegateLotusoot: AppDelegateLotus = LotusootCoordinator.lotusoot(lotus: appDelegateLotus) as! AppDelegateLotus
accountModule.goToHomeVC()
複製代碼
let mainLotus = s(MainLotus.self) 
let mainModule: MainLotus = LotusootCoordinator.lotusoot(lotus: mainLotus) as! MainLotus
mainModule.authLoginSuccess()
複製代碼

到此爲止,就比較完整的解決了模塊間耦合。清爽的風格用一張圖表示就是這樣(這是我在作 Lotusoot 解說時候用的一張配圖):

blog_iOS-Modularization-07.png

LPDBPublicModule 中的 Lotus 協議,像一張清單列出了全部模塊提供的服務聲明,而在各個模塊中,直接經過這些公共協議就能夠調用想要的服務。不少問題均可以在編譯前和編譯階段顯示出來(若是模塊不提供服務,是不能經過編譯的;若是沒有一項服務沒有聲明,是不能經過編譯的)。

4.4 語言耦合

咱們抽模塊中一個重要的目的就是『分割兩種語言』,可是實踐過程當中,會發現,分割語言比分割業務還要難。

一個 Pod 庫中只能包含一種語言,但每每,在抽離代碼的最後,會發現有無數的基礎 Model 耦合,如:

@interface ShopInfo : LPDBModel

...
@property (nullable, nonatomic, strong) DeliveryService *workingProduct;
@property (nullable, nonatomic, strong) DeliveryService *preEffectiveProduct;

@end
複製代碼
class DeliveryService: BaseModel {
    ...
}
複製代碼

若是須要將 ShopInfoDeliveryService 抽出到一個模塊時,必需要『有舍有得』,在涉及到基礎 Model 語言不一樣時,能夠適當的重寫,由於 Model 的代碼量是極小的,Model 一般也只包含屬性聲明,做爲數據傳輸的中介,即便更改,產生的不可預支錯誤的可能性也較低。

若是要抽出的模塊主體使用 OC,那麼能夠將 DeliveryService 從新用 OC 編寫。

但要注意,要先儘可能經過拆分更基礎的服務模塊,在考慮從新編寫文件,保證項目的穩定性。

4.5 模塊的積木化

模塊化的最終目的,不只僅是去耦,還應當讓每一個模塊像積木同樣,隨意拼接,最後達到主工程徹底沒有代碼,經過 Pod 集成各個模塊,組成完整的功能。而每一個模塊也應當能夠獨立測試,獨立開發。

仍是以 LPDBLoginModuleLPDBNetWort 爲例。

登陸模塊是一個很是特殊的模塊,全部的子模塊若是想獨立測試和開發,通常都須要經過登陸驗證,好比訂單模塊,必需要登陸後,該業務模塊內能才能正確的拉取訂單信息。

因爲 LPDBLoginModule 依賴基礎庫 LPDBNetWortLPDBNetWort 須要作的有:

  1. 包含 cer 文件,能夠正確的提供給其餘模塊正常的 https 接口訪問
  2. 便利的網絡服務調用

LPDBLoginModule 至少要作的事有:

  1. 能夠正確的保存登陸信息,完成登陸操做
  2. 提供登陸的 UI 界面,能夠直接調用 LoginVC

在具有以上功能後,LPDBLoginModule 就能夠快速的集成進其餘模塊,爲其餘模塊提供獨立開發、獨立測試的功能。

4.6 資源打包

上一小結提到『 LPDBLoginModule 要提供登陸的 UI 界面』。對於 UI 界面,須要作的是資源打包,在模塊拆分中,要很是注意資源分割。

由於業務模塊的劃分,不只僅是是代碼抽出,也有資源抽出。

資源庫包括但不只限於:

  1. .xib 文件
  2. 聲音資源
  3. 圖片資源
  4. 純文本文件
  5. 視頻資源

因此,全部的資源文件,應當單首創立 Res 文件夾,放入其中,並在 .podspec 中代表資源文件路徑

s.resources 	 = ["Source/**/*.xib", "Source/Res/*.xcassets"]
複製代碼

注意圖片資源,若是想保留 @2x、@3x,是能夠按照 xcassets 的格式直接 copy 過來的。

blog_iOS-Modularization-08.png

5 結尾

以上是我在混編項目中進行 模塊化/ 組件化的經驗總結,寫成了指導的模式,但願這篇文章能對走一樣路的人有所幫助,但願大家會有所收穫,麼麼噠。


有什麼問題均可以在博文後面留言,或者微博上私信我,或者郵件我 coderfish@163.com

博主是 iOS 妹子一枚。

但願你們一塊兒進步。

個人微博:小魚周凌宇

相關文章
相關標籤/搜索