iOS - 基於複用的同構卡片檢視
從iOS 11開發,系統多處採用了卡片式設計風格,加上一定程度上的陰影,提升了介面的立體感。當然不只是系統,淘寶、微信、京東等知名App的卡片也逐漸多了起來。
做什麼?
雖然我所做的並沒有那麼複雜,但任然有一定的代表性:

示例-001
怎麼做?
整個檢視由三部分組成:
- 每個單據對應的Item
- 多個單據的組合分組Section
- 滾動檢視ScrollView
事實上,有多種實現方案存在,但都各有優缺點:
- 有多少個分組就新增多少個Section在
UIScrollView
上
優點:快速、簡單
缺點:當分組足夠多時,對記憶體不友好,導致效能損失
- 基於
UICollectionView
實現
優點:支援複用,記憶體相對輕鬆
缺點:由於每個Item都在一個白底上,白底還有陰影和圓角,實現難度大(PS:我不知道怎麼實現)
最終,我選擇使用 UIScrollView
+複用實現。使用了複用之後自然記憶體消耗就能得到極大的優化,不過難點在於 複用機制 的實現。
如何做?

示例-002
當然,僅僅這樣做是遠遠不夠的,不過通過 示例-002 可以清晰的知道大致有哪些模組。
首先,我們需要一個滾動檢視ICScrollView
,當做父容器;每個分組
ICScrollViewSection
作為子容器,放置每個入口按鈕;
ICScrollViewItem
為每個按鈕的實體物件。其次,還需要
ICScrollViewSectionRecord
來儲存每個每個分組、分組中按鈕的佈局資訊(
frame
)。
重頭戲 - 複用機制的實現
在實現之前,先來重溫一下 UITableView
的複用機制。由於列表檢視的不可控因素,為了節省有限的記憶體資源, cell
只會存在當前螢幕上顯示個數的物件和極少數的快取物件,某些 cell
會在使用完後自動釋放,從而減輕對記憶體的依賴,實現記憶體優化的目的。
在第一次顯示檢視時, UITableView
只會載入當前螢幕上能夠顯示的cell物件和預載入的cell(可複用的cell),如 示例-003 所示:

示例-003
當手指滾動屏幕後,第一行cell移出螢幕,從螢幕上消失;第九行cell顯示在螢幕上,第十行cell預載入,如 示例-004 所示:

示例-004
說的還是比較籠統,實際上這裡還是蘊含了很多細節,比如:
- cell佈局如何進行?
- cell如何預載入?
- cell是如何放入到重用佇列中,又是怎麼從重用佇列中取出?
- 重用佇列如何排除以顯示的cell?
- 力所能及的優化?
等等.....
這些問題,都將在程式碼部分一一實現。
搬磚
上面我對方案和技術點進行了簡單的闡述,具體落實到程式碼又如何實現呢?
上面我們提到了一個類 ICScrollViewSectionRecord
,專門用來儲存每個分組的佈局資訊(我這裡是基於frame佈局的),其中就包括了:header的高度、item的個數、item之間的間隔等等。但是重點不是這裡,而是該分組實際應該佔用的高度,以及每個item對應的rect如何計算和優化?
- (CGFloat)sectionHeight { if (_sectionHeight == 0 && _updateSectionHeight) { _updateSectionHeight = 0; int rows = ceil(((double)_numbersOfItems)/ICScrollViewItemCountPerRow()); double spacing = (self.sectionWidth-ICScrollViewItemCountPerRow()*ICScrollViewItemSideLength())/ICScrollViewItemCountPerRow(); _spacing = spacing; _sectionHeight = ICScrollViewSectionContentVerticalMargin*2 + rows*ICScrollViewItemSideLength() +(rows-1)*spacing + _headerHeight; // 優化滾動時,cpu佔用率 _rectCache = NSMutableDictionary.dictionary; for (int row = 0; row < _numbersOfItems; row++) { const NSInteger count = ICScrollViewItemCountPerRow(); const CGFloat margin = ICScrollViewSectionContentVerticalMargin; const CGFloat length = ICScrollViewItemSideLength(); CGFloat x = _spacing/2 + (row%count)*(length+_spacing); CGFloat y = margin + (row/count)*(length+_spacing) + _headerHeight; [_rectCache setObject:@(Rect(x, y, length, length)) forKey:@(row)]; } } return _sectionHeight; } - (CGRect)rectForItemAtIndex:(NSInteger)index { if (index >= _numbersOfItems) { return CGRectZero; } return [_rectCache objectForKey:@(index)].CGRectValue; }
_updateSectionHeight
是一個 位域 ,用於判斷當前呼叫 - sectionHeight
時,是否需要再次計算,因為當header高度和item個數變化時,都有可能導致分組高度發生改變。這裡有兩個優化點:
1.用位域而不是BOOL(這裡其實可以忽略);
2.由於佈局順序,當在佈局分組時,就提前計算好每個分組中,所有item的佈局資訊,在item佈局時就可以直接從 _rectCache
中取資料,而避免在 scrollView
滾動時計算,一定程度上減輕cpu的壓力。
上面我只是呼叫了dataSource的方法,獲取所有的分組佈局資料,並沒有將分組新增到檢視上。當我拿到佈局陣列後,第一步應該是先確定 scrollView
的滾動範圍: contentSize
:
- (void)_setContentSize { [self setUpdateRecordsIfNeeded]; CGFloat height = 0; _spacing = _delegateHas.spacing ? [_delegate scectionsSpacingForScrollView:self] : 15.f; height += _spacing; for (ICScrollViewSectionRecord *record in _records) { height += ([record sectionHeight] + _spacing); } [self setContentSize:Size(0, height)]; }
contentSize
的計算相對簡單,只需要從 _records
陣列中一一加上每個分組的高度和特定的分組間隔即可。所有的準備工作足夠後,如何新增分組,分組如何複用呢?
- (void)layoutScrollView { const CGSize boundsSize = self.bounds.size; const CGFloat contentOffsetY = self.contentOffset.y; const CGRect visibleBounds = Rect(0, contentOffsetY, boundsSize.width, boundsSize.height); NSMutableDictionary *availableSections = [_shownSections mutableCopy]; const NSInteger numberOfSections = [_records count]; [_shownSections removeAllObjects]; for (int s = 0; s < numberOfSections; s++) { CGRect sRect = [self rectForSection:s]; if (CGRectIntersectsRect(visibleBounds, sRect)) { ICScrollViewSection *section = [availableSections objectForKey:@(s)]; if (CGRectEqualToRect(section.frame, sRect)) { [_shownSections setObject:section forKey:@(s)]; [availableSections removeObjectForKey:@(s)]; continue; } ICScrollViewSectionRecord *record =[_records objectAtIndex:s]; if (section == nil) { section = [self dequeueReusableSection]; if (section == nil) { section = [[ICScrollViewSection alloc] init]; } section.cornerRadius = 7.f; [self addSubview:section]; if (!section.header) { [section addHeader:[[ICScrollViewHeader alloc] init]]; section.header.frame = Rect(0, 0, 0, record.headerHeight); } NSString *title = _delegateHas.headerTitle ? [_delegate scrollView:self titleForHeaderAtSection:s] : NSStringFormat(@"Section - %d", s); section.header.label.text = title; } section.frame = sRect; [_shownSections setObject:section forKey:@(s)]; [availableSections removeObjectForKey:@(s)]; const NSInteger numberOfItems = record.numbersOfItems; for (int r = 0; r < numberOfItems; r++) { if (_dataSourceHas.item) { NSIndexPath *indexPath = [NSIndexPath indexPathForRow:r inSection:s]; ICScrollViewItem *item = [_dataSource scrollView:self itemAtIndexPath:indexPath]; if (item != nil) { [section addSubitem:item]; item.frame = [record rectForItemAtIndex:r]; } [item prepareForDisplay]; } } NSMutableArray *didAddToDisplayItems = [section.items mutableCopy]; for (int idx = (int)numberOfItems; idx < didAddToDisplayItems.count; idx++) { ICScrollViewItem *item = didAddToDisplayItems[idx]; [section removeSubitem:item]; } } } for (ICScrollViewSection *section in availableSections.allValues) { if ([section isKindOfClass:ICScrollViewSection.class]) { [_reusableSections addObject:section]; } } NSMutableArray *reusable = [_reusableSections.allObjects mutableCopy]; for (ICScrollViewSection *section in reusable) { if ([_shownSections.allValues containsObject:section]) { [_reusableSections removeObject:section]; } } [_reusableSections.allObjects makeObjectsPerformSelector:@selector(removeFromSuperview)]; }
這個方法比較長,我來一步一步的解釋下。 UIScrollView
之所以能夠 滾動 是因為 bounds和frame的區別 。換句話說,當前螢幕上顯示的 UIScrollView
的 visibleBounds
區域。
上面簡述tableView的複用時提到,只會載入當前展示區域的檢視,超出的將釋放或存在重用佇列中。所以我們依次取每個分組對應的frame,通過 CGRectIntersectsRect
判斷兩個frame是否存在交集,如果為 true
表示該分組應該顯示在檢視上。核心程式碼如下:
CGRect sRect = [self rectForSection:s]; if (CGRectIntersectsRect(visibleBounds, sRect)) { // 加入顯示邏輯程式碼 }
如何複用section呢?沿用經典套路:先從當前顯示的佇列中取section,如果取到了,那麼該section是存在的,並不需要再次新增到父容器上,如果不存在,那麼從重用佇列去取一個(因為我們這裡是同構的,所以不需要用identifier區分)。如果任然沒有,那麼只能初始化一個了:
/// 是否當前螢幕正在顯示 ICScrollViewSection *section = [availableSections objectForKey:@(s)]; /// 如果正在顯示,則執行下一次迴圈 if (CGRectEqualToRect(section.frame, sRect)) { [_shownSections setObject:section forKey:@(s)]; [availableSections removeObjectForKey:@(s)]; continue; } /// 如果不存在,則在重用佇列中找尋一個 if (section == nil) { section = [self dequeueReusableSection]; /// 如果任然沒有例項,則只能初始化一個 if (section == nil) { section = [[ICScrollViewSection alloc] init]; } }
至此,能夠在 visibleBounds
中顯示的分組就已經新增完了。這時,由於整個顯示情況已經進行了重新繪製,必須同步更新 _reusableSections
,為下次繪製做好準備。這裡分為兩步:
- 將之前剛從螢幕上移除的,還在
availableSections
中的分組新增到重用佇列中,哪怕可能重複。
for (ICScrollViewSection *section in availableSections.allValues) { if ([section isKindOfClass:ICScrollViewSection.class]) { [_reusableSections addObject:section]; } }
- 由於,至始至終重用佇列都只增加了分組,至於剛才已經從佇列中取出去顯示還沒有排除開。
NSMutableArray *reusable = [_reusableSections.allObjects mutableCopy]; for (ICScrollViewSection *section in reusable) { /// 如果在重用佇列中的剛好也在當前螢幕顯示,則從重用佇列移除 if ([_shownSections.allValues containsObject:section]) { [_reusableSections removeObject:section]; } }
- 將重用佇列中的分組從父檢視上移除。
[_reusableSections.allObjects makeObjectsPerformSelector:@selector(removeFromSuperview)];
在每次呼叫 layoutSubviews
時呼叫繪製方法即可完成檢視得繪製 -> 複用 -> 重繪的功能。最終實現如下效果:

示例-005
優化
NSMutableSet CGRect sRect = [self rectForSection:s];
其他
資料型別的別名:
typedef NSMutableDictionary<NSNumber , ICScrollViewSection >* ICShownSectionDictionary;
typedef NSMutableSet<ICScrollViewSection > ICReusableSectionSet;
typedef NSMutableArray<ICScrollViewSectionRecord > ICSectionRecordArray;
typedef NSMutableDictionary<NSNumber , NSValue >* ICScrollViewSectionRectCache;
typedef NSMutableDictionary<NSIndexPath , NSValue >* ICScrollViewItemRectCache;
做完之後,同事說的阿里巴巴有個三方庫 LazyScrollView ,功能更強大,:sob::sob::sob:。我自己的專案地址 ICScrollView ,歡迎光臨!