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),高层次的模块不应该依赖于低层次的模块,两者(的实现)都应该依赖于抽象接口。推广到组件间的关系处理,对于组件间的调用和被调用方,从本质上来说,我们也需要尽量避免它们的直接依赖,而希望它们依赖一个公共的抽象层,通过架构工具来管理和使用这个抽象层。这样我们就可以在解除组件间在构建时不必要的依赖,从而优雅地实现组件间的通讯。

 

图1-1 模型设计

业界现有方案的几大方向

实践依赖倒置原则的方案有很多,在 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");

运行结果显示如下:

 

 

图1-2 性能消耗检测

可以看出原生的接口调用明显是最高效的用法,反射的时长比原生要多一个数量级,不过100W次也就是多了几十毫秒,还在可以接受的范围之内。通知发送相比之下性能就很低了,存取注入对象更低。

当然除了性能消耗外,还有很多不好量化的维度,包括规范约束、功能性、代码量、可读性等,笔者按照实际场景客观评价给出对比的分值。

下面,我们用五种维度的能力值图来对比每一种方案优缺点:

  • 各维度的的评分考虑到了一定的实际场景,可能和常规结果稍有偏差。

  • 已经做了转化,看图面积越大越优。可读性的维度越长代表可读性越高,代码量的维度越长代表代码成本越少。

 

图2-1 各方案优缺点对比

如图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 中对声明的接口进行覆盖。整个过程没有任何硬编码与反射。

演化出的这两种方案能力评估如下(绿色部分),图中也贴了和演化前方案(桔色部分)的对比:

 

图2-2 两种演化方案对比

上文对这两种方案描述的非常概括,可能有同学会对能力评估存在质疑。接下来会分别进行详解的介绍,并描述在实际操作值得注意的细节。这两种方案组合成了外卖内部的组件通信框架 WMScheduler。

WMScheduler组件通信

外卖的 WMScheduler 主要是通过对 Category 特性的运用来实现组件间通信,实际操作中有两种的应用方案:Category+NSInvocation 和 Category CoverOrigin。

1.Category+NSInvocation方案

方案简介:

这个方案将其对 NSInvocation 功能容错封装、参数判断、类型转换的代码写在下层,提供简易万能的接口。并在上层创建通信调度器类提供常用接口,在调度器的的 Category 里扩展特定业务的专用接口。所有的上层接口均有规范约束,这些规范接口的内部会调用下层的简易万能接口即可通过NSInvocation 相关的硬编码操作调用任何方法。

UML图:

 

图3-1 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 Category+NSInvocation的示例图

如图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 CategoryCover 的 UML 图

从图4-1可以看出,WMScheduler 的 Category 被移到了业务仓库,并且 WMScheduler 中有所有接口的全集。

为了更好地理解 CategoryCover 实际应用,笔者再贴一个此方案下的完整完整代码:

 

图4-2 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
优点 只改两个仓库,流程上的时间成本更少
可以实现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 上。

 

图5-2 方法管理环节

根据场景细分的策略如下表所示(需要注意的是,表中有的场景实际不存在,只是为了思考的严谨列出):

我们在前文描述的 CategoryCoverOrigin 的组件通信方案的管控体现在第2点。风险管控中提到的两个案例的管控主要体现在第4点。

Category 数据获取原理

上一章节,我们提到了采用 linkmap 分析的方式进行 Category 数据获取。在这一章节内,我们详细介绍下做法。

启用 linkmap

首先,linkmap 生成功能是默认关闭的,我们需要在 build settings 内手动打开开关并配置存储路径。对于美团工程和美团外卖工程来说,每次正式构建后产生的 linkmap,我们还会通过内部的美团云存储工具进行持久化的存储,保证后续的可追溯。

 

图6 启用 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 解析工具分为解析、模型、解析器三层,每一层都可以单独进行扩展。

 

图7 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。

发现文章有错误、对内容有疑问,都可以关注美团技术团队微信公众号(meituantech),在后台给我们留言。我们每周会挑选出一位热心小伙伴,送上一份精美的小礼品。快来扫码关注我们吧!

posted on 2018-11-09 14:14  美团技术团队  阅读(924)  评论(0编辑  收藏  举报

导航