Category 特性在 iOS 组件化中的应用与管控
背景
iOS Category功能簡介
Category 是 Objective-C 2.0之后添加的語言特性。
Category 就是對裝飾模式的一種具體實現。它的主要作用是在不改變原有類的前提下,動態地給這個類添加一些方法。在 Objective-C(iOS 的開發語言,下文用 OC 代替)中的具體體現為:實例(類)方法、屬性和協議。
除了引用中提到的添加方法,Category 還有很多優勢,比如將一個類的實現拆分開放在不同的文件內,以及可以聲明私有方法,甚至可以模擬多繼承等操作,具體可參考官方文檔Category。
若 Category 添加的方法是基類已經存在的,則會覆蓋基類的同名方法。本文將要提到的組件間通信都是基于這個特性實現的,在本文的最后則會提到對覆蓋風險的管控。
組件通信的背景
隨著移動互聯網的快速發展,不斷迭代的移動端工程往往面臨著耦合嚴重、維護效率低、開發不夠敏捷等常見問題,因此越來越多的公司開始推行“組件化”,通過解耦重組組件來提高并行開發效率。
但是大多數團隊口中的“組件化”就是把代碼分庫,主工程使用 CocoaPods 工具把各個子庫的版本號聚合起來。但能合理的把組件分層,并且有一整套工具鏈支撐發版與集成的公司較少,導致開發效率很難有明顯地提升。
處理好各個組件之間的通信與解耦一直都是組件化的難點。諸如組件之間的 Podfile 相互顯式依賴,以及各種聯合發版等問題,若處理不當可能會引發“災難”性的后果。
目前做到 ViewController (指iOS中的頁面,下文用VC代替)級別解耦的團隊較多,維護一套 mapping 關系并使用 scheme 進行跳轉,但是目前仍然無法做到更細粒度的解耦通信,依然滿足不了部分業務的需求。
實際業務案例
例1:外賣的首頁的商家列表(WMPageKit),在進入一個商家(WMRestaurantKit)選擇5件商品返回到首頁的時候,對應的商家cell需要顯示已選商品“5”。
例2:搜索結果(WMSearchKit)跳轉到商超的容器頁(WMSupermarketKit),需要傳遞一個通用Domain(也有的說法叫模型、Model、Entity、Object等等,下文統一用Domain表示)。
例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所示,可以看出上圖的四種方式或多或少都存在一些缺點:
綜合看來 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 // ... @endAKit(提供方)一個 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 @endBKit(調用方) 一個文件寫法不變:
// 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,看這個文件的17、18行業務邏輯是使用常規 OC 接口調用。在運行時此Category的方法會覆蓋 WMScheduler.h 基類中的同名方法,從而達到目的。CategoryCoverOrigin 方式不需要其他功能類的支撐。
延伸思考:
如果業務庫很多,方法很多,會不會出現 WMScheduler.h 爆炸? 目前我們的工程跨組件調用的實際場景不是很多,所以匯總在一個文件了,如果滿屏都是跨組件調用的工程,則需要思考業務架構與模塊劃分是否合理這一問題。當然,如果真出現 WMScheduler.h 爆炸的情況,完全可以將各個業務的接口移至自己Category 的.h文件中,然后創建一個 WMSchedulerInterfaceList 文件統一 import 這些 Category。
兩種方案的選擇
剛才我們對于 Category+NSInvocation 和 CategoryCoverOrigin 兩種方式都做了詳細的介紹,我們再整理一下兩者的優缺點對比:
| 優點 | 只改兩個倉庫,流程上的時間成本更少 可以實現url調用方法 (scheme://target/method:?para=x) | 無任何硬編碼,常規OC接口調用 除了接口聲明、分類覆蓋、調用,沒有其他多余代碼 不存在下層調用上層的場景 |
| 缺點 | 功能復雜時硬編碼寫法成本較大 下層調上層,上層業務改變時會影響平臺接口 | 不能使用url調用方法 新增接口時需改動三個倉庫,稍有麻煩。 (當接口已存在時,兩種方式都只需修改一處) |
筆者更建議使用 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_pathexpect(@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)endCategory 方法管理總結
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。
總結
以上是生活随笔為你收集整理的Category 特性在 iOS 组件化中的应用与管控的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 机器学习从理论到工程的第一步-编程语言篇
- 下一篇: 小夕的算法入门之路