"拆解不同的页面元素为组件,通过组件组合的方式构建页面"
在版本迭代过程中,随着功能越来越丰富,代码也会越来越多。面对一个“巨无霸”页面,我们如何拆解?拆解后如何协作、如何通信?
本文介绍一种使用组件化方案构建复杂页面的设计思路,以及快手如何应用这个思路重构个人中心页面的实例。
背景介绍
随着业务的发展,项目中的一些核心页面会变得越来越庞大。过大的类本身就散发着坏的代码味道,大量的代码挤在一起,众多复杂的逻辑相互交织,开发和维护变得愈发困难。如果不同业务线同时修改同一个复杂页面,会带来大量的冲突和众多if...else判断。
当一个ViewController变成拥有几千行的庞然大物的时候,在开发和迭代过程中,常常会遇到如下的一些困难:
- 类过大,修复bug不容易定位问题
- 内部逻辑相互依赖,互相关联,新增需求可能破坏原有功能
- 页面样式复杂,且有依赖关系
- 不同的业务操作可能会操作同一个View,容易出现展示错误
这样的页面就好像一个大抽屉,打开之后堆满了各种代码。我们下面要做的,就是利用一些“收纳盒”,把有关联的东西都放在一个个小盒子里。
定义组件
组件是一个个独立的,可复用的部件。对外,组件提供一个绘制好的view;对内,组件管理自己内部的页面元素和业务逻辑。通过添加子组件的操作,组件之间被组织起来,形成一棵组件树。之后我们便可以通过这棵组件树做内部消息的传递。
可以把组件定义成协议,这样,无论是View,ViewController,还是NSObject,都可以通过实现协议,变成组件。定义如下
@protocol Component <NSObject>
@property (nonatomic, readonly) UIView *view;
@property (nonatomic, weak) id<Component> superComponent;
@property (nonatomic, strong) NSMutableArray<id<Component>> *subComponents;
- (void)addComponent:(id<Component>)component;
- (void)removeComponent:(id<Component>)component;
- (void)removeFromSuperComponent;
“各家自扫门前雪”,组件只专注于自己这一块视图的绘制,当然,它也可以通过添加子组件的方式,将自己视图内的一部分区域“外包”给别的组件管理。
如何拆解和形成组件树
view本身有一个树状的层级结构,当其中的一些view是由组件提供出来的时候,这些组件便形成了组件树。
拆解的过程遵循自上而下,化整为零的原则。分析页面元素之间的关系,将相对集中的元素合并在一起,形成组件。拆解的过程中也要遵循适度原则:组件不能太大,对于过大的组件,可以在迭代开发中逐渐拆解;组件也不适宜太小,琐碎或者层级过深的结构都不利于代码的阅读和理解,会增加未来维护的成本。
这里有个问题,在使用组件的时候,如果既要添加组件的view,比如
[self addSubview:component.view]
又要操作组件的父子关系,比如
[self addComponent:component]
就显得有些啰嗦。这里,我们通过重写view的一些生命周期方法,在组件的view被添加的同时,自动构建起组件的父子关系。
例如
- (void)willMoveToSuperview:(UIView *)newSuperview {
[super willMoveToSuperview:newSuperview];
id<Component> component = self.component;
if (!component) {
return;
}
if (newSuperview) {
[newSuperview.component addComponent:component];
} else {
[component removeFromSuperComponent];
}
}
相似的,didMoveToSuperview,didMoveToWindow也有一些组件父子关系自动构建的方法,这里就不一一列举了。这样,在使用组件的时候,只需要添加组件的view,就可以自动构建出组件树的层级结构了。
如何通信
还是那个大抽屉的比喻,当所有东西都放在一起的时候,虽然杂乱了一些,但是彼此的访问却非常顺畅:需要用到什么状态,什么方法,直接调用就好了。拆解成组件之后,组件之间就增加了通信的成本。下面是几种组件间通信方式
父子组件
使用直接通信的方式。父组件持有并使用子组件的视图,所以父组件知道子组件的类型,可以通过子组件的构造函数,设置属性或者调用方法,直接传递消息给子组件。子组件虽然不知道自己父组件的具体类型,但可以通过block或者delegate的方式,将自己内部的消息转发给使用自己的父组件。
跨层级通信
父组件 => 子组件 => ... => 子组件
如果按照上面父子组件通信方式层层传递,比较繁琐,胶水代码也较多。但是如果放开通信限制,允许任意组件之间进行网状通信,工程的复杂度会随着组件数量的增加,爆炸性增长。因此,我们希望提供一种单向的,有明确数据类型的状态同步机制。
本次实践借鉴了ContextProviderConsumer的模式,即组件树上的某一个节点作为状态的提供者(Provider),它子树上的组件,可以作为消费者(Consumer)去注册监听这个提供者状态的变化,当状态发生变化的时候,消费者可以收到消息。
概括来说
- Provider 提供共享状态,负责更新状态
- Consumer 监听Provider状态的变化,对共享状态只读
下面是举一个传递用户信息的Provider和Consumer的例子
@protocol UserProfileProvider <NSObject>
@property (nonatomic, strong) UserProfile *userProfile;
@property (nonatomic, assign) BOOL isMyProfile;
- (void)updateFollowerCount:(NSUInteger)followerCount;
@end
@protocol UserProfileConsumer <NSObject>
@property (nonatomic, weak) id<UserProfileProvider> userProfileProvider;
@optional
- (void)userProfileDidUpdate:(NSDictionary<NSKeyValueChangeKey, id> *)change;
- (void)isMyProfileDidUpdate:(NSDictionary<NSKeyValueChangeKey, id> *)change;
@end
有了协议声明,那如何建立起来状态变化的监听呢?在具体实现上,我们采用了kvo的方式,即在构建组件树的同时,runtime去判断这个组件是否是某一Context的Provider或者Consumer。如果判断成功,则建立相应的kvo监听。这样,在Provider组件修改自身某一状态的时候,监听它的Consumer便可以收到状态变化的消息。
如何协作
对于更复杂的,需要组件间联动来完成某一功能的需求,比如点击一个按钮,带来页面内不同层级的几个组件的UI变化。可以通过上面介绍的ContextProviderConsumer模式,设计一个状态,当子组件的按钮被点击之后,发送消息给Provider,Provider更改状态,之后所有Consumer收到状态变化的消息,自己处理自身的变化。
具体实例
快手iOS客户端的个人中心页,就是这样一个复杂的页面。包含了游戏、商业化、社交链、课程等众多功能入口,同时拥有作品,说说,私密,收藏,喜欢和音乐六大Tab,在
很多地方又需要承担ab测试的分支样式和逻辑。
随着新需求的不断增加,个人中心页变成了一个几千行的大类。重构过程运用了上面介绍的组件化方案。大体上,页面主要被分解为导航组件和列表组件,列表组件又包含了背景图组件,用户信息组件以及各个Tab组件。
具体拆解如下图
在实践过程中,页面的组件树上可能存在多个Context。快手个人中心页重构过程中,就建立了用户信息,Table滑动位置,音乐,说说等多个状态共享通道。另外,根组件通常承担了状态提供者的角色,也承担了较多业务逻辑。
总结
- 通过页面元素组件化的方式,可以有效的拆解复杂页面,降低耦合
- 封装组件树的构建过程,在添加组件view的同时,在内部构建了父子关系
- 利用组件的树状结构,借助ContextProviderConsumer做跨层级的组件通信