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

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

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

背景介绍

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

provider & consumer

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

如何协作

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

具体实例

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

快手个人中心页

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

结构分解

具体拆解如下图

个人中心页组件结构

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

总结

  • 通过页面元素组件化的方式,可以有效的拆解复杂页面,降低耦合
  • 封装组件树的构建过程,在添加组件view的同时,在内部构建了父子关系
  • 利用组件的树状结构,借助ContextProviderConsumer做跨层级的组件通信
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,607评论 6 507
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,239评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,960评论 0 355
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,750评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,764评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,604评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,347评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,253评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,702评论 1 315
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,893评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,015评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,734评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,352评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,934评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,052评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,216评论 3 371
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,969评论 2 355

推荐阅读更多精彩内容