"拆解不同的頁面元素為組件,通過組件組合的方式構建頁面"
在版本迭代過程中,隨著功能越來越豐富,代碼也會越來越多。面對一個“巨無霸”頁面,我們如何拆解?拆解后如何協(xié)作、如何通信?
本文介紹一種使用組件化方案構建復雜頁面的設計思路,以及快手如何應用這個思路重構個人中心頁面的實例。
背景介紹
隨著業(yè)務的發(fā)展,項目中的一些核心頁面會變得越來越龐大。過大的類本身就散發(fā)著壞的代碼味道,大量的代碼擠在一起,眾多復雜的邏輯相互交織,開發(fā)和維護變得愈發(fā)困難。如果不同業(yè)務線同時修改同一個復雜頁面,會帶來大量的沖突和眾多if...else判斷。
當一個ViewController變成擁有幾千行的龐然大物的時候,在開發(fā)和迭代過程中,常常會遇到如下的一些困難:
- 類過大,修復bug不容易定位問題
- 內部邏輯相互依賴,互相關聯(lián),新增需求可能破壞原有功能
- 頁面樣式復雜,且有依賴關系
- 不同的業(yè)務操作可能會操作同一個View,容易出現(xiàn)展示錯誤
這樣的頁面就好像一個大抽屜,打開之后堆滿了各種代碼。我們下面要做的,就是利用一些“收納盒”,把有關聯(lián)的東西都放在一個個小盒子里。
定義組件
組件是一個個獨立的,可復用的部件。對外,組件提供一個繪制好的view;對內,組件管理自己內部的頁面元素和業(yè)務邏輯。通過添加子組件的操作,組件之間被組織起來,形成一棵組件樹。之后我們便可以通過這棵組件樹做內部消息的傳遞。
可以把組件定義成協(xié)議,這樣,無論是View,ViewController,還是NSObject,都可以通過實現(xiàn)協(xié)議,變成組件。定義如下
@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;
“各家自掃門前雪”,組件只專注于自己這一塊視圖的繪制,當然,它也可以通過添加子組件的方式,將自己視圖內的一部分區(qū)域“外包”給別的組件管理。
如何拆解和形成組件樹
view本身有一個樹狀的層級結構,當其中的一些view是由組件提供出來的時候,這些組件便形成了組件樹。

拆解的過程遵循自上而下,化整為零的原則。分析頁面元素之間的關系,將相對集中的元素合并在一起,形成組件。拆解的過程中也要遵循適度原則:組件不能太大,對于過大的組件,可以在迭代開發(fā)中逐漸拆解;組件也不適宜太小,瑣碎或者層級過深的結構都不利于代碼的閱讀和理解,會增加未來維護的成本。
這里有個問題,在使用組件的時候,如果既要添加組件的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,就可以自動構建出組件樹的層級結構了。
如何通信
還是那個大抽屜的比喻,當所有東西都放在一起的時候,雖然雜亂了一些,但是彼此的訪問卻非常順暢:需要用到什么狀態(tài),什么方法,直接調用就好了。拆解成組件之后,組件之間就增加了通信的成本。下面是幾種組件間通信方式
父子組件
使用直接通信的方式。父組件持有并使用子組件的視圖,所以父組件知道子組件的類型,可以通過子組件的構造函數(shù),設置屬性或者調用方法,直接傳遞消息給子組件。子組件雖然不知道自己父組件的具體類型,但可以通過block或者delegate的方式,將自己內部的消息轉發(fā)給使用自己的父組件。
跨層級通信
父組件 => 子組件 => ... => 子組件
如果按照上面父子組件通信方式層層傳遞,比較繁瑣,膠水代碼也較多。但是如果放開通信限制,允許任意組件之間進行網狀通信,工程的復雜度會隨著組件數(shù)量的增加,爆炸性增長。因此,我們希望提供一種單向的,有明確數(shù)據類型的狀態(tài)同步機制。
本次實踐借鑒了ContextProviderConsumer的模式,即組件樹上的某一個節(jié)點作為狀態(tài)的提供者(Provider),它子樹上的組件,可以作為消費者(Consumer)去注冊監(jiān)聽這個提供者狀態(tài)的變化,當狀態(tài)發(fā)生變化的時候,消費者可以收到消息。
概括來說
- Provider 提供共享狀態(tài),負責更新狀態(tài)
- Consumer 監(jiān)聽Provider狀態(tài)的變化,對共享狀態(tài)只讀
下面是舉一個傳遞用戶信息的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

有了協(xié)議聲明,那如何建立起來狀態(tài)變化的監(jiān)聽呢?在具體實現(xiàn)上,我們采用了kvo的方式,即在構建組件樹的同時,runtime去判斷這個組件是否是某一Context的Provider或者Consumer。如果判斷成功,則建立相應的kvo監(jiān)聽。這樣,在Provider組件修改自身某一狀態(tài)的時候,監(jiān)聽它的Consumer便可以收到狀態(tài)變化的消息。
如何協(xié)作
對于更復雜的,需要組件間聯(lián)動來完成某一功能的需求,比如點擊一個按鈕,帶來頁面內不同層級的幾個組件的UI變化。可以通過上面介紹的ContextProviderConsumer模式,設計一個狀態(tài),當子組件的按鈕被點擊之后,發(fā)送消息給Provider,Provider更改狀態(tài),之后所有Consumer收到狀態(tài)變化的消息,自己處理自身的變化。
具體實例
快手iOS客戶端的個人中心頁,就是這樣一個復雜的頁面。包含了游戲、商業(yè)化、社交鏈、課程等眾多功能入口,同時擁有作品,說說,私密,收藏,喜歡和音樂六大Tab,在
很多地方又需要承擔ab測試的分支樣式和邏輯。

隨著新需求的不斷增加,個人中心頁變成了一個幾千行的大類。重構過程運用了上面介紹的組件化方案。大體上,頁面主要被分解為導航組件和列表組件,列表組件又包含了背景圖組件,用戶信息組件以及各個Tab組件。

具體拆解如下圖

在實踐過程中,頁面的組件樹上可能存在多個Context。快手個人中心頁重構過程中,就建立了用戶信息,Table滑動位置,音樂,說說等多個狀態(tài)共享通道。另外,根組件通常承擔了狀態(tài)提供者的角色,也承擔了較多業(yè)務邏輯。
總結
- 通過頁面元素組件化的方式,可以有效的拆解復雜頁面,降低耦合
- 封裝組件樹的構建過程,在添加組件view的同時,在內部構建了父子關系
- 利用組件的樹狀結構,借助ContextProviderConsumer做跨層級的組件通信