1. 程式人生 > >Category 特性在 iOS 元件化中的應用與管控

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