随着业务不停地迭代,优酷 APP 用于分发视频资源的 UI 控件越写越多,也越来越复杂,并且同时相似相近的代码也非常多。仔细研究之后,发现是很多耦合导致的问题:
1)布局代码耦合数据模型,相似布局组件各自一套布局代码;
2)数据模型、UIView 继承关系太长,改动时牵一发而动全身,为保险计不得不自立门户;
3)依赖引入,一个组件在另一 bundle 下使用时将引入连串依赖。
有鉴于此,我们需要寻找一种能够进一步降低通用能力接入门槛,提升单个组件的开发效率;进一步降低组件与页面的耦合,建立各类组件的在不同页面的通用投放能力的架构。
我们先来看一份 ViewController 代码节选,ViewController 内实现 3 个 feature 分别是 A,B,C,并且这些稍微复杂的 feature 无法一次性单步完成(具体一点的话,可以联想成这是一些用户交互的 feature、网络请求等),在某一时机触发,接着在某回调完成余下操作,最终构成了一个完整的 feature。
@implementation ViewController - (void)viewDidLoad { [featureA step1]; [featureB step1]; [featureC step1];} - (void)callback_xxx { [featureA step2]; [featureB step2];} - (void)callback_yyy { [featureC step2];} @end
这是一种基本的代码组织形式,但是面临着两个痛点:
一是依赖爆炸问题,每接入一个 feature 就无可避免地引入一批依赖,当 feature 数量上去之后,光是 import 语句都好几十行;
二是代码分散问题,同一 feature 相关代码分散在各处 callback,复用到另一 ViewController 或者将其废弃下架都必须要求开发者对该 feature 每一步骤甚至每一行代码都极为熟悉。如何才能解决上述痛点是我们在做架构蓝图时的一个突破口。这时,试图把围绕 ViewContorller 的代码组织形式转变成围绕 feature 代码组织形式,那么就可得到下面 3 段代码节选:
@implementation FeatureA - (void)recvViewDidLoad { [self step1];} - (void)recvCallback_xxx { [self step2];} @end
@implementation FeatureB - (void)recvViewDidLoad { [self step1];} - (void)recvCallback_xxx { [self step2];} @end
@implementation FeatureC - (void)recvViewDidLoad { [self step1];} - (void)recvCallback_yyy { [self step2];} @end
不难发现,代码经过重新组织之后分散的问题已经迎刃而解。依赖爆炸的问题在单个 feature 上来看,多个依赖已收敛到 feature 内部,接入 feature 的时候依赖已从 N 个降至 1 个,只要使用得当的方式,也可把最后一个依赖也一并消除。
此时需要发挥一下我们的想象力,把每个 feature 想象成是一个电器,它们都配有统一规格的插头。ViewController 好比一个插线板,电器无论插在哪个板上也是可以工作的。推而广之,不仅 ViewController 是一块插线板,任意一个类也看看作为一块插线板,它们的功能业务逻辑依然以 feature 的模式来组织。插件化页面架构的基调就被确定了。
插件化是业内普遍使用的解耦方案之一,我们不约而同地朝着这一方向来对现架构的改造,同时结合优酷的实际情况,得出一套以模块化、插件化、数据 Key-Value 化为特点的页面架构框架。
1)模块化 – 业务实体进行模块化,模块与模块呈现一定的组织形式;
2)插件化 – 功能单元插件化,满足功能单元可组合、可拆解、可替换;
3)数据 Key-Value 化 – 极简数据组织形式,减除因数据模型引入的依赖。
我们结合优酷 APP 业务将 UI 元素从大到小进行模块的划分,依次是页面、抽屉、组件和坑位。组件由数个相同的坑位组合而成,同理,若干个组件组合成抽屉,若干个抽屉组成页面。
不同层级的模块都各自的功能单元,如下表:
大模块由若干个小模块组合而成,将这些大大小小模块用线段来连成一体,则可以得到一个庞大的树状结构,每个模块相当于树里面的个节点。功能单元则是跟这里的每个节点有着联系,将一个功能单元对应一个或多个插件。模块的功能单元代码由插件承载,模块内外的功能单元通过事件传递消息和数据,再加上 Key-Value 化数据存储,这样我们就可以得出这个架构的雏形,综合整理后得出四大核心 Manager:
1)ModuleManager 负责模块的生命周期和关系管理;
2)PluginManager 负责模块与插件的关系管理;
3)EventManager 负责模块内外,插件与插件之间的消息通信;
4)DataManager 负责模块的数据管理。
在此基础上,我们将常用的列表容器、UI 布局逻辑、埋点统计逻辑、网络请求逻辑、用户交互手势逻辑、路由跳转逻辑等通用逻辑进行抽象插件化改造,最终形成 4+N 的架构组成。
如何表示一个模块,是我们首要解决的问题。在现实世界中,我们用身份证 ID 来区分每一个人,同样地每个模块都应有唯一标识的 ID。模块 ID 在整个架构体系中属于核心中的核心,使用上也非常频繁,如数据的读取、消息的传递、实体之间的关联和绑定。我们用 Context 类的对象来表示一个模块,最简单的 Context 类有且仅有一个 ID 属性。在这里我们特别地定义和引入了 ModuleProtocol,如果其他一般类也遵守这个协议,那么我们就可以把这样的实例对象看作与该同一模块 ID 所表示的模块有所关联。
@protocol SCModuleProtocol <NSObject> // 注:SC 为代码的统一前缀,下同 @property (nonatomic, strong) NSString *scModule; /// 模块 Id,全局唯一 @end @interface SCContext : NSObject <SCModuleProtocol> @end
我们根据业务模块页面、抽屉、组件、坑位四级划分,分别制定 PageContext/CardContext/ComponentContext/ItemContext,同时在 Context 类内建立弱引用属性来方便各层级下不同模块之间的使用。归纳起来 Context 类两大作用:一是表示模块本身,二是模块关系的语法糖。 ModuleManager 负责模块的生命周期管理和模块的关系管理,包含注册模块、注销模块、查询模块的上下级模块等接口。
@interface SCModuleManager : NSObject + (instancetype)sharedInstance; - (void)registerModule:(NSString *)module supermodule:(NSString *)supermodule;/// 注册模块 - (void)unregisterModule:(NSString *)module; /// 注销模块 - (NSString *)querySupermodule:(NSString *)module; /// 查询父模块 - (NSArray<NSString *> *)querySubmodules:(NSString *)module; /// 查询子模块 @end
为了减除数据模型引入的依赖,采用了 Key-Value 存储方案,用字符串作 Key,并约定 Value 只使用基本数据类型( int/double/bool 等)、字符串( NSString )、集合类型( NSArray/NSMutableArray/NSDictionary/NSMutableDictionary )和其他系统提供的数据类型(NSValue 等),在数据的使用上弱化自定义数据模型(协议)的使用。
// 写入数据[[SCDataManager sharedInstance] setdata:propertyValue forKey:propertyKeymoduleId:moduleId]; // 读取数据[[SCDataManager sharedInstance] dataForKey:propertyKey moduleId:moduleId];
每个模块的数据都存放在数据中心内。数据中心为每个模块开辟一块独立的空间存放数据,这是保证不同模块数据不串扰又同时保证同一模块内数据共享。同一模块下只需字段名参数便可读写数据;不同模块下也只是多增加一项目标模块 ID 参数便可读取数据。即:
在数据中心使用上,必须注意的是:563513413,不管你是大牛还是小白都欢迎入驻
1)Key-Value 化存储目的是减除数据模型的依赖,应避免 Value 使用自定义类型,否则失去了 Key-Value 化本身的价值;
2)不是所有的数据都需要存放在数据中心,只将公开化数据放入数据中心,而私有化数据(如临时变量等)则不建议放入数据中心。
在数据中心的能力设计上,我们提供了:
1)提供强引用和弱引用两种存储方案,开发者按需使用;
2)安全的读写接口,对数据进行常规易错的类型检查、合法性检查等。
用 ViewController 来举例,在野蛮生长 iOS 开发时代,把列表逻辑、网络请求逻辑、 Navigationbar 逻辑等诸多功能单元都摊开在 ViewController 来实现。ViewController 实现个各式各样的协议,以至于 ViewController 的代码越来越臃肿。到了后来为这个问题,明确划定功能单元的边界,加入了各种 Manager,各功能单元逻辑实现在 Manager 内部,ViewController 只负责诸多 Manager 之间来回调度,臃肿的问题得以缓解。
日益丰富和复杂的业务逻辑下,只解决代码臃肿是不够的,还需解决灵活调用、代码复用的问题。在实际实践中,常常遇到下列问题:
1)功能单元接口设计变形,之间不时出现相互调用造成“你中有我,我中有你”的高度耦合,维护成本越来越高;
2)功能单元个性化定制引出继承链的问题:不同业务的子类太多,父类牵一发动全身,不好改也不敢改,补丁补上补;
3)功能单元复用成本高,复用一小块,依赖一大片,造成代码复用意愿低。接入方宁愿重写一遍或将相关代码 Copy&Rename 一遍。
功能单元插件化目标是进一步降低功能单元之间的耦合。插件化思路和原则需要保证上述问题得到有效解决。
1)轻量化接入。减少甚至消灭类与类,类与协议引用依赖;
2)插件可组合、可拆解、可替换,业务逻辑上下游相关方能做到无感知;
3)插件边界清晰,明确输入输出。
事件机制 - 更灵活的通信方式
事件机制采用“发布 - 订阅”设计模式,功能单元通过发布事件来驱动信息的流转,通过订阅事件来接收并处理信息。信息收发双方按事前约定的事件名进行通信,事件处理中枢负责事件的派发,因此收发双方不存在直接依赖。值得留意的是事件机制中的信息接收方可以是多个。 EventManager 担当起事件处理中枢的角色,发布者通过 EventManager 发布事件, EventManger 以订阅优先级从高到低把事件分发到订阅者。高优先级订阅者处理完事件后将返回值(如有)交给 EventManager,EventManager 将上一订阅者返回值(如有)和发布者入参一同分发到下一订阅者,如此往复直到所有订阅者处理完毕,此时 EventManager 将最终返回值(如有)输出给发布者。图示如下:
事件发布与事件订阅及处理的代码示例:
// 事件发布NSString *eventName = @"demoEvent";NSString *moduleId = ...;NSDictionary *params = @{...}; NSDictionary *response = [[SCEventManager sharedInstance] fireEvent:eventName module:moduleId params:params]; // 事件订阅、处理+ (NSArray *)scEventHandlerInfo{ return @[@{@“event": @"demoEvent", @"selector": @"receiveDemoEvent:", @"priority": @500}, ];}{1}- (void)receiveDemoEvent:(SCEvent *)event{ //do something ...
event.responseInfo = @{...}; // 返回值 (可选);}{1}
在插件中使用事件机制
我们把插件当作是事件机制用订阅者,同时允许在处理事件的实现中,发起一个新的事件。这样就可以使得插件与插件之间通过事件串联起来,合力地完成一项完整的业务逻辑。
在插件间的通信上,除了事件机制协议外,就只有事件名的依赖(事件参数中不推荐使用自定义数据类型,否则将重新引入显式依赖),事件名本身是一串字符串,这可以减少因调用引起的各种功能单元间头文件依赖。
用插件来承载业务逻辑的实现上具有非常灵活的特性,开发者可根据自己的判断来决定插件的规模,插件的粒度可大可小,插件内部实现也可随时中止使用事件机制并转回其他一般的类与类、类与协议机制来实现具体的业务逻辑。
在插件的使用上具有非常灵活的特性,因此我们约定插件边界必须清晰,必须做到单一职责原则,输入输出明确并足够简单,如果不满足以上条件,则表示该插件有拆解细分的可能性和必要。
插件与模块的结合
插件、功能单元和模块的关系有以下 4 点:
1)一个模块实例关联多个插件实例,但一个插件实例仅对应一个模块实例;
2)模块初始化时,完成全部所属插件的挂载,插件的生命周期与模块的生命周期基本同步,不允许中途某一时刻外挂或卸载某一插件;
3)单一模块内的一项业务功能,即一个功能单元,由一个或多个插件组成承载;
4)跨模块的一项业务功能,即一个跨模块功能单元,由分属多个模块的多个插件协同承载。
插件与模块之间的联系通过配置文件声明,每个模块在初始化之时,通过配置文件的记载,把与之关联的插件进行初始化和绑定,插件订阅具体事件并开始运作事件机制,直到模块被注销,插件取消订阅所有事件并结束生命周期。
本章节用图来说明如何使用插件化来编写一个按钮功能。一个页面上有一个按钮并支持点击跳转。
我们将这个功能看作一个单元整体简单地用一个插件实现:
1)在 ViewController 初始化的时候进行模块注册,通过一系列 Manager 初始化 ButtonPlugin;
2)在 ButtonPlugin 内收敛所有 Button 相关逻辑,ViewController 不会直接出现与 Button 有关的代码;
3)ViewController 发送 ViewDIDLoad 事件来驱动其他插件工作;
4)ButtonPlugin 接收 ViewDIDLoad 事件,进行初始化、添加到 ViewController 等操作,当用户点击屏幕时,自行处理 Tap 操作。
按钮的点击会涉及到统计和跳转两部分逻辑,所以 ButtonPlugin 实际上可拆出为另外 2 个插件来分别实现其逻辑。
我们可以看见点击行为拆分为跳转和统计 2 个插件后,插件的职责更加单一,可复用性大大得到了提升。若遇到产品提出新的点击需求,如跳转前必须检查是否登录状态,未登录者需要先登录再继续后续的操作。那么我们在现有基础上只需要多增加一个 LoginCheckPlugin 来处理这些逻辑并且不需要修改原有 plugin 代码,这也是插件化其中的一个优势。