iOS列表性能优化之异步绘制|掘金年度征文

4,360 阅读9分钟

一、需求背景

1、现状

iOS所提供的UIKit框架,其工作基本是在主线程上进行,界面绘制、用户输入响应交互等等。当大量且频繁的绘制任务,以及各种业务逻辑同时放在主线程上完成时,便有可能造成界面卡顿,丢帧现象,即在16.7ms内未能完成1帧的绘制,帧率低于60fps黄金标准。目前常用的UITableView或UICollectionView,在大量复杂文本及图片内容填充后,如果没有优化处理,快速滑动的情况下易出现卡顿,流畅性差问题。

2、需求

不依赖任何第三方pod框架,主要从异步线程绘制、图片异步下载渲染等方面,尽可能优化UITableView的使用,提高滑动流畅性,让帧率稳定在60fps。

(网上有很多优秀的性能优化博客和开源代码,本方案也是基于前人的经验,结合自身的理解和梳理写成demo,关键代码有做注释,很多细节值得推敲和持续优化,不足之处望指正。)

二、解决方案及亮点

1、方案概述

  • 异步绘制任务收集与去重;
  • 通过单例监听main runloop回调,执行异步绘制任务;
  • 支持异步绘制动态文本内容,减轻主线程压力,并缓存高度减少CPU计算;
  • 支持异步下载和渲染图片并缓存,仅在可视区域渲染;
  • 异步队列并发管理,择优选取执行任务;
  • 发现UITableView首次reload会触发3次的系统问题,初始开销增大,待优化;

2、问题点

  • 异步绘制时机及减少重复绘制;
  • 队列的并发和择优;

3、分析过程

1)异步绘制时机及减少重复绘制

(UIView绘制流程图,引用自:www.jianshu.com/p/1c1b3f7cf…

这里简单描述下绘制原理:当UI被添加到界面后,我们改变Frame,或更新 UIView/CALayer层次,或调用setNeedsLayout/setNeedsDisplay方法,均会添加重新绘制任务。这个时候系统会注册一个Observer监听BeforeWaiting(即将进入休眠)和Exit(即将退出Loop)事件,并回调执行当前绘制任务(setNeedsDisplay->display->displayLayer),最终更新界面。

由上可知,我们可以模拟系统绘制任务的收集,在runloop回调中去执行,并重写layer的dispaly方法,开辟子线程进行异步绘制,再返回主线程刷新。

当同个UI多次触发绘制请求时,怎样减少重复绘制,以便减轻并发压力比较重要。本案通过维护一个全局线程安全的原子性状态,在绘制过程中的关键步骤处理前均校验是否要放弃当前多余的绘制任务。

2)队列的并发和择优

一次runloop回调,经常会执行多个绘制任务,这里考虑开辟多个线程去异步执行。首选并行队列可以满足,但为了满足性能效率的同时确保不过多的占用资源和避免线程间竞争等待,更好的方案应该是开辟多个串行队列单线程处理并发任务。

接下来的问题是,异步绘制创建几个串行队列合适?

我们知道一个n核设备,并发执行n个任务,最多创建n个线程时,线程之间将不会互相竞争资源。因此,不建议数量设置超过当前激活的处理器数,并可根据项目界面复杂度以及设备性能适配,适当限制并发开销,文本异步绘制最大队列数设置如下:

#define kMAX_QUEUE_COUNT 6
    
- (NSUInteger)limitQueueCount {
    if (_limitQueueCount == 0) {
        // 获取当前系统处于激活状态的处理器数量
        NSUInteger processorCount = [NSProcessInfo processInfo].activeProcessorCount;
        // 根据处理器的数量和设置的最大队列数来设定当前队列数组的大小
        _limitQueueCount = processorCount > 0 ? (processorCount > kMAX_QUEUE_COUNT ? kMAX_QUEUE_COUNT : processorCount) : 1;
    }
    
    return _limitQueueCount;
}

文本的异步绘制串行队列用GCD实现,图片异步下载通过NSOperationQueue实现,两者最大并发数参考SDWebImage图片下载并发数的限制数:6。

如何择优选取执行任务?文本异步队列的选取,可以自定义队列的任务数标记,在队列执行任务前计算+1,当任务执行结束计算-1。这里忽略每次绘制难易度的略微差异,我们便可以判定任务数最少接近于最优队列。图片异步下载任务,交由NSOperationQueue处理并发,我们要处理的是,让同个图片在多次并发下载请求下,仅生成1个NSOperation添加到queue,即去重只下载一次并缓存,且在下载完成后返回主线程同步渲染多个触发该下载请求的控件(本案demo仅用一张图片,所以这种情况必须考虑到)。

三、详细设计

1、设计图

2、代码原理剖析(写在注释)

1)设置runloop监听及回调

/**
 runloop回调,并发执行异步绘制任务
 */
static NSMutableSet<ADTask *> *_taskSet = nil;
static void ADRunLoopCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    if (_taskSet.count == 0) return;
    NSSet *currentSet = _taskSet;
    _taskSet = [NSMutableSet set];
    [currentSet enumerateObjectsUsingBlock:^(ADTask *task, BOOL *stop) {
        [task excute];
    }];
}


/** task调用函数
- (void)excute {
    ((void (*)(id, SEL))[self.target methodForSelector:self.selector])(self.target, self.selector);
}
*/


- (void)setupRunLoopObserver {
    // 创建任务集合
    _taskSet = [NSMutableSet set];
    // 获取主线程的runloop
    CFRunLoopRef runloop = CFRunLoopGetMain();
    // 创建观察者,监听即将休眠和退出
    CFRunLoopObserverRef observer = CFRunLoopObserverCreate(CFAllocatorGetDefault(),
                                       kCFRunLoopBeforeWaiting | kCFRunLoopExit,
                                       true,      // 重复
                                       0xFFFFFF,  // 设置优先级低于CATransaction(2000000)
                                       ADRunLoopCallBack, NULL);
    CFRunLoopAddObserver(runloop, observer, kCFRunLoopCommonModes);
    CFRelease(observer);

2)创建、获取文本异步绘制队列,并择优选取

- (ADQueue *)ad_getExecuteTaskQueue {
    // 1、创建对应数量串行队列处理并发任务,并行队列线程数无法控制
    if (self.queueArr.count < self.limitQueueCount) {
        ADQueue *q = [[ADQueue alloc] init];
        q.index = self.queueArr.count;
        [self.queueArr addObject:q];  
        q.asyncCount += 1;
        NSLog(@"queue[%ld]-asyncCount:%ld", (long)q.index, (long)q.asyncCount);
        return q;
    }
    
    // 2、当队列数已达上限,择优获取异步任务数最少的队列
    NSUInteger minAsync = [[self.queueArr valueForKeyPath:@"@min.asyncCount"] integerValue];
    __block ADQueue *q = nil;
    [self.queueArr enumerateObjectsUsingBlock:^(ADQueue * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        if (obj.asyncCount <= minAsync) {
            *stop = YES;
            q = obj;
        }
    }];
    q.asyncCount += 1;
    NSLog(@"queue[%ld]-excute-count:%ld", (long)q.index, (long)q.asyncCount);
    return q;
}


- (void)ad_finishTask:(ADQueue *)q {
    q.asyncCount -= 1;
    if (q.asyncCount < 0) {
        q.asyncCount = 0;
    }
    NSLog(@"queue[%ld]-done-count:%ld", (long)q.index, (long)q.asyncCount);
}

3)异步绘制

/**
 维护线程安全的绘制状态
 */
@property (atomic, assign) ADLayerStatus status;


- (void)setNeedsDisplay {
    // 收到新的绘制请求时,同步正在绘制的线程本次取消
    self.status = ADLayerStatusCancel;
    
    [super setNeedsDisplay];
}


- (void)display {
    // 标记正在绘制
    self.status = ADLayerStatusDrawing;
    
    if ([self.delegate respondsToSelector:@selector(asyncDrawLayer:inContext:canceled:)]) {
        [self asyncDraw];
    } else {
        [super display];
    }
}


- (void)asyncDraw {
    
    __block ADQueue *q = [[ADManager shareInstance] ad_getExecuteTaskQueue];
    __block id<ADLayerDelegate> delegate = (id<ADLayerDelegate>)self.delegate;
    
    dispatch_async(q.queue, ^{
        // 重绘取消
        if ([self canceled]) {
            [[ADManager shareInstance] ad_finishTask:q];
            return;
        }
        // 生成上下文context
        CGSize size = self.bounds.size;
        BOOL opaque = self.opaque;
        CGFloat scale = [UIScreen mainScreen].scale;
        CGColorRef backgroundColor = (opaque && self.backgroundColor) ? CGColorRetain(self.backgroundColor) : NULL;
        UIGraphicsBeginImageContextWithOptions(size, opaque, scale);
        CGContextRef context = UIGraphicsGetCurrentContext();
        if (opaque && context) {
            CGContextSaveGState(context); {
                if (!backgroundColor || CGColorGetAlpha(backgroundColor) < 1) {
                    CGContextSetFillColorWithColor(context, [UIColor whiteColor].CGColor);
                    CGContextAddRect(context, CGRectMake(0, 0, size.width * scale, size.height * scale));
                    CGContextFillPath(context);
                }
                if (backgroundColor) {
                    CGContextSetFillColorWithColor(context, backgroundColor);
                    CGContextAddRect(context, CGRectMake(0, 0, size.width * scale, size.height * scale));
                    CGContextFillPath(context);
                }
            } CGContextRestoreGState(context);
            CGColorRelease(backgroundColor);
         } else {            CGColorRelease(backgroundColor);
        }        // 使用context绘制
        [delegate asyncDrawLayer:self inContext:context canceled:[self canceled]];
        // 重绘取消
        if ([self canceled]) {
            [[ADManager shareInstance] ad_finishTask:q];
            UIGraphicsEndImageContext();
            return;
        }
        // 获取image
        UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
        UIGraphicsEndImageContext();
        // 结束任务
        [[ADManager shareInstance] ad_finishTask:q];
        // 重绘取消
        if ([self canceled]) {
            return;
        }
        // 主线程刷新
        dispatch_async(dispatch_get_main_queue(), ^{
            self.contents = (__bridge id)(image.CGImage);
        });
        
    });
}

3)异步下载缓存图片

#pragma mark - 处理图片
- (void)ad_setImageWithURL:(NSURL *)url target:(id)target completed:(void (^)(UIImage * _Nullable image, NSError * _Nullable error))completedBlock {
    if (!url) {
        if (completedBlock) {
            NSDictionary *userInfo = @{NSLocalizedFailureReasonErrorKey: NSLocalizedStringFromTable(@"Expected URL to be a image URL", @"AsyncDraw", nil)};
            NSError *error = [[NSError alloc] initWithDomain:kERROR_DOMAIN code:NSURLErrorBadURL userInfo:userInfo];
            completedBlock(nil, error);
        }
        return;
    }
    
    // 1、缓存中读取
    NSString *imageKey = url.absoluteString;
    NSData *imageData = self.imageDataDict[imageKey];
    if (imageData) {
        UIImage *image = [UIImage imageWithData:imageData];
        if (completedBlock) {
            completedBlock(image, nil);
        }
    } else {
        
        // 2、沙盒中读取
        NSString *imagePath = [NSString stringWithFormat:@"%@/Library/Caches/%@", NSHomeDirectory(), url.lastPathComponent];
        imageData = [NSData dataWithContentsOfFile:imagePath];
        if (imageData) {
            UIImage *image = [UIImage imageWithData:imageData];
            if (completedBlock) {
                completedBlock(image, nil);
            }
        } else {
            
            // 3、下载并缓存写入沙盒
            ADOperation *operation = [self ad_downloadImageWithURL:url toPath:imagePath completed:completedBlock];
            // 4、添加图片渲染对象
            [operation addTarget:target];
        }
    }
}


- (ADOperation *)ad_downloadImageWithURL:(NSURL *)url toPath:(NSString *)imagePath completed:(void (^)(UIImage * _Nullable image, NSError * _Nullable error))completedBlock  {
    NSString *imageKey = url.absoluteString;
    
    ADOperation *operation = self.operationDict[imageKey];
    if (!operation) {
        operation = [ADOperation blockOperationWithBlock:^{
            NSLog(@"AsyncDraw image loading~");
            NSData *newImageData = [NSData dataWithContentsOfURL:url];
            
            // 下载失败处理
            if (!newImageData) {
                [self.operationDict removeObjectForKey:imageKey];
                
                NSDictionary *userInfo = @{NSLocalizedFailureReasonErrorKey: NSLocalizedStringFromTable(@"Failed to load the image", @"AsyncDraw", nil)};
                NSError *error = [[NSError alloc] initWithDomain:kERROR_DOMAIN code:NSURLErrorUnknown userInfo:userInfo];
                if (completedBlock) {
                    completedBlock(nil, error);
                }
                return;
            }
            
            // 缓存图片数据
            [self.imageDataDict setValue:newImageData forKey:imageKey];
        }];
        
        // 设置完成回调
        __block ADOperation *blockOperation = operation;
        [operation setCompletionBlock:^{
            NSLog(@"AsyncDraw image load completed~");
            // 取缓存
            NSData *newImageData = self.imageDataDict[imageKey];
            if (!newImageData) {
                return;
            }
            
            // 返回主线程刷新
            [[NSOperationQueue mainQueue] addOperationWithBlock:^{
                UIImage *newImage = [UIImage imageWithData:newImageData];
                // 遍历渲染同个图片地址的所有控件
                [blockOperation.targetSet enumerateObjectsUsingBlock:^(id  _Nonnull obj, BOOL * _Nonnull stop) {
                    if ([obj isKindOfClass:[UIImageView class]]) {
                        UIImageView *imageView = (UIImageView *)obj;
                        // ADImageView内部判断“超出可视范围,放弃渲染~”
                        imageView.image = newImage;
                    }
                }];
                [blockOperation removeAllTargets];
            }];
            
            // 写入沙盒
            [newImageData writeToFile:imagePath atomically:YES];
            
            // 移除任务
            [self.operationDict removeObjectForKey:imageKey];
        }];
        
        // 加入队列
        [self.operationQueue addOperation:operation];
        
        // 添加opertion
        [self.operationDict setValue:operation forKey:imageKey];
    }
    
    return operation;
}

四、使用示例

1)文本异步绘制

@implementation ADLabel


#pragma mark - Pub MD
- (void)setText:(NSString *)text {
    _text = text;


    [[ADManager shareInstance] addTaskWith:self selector:@selector(asyncDraw)];
}
// 绑定异步绘制layer
+ (Class)layerClass {
    return ADLayer.class;
}


#pragma mark - Pri MD
- (void)asyncDraw {
    [self.layer setNeedsDisplay];
}


#pragma mark - ADLayerDelegate
- (void)layerWillDraw:(CALayer *)layer {
}


- (void)asyncDrawLayer:(ADLayer *)layer inContext:(CGContextRef __nullable)ctx canceled:(BOOL)canceled {
    
    if (canceled) {
        NSLog(@"异步绘制取消~");
        return;
    }
    
    UIColor *backgroundColor = _backgroundColor;
    NSString *text = _text;
    UIFont *font = _font;
    UIColor *textColor = _textColor;
    CGSize size = layer.bounds.size;
    
    CGContextSetTextMatrix(ctx, CGAffineTransformIdentity);
    CGContextTranslateCTM(ctx, 0, size.height);
    CGContextScaleCTM(ctx, 1, -1);


    // 绘制区域
    CGMutablePathRef path = CGPathCreateMutable();
    CGPathAddRect(path, NULL, CGRectMake(0, 0, size.width, size.height));
    
    // 绘制的内容属性字符串
    NSDictionary *attributes = @{NSFontAttributeName : font,
                                 NSForegroundColorAttributeName: textColor,
                                 NSBackgroundColorAttributeName : backgroundColor,
                                 NSParagraphStyleAttributeName : self.paragraphStyle ?:[NSParagraphStyle new]
                                 };
    NSMutableAttributedString *attrStr = [[NSMutableAttributedString alloc] initWithString:text attributes:attributes];
    
    // 使用NSMutableAttributedString创建CTFrame
    CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attrStr);
    CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attrStr.length), path, NULL);
    CFRelease(framesetter);
    CGPathRelease(path);
    
    // 使用CTFrame在CGContextRef上下文上绘制
    CTFrameDraw(frame, ctx);
    CFRelease(frame);
}

2)图片异步下载渲染

@implementation ADImageView


#pragma mark - Public Methods
- (void)setUrl:(NSString *)url {
    _url = url;
    
    [[ADManager shareInstance] ad_setImageWithURL:[NSURL URLWithString:self.url] target:self completed:^(UIImage * _Nullable image, NSError * _Nullable error) {
        if (image) {
            self.image = image;
        }
    }];
}

五、成效举证

针对本案制作了AsyncDrawDemo,是一个图文排列布局的UITableView列表,类似新闻列表,TestTableViewCell.m中有异步绘制和图片异步下载渲染开关

#define kAsyncDraw true // 异步开关
//#define kOnlyShowText true // 仅显示文本进行测试

kAsyncDraw开启前后测试对比清单:

  • 同样加载1000条数据的列表
  • 动态文本缓存高度
  • 同一设备:真机iPhone11 iOS13.5.1
  • 操作:列表首次加载完成,帧率显示60fps后,快速向上滑动至底部

本案通过YYFPSLabel观察帧率大致均值变化,以及内存/CPU变化截图如下:

1)未开启异步前:

稳定60fps后开始快速滑动至列表底部的前后对比(帧率最低到1fps,滑动过程异常卡顿,cpu未超过40%,内存占用也不多,但非常耗电):

2)开启异步后:

稳定60fps后开始快速滑动至列表底部的前后对比(帧率稳定在60fps,滑动过程非常流畅,cpu最高超过90%,内存占用到达200MB,耗电小)

通过以上对比得出的结论是:未开启“异步绘制和异步下载渲染”,虽然cpu、内存未见异常,但列表滑动卡顿,非常耗电;开启后,虽然内存占用翻倍、cpu也达到过90%,但相对于4G内存和6核CPU的iPhone11来说影响不大,流畅性和耗电得到保障。由此得出结论,UITableView性能优化的关键在于“系统资源充分满足调配的前提下,能异步的尽量异步”,否则主线程压力大引起卡顿,丢帧和耗电在所难免。

补充说明:当打开kOnlyShowText开关,仅显示文本内容进行测试时,在未打开kAsyncDraw开关前快速滑动列表,帧率出现40~50fps,可感知快速滑动下并不流畅。虽然UITableView性能优化主要体现在大图异步下载渲染的优化,文本高度的缓存对于多核CPU设备性能提升效果确实不明显,但文本异步绘制则让性能更上一层。

六、核心代码范围

DEMO地址:github.com/stkusegithu…

代码位于目录 AsyncDrawDemo/AsyncDrawDemo/Core/下

\---AsyncDraw

+---ADManager.h

+---ADManager.m

+---ADLayer.h

+---ADLayer.m

+---ADTask.h

+---ADTask.m

+---ADQueue.h

+---ADQueue.m

+---ADOperation.h

+---ADOperation.m

\---AsyncUI

+---ADLabel.h

+---ADLabel.m

+---ADImageView.h

+---ADImageView.m

掘金年度征文 | 2020 与我的技术之路 征文活动正在进行中......