美文网首页程序员
iOS - 基于复用的同构卡片视图

iOS - 基于复用的同构卡片视图

作者: Yeeshe | 来源:发表于2019-01-22 15:04 被阅读35次

    从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的区别。换句话说,当前屏幕上显示的UIScrollViewvisibleBounds区域。
    上面简述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能够很容易的去重。
    • 取每个分组的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,欢迎光临!

    相关文章

      网友评论

        本文标题:iOS - 基于复用的同构卡片视图

        本文链接:https://www.haomeiwen.com/subject/nkehjqtx.html