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

小魚周凌宇發表於2017-12-14

1. 開篇

本文的初衷,是為了給正在做混編或者模組化的同學們一個建議和參考。

因為來餓廠以後做的專案是全公司唯一一個 Swift/OC 混編的 iOS 專案,所以一路上踩坑無數,現在把一些踩坑的過程和經驗總結起來,供大家參考。

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

我來的時候專案已經開始 Swift 改造了,慢慢的把專案 Swift 化,新程式碼都是 Swift 的。

先公佈七個月成果,下圖是我們最終的專案結構:

blog_iOS-Modularization-02.png

對於我們混編的情況,在五個月前大家就展開了討論。

給我們的選擇有兩種:

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

一開始我們是從 選擇1 開始做的,但是很快我們就發現,對於我們 74% 都是 OC 程式碼的專案來說,太痛了,太漫長了,而且期間迭代的過程中還在不斷地迭代,不斷的耦合。

所以在經過一番利害分析後我們迅速投入到了 選擇2 中。一方面,模組化本身就是越來越臃腫的專案的最終歸宿,一方面可以慢慢將兩種語言剝離。

注:這裡的模組化,也就是大家說的『元件化』,不是在主工程用資料夾分模組,而是指將獨立模組抽調成 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];
複製程式碼

經過這些改造之後,模組間的狀態如圖所示:

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

但是,可以很明顯感受到,這次的改變並不徹底:

  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 妹子一枚。

希望大家一起進步。

我的微博:小魚周凌宇

相關文章