新网站建设银行提升转账额度,小程序自助搭建平台,电商设计教程,旅游建设网站目的及功能定位背景 iOS Category功能简介 Category 是 Objective-C 2.0之后添加的语言特性。 Category 就是对装饰模式的一种具体实现。它的主要作用是在不改变原有类的前提下#xff0c;动态地给这个类添加一些方法。在 Objective-C#xff08;iOS 的开发语言#xff0c;下文用 OC 代替动态地给这个类添加一些方法。在 Objective-CiOS 的开发语言下文用 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机制的范围图很大但使用了反射并且代码开发成本较高实践上来看对协议管理有一定要求。通知中心看上去挺方便但发送与接收大多成对出现还附带绑定方法或者Block代码量并不少。而msgsend功能强大代码量也少但是在规范约束和可读性上几乎为零。综合看来 SPI 和 objc_msgSend 两者的特点比较明显很有潜力如果针对这两种方案分别进行一定程度的完善应该可以实现一个综合评分更高的方案。 从现有方案中完善或衍生出的方案 5.使用CategoryNSInvocation 此方案从 objc_msgSend 演化而来。NSInvocation 的调用方式的底层还是会使用到 objc_msgSend但是通过一些方法签名和返回值类型校验可以解决很多类型规范相关的问题并且这种方式没有繁琐的注册步骤任何一次新接口的添加都可以直接在低层的库中进行完成。 为了更进一步限制调用者能够调用的接口创建一些 Category 来提供接口内部包装下层接口把返回值和入参都限制实际的类型。业界比较接近的例子有 casatwy 的 CTMediator。 6.原生CategoryCoverOrigin方式 此方案从 SPI 方式演化而来。两个的共同点是都在平台层提供接口供业务方调用不同点是此方式完全规避了各种硬编码。而且 CategoryCoverOrigin 是一个思想没有任何框架代码可以说 OC 的 Runtime 就是这个方案的框架支撑。此方案的核心操作是在基类里汇总所有业务接口在上层的业务库中创建基类的 Category 中对声明的接口进行覆盖。整个过程没有任何硬编码与反射。 演化出的这两种方案能力评估如下绿色部分图中也贴了和演化前方案桔色部分的对比 上文对这两种方案描述的非常概括可能有同学会对能力评估存在质疑。接下来会分别进行详解的介绍并描述在实际操作值得注意的细节。这两种方案组合成了外卖内部的组件通信框架 WMScheduler。 WMScheduler组件通信 外卖的 WMScheduler 主要是通过对 Category 特性的运用来实现组件间通信实际操作中有两种的应用方案CategoryNSInvocation 和 Category CoverOrigin。 1.CategoryNSInvocation方案 方案简介 这个方案将其对 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库方法”的需求只需要改两个仓库的代码需要改动的文件标了下划线请仔细看下示例代码。 示例代码 平台通用功能库三个文件 ① // WMSchedulerAKit.h
#import WMScheduler.h
interface WMScheduler(AKit)
/*** 通过商家id查到当前购物车已选e的小红点数量* param poiid 商家id* return 实际的小红点数量*/(NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID;
end② // WMSchedulerAKit.m
#import WMSchedulerCore.h
#import WMSchedulerAKit.h
#import NSObjectWMScheduler.h
implementation WMScheduler (AKit)(NSUInteger)wms_getOrderedFoodCountWithPoiID:(NSNumber *)poiID{if (nil poiid) {return 0;}
#pragma clang diagnostic push
#pragma clang diagnostic ignored -Wundeclared-selectorid 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 WMSchedulerAKit.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代码分析 上文四个文件完成了一次跨组件的调用在 WMSchedulerAKit.m 中的第30、31行调用的都是AKit提供方的现有方法因为 WMSchedulerCore 提供了 NSInvocation 的调用方式所以可以直接向上调用。WMSchedulerAKit 中提供的接口就是上文说的“规范接口”这个接口在WMHomeVC调用方调用时和调用本仓库内的OC方法并没有区别。 延伸思考 上文的例子中入参和返回值都是基本数据类型Domain 也是支持的前提是这个 Domain 是放在平台库的。我们可以将工程中的 Domain 分为BOBusiness Object、VOView Object与TOTransfer ObjectVO 经常出现在 view 和 cellBO一般仅在各业务子库内部使用这个TO则是需要放在平台库是用于各个组件间的通信的通用模型。例如通用 PoiDomain通用 OrderDomain通用 AddressDomain 等等。这些称为 TO 的 Domain 可以作为规范接口的入参类型或返回值类型。在实际业务场景中跳转页面时传递 Domain 的需求也是一个老生常谈的问题大多数页面级跳转框架仅支持传递基本数据类型也有 trick 的方式传 Domain 内存地址但很不优雅。在有了上文支持的能力我们可以在规范接口内通过万能接口获取目标页面的VC并调用其某个属性的 set 方法将我们想传递的Domain赋值过去然后将这个 VC 对象作为返回值返回。调用方获得这个 VC 后在当前的导航栈内push即可。上文代码中我们用 WMScheduler 调用了 Akit 的一个名为calculateOrderedFoodCount WithPoiID的方法。那么有个争议点在组件通信需要调用某方法时是允许直接调用现有方法还是复制一份加上前缀标注此方法专门用于提供组件通信 前者的问题点在于现有方法可能会被修改扩充参数会直接导致调用方找不到方法Method 字符串的不会编译报错上文平台代码 WMSchedulerAKit.m 中第31行。后者的问题在于大大增加了开发成本。权衡后我们还是使用了前者加了些特殊处理若现有方法被修改了则会在isReponseForSelector这里检查出来并走到 else 的断言及时发现。阶段总结 CategoryNSInvocation 方案的优点是便捷因为 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 文件 // WMSchedulerAKit.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文件中编写了空实现。功能代码编写在服务提供方仓库的 WMSchedulerAKit.m看这个文件的17、18行业务逻辑是使用常规 OC 接口调用。在运行时此Category的方法会覆盖 WMScheduler.h 基类中的同名方法从而达到目的。CategoryCoverOrigin 方式不需要其他功能类的支撑。 延伸思考 如果业务库很多方法很多会不会出现 WMScheduler.h 爆炸 目前我们的工程跨组件调用的实际场景不是很多所以汇总在一个文件了如果满屏都是跨组件调用的工程则需要思考业务架构与模块划分是否合理这一问题。当然如果真出现 WMScheduler.h 爆炸的情况完全可以将各个业务的接口移至自己Category 的.h文件中然后创建一个 WMSchedulerInterfaceList 文件统一 import 这些 Category。 两种方案的选择 刚才我们对于 CategoryNSInvocation 和 CategoryCoverOrigin 两种方式都做了详细的介绍我们再整理一下两者的优缺点对比 CategoryNSInvocationCategoryCover优点只改两个仓库流程上的时间成本更少可以实现url调用方法scheme://target/method:?parax无任何硬编码常规OC接口调用除了接口声明、分类覆盖、调用没有其他多余代码不存在下层调用上层的场景缺点功能复杂时硬编码写法成本较大下层调上层上层业务改变时会影响平台接口不能使用url调用方法新增接口时需改动三个仓库稍有麻烦。当接口已存在时两种方式都只需修改一处笔者更建议使用 CategoryCoverOrigin 的无硬编码的方案当然具体也要看项目的实际场景从而做出最优的选择。 更多建议 关于组件对外提供的接口我们更倾向于借鉴 SPI 的思想作为一个 Kit 哪些功能是需要对外公开的提供哪些服务给其他方解耦调用建议主动开放核心方法尽量减少“用到才补”的场景。例如全局购物车就需要“提供获取小红点数量的方法”商家中心就需要提供“根据字符串 id 得到整个 Poi 的 Domain”的接口服务。需要考虑到抽象能力提供更有泛用性的接口。比如“获取到了最低满减价格后拼接成一个文案返回字符串” 这个方法就没有“获取到了最低满减价格” 这个方法具备泛用性。Category 风险管控 先举两个发生过的案例 1. 2017年10月 一个关于NSDate重复覆盖的问题 当时美团平台有 NSDateMTAddition 类在外卖侧有 NSDateWMAddition 类。前者 NSDateMTAddition 之前就有方法 getCurrentTimestamp返回的时间戳是秒。后者 NSDateWMAddition 在一次需求中也增加了 getCurrentTimestamp 方法但是为了和其他平台统一口径返回值使用了毫秒。在正常的加载顺序中外卖类比平台类要晚因此在外卖的测试中没有发现问题。但集成到 imeituan 主项目之后原先其他业务方调用这个返回“秒”的方法就被外卖测的返回“毫秒”的同名方法给覆盖了出现接口错误和UI错乱等问题。 2. 2018年3月 一个WMScheduler组件通信遇到的问题 在外卖侧有订单组件和商家容器组件这两个组件的联系是十分紧密的有的功能放在两个仓库任意一个中都说的通。因此出现了了两个仓库写了同名方法的场景。在 WMSchedulerRestaurant 和 WMSchedulerOrder 两个仓库都添加了方法 -(void)wms_enterGlobalCartPageFromPage:在运行中这两处有一处被覆盖。在有一次 Bug 解决中给其中一处增加了异常处理的代码恰巧增加的这处先加载就被后加载的同名方法覆盖了这就导致了异常处理代码不生效的问题。 那么使用 CategoryCover 的方式是不是很不安全 NO只要弄清其中的规律风险点都是完全可以管控的接下来我们来分析 Category 的覆盖原理。 Category 方法覆盖原理 1) Category 的方法没有“完全替换掉”原来类已经有的方法也就是说如果 Category 和原来类都有methodA那么 Category 附加完成之后类的方法列表里会有两个 methodA。 2) Category 方法被放到了新方法列表的前面而原来类的方法被放到了新方法列表的后面这也就是我们平常所说的 Category 的方法会“覆盖”掉原来类的同名方法这是因为运行过程中我们在查找方法的时候会顺着方法列表的顺序去查找它只要一找到对应名字的方法就会罢休^_^殊不知后面可能还有一样名字的方法。 Category 在运行期进行决议而基类的类是在编译期进行决议因此分类中方法的加载顺序一定在基类之后。 美团曾经有一篇技术博客深入分析了 Category并且从编译器和源码的角度对分类覆盖操作进行详细解析深入理解Objective-CCategory 根据方法覆盖的原理我们可以分析出哪些操作比较安全哪些存在风险并针对性地进行管理。接下来我们就介绍美团 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 doparser LinkmapParser::Parser.newfile_path spec/fixtures/imeituan-LinkMap-normal-arm64.txtanalyze_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 北京、上海、成都欢迎有兴趣的同学投递简历到 chenhang03meituan.com。