从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所示:
当手指滚动屏幕后,第一行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
时调用绘制方法即可完成视图得绘制 -> 复用 -> 重绘的功能。最终实现如下效果:
优化
- 重用队列使用
NSMutableSet
能够很容易的去重。 - 取每个分组的rect时
CGRect sRect = [self rectForSection:s];
,如果在方法中加入缓存机制,能够在数据未改变时,极大的减少cpu的计算量,给cpu减压。 - 在判断显示时,如何减少循环次数?(目前未实现)
其他
数据类型的别名:
typedef NSMutableDictionary<NSNumber, ICScrollViewSection>* ICShownSectionDictionary;
typedef NSMutableSet<ICScrollViewSection> ICReusableSectionSet;
typedef NSMutableArray<ICScrollViewSectionRecord> ICSectionRecordArray;
typedef NSMutableDictionary<NSNumber, NSValue>* ICScrollViewSectionRectCache;
typedef NSMutableDictionary<NSIndexPath, NSValue>* ICScrollViewItemRectCache;
做完之后,同事说的阿里巴巴有个三方库LazyScrollView,功能更强大,😭😭😭。我自己的项目地址ICScrollView,欢迎光临!
网友评论