在很多以內容為主的應用中,比如考拉、嚴選,以及我們美學,界面內容通常比較復雜豐富,一個頁面通常分為多個模塊,各個模塊之間獨立性強,這樣勢必一個controller里面會有很多很多代碼與邏輯來處理模塊組合,導致代碼日益膨脹。
這是一個表現層模塊化組件,按照頁面視覺結構,將一個頁面劃分為多個模塊,然后通過模塊間的簡單組合,來實現復雜頁面。并且將部分邏輯功能放到了對應組件,以達到復用與使用簡單的效果。
目前,我們大部分展示型頁面controller只有請求相關代碼,基本能夠控制在200行左右。
背景:
由于我們的項目處于一個比較早期的階段,所以我們需要很多的嘗試來改良我們的方案。所以在這期間的頁面結構極不穩定,內容以及位置順序等,都可能會發生較大變化。事實上,在1.0之后的3個版本中,每個版本的首頁都在大改。
如下我們的一個首頁版本,模塊非常明顯,并且在其他頁面也會使用到類似模塊。
用戶內容的高自由性,大部分內容為用戶選填,如果內容缺省,需要刪掉該行,所以需要動態計算布局也是非常麻煩。如下除了用戶和產品,都是可選內容,不可控因素太多。
可以看出,我們的內容可能會達到一個非常大的級別,此時性能也會是一個問題,必須采用視圖重用才可以避免內存問題。
同時,不同模塊的加載可能是異步的,返回結果也可能不同,需要部分顯示空態、錯誤等提示,這樣又進一步導致了頁面的復雜性。
接下來,我們一個個的解決這樣的問題。
方案
布局選型與重用問題:
一種是tableView來實現這些類似于列表的功能,另外一種是使用CollectionView來實現同樣的功能。
雖然分別實現了這兩種對應方案,但是最終使用最多的還是CollectionView,有幾個原因:
- CollectionView的布局是一次性算出來的,會有緩存,相當于性能優化
- 模塊間的間距控制,CollectionView更加靈活,不需要調整cell就可以改變間距
- 可以看到我們的模塊并不一定一行只有一個元素(比如首頁),也不一定一個模塊只有固定行數(比如上圖的標簽模塊),如果使用tableView,還是會需要復雜的計算,而使用CollectionView,我們可以控制每個cell為最小的單位。
組件間組合與順序問題
有需求是服務器控制組合與順序,所以這是我們首先需要解決的問題。所以這里引入兩個概念:
1,視圖組件: 只負責視圖展示,比如一個包含小列表的模塊,或者僅僅只有一個元素的模塊。只負責職責內的視圖展示。
2,容器組件: 只負責組件間的組合,比如按照順序或者空態等組合模式,當然最頂層的一個組件也是一個容器類組件。
這里容器類組件可以包含任意視圖組件及容器類組件,而視圖組件不能作為組合使用(這里有個特例HeaderFooterSectionComponent
,其實提供了部分容器的概念,可以配置header和footer,一個細化)。
職責明確之后,我們就可以通過這種從屬關系來任意組合我們的組件,如果不需要顯示該視圖,可以從容器組件中移除該組件或者將numberOf
返回0個。
空態頁、錯誤頁等
有了上一個的兩個概念,處理這兩個問題就變得簡單了。抽象的來說,就是組件依據不同狀態,而分別展示不同的子組件。相當于增加一層組件,該組件的功能是控制展示當前子組件。
那么設計一個狀態與組件間對應的字典,在需要的時候切換該狀態就行了,這就是后來增加的StatusComponent
。
布局的多樣化
可能有些頁面需要內容元素需要居中顯示,或者FlowLayout默認的居左顯示(多行的時候,除最后一行外為兩端對齊模式),又或者需要永遠居左顯示(比如我們的標簽)。
當選擇了CollectionView作為方案時,這個問題就很好解決了,不需要改動component代碼,只需要創建的時候輸入自定義的Layout就可以輕松改變布局了。
實現
按照以上的分析結果,最終實現了一套組件化實現方案(TableView結構類似,這里不做說明),源碼大家自己看吧,就不介紹了:
上圖藍色的是視圖組件,黃色的是容器組件。Group類型為順序組合,Status組件為狀態型組合。
請不要問我組件該怎么寫,和寫一個只有該模塊內容的CollectionView一模一樣,不會請參考蘋果官方事例吧~
使用流程
其中紅色部分為日常開發需要真正關心的,可能需要寫代碼的部分,其他均由組件化解決,減少了開發一個新頁面的成本。
Demo
以我們的首頁推薦為例,雖然我們的首頁內容多而且復雜,但是Controller代碼也在200行左右。下面來看看一個主要流程:
- (void)viewDidLoad {
[super viewDidLoad];
// 外層容器結構
self.sectionGroupComponent = [DDCollectionViewSectionGroupComponent new];
self.statusComponent = [MZCollectionViewStatusComponent defaultComponent];
self.statusComponent.normalComponent = self.sectionGroupComponent;
self.componentArray = @[self.statusComponent];
}
MZHomeRecommendRequest *request = [[MZHomeRecommendRequest alloc] init];
[request startWithBlock:^(MZHomeRecommendRequest *request, NSError *error) {
// 網絡請求回來后首先判斷狀態,來切換空態頁或者錯誤頁,其實這里還可以加入loading頁
if (!error) {
if (request.response.banners.count > 0 && request.response.groups.count > 0) {
self.statusComponent.currentState = MZCollectionViewStateNormal;
// 正常數據會根據數據來生成對應的component
self.sectionGroupComponent.subComponents = [self componentFromData:request.response.groups];
}
else {
self.statusComponent.currentState = MZCollectionViewStateNoData;
}
}
else {
self.statusComponent.currentState = MZCollectionViewStateError;
self.statusComponent.errorComponent.title = error.localizedDescription;
self.statusComponent.errorComponent.delegate = self; // 這里點擊重新加載
}
[self.collectionView reloadData];
}];
再來看看單個component的結構,和一個單一元素的collectionView非常相似。
- (void)prepareCollectionView {
[super prepareCollectionView];
// 由于依賴collectionView,所以還是需要注冊
[self.collectionView registerClass:MZRepoNormalStyleCollectionViewCell.class
forCellWithReuseIdentifier:NSStringFromClass(MZRepoNormalStyleCollectionViewCell.class)];
}
#pragma mark - UICollectionView
- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView {
return 1;
}
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
return self.repos.count;
}
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView
cellForItemAtIndexPath:(NSIndexPath *)indexPath {
MZRepoNormalStyleCollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:NSStringFromClass(MZRepoNormalStyleCollectionViewCell.class)
forIndexPath:indexPath];
// config...
return cell;
}
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
return size;
}
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
[collectionView deselectItemAtIndexPath:indexPath animated:YES];
// push detail view controller
}
如何控制組件的順序以及顯示特性呢?
[self componentFromData:request.response.groups]
,在我們組裝視圖組件時,可以隨意調整組件的順序,控制組件的顯示,而無需關系兄弟組件的情況。
- (NSArray *)componentFromData:(NSArray *)data {
NSMutibleArray *retArray;
forEach switch (data[index].type) {
case type1:
// add to array ...
case type2:
// add to array ...
}
}
擴展性
在一些場景下,我們需要額外的delegate方法來滿足我們的需求,比如我們的居左對齊和左劃刪除,需要把這些事件傳入最終的視圖component也很簡單,只要擴展上面幾個容器類組件的方法即可:
@protocol MZRepoAlignLeftCollectionFlowLayoutDelegate <UICollectionViewDelegateFlowLayout>
@interface DDCollectionViewSectionGroupComponent (MZRepoAlignLeftCollectionFlowLayout) <MZRepoAlignLeftCollectionFlowLayoutDelegate>
- (BOOL)collectionView:(UICollectionView *)collectionView shouldAlignLeftAtSection:(NSInteger)section {
DDCollectionViewBaseComponent *comp = [self componentAtSection:section];
if ([comp respondsToSelector:@selector(collectionView:shouldAlignLeftAtSection:)]) {
return [(id<MZRepoAlignLeftCollectionFlowLayoutDelegate>)comp collectionView:collectionView shouldAlignLeftAtSection:section];
}
return NO;
}
按照這樣的思想,就具有了高度的可擴展性。
一個對比 Facebook ComponentsKit
Facebook
優點:
完全實現了自己的一套布局系統,粗略的看了下,反正沒看懂(⊙﹏⊙)
能夠很好的實現流式布局,類似于iOS的stack,或者說更像網頁的flex布局(視圖重用性應該不好)
缺點:
完全顛覆了原生的布局方式和代碼習慣,學習成本高
C++編寫而成,所以需要Objective-C++來編寫,必須承認C++還是很難掌握的
美學
優點:
和原生CollectionView代碼保持一致,學習成本低
從以前代碼的轉換成本低,我們也是一步步從原來的代碼轉到組件化的
缺點:
刷新數據需要重新計算整個Layout,此時會有性能損耗(這個要看數據量和視圖復雜度,通常發生在頁面切換,請求回來的時候,其實此時用戶感知不到)
需要按照CollectionView的寫法來組建,因此部分接口需要暴露indexPath,如果亂用,可能會導致崩潰
目前
到目前為止,美學大部分頁面,都是采用組件化組合而成,隨意數數,已經有超過100個組件了,接下來可能需要整理下組件,增加單個組件的復用性了。
歷經幾個版本,組件化目前已經是比較完善和穩定的一個版本了,也滿足了目前所有的需求和日常開發,期間也接受了各種奇怪的需求,目前來看擴展性還是可以的,有疑問可以直接私密我。