Category 特性在 iOS 組件化中的應用與管控

背景

iOS Category功能簡介

Category 是 Objective-C 2.0以後添加的語言特性。html

Category 就是對裝飾模式的一種具體實現。它的主要做用是在不改變原有類的前提下,動態地給這個類添加一些方法。在 Objective-C(iOS 的開發語言,下文用 OC 代替)中的具體體現爲:實例(類)方法、屬性和協議。git

除了引用中提到的添加方法,Category 還有不少優點,好比將一個類的實現拆分開放在不一樣的文件內,以及能夠聲明私有方法,甚至能夠模擬多繼承等操做,具體可參考官方文檔Categorygithub

若 Category 添加的方法是基類已經存在的,則會覆蓋基類的同名方法。本文將要提到的組件間通訊都是基於這個特性實現的,在本文的最後則會提到對覆蓋風險的管控。安全

組件通訊的背景

隨着移動互聯網的快速發展,不斷迭代的移動端工程每每面臨着耦合嚴重、維護效率低、開發不夠敏捷等常見問題,所以愈來愈多的公司開始推行「組件化」,經過解耦重組組件來提升並行開發效率。ruby

可是大多數團隊口中的「組件化」就是把代碼分庫,主工程使用 CocoaPods 工具把各個子庫的版本號聚合起來。但能合理的把組件分層,而且有一整套工具鏈支撐發版與集成的公司較少,致使開發效率很難有明顯地提高。bash

處理好各個組件之間的通訊與解耦一直都是組件化的難點。諸如組件之間的 Podfile 相互顯式依賴,以及各類聯合發版等問題,若處理不當可能會引起「災難」性的後果。架構

目前作到 ViewController (指iOS中的頁面,下文用VC代替)級別解耦的團隊較多,維護一套 mapping 關係並使用 scheme 進行跳轉,可是目前仍然沒法作到更細粒度的解耦通訊,依然知足不了部分業務的需求。app

實際業務案例

例1:外賣的首頁的商家列表(WMPageKit),在進入一個商家(WMRestaurantKit)選擇5件商品返回到首頁的時候,對應的商家cell須要顯示已選商品「5」。框架

例2:搜索結果(WMSearchKit)跳轉到商超的容器頁(WMSupermarketKit),須要傳遞一個通用Domain(也有的說法叫模型、Model、Entity、Object等等,下文統一用Domain表示)。iphone

例3:作一鍵下單需求(WMPageKit),須要調用下單功能的一個方法(WMOrderKit)入參是一個訂單相關 Domain 和一個 VC,不須要返回值。

這幾種場景基本涵蓋了組件通訊所需的的基本功能,那麼怎樣才能夠實現最優雅的解決方案?

組件通訊的探索

模型分析

對於上文的實際業務案例,很容易想到的應對方案有三種,第一是拷貝共同依賴代碼,第二是直接依賴,第三是下沉公共依賴。

對於方案一,會維護多份冗餘代碼,邏輯更新後代碼不一樣步,顯然是不可取的。對於方案二,對於調用方來講,會引入較多無用依賴,且可能形成組件間的循環依賴問題,致使組件沒法發佈。對於方案三,實際上是可行解,可是開發成本較大。對於下沉出來的組件來講,其實很難找到一個明確的定位,最終淪爲多個組件的「大雜燴」依賴,從而致使嚴重的維護性問題。

那如何解決這個問題呢?根據面向對象設計的五大原則之一的「依賴倒置原則」(Dependency Inversion Principle),高層次的模塊不該該依賴於低層次的模塊,二者(的實現)都應該依賴於抽象接口。推廣到組件間的關係處理,對於組件間的調用和被調用方,從本質上來講,咱們也須要儘可能避免它們的直接依賴,而但願它們依賴一個公共的抽象層,經過架構工具來管理和使用這個抽象層。這樣咱們就能夠在解除組件間在構建時沒必要要的依賴,從而優雅地實現組件間的通信。

業界現有方案的幾大方向

實踐依賴倒置原則的方案有不少,在 iOS 側,OC 語言和 Foundation 庫給咱們提供了數個可用於抽象的語言工具。在這一節咱們將對其中部分實踐進行分析。

1.使用依賴注入

表明做品有 Objection 和 Typhoon,二者都是 OC 中的依賴注入框架,前者輕量級,後者較重並支持 Swift。

比較具備通用性的方法是使用「協議」 <-> 「類」綁定的方式,對於要注入的對象會有對應的 Protocol 進行約束,會常常看到一些RegisterClass:ForProtocol:classFromProtocol的代碼。在須要使用注入對象時,用框架提供的接口以協議做爲入參從容器中得到初始化後的所需對象。也能夠在 Register 的時候直接註冊一段 Block-Code,這個代碼塊用來初始化本身,做爲id類型的返回值返回,能夠支持一些編譯檢查來確保對應代碼被編譯。

美團內推行將一些運行時加載的操做前移至編譯時,好比將各項註冊從 +load 改成在編譯期使用__attribute((used,section("__DATA,key"))) 寫入 mach-O 文件 Data 的 Segment 中來減小冷啓動的時間消耗。

所以,該方案的侷限性在於:代碼塊存取的性能消耗較大,而且協議與類的綁定關係的維護須要花費更多的時間成本。

2.基於SPI機制

全稱是 Service Provider Interfaces,表明做品是 ServiceLoader。

實現過程大體是:A庫與B庫之間無依賴,但都依賴於P平臺。把B庫內的一個接口I下沉到平臺層(「平臺層」也叫作「通用能力層」,下文統一用平臺層表示),入參和返回值的類型須要平臺層包含,接口I的實現放在B庫裏(由於實如今B庫,因此實現裏能夠正常引用B庫的元素)。而後A庫經過P平臺的這個接口I來實現功能。A能夠調用的到接口I,可是在B的庫中進行實現。

在A庫須要經過一個接口I實例化出一個對象,使用ServiceLoader.load(接口,key),經過註冊過的key使用反射找到這個接口imp的文件路徑而後獲得這個實例對象調用對應接口。

這個操做在安卓中使用較爲普遍,大體至關於用反射操做來替代一次了 import 這樣的耦合引用。但實際上iOS中若使用反射來實現功能則徹底沒必要這麼麻煩。

關於反射,Java能夠實現相似於ClassFromString的功能,可是沒法直接使用 MethodFromString的功能。而且ClassFromString也是經過字符串map到這個類的文件路徑,相似於 com.waimai.home.searchImp,從而能夠得到類型而後實例化,而OC的反射是經過消息機制實現。

3.基於通知中心

以前和一個作讀書類App的同窗交流,發現行業內有些公司的團隊在使用 NotificationCenter 進行一些解耦的通訊,由於通知中心自己支持傳遞對象,而且通知中心的功能也原生支持同步執行,因此也能夠達到目的。

通知中心在iOS 9以後有一次比較大的升級,將通知支持了 request 和 response 的處理邏輯,並支持獲取到通知的發送者。比以往的通知羣發但不感知發送者和是否收到,進步了不少。

字符串的約定也能夠理解爲一個簡化的協議,可設置成宏或常量放在平臺層進行統一的維護。

比較明顯的缺陷是開發的統一範式難以約束,風格迥異,且字符串相較於接口而言仍是難以管理。

4.使用objc_msgSend

這是iOS原生消息機制中最萬能的方法,編寫時會有一些硬編碼。核心代碼以下:

id s = ((id(*)(id, SEL))objc_msgSend)(ClassName,@selector(methodName)); 
複製代碼

這種方法的特色是即插即用,在開發者能100%肯定整條調用鏈沒問題的時候,能夠快速實現功能。

此方案的缺陷在於編寫十分隨意,檢查和校驗的邏輯還不夠,滿屏的強轉。對於 int、Integer、NSNumber 這樣的很容易發生類型轉換錯誤,結果雖然不報錯,但數字會有錯誤。

方案對比

接下來,咱們對這幾個大方向進行一些性能對比。

考慮到在公司內的實際用法與限制,可能比常規方法增長了若干步驟,結果也可能會與常規裸測存在必定的誤差。 例如依賴注入經常使用作法是存在單例(內存)裏,可是咱們爲了優化冷啓動時間都寫入 mach-O 文件 Data 的 Segment 裏了,因此在咱們的統計口徑下存取時間會相對較長。

// 爲了避免暴露類名將業務屬性用「some」代替,並隱藏初始化、循環100W次、差值計算等代碼,關鍵操做代碼以下

// 存取注入對象
xxConfig = [[WMSomeGlueCore sharedInstance] createObjectForProtocol:@protocol(WMSomeProtocol)];
// 通知發送
[[NSNotificationCenter defaultCenter]postNotificationName:@"nixx" object:nil];
// 原生接口調用
a = [WMSomeClass class];
// 反射調用
b = objc_getClass("WMSomeClass");

複製代碼

運行結果顯示以下:

能夠看出原生的接口調用明顯是最高效的用法,反射的時長比原生要多一個數量級,不過100W次也就是多了幾十毫秒,還在能夠接受的範圍以內。通知發送相比之下性能就很低了,存取注入對象更低。

固然除了性能消耗外,還有不少很差量化的維度,包括規範約束、功能性、代碼量、可讀性等,筆者按照實際場景客觀評價給出對比的分值。

下面,咱們用五種維度的能力值圖來對比每一種方案優缺點:

  • 各維度的的評分考慮到了必定的實際場景,可能和常規結果稍有誤差。

  • 已經作了轉化,看圖面積越大越優。可讀性的維度越長表明可讀性越高,代碼量的維度越長表明代碼成本越少。

如圖2所示,能夠看出上圖的四種方式或多或少都存在一些缺點:

  1. 依賴注入是由於美團的實際場景問題,因此在性能消耗上存在明顯的短板,而且代碼量和可讀性都不突出,規範約束這裏是亮點。
  2. SPI機制的範圍圖很大,但使用了反射,而且代碼開發成本較高,實踐上來看,對協議管理有必定要求。
  3. 通知中心看上去挺方便,但發送與接收大多成對出現,還附帶綁定方法或者Block,代碼量並很多。
  4. 而msgsend功能強大,代碼量也少,可是在規範約束和可讀性上幾乎爲零。

綜合看來 SPI 和 objc_msgSend 二者的特色比較明顯,頗有潛力,若是針對這兩種方案分別進行必定程度的完善,應該能夠實現一個綜合評分更高的方案。

從現有方案中完善或衍生出的方案

5.使用Category+NSInvocation

此方案從 objc_msgSend 演化而來。NSInvocation 的調用方式的底層仍是會使用到 objc_msgSend,可是經過一些方法簽名和返回值類型校驗,能夠解決不少類型規範相關的問題,而且這種方式沒有繁瑣的註冊步驟,任何一次新接口的添加,均可以直接在低層的庫中進行完成。

爲了更進一步限制調用者可以調用的接口,建立一些 Category 來提供接口,內部包裝下層接口,把返回值和入參都限制實際的類型。業界比較接近的例子有 casatwy 的 CTMediator。

6.原生CategoryCoverOrigin方式

此方案從 SPI 方式演化而來。兩個的共同點是都在平臺層提供接口供業務方調用,不一樣點是此方式徹底規避了各類硬編碼。並且 CategoryCoverOrigin 是一個思想,沒有任何框架代碼,能夠說 OC 的 Runtime 就是這個方案的框架支撐。此方案的核心操做是在基類裏彙總全部業務接口,在上層的業務庫中建立基類的 Category 中對聲明的接口進行覆蓋。整個過程沒有任何硬編碼與反射。

演化出的這兩種方案能力評估以下(綠色部分),圖中也貼了和演化前方案(桔色部分)的對比:

上文對這兩種方案描述的很是歸納,可能有同窗會對能力評估存在質疑。接下來會分別進行詳解的介紹,並描述在實際操做值得注意的細節。這兩種方案組合成了外賣內部的組件通訊框架 WMScheduler。

WMScheduler組件通訊

外賣的 WMScheduler 主要是經過對 Category 特性的運用來實現組件間通訊,實際操做中有兩種的應用方案:Category+NSInvocation 和 Category CoverOrigin。

1.Category+NSInvocation方案

方案簡介:

這個方案將其對 NSInvocation 功能容錯封裝、參數判斷、類型轉換的代碼寫在下層,提供簡易萬能的接口。並在上層建立通訊調度器類提供經常使用接口,在調度器的的 Category 裏擴展特定業務的專用接口。全部的上層接口均有規範約束,這些規範接口的內部會調用下層的簡易萬能接口便可經過NSInvocation 相關的硬編碼操做調用任何方法。

UML圖:

如圖3-1所示,代碼的核心在 WMSchedulerCore 類,其包含了基於 NSInvocation 對 target 與 method 的操做、對參數的處理(包括對象,基本數據類型,NULL類型)、對異常的處理等等,最終開放了簡潔的萬能接口,接口參數有 target、method、parameters等等,而後內部幫咱們完成調用。但這個接口並非讓上層業務直接進行調用,而是須要建立一個 WMSchedule r的 Category,在這個 Category 中編寫規範的接口(前綴、入參類型、返回值類型都是肯定的)。

值得一提的是,提供業務專用接口的 Category 沒有以 WMSchedulerCore 爲基類,而是以 WMScheduler 爲基類。看似畫蛇添足,其實是爲了作權限的隔離。 上層業務只能訪問到 WMScheduler.h 及其 Category 的規範接口。並不能訪問到 WMSchedulerCore.h 提供的「萬能但不規範」接口。

例如:在UML圖中能夠看到 外界只能夠調用到wms_getOrderCountWithPoiid(規範接口),並不能使用wm_excuteInstance Method(萬能接口)。

爲了更好地理解實際使用,筆者貼一個組件調用週期的完整代碼:

如圖3-2,在這種方案下,「B庫調用A庫方法」的需求只須要改兩個倉庫的代碼,須要改動的文件標了下劃線,請仔細看下示例代碼。

示例代碼:

平臺(通用功能)庫三個文件:

// WMScheduler+AKit.h
#import "WMScheduler.h"
@interface WMScheduler(AKit)
/** * 經過商家id查到當前購物車已選e的小紅點數量 * @param poiid 商家id * @return 實際的小紅點數量 */
+ (NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID;
@end
複製代碼

// WMScheduler+AKit.m
#import "WMSchedulerCore.h"
#import "WMScheduler+AKit.h"
#import "NSObject+WMScheduler.h"
@implementation WMScheduler (AKit)
+ (NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID{
    if (nil == poiid) {
        return 0;
    }
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wundeclared-selector"
    id singleton = [wm_scheduler_getClass("WMXXXSingleton") wm_executeMethod:@selector(sharedInstance)];
    NSNumber* orderFoodCount = [singleton wm_executeMethod:@selector(calculateOrderedFoodCountWithPoiID:) params:@[poiID]];
    return orderFoodCount == nil ? 0 : [orderFoodCount integerValue];
#pragma clang diagnostic pop
}
@end
複製代碼

// WMSchedulerInterfaceList.h
#ifndef WMSchedulerInterfaceList_h
#define WMSchedulerInterfaceList_h
// 這個文件會被加到上層業務的pch裏,因此下文不用import本文件
#import "WMScheduler.h"
#import "WMScheduler+AKit.h"
#endif /* WMSchedulerInterfaceList_h */
複製代碼

BKit (調用方)一個文件:

// WMHomeVC.m
@interface WMHomeVC () <UITableViewDataSource, UITableViewDelegate>
@end
@implementation WMHomeVC
...
    NSUInteger *foodCount = [WMScheduler wms_getOrderedFoodCountWithPoiID:currentPoi.poiID];
    NSLog(@"%ld",foodCount);
...
@end
複製代碼

代碼分析:

上文四個文件完成了一次跨組件的調用,在 WMScheduler+AKit.m 中的第30、31行,調用的都是AKit(提供方)的現有方法,由於 WMSchedulerCore 提供了 NSInvocation 的調用方式,因此能夠直接向上調用。WMScheduler+AKit 中提供的接口就是上文說的「規範接口」,這個接口在WMHomeVC(調用方)調用時和調用本倉庫內的OC方法,並無區別。

延伸思考:

  • 上文的例子中入參和返回值都是基本數據類型,Domain 也是支持的,前提是這個 Domain 是放在平臺庫的。咱們能夠將工程中的 Domain 分爲BO(Business Object)、VO(View Object)與TO(Transfer Object),VO 常常出如今 view 和 cell,BO通常僅在各業務子庫內部使用,這個TO則是須要放在平臺庫是用於各個組件間的通訊的通用模型。例如:通用 PoiDomain,通用 OrderDomain,通用 AddressDomain 等等。這些稱爲 TO 的 Domain 能夠做爲規範接口的入參類型或返回值類型。

  • 在實際業務場景中,跳轉頁面時傳遞 Domain 的需求也是一個老生常談的問題,大多數頁面級跳轉框架僅支持傳遞基本數據類型(也有 trick 的方式傳 Domain 內存地址但很不優雅)。在有了上文支持的能力,咱們能夠在規範接口內經過萬能接口獲取目標頁面的VC,並調用其某個屬性的 set 方法將咱們想傳遞的Domain賦值過去,而後將這個 VC 對象做爲返回值返回。調用方得到這個 VC 後在當前的導航棧內push便可。

  • 上文代碼中咱們用 WMScheduler 調用了 Akit 的一個名爲calculateOrderedFoodCount WithPoiID:的方法。那麼有個爭議點:在組件通訊須要調用某方法時,是容許直接調用現有方法,仍是複製一份加上前綴標註此方法專門用於提供組件通訊? 前者的問題點在於現有方法可能會被修改,擴充參數會直接致使調用方找不到方法,Method 字符串的不會編譯報錯(上文平臺代碼 WMScheduler+AKit.m 中第31行)。後者的問題在於大大增長了開發成本。權衡後咱們仍是使用了前者,加了些特殊處理,若現有方法被修改了,則會在isReponseForSelector這裏檢查出來,並走到 else 的斷言及時發現。

階段總結:

Category+NSInvocation 方案的優勢是便捷,由於 Category 的專用接口放在平臺庫,之後有除了 BKit 之外的其餘調用方也能夠直接調用,還有更多強大的功能。

可是,不優雅的地方咱們也列舉一下:

  • 當這個跨組件方法內部的代碼行數比較多時,會寫不少硬編碼。

  • 硬編碼method字符串,在現有方法被修改時,編譯檢測不報錯(只能靠斷言約束)。

  • 下層庫向上調用的設計會被詬病。

接下來介紹的 CategoryCoverOrigin 的方案,能夠解決這三個問題。

2.CategoryCoverOrigin方案

方案簡介:

首先說明下這個方案和 NSInvocation 沒有任何關係,此方案與上一方案也是徹底不一樣的兩個概念,不要將上一個方案的思惟帶到這裏。

此方案的思路是在平臺層的 WMScheduler.h 提供接口方法,接口的實現只寫空實現或者兜底實現(兜底實現中可根據業務場景在 Debug 環境下增長 toast 提示或斷言),上層庫的提供方實現接口方法並經過 Category 的特性,在運行時進行對基類同名方法的替換。調用方則正常調用平臺層提供的接口。在 CategoryCoverOrigin 的方案中 WMScheduler 的 Category 在提供方倉庫內部,所以業務邏輯的依賴能夠在倉庫內部使用常規的OC調用。

UML圖:

從圖4-1能夠看出,WMScheduler 的 Category 被移到了業務倉庫,而且 WMScheduler 中有全部接口的全集。

爲了更好地理解 CategoryCover 實際應用,筆者再貼一個此方案下的完整完整代碼:

如圖4-2,在這種方案下,「B庫調用A庫方法」的需求須要修改三個倉庫的代碼,但除了這四個編輯的文件,沒有其餘任何的依賴了,請仔細看下代碼示例。

示例代碼:

平臺(通用功能庫)兩個文件

// WMScheduler.h
@interface WMScheduler : NSObject
// 這個文件是全部組件通訊方法的彙總
#pragma mark - AKit 
/** * 經過商家id查到當前購物車已選e的小紅點數量 * @param poiid 商家id * @return 實際的小紅點數量 */
+ (NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID;
#pragma mark - CKit
// ...
#pragma mark - DKit
// ...
@end
複製代碼

// WMScheduler.m
#import "WMScheduler.h"
@implementation WMScheduler
#pragma mark - Akit
+ (NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID
{
		return 0; // 這個.m裏只要求一個空實現 做爲兜底方案。
}
#pragma mark - Ckit
// ...
#pragma mark - Dkit
// ...
@end
複製代碼

AKit(提供方)一個 Category 文件:

// WMScheduler+AKit.m
#import "WMScheduler.h"
#import "WMAKitBusinessManager.h"
#import "WMXXXSingleton.h" 
// 直接導入了不少AKit相關的業務文件,由於自己就在AKit倉庫內
@implementation WMScheduler (AKit)
// 這個宏能夠屏蔽分類覆蓋基類方法的警告
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wobjc-protocol-method-implementation"
// 在平臺層寫過的方法,這邊是是自動補全的
+ (NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID
{
  	if (nil == poiid) {
        return 0;
    }
  	// 全部AKIT相關的類都能直接接口調用,不須要任何硬編碼,能夠和以前的寫法對比下。
    WMXXXSingleton *singleton = [WMXXXSingleton sharedInstance];
    NSNumber *orderFoodCount = [singleton calculateOrderedFoodCountWithPoiID:poiID];
    return orderFoodCount == nil ? 0 : [orderFoodCount integerValue];
}
#pragma clang diagnostic pop
@end
複製代碼

BKit(調用方) 一個文件寫法不變:

// WMHomeVC.m
@interface WMHomeVC () <UITableViewDataSource, UITableViewDelegate>
@end
@implementation WMHomeVC
...
    NSUInteger *foodCount = [WMScheduler wms_getOrderedFoodCountWithPoiID:currentPoi.poiID];
    NSLog(@"%ld",foodCount);
...
@end
複製代碼

代碼分析:

CategoryCoverOrigin 的方式,平臺庫用 WMScheduler.h 文件存放全部的組件通訊接口的彙總,各個倉庫用註釋隔開,並在.m文件中編寫了空實現。功能代碼編寫在服務提供方倉庫的 WMScheduler+AKit.m,看這個文件的1七、18行業務邏輯是使用常規 OC 接口調用。在運行時此Category的方法會覆蓋 WMScheduler.h 基類中的同名方法,從而達到目的。CategoryCoverOrigin 方式不須要其餘功能類的支撐。

延伸思考:

若是業務庫不少,方法不少,會不會出現 WMScheduler.h 爆炸? 目前咱們的工程跨組件調用的實際場景不是不少,因此彙總在一個文件了,若是滿屏都是跨組件調用的工程,則須要思考業務架構與模塊劃分是否合理這一問題。固然,若是真出現 WMScheduler.h 爆炸的狀況,徹底能夠將各個業務的接口移至本身Category 的.h文件中,而後建立一個 WMSchedulerInterfaceList 文件統一 import 這些 Category。

兩種方案的選擇

剛纔咱們對於 Category+NSInvocation 和 CategoryCoverOrigin 兩種方式都作了詳細的介紹,咱們再整理一下二者的優缺點對比:

Category+NSInvocation CategoryCover
優勢 只改兩個倉庫,流程上的時間成本更少
能夠實現url調用方法
(scheme://target/method:?para=x)
缺點 功能複雜時硬編碼寫法成本較大
下層調上層,上層業務改變時會影響平臺接口

筆者更建議使用 CategoryCoverOrigin 的無硬編碼的方案,固然具體也要看項目的實際場景,從而作出最優的選擇。

更多建議

  • 關於組件對外提供的接口,咱們更傾向於借鑑 SPI 的思想,做爲一個 Kit 哪些功能是須要對外公開的?提供哪些服務給其餘方解耦調用?建議主動開放核心方法,儘可能減小「用到才補」的場景。例如全局購物車就須要「提供獲取小紅點數量的方法」,商家中心就須要提供「根據字符串 id 獲得整個 Poi 的 Domain」的接口服務。

  • 須要考慮到抽象能力,提供更有泛用性的接口。好比「獲取到了最低滿減價格後拼接成一個文案返回字符串」 這個方法,就沒有「獲取到了最低滿減價格」 這個方法具有泛用性。

Category 風險管控

先舉兩個發生過的案例

1. 2017年10月 一個關於NSDate重複覆蓋的問題

當時美團平臺有 NSDate+MTAddition 類,在外賣側有 NSDate+WMAddition 類。前者 NSDate+MTAddition 以前就有方法 getCurrentTimestamp,返回的時間戳是秒。後者 NSDate+WMAddition 在一次需求中也增長了 getCurrentTimestamp 方法,可是爲了和其餘平臺統一口徑返回值使用了毫秒。在正常的加載順序中外賣類比平臺類要晚,所以在外賣的測試中沒有發現問題。但集成到 imeituan 主項目以後,原先其餘業務方調用這個返回「秒」的方法,就被外賣測的返回「毫秒」的同名方法給覆蓋了,出現接口錯誤和UI錯亂等問題。

2. 2018年3月 一個WMScheduler組件通訊遇到的問題

在外賣側有訂單組件和商家容器組件,這兩個組件的聯繫是十分緊密的,有的功能放在兩個倉庫任意一箇中都說的通。所以出現了了兩個倉庫寫了同名方法的場景。在 WMScheduler+Restaurant 和 WMScheduler+Order 兩個倉庫都添加了方法 -(void)wms_enterGlobalCartPageFromPage:,在運行中這兩處有一處被覆蓋。在有一次 Bug 解決中,給其中一處增長了異常處理的代碼,恰巧增長的這處先加載,就被後加載的同名方法覆蓋了,這就致使了異常處理代碼不生效的問題。

那麼使用 CategoryCover 的方式是否是很不安全? NO!只要弄清其中的規律,風險點都是徹底能夠管控的,接下來,咱們來分析 Category 的覆蓋原理。

Category 方法覆蓋原理

  1. Category 的方法沒有「徹底替換掉」原來類已經有的方法,也就是說若是 Category 和原來類都有methodA,那麼 Category 附加完成以後,類的方法列表裏會有兩個 methodA。

  2. Category 方法被放到了新方法列表的前面,而原來類的方法被放到了新方法列表的後面,這也就是咱們日常所說的 Category 的方法會「覆蓋」掉原來類的同名方法,這是由於運行過程當中,咱們在查找方法的時候會順着方法列表的順序去查找,它只要一找到對應名字的方法,就會罷休^_^,卻不知後面可能還有同樣名字的方法。

Category 在運行期進行決議,而基類的類是在編譯期進行決議,所以分類中,方法的加載順序必定在基類以後。

美團曾經有一篇技術博客深刻分析了 Category,而且從編譯器和源碼的角度對分類覆蓋操做進行詳細解析:深刻理解Objective-C:Category

根據方法覆蓋的原理,咱們能夠分析出哪些操做比較安全,哪些存在風險,並針對性地進行管理。接下來,咱們就介紹美團 Category 管理相關的一些工做。

Category 方法管理

因爲歷史緣由,無論是什麼樣的管理規則,都沒法直接「一刀切」。因此針對現狀,咱們將整個管理環節先拆分爲「數據」、「場景」、 「策略」三部分。

其中數據層負責發現異常數據,全部策略公用一個數據層。針對 Category 方法的數據獲取,咱們有以下幾種方式:

根據優缺點的分析,再考慮到美團已經完全實現了「組件化」的工程,因此對 Category 的管控最好放在集成階段之後進行。咱們最終選擇了使用 linkmap 進行數據獲取,具體方法咱們將在下文進行介紹。

策略部分則針對不一樣的場景異常進行控制,主要的開發工做位於咱們的組件化 CI 系統上,即以前介紹過的 Hyperloop 系統。

Hyperloop 自己即提供了包括白名單,發佈集成流程管理等一系列策略功能,咱們只須要將工具進行關聯開發便可。咱們開發的數據層做爲一個獨立組件,最終也是運行在 Hyperloop 上。

根據場景細分的策略以下表所示(須要注意的是,表中有的場景實際不存在,只是爲了思考的嚴謹列出):

咱們在前文描述的 CategoryCoverOrigin 的組件通訊方案的管控體如今第2點。風險管控中提到的兩個案例的管控主要體如今第4點。

Category 數據獲取原理

上一章節,咱們提到了採用 linkmap 分析的方式進行 Category 數據獲取。在這一章節內,咱們詳細介紹下作法。

啓用 linkmap

首先,linkmap 生成功能是默認關閉的,咱們須要在 build settings 內手動打開開關並配置存儲路徑。對於美團工程和美團外賣工程來講,每次正式構建後產生的 linkmap,咱們還會經過內部的美團雲存儲工具進行持久化的存儲,保證後續的可追溯。

linkmap 組成

若要解析 linkmap,首先須要瞭解 linkmap 的組成。

如名稱所示,linkmap 文件生成於代碼連接以後,主要由4個部分組成:基本信息、Object files 表、Sections 表和 Symbols 表。

前兩行是基本信息,包括連接完成的二進制路徑和架構。若是一個工程內有多個最終產物(如 Watch App 或 Extension),則通過配置後,每個產物的每一種架構都會生成一份 linkmap。

# Path: /var/folders/tk/xmlx38_x605127f0fhhp_n1r0000gn/T/d20180828-59923-v4pjhg/output-sandbox/DerivedData/Build/Intermediates.noindex/ArchiveIntermediates/imeituan/InstallationBuildProductsLocation/Applications/imeituan.app/imeituan
# Arch: arm64
複製代碼

第二部分的 Object files,列舉了連接所用到的全部的目標文件,包括代碼編譯出來的,靜態連接庫內的和動態連接庫(如系統庫),而且給每個目標文件分配了一個 file id。

# Object files:
[  0] linker synthesized
[  1] dtrace
[  2] /var/folders/tk/xmlx38_x605127f0fhhp_n1r0000gn/T/d20180828-59923-v4pjhg/output-sandbox/DerivedData/Build/Intermediates.noindex/ArchiveIntermediates/imeituan/IntermediateBuildFilesPath/imeituan.build/DailyBuild-iphoneos/imeituan.build/Objects-normal/arm64/main.o
……
[ 26] /private/var/folders/tk/xmlx38_x605127f0fhhp_n1r0000gn/T/d20180828-59923-v4pjhg/repo-sandbox/imeituan/Pods/AFNetworking/bin/libAFNetworking.a(AFHTTPRequestOperation.o)
……
[25919] /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS11.3.sdk/usr/lib/libobjc.tbd
[25920] /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS11.3.sdk/usr/lib/libSystem.tbd
複製代碼

第三部分的 Sections,記錄了全部的 Section,以及它們所屬的 Segment 和大小等信息。

# Sections:
# Address Size Segment Section
0x100004450	0x07A8A8D0	__TEXT	__text
……
0x109EA52C0	0x002580A0	__DATA	__objc_data
0x10A0FD360	0x001D8570	__DATA	__data
0x10A2D58D0	0x0000B960	__DATA	__objc_k_kylin
……
0x10BFE4E5D	0x004CBE63	__RODATA	__objc_methname
0x10C4B0CC0	0x000D560B	__RODATA	__objc_classname
複製代碼

第四部分的 Symbols 是重頭戲,列舉了全部符號的信息,包括所屬的 object file、大小等。符號除了咱們關注的 OC 的方法、類名、協議名等,也包含 block、literal string 等,能夠供其餘需求分析進行使用。

# Symbols:
# Address Size File Name
0x1000045B8	0x00000060	[  2] ___llvm_gcov_writeout
0x100004618	0x00000028	[  2] ___llvm_gcov_flush
0x100004640	0x00000014	[  2] ___llvm_gcov_init
0x100004654	0x00000014	[  2] ___llvm_gcov_init.4
0x100004668	0x00000014	[  2] ___llvm_gcov_init.6
0x10000467C	0x0000015C	[  3] _main
……
0x10002F56C	0x00000028	[ 38] -[UIButton(_AFNetworking) af_imageRequestOperationForState:]
0x10002F594	0x0000002C	[ 38] -[UIButton(_AFNetworking) af_setImageRequestOperation:forState:]
0x10002F5C0	0x00000028	[ 38] -[UIButton(_AFNetworking) af_backgroundImageRequestOperationForState:]
0x10002F5E8	0x0000002C	[ 38] -[UIButton(_AFNetworking) af_setBackgroundImageRequestOperation:forState:]
0x10002F614	0x0000006C	[ 38] +[UIButton(AFNetworking) sharedImageCache]
0x10002F680	0x00000010	[ 38] +[UIButton(AFNetworking) setSharedImageCache:]
0x10002F690	0x00000084	[ 38] -[UIButton(AFNetworking) imageResponseSerializer]
……
複製代碼

linkmap 數據化

根據上文的分析,在理解了 linkmap 的格式後,經過簡單的文本分析便可提取數據。因爲美團內部 iOS 開發工具鏈統一採用 Ruby,因此 linkmap 分析也採用 Ruby 開發,整個解析器被封裝成一個 Ruby Gem。

具體實施上,處於通用性考慮,咱們的 linkmap 解析工具分爲解析、模型、解析器三層,每一層均可以單獨進行擴展。

對於 Category 分析器來講,link map parser 解析指定 linkmap,生成通用模型的實例。從實例中獲取 symbol 類,將名字中有「()」的符號過濾出來,即爲 Category 方法。

接下來只要按照方法名聚合,若是超過1個則確定有 Category 方法衝突的狀況。按照上一節中分析的場景,分析其具體衝突類型,提供結論輸出給 Hyperloop。

具體對外接口能夠直接參考咱們的工具測試用例。最後該 Gem 會直接被 Hyperloop 使用。

it 'should return a map with keys for method name and classify' do
    @parser = LinkmapParser::Parser.new
    @file_path = 'spec/fixtures/imeituan-LinkMap-normal-arm64.txt'
    @analyze_result_with_classification = @parser.parse @file_path

    expect(@analyze_result_with_classification.class).to eq(Hash)

    # Category 方法互相沖突
    symbol = @analyze_result_with_classification["-[NSDate isEqualToDateDay:]"]
    expect(symbol.class).to eq(Hash)
    expect(symbol[:type]).to eq([LinkmapParser::CategoryConflictType::CONFLICT])
    expect(symbol[:detail].class).to eq(Array)
    expect(symbol[:detail].count).to eq(3)

    # Category 方法覆蓋原方法
    symbol = @analyze_result_with_classification["-[UGCReviewManager setCommonConfig:]"]
    expect(symbol.class).to eq(Hash)
    expect(symbol[:type]).to eq([LinkmapParser::CategoryConflictType::REPLACE])
    expect(symbol[:detail].class).to eq(Array)
    expect(symbol[:detail].count).to eq(2)
  end
複製代碼

Category 方法管理總結

1. 風險管理

對於任何語法工具,都是有利有弊的。因此除了發掘它們在實際場景中的應用,也要時刻對它們可能帶來的風險保持警戒,並選擇合適的工具和時機來管理風險。

而 Xcode 自己提供了很多的工具和時機,能夠供咱們分析構建過程和產物。如果在平常工做中遇到一些坑,不妨從構建期工具的角度去考慮管理。好比本文內提到的 linkmap,不只能夠用於 Category 分析,還能夠用於二進制大小分析、組件信息管理等。投入必定資源在相關工具開發上,每每能夠得到事半功倍的效果。

2. 代碼規範

回到 Category 的使用,除了工具上的管控,咱們也有相應的代碼規範,從源頭管理風險。如咱們在規範中要求全部的 Category 方法都使用前綴,下降無心衝突的可能。而且咱們也計劃把「使用前綴」作成管控之一。

3. 後續規劃

1.覆蓋系統方法檢查
因爲目前在管控體系內暫時沒有引入系統符號表,因此沒法對覆蓋系統方法的行爲進行分析和攔截。咱們計劃後續和 Crash 分析系統打通符號表體系,提前發現對系統庫的不當覆蓋。

2.工具複用
當前的管控系統僅針對美團外賣和美團 App,將來計劃推廣到其餘 App。因爲有 Hyperloop,事情在技術上並無太大的難度。
從工具自己的角度看,咱們有計劃在合適的時機對數據層代碼進行開源,但願能對更多的開發有所幫助。

總結

在這篇文章中,咱們從具體的業務場景入手,總結了組件間調用的通用模型,並對經常使用的解耦方案進行了分析對比,最終選擇了目前最適合咱們業務場景的方案。即經過 Category 覆蓋的方式實現了依賴倒置,將構建時依賴延後到了運行時,達到咱們預期的解耦目標。同時針對該方案潛在的問題,經過 linkmap 工具管控的方式進行規避。

另外,咱們在模型設計時也提到,組件間解耦其實在 iOS 側有多種方案選擇。對於其餘的方案實踐,咱們也會陸續和你們分享。但願咱們的工做能對你們的 iOS 開發組件間解耦工做有所啓發。

做者簡介

尚先,美團資深工程師。2015年加入美團,目前做爲美團外賣 iOS 端平臺化虛擬小組組長,主要負責業務架構、持續集成和工程化相關工做。同時也是移動端領域新技術的愛好者,負責多項新技術在外賣業務落地中的難點攻關,目前我的擁有七項國家發明專利。

澤響,美團技術專家,2014年加入美團,前後負責過公司 iOS 持續集成體系建設,美團 iOS 端平臺業務,美團 iOS 端基礎業務等工做。目前做爲美團移動平臺架構平臺組 Team Leader,主要負責美團 App 平臺架構、組件化、研發流程優化和部分基礎設施建設,致力於提高平臺上全業務的研發效率與質量。

招聘信息

美團外賣長期招聘 iOS、Android、FE 高級/資深工程師和技術專家,Base 北京、上海、成都,歡迎有興趣的同窗投遞簡歷到 chenhang03@meituan.com

相關文章
相關標籤/搜索