随着业务不停地迭代,优酷 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 元素从大到小进行模块的划分,依次是页面、抽屉、组件和坑位。组件由数个相同的坑位组合而成,同理,若干个组件组合成抽屉,若干个抽屉组成页面。
不同层级的模块都各自的功能单元,如下表:
模块层级 | 功能单元 |
---|---|
父页面 | 页卡容器、埋点统计(PV) |
页面 | NavigationBar列表容器(CollectionView/TableView)上下拉刷新提示面板(空数据、网络异常)页面级网络数据请求页面级数据缓存埋点统计(PV) |
抽屉 | 列表容器抽屉级布局管理(平铺、多 Tab 翻页抽屉级网络数据请求 |
组件 | 列表容器组件级布局管理(多行多列平铺、瀑布流、横滑、轮播)组件级网络数据请求 |
坑位 | UI 单元(即具体的、局部的 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:propertyKey
moduleId:moduleId];
//读取数据
[[SCDataManager sharedInstance] dataForKey:propertyKey moduleId:moduleId];
每个模块的数据都存放在数据中心内。数据中心为每个模块开辟一块独立的空间存放数据,这是保证不同模块数据不串扰又同时保证同一模块内数据共享。同一模块下只需字段名参数便可读写数据;不同模块下也只是多增加一项目标模块 ID 参数便可读取数据。即:
在数据中心使用上,必须注意的是:
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},
];
}
- (void)receiveDemoEvent:(SCEvent *)event
{
//do something
...
event.responseInfo = @{...}; //返回值(可选);
}
我们把插件当作是事件机制用订阅者,同时允许在处理事件的实现中,发起一个新的事件。这样就可以使得插件与插件之间通过事件串联起来,合力地完成一项完整的业务逻辑。
在插件间的通信上,除了事件机制协议外,就只有事件名的依赖(事件参数中不推荐使用自定义数据类型,否则将重新引入显式依赖),事件名本身是一串字符串,这可以减少因调用引起的各种功能单元间头文件依赖。
用插件来承载业务逻辑的实现上具有非常灵活的特性,开发者可根据自己的判断来决定插件的规模,插件的粒度可大可小,插件内部实现也可随时中止使用事件机制并转回其他一般的类与类、类与协议机制来实现具体的业务逻辑。
在插件的使用上具有非常灵活的特性,因此我们约定插件边界必须清晰,必须做到单一职责原则,输入输出明确并足够简单,如果不满足以上条件,则表示该插件有拆解细分的可能性和必要。
插件、功能单元和模块的关系有以下 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 代码,这也是插件化其中的一个优势。
只有合适的架构,没有最好的架构。插件化页面架构有利也有弊,它颠覆了 MVC 架构的开发体验,增加了开发者学习成本,编译器也无法帮助开发者编译时(事件名错配等)校验。因此,我们充分发挥它的面向切面编程能力,在开发过程中,我们通过插件的形式加入调试类和监控类逻辑来缓解架构的不足,另一方面则建立标准化插件管理平台对所有插件进行系统化管理。与此同时,标准化事件的开发方式使得存在统一的逻辑收口,极大地方便了代码调试、线上问题定位等工具的建设。
优酷 APP 主要场景已接入插件化页面架构,包括首页、热点、会员、个人中心、搜索、播放页等六大板块。沉淀了 CollectionView、网络请求、手势处理、路由跳转、埋点统计等各系列系统性插件。
在搭建新页面时,将上述各系列插件通过以配置加调参的形式即可快速接入和实现已有功能。同时也得益于越来越完善的列表布局插件,使得在开发如横滑、瀑布流、轮播等复杂布局组件与开发平铺组件时效一致。据粗略的测算,组件的开发效率提升了 30%以上。同时通过统一的配置格式使得客户端具备组件跨页面、跨板块投放能力,打破了 framework 间的依赖界限。插件化页面架构是一个很好的起点,我们将会持续地完善和深挖它的能力,最终让其更稳定且高效地支撑业务发展。
作者 | 阿里文娱高级无线开发工程师 未熙
领取专属 10元无门槛券
私享最新 技术干货