复杂页面如何拆解?——页面元素组件化方案

2018-12-26  本文已影响0人  kuaishou

"拆解不同的页面元素为组件,通过组件组合的方式构建页面"

在版本迭代过程中,随着功能越来越丰富,代码也会越来越多。面对一个“巨无霸”页面,我们如何拆解?拆解后如何协作、如何通信?
本文介绍一种使用组件化方案构建复杂页面的设计思路,以及快手如何应用这个思路重构个人中心页面的实例。

背景介绍

随着业务的发展,项目中的一些核心页面会变得越来越庞大。过大的类本身就散发着坏的代码味道,大量的代码挤在一起,众多复杂的逻辑相互交织,开发和维护变得愈发困难。如果不同业务线同时修改同一个复杂页面,会带来大量的冲突和众多if...else判断。

当一个ViewController变成拥有几千行的庞然大物的时候,在开发和迭代过程中,常常会遇到如下的一些困难:

这样的页面就好像一个大抽屉,打开之后堆满了各种代码。我们下面要做的,就是利用一些“收纳盒”,把有关联的东西都放在一个个小盒子里。

定义组件

组件是一个个独立的,可复用的部件。对外,组件提供一个绘制好的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的例子

@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

provider & consumer

有了协议声明,那如何建立起来状态变化的监听呢?在具体实现上,我们采用了kvo的方式,即在构建组件树的同时,runtime去判断这个组件是否是某一Context的Provider或者Consumer。如果判断成功,则建立相应的kvo监听。这样,在Provider组件修改自身某一状态的时候,监听它的Consumer便可以收到状态变化的消息。

如何协作

对于更复杂的,需要组件间联动来完成某一功能的需求,比如点击一个按钮,带来页面内不同层级的几个组件的UI变化。可以通过上面介绍的ContextProviderConsumer模式,设计一个状态,当子组件的按钮被点击之后,发送消息给Provider,Provider更改状态,之后所有Consumer收到状态变化的消息,自己处理自身的变化。

具体实例

快手iOS客户端的个人中心页,就是这样一个复杂的页面。包含了游戏、商业化、社交链、课程等众多功能入口,同时拥有作品,说说,私密,收藏,喜欢和音乐六大Tab,在
很多地方又需要承担ab测试的分支样式和逻辑。

快手个人中心页

随着新需求的不断增加,个人中心页变成了一个几千行的大类。重构过程运用了上面介绍的组件化方案。大体上,页面主要被分解为导航组件和列表组件,列表组件又包含了背景图组件,用户信息组件以及各个Tab组件。

结构分解

具体拆解如下图

个人中心页组件结构

在实践过程中,页面的组件树上可能存在多个Context。快手个人中心页重构过程中,就建立了用户信息,Table滑动位置,音乐,说说等多个状态共享通道。另外,根组件通常承担了状态提供者的角色,也承担了较多业务逻辑。

总结

上一篇 下一篇

猜你喜欢

热点阅读