Swift高级进阶

文章40 |   阅读 19084 |   点赞0

来源:https://blog.csdn.net/forever_wj/category_6167373.html

iOS之性能优化·UITableView深度优化

x33g5p2x  于2022-03-08 转载在 其他  
字(12.5k)|赞(0)|评价(0)|浏览(584)

一、前言

  • UITableView 的优化主要从四个方面入手:
    • 提前计算并缓存好高度(布局),因为 tableView:heightForRowAtIndexPath: 是调用最频繁的方法;
    • 滑动时按需加载,防止卡顿。这个在大量图片展示,网络加载的时候很管用,配合 SDWebImage;
    • 异步绘制,遇到复杂界面,遇到性能瓶颈时,可能就是突破口;
    • 缓存一切可以缓存的,这个在开发的时候,往往是性能优化最多的方向。
  • 大概需要关注的:
    • cell 复用;
    • cell 高度的计算;
    • 渲染(混合问题);
    • 减少视图的数目(重写 drawRect:);
    • 减少多余的绘制操作;
    • 不要给 cell 动态添加 subView;
    • 异步化 UI,不要阻塞主线程;
    • 滑动时按需加载对应的内容。

二、Cell 复用

  • UITableView 最核心的思想就是 UITableViewCell 的重用机制。简单的理解就是:UITableView 只会创建一屏幕(或一屏幕多一点)的 UITableViewCell,其它都是从中取出来重用的,每当 Cell 滑出屏幕时,就会放入到一个集合(或数组)中(相当于一个重用池),当要显示某一位置的 Cell 时,会先去集合(或数组)中取,如果有,就直接拿来显示;如果没有,才会创建。这样做的好处可想而知,极大的减少了内存的开销。
  • 了解了 UITableViewCell 的重用原理后,来看看 UITableView 的回调方法,UITableView 最主要的两个回调方法:
tableView:cellForRowAtIndexPath: 
tableView:heightForRowAtIndexPath:
  • 理想上我们是会认为 UITableView 会先调用前者,再调用后者,因为这和创建控件的思路是一样的,先创建它,再设置它的布局,但实际上却并非如此。
  • 我们都知道,UITableView 是继承自 UIScrollView 的,需要先确定它的 contentSize 及每个 Cell 的位置,然后才会把重用的 Cell 放置到对应的位置。所以事实上,UITableView 的回调顺序是先多次调用 tableView:heightForRowAtIndexPath: 以确定 contentSize 及 Cell 的位置,然后才会调用 tableView:cellForRowAtIndexPath:,从而来显示在当前屏幕的 Cell。
  • 因此,在可见的页面会重复绘制页面,每次刷新显示都会去创建新的 Cell,非常耗费性能。 解决方案就是创建一个静态变量 reuseID,防止重复创建(提高性能),使用系统的缓存池功能。
// 调用次数太多,static 保证只创建一次 reuseID,提高性能
static NSString *kCELL_RUID = @"Cell"; 

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    // 缓存池中取已创建的 cell
    UITableViewCell * cell = [tableView dequeueReusableCellWithIdentifier:kCELL_RUID
                                                             forIndexPath:indexPath];
    return cell;
}
  • 通过 identifier 标识不同类型的 cell,缓存池中只会保存已经被移出屏幕的不同类型的 cell,复用 Cell 时 不会调用 awakeFromNib:
- (nullable __kindof UITableViewCell *)dequeueReusableCellWithIdentifier:(NSString *)identifier;  // Used by the delegate to acquire an already allocated cell, in lieu of allocating a new one.
 - (__kindof UITableViewCell *)dequeueReusableCellWithIdentifier:(NSString *)identifier forIndexPath:(NSIndexPath *)indexPath NS_AVAILABLE_IOS(6_0); // newer dequeue method guarantees a cell is returned and resized properly, assuming identifier is registered
  • 两个获取方法的区别:
    • dequeueReusableCellWithIdentifier:forIndexPath 如果没有注册复用 identifier,执行这句时会崩溃,提示:
reason: 'unable to dequeue a cell with identifier CELL - must register a nib or a class for the identifier or connect a prototype cell in a storyboard'
    • dequeueReusableCellWithIdentifier 如果没有注册复用 identifier,语句返回 nil,继续执行会崩溃,提示:
failed to obtain a cell from its dataSource
    • 判断 nil 后可以自己创建 cell,如下:
MyCell * cell = [tableView dequeueReusableCellWithIdentifier:@"Cell"];
if (cell == nil) {
	cell = [[MyCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"Cell"];
}
  • 为什么需要 forIndexPath:?
    • 因为在返回 cell 之前,会调用委托 tableView:heightForRowAtIndexPath:来确定 cell 尺寸(如果已经定义该函数)。
    • 我们经常在 tableView:cellForRowAtIndexPath: 中为每一个 cell 绑定数据,实际上在调用 cellForRowAtIndexPath: 的时候 cell 还没有被显示出来,为了提高效率应该把数据绑定的操作放在 cell 显示出来后再执行,可以在 tableView:willDisplayCell:forRowAtIndexPath: 方法中绑定数据。
    • 注意 willDisplayCell 中 cell 在 tableview 展示之前就会调用,此时 cell 实例已经生成,所以不能更改 cell 的结构,只能是改动 cell 上的 UI 的一些属性,如 label 的内容、控件的隐藏等。

三、定义一种(尽量少)类型的 Cell 及善用 hidden 隐藏(显示)subviews

  • 分析 Cell 结构,尽可能的将相同内容的抽取到一种样式 Cell 中,UITableView 真正创建出的 Cell 可能只比屏幕显示的多一点。虽然 Cell 的“体积”可能会大点,但是因为 Cell 的数量不会很多,完全可以接受的。
  • 这样的好处就是:
    • 减少代码量,减少 Nib 文件的数量,在一个 Nib 文件定义 Cell,容易修改、维护;
    • 基于复用机制,真正运行时铺满屏幕所需的 Cell 数量大致是固定的,设为 N 个,如果只有一种 cell,那就是只有 N + c 个 cell 的实例;但是如果有 M 种 cell,那么运行时最多可能会是 M * (N + c) 个 cell 的实例,虽然这可能并不会占用太多内存,但能少一些更好。
  • 既然只定义一种 Cell,那么需要把所有不同类型的 view 都定义好,放在 Cell 里面,通过 hidden 属性控制,来显示不同类型的内容。毕竟,在用户快速滑动中,只是单纯的显示/隐藏 subview 比实时创建要快得多。
  • 尽量少用 [cell addSubview:] 动态添加 View,可以初始化时就添加,然后通过 hidden 属性来控制。

四、提前计算并缓存 Cell 的高度

① rowHeight

  • UITableView 询问 cell 高度有两种方式:
    • 一种是针对所有 Cell 具有固定高度的情况,通过:
self.tableView.rowHeight = 88;
    • 直接采用上面方式给定高度,不需要实现 tableView:heightForRowAtIndexPath: 以节省不必要的计算和开销。指定一个所有 cell 都是 88 高度的 UITableView,对于定高需求的表格,强烈建议使用这种(而非下面的)方式保证不必要的高度计算和调用,rowHeight 属性的默认值是 44。
    • 另一种方式就是实现 UITableViewDelegate 中的:
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    // return xxx
}
    • 实现了这个方法后,rowHeight 的设置将无效,因此这个方法适用于具有多种 cell 高度的 UITableView。

② estimatedRowHeight

  • iOS7 就出现这个属性, 文档是这么描述它的作用:
If the table contains variable height rows, it might be expensive to calculate all their heights when the table loads. Using estimation allows you to defer some of the cost of geometry calculation from load time to scrolling time.
  • UITableView 是个 UIScrollView,就像平时使用 UIScrollView 一样,加载时指定 contentSize 后它才能根据自己的 bounds、contentInset、contentOffset 等属性共同决定是否可以滑动以及滚动条的长度。而 UITableView 在一开始并不知道自己会被填充多少内容,于是询问 data source 个数和创建 cell,同时询问 delegate 这些 cell 应该显示的高度,这就造成它在加载的时候浪费了多余的计算在屏幕外边的 cell 上。
  • 和上面的 rowHeight 很类似,设置这个估算高度有两种方法:
self.tableView.estimatedRowHeight = 88;
// or
 - (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
    // return xxx
}
  • 有所不同的是,即使面对种类不同的 cell,依然可以使用简单的 estimatedRowHeight 属性赋值,只要整体估算值接近就可以,比如大概有一半 cell 高度是 44, 一半 cell 高度是 88, 那就可以估算一个 66,基本符合预期。
  • 说完了估算高度的基本使用,会有以下问题:
    • 设置估算高度后,contentSize.height 根据“cell估算值 x cell个数”计算,这就导致滚动条的大小处于不稳定的状态,contentSize 会随着滚动从估算高度慢慢替换成真实高度,肉眼可见滚动条突然变化甚至“跳跃”;
    • 若是有设计不好的下拉刷新或上拉加载控件,或是 KVO 了 contentSize 或 contentOffset 属性,有可能使表格滑动时跳动;
    • 估算高度设计初衷是好的,让加载速度更快,那凭啥要去侵害滑动的流畅性呢,用户可能对进入页面时多零点几秒加载时间感觉不大,但是滑动时实时计算高度带来的卡顿是明显能体验到的,个人觉得还不如一开始都算好(iOS8 之后更过分,即使都算好了也会边划边计算)。
  • 因此,tableView:estimatedHeightForRowAtIndexPath: -> tableView:heightForRowAtIndexPath: 获取每个 Cell 即将显示的高度,从而确定表格视图的布局,实际是要获取滚动视图的 contentSize,然后调用 tableView:cellForRowAtIndexPath:,获取每个 Cell,进行赋值。如果有很多个 Cell 要显示,那么方法会执行很多次。
  • 解决方案:在 Model(Entity)中计算并保存 Cell 的高度,其实 Model 中保存 UI 的参数是很奇怪的,最好放在 MVVM 模式的 ViewModel(视图模型)中,让 Model(数据模型)只负责处理数据。
@interface Model : NSObject

@property (nonatomic, assign) CGFloat cellHeight;  // Cell 高度

/**
 * @brief  计算高度
 */ 
- (void)calculateCellHeight;

@end
  • 在 tableView:heightForRowAtIndexPath: 中尽量不使用 cellForRowAtIndexPath: 方法来获取 cell,如果需要用到它,只用一次然后缓存结果。
  • 还可以继续进行优化,提前创建真正显示的、需要加工的数据并缓存。如:接口返回 NSString 而展示 NSAttributeString。

③ iOS8 self-sizing cell

    • 具有动态高度内容的 cell 一直是个头疼的问题,比如聊天气泡的 cell, frame 布局时代通常是用数据内容反算高度:
CGFloat height = textHeightWithFont() + imageHeight + topMargin + bottomMargin + ...;
  • 供 UITableViewDelegate 调用时很可能是个 cell 的类方法:
@interface BubbleCell : UITableViewCell
 + (CGFloat)heightWithEntity:(id)entity;
@end
  • AutoLayout 时代好了不少,提供了 -systemLayoutSizeFittingSize: 的 API,在 contentView 中设置约束后,就能计算出准确的值;缺点是计算速度肯定没有手算快,而且这是个实例方法,需要维护专门为计算高度而生的 template layout cell,它还要求使用者对约束设置的比较熟练,要保证 contentView 内部上下左右所有方向都有约束支撑,设置不合理的话计算的高度就成了0。
  • 这里不得不提到一个 UILabel 的蛋疼问题,当 UILabel 行数大于 0 时,需要指定 preferredMaxLayoutWidth 后它才知道自己什么时候该换行,因为 UILabel 需要知道 superview 的宽度才能换,而 superview 的宽度还依仗着子 view 宽度的累加才能确定。
  • 自从 iOS8 之后,有了 self-sizing Cell 的概念,Cell 可以自己算出高度,使用 self-sizing cell 需要满足以下三个条件:
    • 使用 AutoLayout 进行 UI 布局约束,要求 cell.contentView 的四条边都与内部元素有约束关系;
    • 指定 TableView 的 estimatedRowHeight 属性的默认值;
    • 指定 TableView 的 rowHeight 属性为 UITableViewAutomaticDimension;
self.tableView.estimatedRowHeight = 44.0;
    self.tableView.rowHeight = UITableViewAutomaticDimension;
  • 这里又不得不吐槽,自动计算 rowHeight 跟 estimatedRowHeight 到底是有什么仇,如果不加上估算高度的设置,自动算高就失效了。iOS8 系统中 rowHeight 的默认值已经设置成了 UITableViewAutomaticDimension,所以第二行代码可以省略。

④ UITableView+FDTemplateLayoutCell

  • 使用 UITableView+FDTemplateLayoutCell 无疑是解决算高问题的最佳实践之一,既有 iOS8 以后的 self-sizing 功能简单的 API,又可以达到 iOS7 流畅的滑动效果,还保持了最低支持 iOS6。这个开源的扩展,请参考:UITableView-FDTemplateLayoutCell
  • 使用 Cocoapods 可以直接安装:
pod search UITableView+FDTemplateLayoutCell
  • 使用起来大概是这样:
#import <UITableView+FDTemplateLayoutCell.h>
 - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    return [tableView fd_heightForCellWithIdentifier:@"identifer" cacheByIndexPath:indexPath configuration:^(id cell) {
        // 配置 cell 的数据源,和 "cellForRow" 干的事一致,比如:
        cell.entity = self.feedEntities[indexPath.row];
    }];
}
  • 以上代码,产生的优化收益:
    • 和每个 UITableViewCell ReuseID 一一对应的 template layout cell,这个 cell 只为参加高度计算,不会真的显示到屏幕上;它通过 UITableView 的 -dequeueCellForReuseIdentifier: 方法 lazy 创建并保存,所以要求这个 ReuseID 必须已经被注册到了 UITableView 中,也就是说,要么是 Storyboard 中的原型 cell,要么就是使用了 UITableView 的 -registerClass:forCellReuseIdentifier: 或 -registerNib:forCellReuseIdentifier:其中之一的注册方法;
    • 根据 autolayout 约束自动计算高度:使用系统提供的 API:-systemLayoutSizeFittingSize:;
    • 根据 index path 的一套高度缓存机制:计算出的高度会自动进行缓存,所以滑动时每个 cell 真正的高度计算只会发生一次,后面的高度询问都会命中缓存,减少了非常可观的多余计算;
    • 自动的缓存失效机制:无须担心数据源的变化引起的缓存失效,当调用如 -reloadData,-deleteRowsAtIndexPaths:withRowAnimation: 等任何一个触发 UITableView 刷新机制的方法时,已有的高度缓存将以最小的代价执行失效。如删除一个 indexPath 为 [0:5] 的 cell 时,[0:0] ~ [0:4] 的高度缓存不受影响,而 [0:5] 后面所有的缓存值都向前移动一个位置;自动缓存失效机制对 UITableView 的 9 个公有 API 都进行了分别的处理,以保证没有一次多余的高度计算;
    • 预缓存机制:预缓存机制将在 UITableView 没有滑动的空闲时刻执行,计算和缓存那些还没有显示到屏幕中的 cell,整个缓存过程完全没有感知,这使得完整列表的高度计算既没有发生在加载时,又没有发生在滑动时,同时保证了加载速度和滑动流畅性。

⑤ 在 Model(Entity)中计算并保存 Cell 的高度

  • 在 Model(Entity)中保存 UI 的参数是很奇怪的,最好放在 ViewModel 中,就是 MVVM 模式的,那么 Entity 可能就是如下样子:
@interface DataEntity : NSObject

// 原始数据
@property(copy, nonatomic) NSString *content;
@property(copy, nonatomic) NSString *title;

// Cell 高度
@property(assign, nonatomic) CGFloat cellHeight;

// 计算高度
- (void)calculateCellHeight;

@end
  • 这样,就不用在 tableView:heightForRowAtIndexPath: 中每次都计算 cell 的高度。

五、异步绘制(自定义 Cell 绘制)

  • 遇到比较复杂的界面时(复杂点的图文混排),上面缓存行高的方式可能就不能满足要求。绘制的各个信息都是根据之前算好的布局进行绘制的,那么就需要异步绘制:
/**
 *  @brief  cell 添加 draw 方法
 */
- (void)draw {
    // 异步绘制
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        
    });
}

/** 
 *  @brief  重写 drawRect: 方法 
 */
- (void)drawRect:(CGRect)rect {
    // 不需要用 GCD 异步线程,因为 drawRect: 本来就是异步绘制的
}

六、滑动时,按需加载

  • 自定义 Cell 的种类千奇百怪,但它本来就是用来显示数据的,几乎百分之百的时候都带有图片,这个时候就要考虑,下滑的过程中可能会有点卡顿,尤其网络不好的时候,异步加载图片是个程序员都会想到,但是如果给每个循环对象都加上异步加载,开启的线程太多,一样会卡顿。
  • 这个时候,利用 UIScrollViewDelegate 两个代理方法就能很好地解决这个问题,如下所示:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { 
    if (needLoadArr.count > 0 && [needLoadArr indexOfObject:indexPath] == NSNotFound) {
         [cell clear];  // 清掉内容
    } 
    return cell;
}

// 按需加载 - 如果目标行与当前行相差超过指定行数,只在目标滚动范围的前后指定 3 行加载
 - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {
    NSIndexPath * ip  = [self.tableView indexPathForRowAtPoint:CGPointMake(0, targetContentOffset->y)];
    NSIndexPath * cip = [[self.tableView indexPathsForVisibleRows] firstObject];
    
    NSInteger skipCount = 8;
    
    // -8 < 当前位置 - 目标位置 < 8
    if (labs(cip.row - ip.row) > skipCount) {
        // 目标区域的 cell 的 indexPaths
        NSArray * temp = [self.tableView indexPathsForRowsInRect:CGRectMake(0, targetContentOffset->y, self.tableView.frame.size.width, self.tableView.frame.size.height)];
        
        NSMutableArray *arr = [NSMutableArray arrayWithArray:temp];
        if (velocity.y < 0) {
            NSIndexPath *indexPath = [temp lastObject];
            if (indexPath.row + 33) {
                [arr addObject:[NSIndexPath indexPathForRow:indexPath.row - 3 inSection:0]];
                [arr addObject:[NSIndexPath indexPathForRow:indexPath.row - 2 inSection:0]];
                [arr addObject:[NSIndexPath indexPathForRow:indexPath.row - 1 inSection:0]];
            }
        }
        [needLoadArr addObjectsFromArray:arr];
    }
}
  • 识别 UITableView 拖拽即将结束的时候,进行异步加载图片,快滑动过程中,只加载目标范围内的 Cell,这样按需加载,极大的提高流畅度。而 SDWebImage 可以实现异步加载,与这条性能配合就完美了,尤其是大量图片展示的时候,而且也不用担心图片缓存会造成内存警告的问题。

七、缓存 View

  • 当 Cell 中的部分 View 是非常独立且不便于重用的,"体积"非常小,在内存可控的前提下,完全可以将这些 view 缓存起来。
  • 方法当然也是将缓存的 view 放在 Entity 中。

八、尽量显示大小刚好合适的图片资源

  • 避免大量的图片缩放、颜色渐变等。

九、避免同步的从网络、文件获取数据

  • Cell 内实现的内容来自 web,使用异步加载,缓存请求结果。

十、渲染

  • 减少 subviews 的个数和层级:子控件的层级越深,渲染到屏幕上所需要的计算量就越大;如多用 drawRect 绘制元素,替代用 view 显示。
  • 少用 subviews 的透明图层:渲染最耗时的操作之一就是混合(blending)了。对于不透明的 View,设置 opaque = YES,这样在绘制该 View 时,避免 GPU 对 View 覆盖的其他内容也进行绘制。
  • 背景色不要使用 clearColor;
  • 避免 CALayer 特效(shadowPath):给 Cell 中 View 加阴影会引起性能问题,如下面代码会导致滚动时有明显的卡顿:
view.layer.shadowColor   = color.CGColor;
view.layer.shadowOffset  = offset;

view.layer.shadowOpacity = 1;

view.layer.shadowRadius  = radius;
  • 当有图像时,预渲染图像,在 bitmap context 先将其画一遍,导出成 UIImage 对象,然后再绘制到屏幕,这会大大提高渲染速度。

十一、异步加载图像

  • 缓存图像可以帮助我们在应用程序中快速实例化 tableView,并快速响应滚动,图片不是资产目录的一部分,而是应用包的一部分,用来模拟通过 URL 异步加载每个图片,这确保了用户界面保持响应性。
  • 当用户在视图中滚动时,应用程序会反复请求相同的图像,保存相关的完成模块直到图像加载,然后将图像传递给所有请求块,因此 API 只需要调用一次就可以为给定 URL 获取图像。
  • 如下所示,展示了项目如何构造一个基本的缓存和加载方法:
final func load(url: NSURL, item: Item, completion: @escaping (Item, UIImage?) -> Swift.Void) {
    // Check for a cached image.
    if let cachedImage = image(url: url) {
        DispatchQueue.main.async {
            completion(item, cachedImage)
        }
        return
    }
    // In case there are more than one requestor for the image, we append their completion block.
    if loadingResponses[url] != nil {
        loadingResponses[url]?.append(completion)
        return
    } else {
        loadingResponses[url] = [completion]
    }
    // Go fetch the image.
    ImageURLProtocol.urlSession().dataTask(with: url as URL) { (data, response, error) in
        // Check for the error, then data and try to create the image.
        guard let responseData = data, let image = UIImage(data: responseData),
            let blocks = self.loadingResponses[url], error == nil else {
            DispatchQueue.main.async {
                completion(item, nil)
            }
            return
        }
        // Cache the image.
        self.cachedImages.setObject(image, forKey: url, cost: responseData.count)
        // Iterate over each requestor for the image and pass it back.
        for block in blocks {
            DispatchQueue.main.async {
                block(item, image)
            }
            return
        }
    }.resume()
}
  • 在启动时加载所有数据的应用有耗尽内存或因耗时太长而终止的风险,除非应用程序需要在操作前加载所有数据,否则在 UI 请求时加载图像。
  • 通常,应用程序应该等到数据源请求一个单元格来获取和设置一个图像。如下,演示在可重用视图中获取和显示图像的一种方法:
var content = cell.defaultContentConfiguration()
content.image = item.image
ImageCache.publicCache.load(url: item.url as NSURL, item: item) { (fetchedItem, image) in
    if let img = image, img != fetchedItem.image {
        var updatedSnapshot = self.dataSource.snapshot()
        if let datasourceIndex = updatedSnapshot.indexOfItem(fetchedItem) {
            let item = self.imageObjects[datasourceIndex]
            item.image = img
            updatedSnapshot.reloadItems([item])
            self.dataSource.apply(updatedSnapshot, animatingDifferences: true)
        }
    }
}
cell.contentConfiguration = content

十二、参考资料

相关文章