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所示,可以看出上圖的四種方式或多或少都存在一些缺點:

  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,看這個文件的17、18行業務邏輯是使用常規 OC 接口調用。在運行時此Category的方法會覆蓋 WMScheduler.h 基類中的同名方法,從而達到目的。CategoryCoverOrigin 方式不需要其他功能類的支撐。

延伸思考:

如果業務庫很多,方法很多,會不會出現 WMScheduler.h 爆炸? 目前我們的工程跨組件調用的實際場景不是很多,所以彙總在一個文件了,如果滿屏都是跨組件調用的工程,則需要思考業務架構與模塊劃分是否合理這一問題。當然,如果真出現 WMScheduler.h 爆炸的情況,完全可以將各個業務的接口移至自己Category 的.h文件中,然後創建一個 WMSchedulerInterfaceList 文件統一 import 這些 Category。

兩種方案的選擇

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

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

筆者更建議使用 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 北京、上海、成都,歡迎有興趣的同學投遞簡歷到 [email protected]

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章