iOS—UITableView性能优化

文章目录

前言

UITableView的优化本质上是提高滚动性能并减少内存使用,保证使用过程的流畅。核心是通过降低CPU与GPU的工作来提升性能

  • CPU;负责对象的创建和销毁,对象属性的调整、布局的计算、文本的计算与排版、图片的格式转换、解码、图像的绘制
  • 接受提交的纹理和顶点描述,应用变换、混合并渲染输出到屏幕上

卡顿产生原因

APP在主线程CPU中显示计算内容,然后将计算好的内容提交的GPU中进行变换、合成、渲染。其中包括离屏渲染

CPU层面优化

1.使用轻量级的对象

在一些不需要响应事件处理的地方,可以使用CALayer代替UIView,因为CALayer更底层一些,更接近于底层的渲染引擎,UIView的渲染最终也是由底层的CALayer来完成的,但是直接使用CALayer可以减少一些UIView带来的额外计算和抽象层次。

objc 复制代码
CALayer* imageLayer = [CALayer layer];
imageLayer.bounds = CGRectMake(0, 0, 200, 100);
imageLayer.position = CGPointMake(200, 200);
imageLayer.contents = (id)[UIImage imageNamed:@"xx.jpg"].CGIMage;
imageLayer.contentsGravity = kCAGravityResizeAspect;
[tablesCell.contentView.layer addSublayer:imageLayer];

2.cellForRowAtIndex方法中尽量不要做耗时操作

Frame、bounds、transform等属性尽量减少不必要的修改。

  • 不读取、写入文件
  • 不给cell动态添加subView,可以在cell初始化的时候就将所有需要展示的内容添加完毕,然后根据需要通过hidden顺序显示或者隐藏

3.提前计算好布局

了解tableView的代理方法执行顺序:

  • 如果我们设定了estimatedRowHeight,tableView会在每一次调用cellForRow的时候调用一次对应的heightFor'Row方法,这种情况cell的高度可能会跳动,先预估后修正。
  • 如果我们没有设置estimatedRowHeight,tableView会先全量遍历heightForRow获取总内容高度contentSize,再调用cellForRow,此时需要再调用一次heightForRow来确认
  • 在iOS11之后estimatedRowHeight默认等于-1

UITableViewCell高度计算主要有两种,一种固定高度,一种动态高度

  • 固定:rowHeight高度默认为44,我们直接设置rowHeight比heightForRow更加高效

  • 动态:采用heightForRow代理方式,设置代理之后,rowHeight无效,需要满足下面三个条件(自适应行高条件)

    • 使用自动布局进行UI布局约束

    • 指定estimatedRowHeight默认值

    • 指定tableView的rowheight属性为UITableViewAutomaticDimension

    objc 复制代码
    self.tableView.rowHeight = UITableViewAutomaticDimension;
    self.tableView.estimatedRowHeight = 50;

对于已经计算好的cell高度,需要进行缓存

UITab了View在计算cell高度时流程:

  1. 创建一个"离屏 cell"
  2. 给它固定宽度
  3. 让 AutoLayout 计算 contentView 的压缩高度
  4. 用系统计算出的高度作为 rowHeight

estimatedRowHeight计算时机

  • 估算阶段:通过设置estimatedRowHeight,tableView使用其快速估算整个表格的滚动范围,这个估算值不需要精确匹配每个cell的时机高度,但是应该接近平均高度,可以优化性能。
  • 精确计算阶段:当cell即将显示在屏幕上的时候,tableView根据自动布局约束来计算cell1的实际高度,通常发生在滚动过程中,当新的cell即将进入可视区域时。

这个阶段通常发生在heightForRow之后willDisplayCell之前

实现高度缓存

存储每个cell经过计算过的高度值,在表格请求高度时重用。

大概过程就是在heightForRow方法中获取缓存高度,在willDisplayCell中缓存cell高度,下次直接获取。

objc 复制代码
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {{
        NSString *cacheKey = [NSString stringWithFormat:@"%ld-%ld", (long)indexPath.section, (long)indexPath.row];
        // 检查缓存
        NSNumber *cachedHeight = [self.heightCache objectForKey:cacheKey];
        if (cachedHeight) {
            NSLog(@"存在缓存%@", cacheKey);
            return cachedHeight.doubleValue;
        } else {
            // 缓存行高
            return UITableViewAutomaticDimension;
        }
    }
}

然后我们在willDisplayCell中获取行高并将其加入缓存中,当滑动到已经缓存过高度的cell时,heightForRowAtIndexPath方法就会先检查缓存是否存储过,避免重新计算产生的开销。

objc 复制代码
- (void)tableView:(UITableView* )tableView willDisplayCell:(UITableViewCell* )cell forRowAtIndexPath:(NSIndexPath* )indexPath {
  CGFloat cellHeight = cell.frame.size.height;
  NSString* cacheKey = [NSString stringWithFormat:@"%ld-%ld", (long)indexPath.section, (long)indexPath.row];
  [self.heightCache setObject:@(cellHeight) forKey:cacheKey];
}

缓存实效

当我们删除数据时,cell的顺序也会跟着移动,导致缓存也跟随着移动。所以我们可以选择不易变动的index,例如评论id

objc 复制代码
@interface LZTableViewHeightCache : NSObject
@property (nonatomic, strong)NSMutableDictionary<NSString* , NSNumber* >* heightMap;
+ (instancetype)shared;
@end

@implementation LZTableViewHeightCache
+ (instancetype)shared {
  static LZTableViewHeightCache* instance = nil;
  static dispatch_once_t onceToken;
  dispatch_once(&onceToken, ^{
    instance = [[LZTableViewHeightCache alloc] init];
  });
  return instance;
}

- (instancetype)init {
  if (self = [super init]) {
    _heightMap = [NSMutableDictionary dictionary];
  }
  return self;
}

- (NSString *)keyForItemId:(NSString *)itemId width:(CGFloat)width {
    return [NSString stringWithFormat:@"%@|%.0f", itemId, width];
}

- (NSNumber *)heightForItemId:(NSString *)itemId width:(CGFloat)width {
    return self.heightMap[[self keyForItemId:itemId width:width]];
}

- (void)setHeight:(CGFloat)height forItemId:(NSString *)itemId width:(CGFloat)width {
    self.heightMap[[self keyForItemId:itemId width:width]] = @(height);
}

- (void)invalidateForItemIds:(NSArray<NSString *> *)itemIds width:(CGFloat)width {
    for (NSString *itemId in itemIds) {
        [self.heightMap removeObjectForKey:[self keyForItemId:itemId width:width]];
    }
}

- (void)invalidateAll {
  [self.heightMap removeAllObjects];
}

@end

在cell中尝试获取缓存高度:

objc 复制代码
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    MyModel *model = self.dataSource[indexPath.row];
    NSNumber *height = [self.heightCache heightForItemId:m.itemId width:CGRectGetWidth(tableView.bounds)];
    return height ? height.doubleValue : UITableViewAutomaticDimension;
}

- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell
forRowAtIndexPath:(NSIndexPath *)indexPath {
    MyModel *model = self.dataSource[indexPath.row];
    CGFloat height = CGRectGetHeight(cell.frame);
    [self.heightCache setHeight:height forItemId:model.itemId width:CGRectGetWidth(tableView.bounds)];
}

// 数据变更时缓存失效,重新计算
- (void)applyUpdates:(NSArray<MyModel *> *)changed {
    CGFloat width = CGRectGetWidth(self.tableView.bounds);
    [self.heightCache invalidateForItemIds:[changed valueForKey:@"itemId"] width:width];
    [self.tableView reloadData];
}

// 横竖屏/宽度变化时高度缓存全部清除
- (void)viewWillTransitionToSize:(CGSize)size
       withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
    [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
    [self.heightCache invalidateAll];
}

预缓存

通过RunLoop,页面空闲时执行计算,对未显示的cell高度进行缓存。

整体架构设计如下:

页面加载 -> 先显示列表 -> RunLoop空闲 -> 诸葛计算未显示cell的高度 -> 缓存高度 -> 刷新对应的cell

heightCacheManager -> RunLoop Observer -> 空闲时执行一个任务

  • 准备高度缓存
objc 复制代码
@interface FreeModel : NSObject
@property (nonatomic, assign)
  • 实现RunLoop计算类
objc 复制代码
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN
typedef void(^IdleTask) (void);
@interface LZIdleTaskQueue : NSObject
- (void)addTask:(IdleTask)task;
- (void)start;
- (void)stop;
@end

NS_ASSUME_NONNULL_END


#import "LZIdleTaskQueue.h"

@implementation LZIdleTaskQueue {
  CFRunLoopObserverRef _observer;
  NSMutableArray<IdleTask>* _tasks;
  BOOL _started;
}

- (instancetype)init {
  if (self = [super init]) {
    _tasks = [NSMutableArray array];
  }
  return self;
}

- (void)addTask:(IdleTask)task {
  if (task) {
    [_tasks addObject:[task copy]];
  }
}

static void RunLoopIdleCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
  LZIdleTaskQueue *queue = (__bridge LZIdleTaskQueue *)info;
  NSInteger burst = 3;    // 控制单次处理任务数,避免长时间占用
  while (burst-- > 0 && queue->_tasks.count > 0) {
    IdleTask t = queue->_tasks.firstObject;
    [queue->_tasks removeObjectAtIndex:0];
    t();
  }
}

- (void)start {
  if (_started) {
    _started = YES;
  }
  CFRunLoopObserverContext ctx = {0, (__bridge  void*)self, NULL, NULL, NULL};
  _observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, INT_MAX, RunLoopIdleCallback, &ctx);
  CFRunLoopAddObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);
}

- (void)stop {
  if (!_started) {
    return;
  }
  _started = NO;
  if (_observer) {
    CFRunLoopRemoveObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);
    CFRelease(_observer);
    _observer = nil;
  }
  [_tasks removeAllObjects];
}
@end
  • 实现预加载
objc 复制代码
#pragma mark -UITableView预缓存高度实实现

- (MainCommentCell*)sizingCell {
  static MainCommentCell* cell;
  if (!cell) {
    cell = [self.tableView dequeueReusableCellWithIdentifier:@"cellOfComment"];
  }
  return cell;
}

- (CGFloat)heightForModel:(ZLCommentModel* )model width:(CGFloat)width {
  MainCommentCell* cell = [self sizingCell];
  [cell prepareForReuse];
  [self configureCell:cell withModel:model];
  cell.bounds = (CGRect) {
    CGPointZero,{
      width, CGFLOAT_MAX
    }
  };
  [cell setNeedsLayout];
  [cell layoutIfNeeded];
  CGSize size = [cell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
  return size.height;
}

- (void)configureCell:(MainCommentCell* )cell withModel:(ZLCommentModel* )model {
  //
}
//
- (void)preCacheHeightAroundIndex:(NSInteger)start count:(NSInteger)count {
  CGFloat width = CGRectGetWidth(self.tableView.bounds);
  for (int i = 0; i < self.commentsArray.count; i++) {
    __weak typeof (self) weakSelf = self;
    ZLCommentModel* model = [self.commentsArray objectAtIndex:i];
    if (model.cellHeight) {
      continue;
    }
    [self.idleQueue addTask:^{
      CGFloat h = [weakSelf heightForModel:model width:width];
      model.cellHeight = h;
    }];
  }
}

4.直接设置frame

自动布局与直接设置frame相比会消耗更多的CPU资源去计算尺寸

5. 使用合适图片尺寸

图片的 size 最好与 UIImageView 的 size 保持一致, 图片通过contentMode处理显示,对tableview滚动速度同样会造成影响 。从网络下载图片后先根据需要显示的图片大小压缩活剪切成合适大小的图,每次只显示处理过大小的图片,当查看大图时在显示大图。 (服务器直接返回预处理好的小图和大图以及对应的尺寸最好)

objc 复制代码
// 根据特定的区域对图片进行裁剪
+ (UIImage*)lz_cutImageWithImage:(UIImage*)image andFrame:(CGRect)cropRect{
    return ({
        CGImageRef tmp = CGImageCreateWithImageInRect([image CGImage], cropRect);
        UIImage *newImage = [UIImage imageWithCGImage:tmp scale:image.scale orientation:image.imageOrientation];
        CGImageRelease(tmp);
        newImage;
    });
}//将原图按照给定的矩形裁剪出一块子视图位新的UIImage

6.控制最大并发数量

可以根据情况调整下载线程的并发数量,一般当下载线程超过2时会显著影响主线程性能。可以通过设置NSOperationQueue的maxCountOperationCount来维护下载请求

7. 异步绘制加载图片

使用SDWebImage异步加载图片。

  • 使用异步子线程处理,再返回主线程操作。
  • 图片缓存
  • 图片圆角处理,设置layer的shouldRasterize属性位YES,可以将负载转移给CPU。
objc 复制代码
// 切换至子线程
dispatch_async(dispatch_get_global_queue(0, 0), ^{
    UIGraphicsBeginImageContextWithOptions(size, NO, scale);
    CGContextRef context = UIGraphicsGetCurrentContext();
    //draw in context...
    CGImageRef imgRef = CGBitmapContextCreateImage(context);
    UIGraphicsEndImageContext();
    dispatch_async(dispatch_get_main_queue(), ^{
        self.layer.contents = imgRef;
    });
});

GPU层面

1.避免短时间内大量显示图片

2.控制尺寸

GPU能处理的最大纹理尺寸是4096*4096,超过这个尺寸就会占用CPU资源处理,所以纹理最好不要超过尺寸

3.减少图层混合操作

当多个视图叠加,放在上面的视图是半透明的,那么此时GPU把透明的颜色加上放在下面的视图的颜色混合之后得出一个颜色再显示在屏幕上,这一步消耗GPU的资源

4.减少透明的视图,不透明就设置opaque为yes

5.避免离屏渲染

离屏渲染就是在当前屏幕缓冲区外开辟一个缓冲区进行擦欧哦,整个过程中多次切换上下文环境,先是将屏幕切换到离屏,等待离屏渲染结束后,将离屏缓冲区的渲染结果显示到屏幕上,又需要将上下文环境从离屏切换为当前屏幕。都需要消耗资源。

下面情况或操作会触发离屏渲染

  • 光栅化,layer.shouldRasterize = yes;(光栅化是将layer及其子layer渲染成一张位图缓存,开启光栅化后可以直接复用,适合用于复杂但不经常变化的UI,开启后GOU只需要贴图,不需要每帧重新计算)
  • 遮罩,layer.mask(GPU需要先渲染mask,再和原layer做alpha混合。在tableView Cell中使用容易掉帧)
  • 圆角,同时设置 layer.masksToBounds = YES 和 layer.cornerRadius > 0
  • 阴影,layer.shadow
  • layer.allowsGroupOpacity = YES 和 layer.opacity != 1
  • 重写drawRect方法(原本是GPU渲染,但是重写后CPU会参与渲染)

圆角优化

同时设置layer的masksToBounds为YES和layer的cornerRadius大于0会产生离屏渲染。不过常规视图切圆角时可以只使用view.layer.cornersRadius = 3.0,这时不会产生离屏渲染,但是UImageView比较特殊,切圆角时需要同时设置。可以考虑通过CoreGraphics绘制裁剪圆角。

objc 复制代码
# pragma mark -美工提供圆角图片
- (UIImage* )lz_ellipseImage {
  UIGraphicsBeginImageContextWithOptions(self.size, NO, 0.0);
  CGContextRef ctx = UIGraphicsGetCurrentContext();
  CGRect rect = CGRectMake(0, 0, self.size.width, self.size.height);
  CGContextAddEllipseInRect(ctx, rect);
  CGContextClip(ctx);
  [self drawInRect:rect];
  UIImage* image = UIGraphicsGetImageFromCurrentImageContext();
  UIGraphicsEndImageContext();
  return image;
}

此外还可以使用贝塞尔曲线绘制圆角,具体实现前面博客有涉及过。通过此方式还可以设置特定角是否为圆角,但是这种情况喜爱切割角度有限。

阴影优化

如果涂层只是个简单的几何图形,我们可以通过设置shadowPath来优化性能

objc 复制代码
imageView.layer.shadowColor = [UIColor grayColor].CGColor;
imageView.layer.shadowOpacity = 1.0;
imageView.layer.shadowRadius = 1.0;
UIBezierPath* path = [UIBezierPath bezierPathWithRect:imageView.frame];//按照边框绘出阴影路径
imageView.layer.shadowPath = path.CGPath;

强制开启光栅化

当图像混合来多个图层,每次移动时每一帧都需要重新合成这些图层,十分消耗性能,这时就可以强制开启光栅化,layer.shouldRasterize = YES,开启光栅化之后,会在首次产生一个位图缓存,当再次使用时就会复用这个缓存,但是如果图层发生改变的时候就会重新产生位图缓存,所以这个功能不用在UItableViewCell中。

优化建议

  • 使用shadowpath绘制阴影路径。
  • 使用异步进行layer渲染
  • 将uitableview的opaque属性设置为YES,减少复杂图层的合成
  • 尽量使用不包含透明alpha通道的图片资源
  • 尽量设置layer的大小值为整形值
  • 背景色的alpha应该为1
  • 使用美工方法裁剪图片圆角
相关推荐
龙智DevSecOps解决方案2 小时前
活动邀请 | Perforce on Tour 2026—游戏研发效能进阶沙龙(3月25日,广州)
游戏·性能优化·版本控制·perforce
老师好,我是刘同学2 小时前
贪心算法与优先队列实战解析
算法·ios·贪心算法
2501_915918412 小时前
iOS App HTTPS 抓包工具,代理抓包和数据线直连 iPhone 抓包的流程
android·ios·小程序·https·uni-app·iphone·webview
少云清2 小时前
【UI自动化测试】2_IOS自动化测试 _使用模拟器
ui·ios
2501_915909062 小时前
iOS 开发编译与真机调试流程的新思路,用快蝎 IDE 构建应用
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
小小unicorn2 小时前
[微服务即时通讯系统]语音子服务的实现与测试
c++·算法·微服务·云原生·架构·xcode
2501_915106322 小时前
iOS 应用打包流程,不用 Xcode 生成安装包
ide·vscode·macos·ios·个人开发·xcode·敏捷流程
Shining05963 小时前
CPU 并行编程系列《CPU 性能优化导论》
人工智能·学习·其他·性能优化·infinitensor
AI成长日志3 小时前
【微调专栏】微调性能优化实战:显存优化、训练加速与成本控制的全链路解决方案
人工智能·深度学习·机器学习·性能优化