隨著移動網際網路的快速發展,專案的迭代速度越來越快,需求改變越來越頻繁,傳統開發方式的工程所面臨的一些,如程式碼耦合嚴重、維護效率低、開發不夠敏捷等問題就凸現了出來。於是越來越多的公司開始推行"元件化",通過對原有業務或新業務進行元件(或模組)拆分來提高並行開發效率。
在筆者面試過程中發現,很多同學口中的"元件化"也只是把程式碼分庫,然後在主專案中使用 CocoaPods 把各個子庫聚合起來。對於怎樣合理地對元件分層、如何管理元件(主要包括元件的生命週期管理和元件的通訊管理),如何管理不同版本的依賴,以及是否有整套整合和釋出工具,這類問題的知之甚少。如果完全不瞭解這些問題,那麼只是簡單的對主專案進行元件拆分,並不能提高多少開發效率。
筆者認為合理地進行元件拆分和管理各個元件之間的通訊是元件化過程中最大的難點。合理地進行元件拆分是為了解耦,並且各個元件能更容易地獨立變化。而對於一個完整的應用來說,每個元件不可能孤零零地存在,必定會互相呼叫。這樣不同元件之間必須能進行通訊而又沒有編譯期的依賴。
元件生命週期管理
可能很多同學在實施元件化的過程中知道要解決元件通訊的問題,卻很少關注元件的生命週期。這裡的生命週期主要是指 AppDelegate 中的生命週期方法。有時候一些元件需要在這些鉤子方法中做一些事情,這時候就需要一個能夠管理元件的工具,並在適當的時機執行元件相應的邏輯。
比如筆者在專案中是這樣做的:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
[[Ant shareInstance] application:application didFinishLaunchingWithOptions:launchOptions];
return YES;
}
- (void)applicationWillResignActive:(UIApplication *)application
{
[[Ant shareInstance] applicationWillResignActive:application];
}
- (void)applicationDidEnterBackground:(UIApplication *)application
{
[[Ant shareInstance] applicationDidEnterBackground:application];
}
- (void)applicationWillEnterForeground:(UIApplication *)application
{
[[Ant shareInstance] applicationWillEnterForeground:application];
}
複製程式碼
所有註冊的元件(模組)會在 AppDelegate 相應的生命週期方法呼叫時自動呼叫。例如有如下元件定義:
ANT_MODULE_EXPORT(Module1App)
@interface Module1App() <ATModuleProtocol> {
NSInteger state;
}
@end
@implementation Module1App
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
state = 0;
NSLog(@"Module A state: %zd", state);
return YES;
}
- (void)applicationWillEnterForeground:(UIApplication *)application {
state += 1;
NSLog(@"Module A state: %zd", state);
}
@end
複製程式碼
上面示例程式碼中第一行的 ANT_MODULE_EXPORT(Module1App)
是匯出元件。Ant 會在 dyld 載入完 image 後將匯出的元件進行註冊。當應用生命週期方法被呼叫時,會例項化所有註冊過的元件,呼叫元件相應的方法,並進行快取,之後再次呼叫就會從快取中取出元件的例項物件。
一般擁有完整生命週期的元件一般稱為一個模組,一個模組其實也是一個獨立的元件,它一般是包含一個完整的業務,列如:登入模組,外賣模組,訊息模組等。
元件的生命週期管理並不複雜,實現方案都沒有太大區別,但它也是元件化中必不可少的部分。
元件通訊
業界關於元件通訊的方案比較多,主要有:url-block
, target-action
, protocol-class
。下面筆者會對這三種方案做個簡單的介紹。
URL-Block
這是蘑菇街在元件化過程中使用的一種元件間通訊方式,在應用啟動時註冊元件提供的服務,把呼叫元件使用的url
和元件提供的服務block
對應起來,儲存到記憶體中。在使用元件的服務時,通過url
找到對應的block
,然後獲取服務。
[MGJRouter registerURLPattern:@"mgj://foo/bar" toHandler:^(NSDictionary *routerParameters) {
NSLog(@"routerParameterUserInfo:%@", routerParameters[MGJRouterParameterUserInfo]);
}];
[MGJRouter openURL:@"mgj://foo/bar"];
複製程式碼
筆者是在15年開始學習元件化,那個時候就是使用的蘑菇街的這種發案。不過筆者從來沒有在實際專案中使用這種方案。casa 在這篇文章中批判了這種方案。筆者對 case 的觀點很是贊同。
如果專案中需要很多元件的服務,那麼就需要在記憶體中維護大量的 url-block
項,造成記憶體問題,對於服務註冊的程式碼應該放在什麼地方也是一個問題。筆者一直認為 url-block
註冊是一種很粗暴的方式,比如某個應用在啟動時註冊了100個服務,但某些服務在使用者使用過程中根本就沒有觸發,這就造成了記憶體浪費。比如我們點選應用中的按鈕跳轉到某個頁面,如果使用者沒有點選按鈕,下個頁面就永遠不會建立,我們一般不會提前建立這個頁面的。筆者更傾向於在需要服務的時候才進行服務物件的建立,在特定場景下也提供服務物件的快取。
使用 url
傳參也是一個不可忽略的問題,對於一些基礎資料型別,使用這種方案倒是沒有問題,但是對於一些非常規物件就無能為力了,如 UIImage
, NSData
等型別。
還有一個問題是 casa 在文章中沒有指出的,這個問題在他的 target-action
方案中也存在。下面用一個例子來說明一下。
比如在一個元件 A 中提供了一個服務:
[MGJRouter registerURLPattern:@"mgj://foo/bar" toHandler:^(NSDictionary *routerParameters) {
NSLog(@"routerParameterUserInfo:%@", routerParameters[MGJRouterParameterUserInfo]);
}];
複製程式碼
然後在一個元件 B 中使用了服務:
[MGJRouter openURL:@"mgj://foo/bar"];
複製程式碼
從上面示例程式碼中可以看到,兩個不同元件能通訊其實是通過一個字串來定義的。如果服務使用方在寫程式碼時寫錯了一個字元,那麼使用方根本就不可能調起正確的服務,一旦出現這個問題,在開發過程中很難被發現。如果我們對元件多,註冊的服務多,那麼在使用時就存在很大的溝通問題,提供方和接入方可能會在每個字串所代表的意義上浪費大量的時間。而這些問題都可以在工程設計上避免的。雖說我們在寫程式碼時要低耦合,但並不代表不要耦合,有時候需要一些耦合來提高程式碼的健壯性和可維護性。
在 Swift 中可以使用列舉來解決上面的問題,我們可以像下面這樣做:
protocol URLPatternCompatible {
var URLPattern: String { get }
}
enum SomeService {
case orderDetail
case others
}
enum SomeService: URLPatternCompatible {
var URLPattern: String {
switch self {
case .orderDetail:
return "mgj://foo/bar/orderdetail"
case .others:
return "mgj://foo/bar/others"
}
}
}
// 元件 A (服務提供方)
MGJRouter.register(.orderDetail) { ... }
// 元件 B (服務使用方)
MGJRouter.open(.orderDetail)
複製程式碼
SomeService 的定義可以放到一個專門的元件中,服務提供方和使用方都依賴這個專門的元件。我們這裡不僅將字串放到了一個統一的地方進行維護,而且還將一些在執行期才能發現的問題提前暴露到編譯器。這裡我們通過耦合來達到提高程式碼的健壯性和可維護性的目的。
Target-Action
Target-actin 是 casa 在批判蘑菇街的方案時提出的一種方案。它解決了 url-block
方案中記憶體問題、url 傳參問題、沒有區分本地呼叫和遠端呼叫等問題。其核心就是使用了 NSObject 的 - (id)performSelector:(SEL)aSelector withObject:(id)object;
方法。
在本地應用呼叫中,本地元件A在某處呼叫 [[CTMediator sharedInstance] performTarget:targetName action:actionName params:@{...}]
向 CTMediator
發起跨元件呼叫,CTMediator
根據獲得的 target 和 action 資訊,通過 objective-C 的 runtime 轉化生成 target 例項以及對應的 action 選擇子,然後最終呼叫到目標業務提供的邏輯,完成需求。
casa 在文章中也給出了 demo,在具體的專案中,我們可以這樣使用:
// CTMediator+SomeAction.h
- (UIViewController *)xx_someAction:(NSDictionary *)params;
// CTMediator+SomeAction.m
- (UIViewController *)xx_someAction:(NSDictionary *)params {
return [self performTarget:@"A" action:@"someAction" params:params shouldCacheTarget:NO]
}
複製程式碼
上面是提供給服務呼叫方的一個簡潔的介面。其實就是對 CTMediator 方法的封裝。我們一般將 CTMediator 的這個分類放到一個獨立的元件中。呼叫方依賴這個獨立的元件就可以了。
在某個元件中呼叫服務:
// 元件 A 中
UIViewController *vc = [CTMediator sharedInstance] xx_someAction:@{@"key": value}];
複製程式碼
針對上面服務的定義,服務提供方的定義就必須是下面這樣:
// TargetA.h
@interface Target_A : NSObject
- (UIViewController *)someAction:(NSDictionary *)params;
@end
// TargetA.m
- (UIViewController *)someAction:(NSDictionary *)params { ... }
複製程式碼
在這整個過程中可以看到,服務的呼叫方只需要依賴 CTMediator 這個中介軟體及其分類(定義服務)。服務提供方和呼叫方沒有任何依賴。確實做到了元件解耦。可以肯定的是 target-action 方案確實解決了 url-block 方案的一些問題。但是仔細一看,也是存在一些問題的。
跟 url-block 方案一樣,兩個不同元件能通訊其實仍然是通過一個字串來定義的。為什麼這麼說呢,我們可以看一下下面的程式碼:
// CTMediator+SomeAction.m
- (UIViewController *)xx_someAction:(NSDictionary *)params {
return [self performTarget:@"A" action:@"someAction" params:params shouldCacheTarget:NO]
}
// TargetA.h
@interface Target_A : NSObject
- (UIViewController *)someAction:(NSDictionary *)params;
@end
複製程式碼
從上面的程式碼中可以看到,服務能調起主要是呼叫了 CTMediator 的
- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params shouldCacheTarget:(BOOL)shouldCacheTarget;
方法。這裡不管是 targetName
還是 action
都是字串,在實現中 CTMediator 會示例化一個 Target_targetName
類的物件,並且建立一個 Action_actionName
的 selector,所有我們在服務提供的元件中的 Target 以及 Action 是不能隨便定義的。Target 必須是以 Target_
開頭,方法必須以 Action_
開頭。這種強制要求感覺不是一種工程師的思維。這裡想去耦合,卻以一種不是很正確的方式造成了隱式的耦合。這也是讓我拋棄 CTMediator 轉而去開發自己的元件化通訊方案的原因之一。
Protocol-Class
Protocol-Class 方案也是常用的元件化通訊方式之一。這裡把它放到最後,肯定是因為筆者使用的是這種方案咯(笑)。
Protocol-Class 方案就是通過 protocol 定義服務介面,服務提供方通過實現該介面來提供介面定義的服務。具體實現就是把 protocol 和 class 做一個對映,同時在記憶體中儲存一張對映表,使用的時候,就通過 protocol 找到對應的 class 來獲取需要的服務。這種方案的優缺點先不說,可以先看一下具體的實踐:
示例圖:
示例程式碼:
// TestService.h (定義服務)
@protocol TestService <NSObject>
/// 測試
- (void)service1;
@end
// 元件 A (服務提供方)
ANT_REGISTER_SERVICE(TestServiceImpl, TestService)
@interface TestServiceImpl() <TestService> @end
@implementation TestServiceImpl
- (void)service1 {
NSLog(@"Service test from Impl");
}
@end
// 元件 B (服務使用方)
id <TestService> obj = [Ant serviceImplFromProtocol:@protocol(TestService)];
[obj service1];
複製程式碼
像上面的方案一樣,我們會將服務的定義放到獨立的元件中。這個元件僅僅只包含了服務的宣告。不管是服務提供方還是服務使用方都依賴這個獨立的元件,服務提供方還是服務使用方互不依賴。
這裡將系統提供的服務定義為協議,通過耦合提高了程式碼的健壯性和可維護性。這裡定義服務的 protocol 對服務提供方做了一個限定:你可以提供哪些服務,同時也給服務使用方做了限定:你可以使用哪些服務。這種設計將系統有哪些服務都交代的清清楚楚,通過服務的 protocol 我們就知道了每個服務的功能,呼叫需要的引數,返回值等。這裡的定義服務的同時也可以作為系統服務的介面文件,這節省了服務提供方和使用方很多的溝通時間,讓其能關注業務的開發。這在大型專案,多團隊開發中優勢尤為明顯。
當然 protocol-class 這種方案缺點也很明顯,需要在記憶體中儲存 protocol 到 Class 的對映關係。但是我們可以通過將服務分類,讓系統註冊的 protocol-class 項儘量少一些,不要一個服務定義一個實現。對於一個有100個服務的系統,定義10個服務實現,每個實現提供10個服務,肯定要比100個服務實現佔用的記憶體少很多。這就要求我們在實踐過程中能對系統中的服務能做好劃分。
總結
以上就是筆者對元件化的一些思考,很多觀點可能也不太成熟,如果有什麼不合理的地方,也歡迎各位同學提出建議。元件解耦在 iOS 中其實有多種解決方案,各位同學可以根據專案實際情況選擇適合自己的方案。
上面程式碼中的 Ant 是筆者最近開發的一個負責元件生命週期管理和通訊的開源工具。因為筆者公司從17年開始就一直使用 Swift 進行開發,原來的工具是用 Swift 編寫的,使用了很多 Swift 的特性,在 OC 中使用就顯得不倫不類了,就針對 OC 進行了重新設計,於是就有了 Ant 。