Async Drawing and Image Download Optimization for UITableView to Achieve Stable 60 fps
This article presents a complete, pod‑free solution for improving UITableView performance on iOS by moving heavy text rendering and image loading to background threads, deduplicating tasks, managing concurrent queues, and caching results, ultimately keeping the UI consistently at 60 fps.
1. Background
UIKit operates mainly on the main thread; intensive drawing, layout, and user‑interaction tasks can cause UI stutter and frame drops when a frame cannot be rendered within the 16.7 ms budget (60 fps). Large UITableView cells with complex text and images often suffer from this problem.
2. Requirement
Provide a solution that does not rely on any third‑party CocoaPods, focusing on asynchronous drawing, asynchronous image download, and caching to improve UITableView scrolling smoothness and keep the frame rate at 60 fps.
3. Solution Overview
Collect and deduplicate asynchronous drawing tasks.
Use a singleton to listen to the main run‑loop callbacks and execute pending drawing tasks.
Support asynchronous rendering of dynamic text, cache calculated heights, and reduce CPU load.
Support asynchronous image download with caching, rendering only visible cells.
Manage a pool of serial queues to execute tasks efficiently.
Identify a UIKit issue where the first reload of a UITableView triggers three system calls, increasing initial overhead.
4. Problem Points
When to trigger asynchronous drawing and how to avoid duplicate draws.
How to configure queue concurrency and select the optimal queue for a task.
5. Analysis – Asynchronous Drawing Timing
UIKit adds a redraw request whenever a view’s frame changes, a layer hierarchy is updated, or setNeedsLayout/setNeedsDisplay is called. The system registers an observer for kCFRunLoopBeforeWaiting and kCFRunLoopExit events and executes the pending draw calls during those callbacks.
By intercepting these callbacks we can move the actual drawing to a background thread, then push the final image back to the main thread.
6. Analysis – Queue Concurrency and Selection
Multiple draw tasks may be ready in a single run‑loop callback. Parallel queues provide raw concurrency but can cause resource contention; therefore we create a set of serial queues and assign tasks to the queue with the smallest pending count.
#define kMAX_QUEUE_COUNT 6
- (NSUInteger)limitQueueCount {
if (_limitQueueCount == 0) {
// Get the number of active processors
NSUInteger processorCount = [NSProcessInfo processInfo].activeProcessorCount;
// Choose the smaller of processorCount and kMAX_QUEUE_COUNT, default to 1
_limitQueueCount = processorCount > 0 ? (processorCount > kMAX_QUEUE_COUNT ? kMAX_QUEUE_COUNT : processorCount) : 1;
}
return _limitQueueCount;
}Text drawing uses GCD serial queues, while image download uses NSOperationQueue with a maximum concurrency of 6, similar to SDWebImage.
7. Detailed Design – Run‑Loop Listener
/**
runloop callback, concurrently execute async draw tasks
*/
static NSMutableSet
*_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];
}];
}
- (void)setupRunLoopObserver {
_taskSet = [NSMutableSet set];
CFRunLoopRef runloop = CFRunLoopGetMain();
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(CFAllocatorGetDefault(),
kCFRunLoopBeforeWaiting | kCFRunLoopExit,
true,
0xFFFFFF,
ADRunLoopCallBack, NULL);
CFRunLoopAddObserver(runloop, observer, kCFRunLoopCommonModes);
CFRelease(observer);
}8. Detailed Design – Async Text Drawing Queue
- (ADQueue *)ad_getExecuteTaskQueue {
// Create a new serial queue if we have not reached the limit
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;
}
// Otherwise pick the queue with the smallest asyncCount
NSUInteger minAsync = [[self.queueArr valueForKeyPath:@"@min.asyncCount"] integerValue];
__block ADQueue *q = nil;
[self.queueArr enumerateObjectsUsingBlock:^(ADQueue *obj, NSUInteger idx, BOOL *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);
}9. Detailed Design – Async Drawing Implementation
- (void)asyncDraw {
__block ADQueue *q = [[ADManager shareInstance] ad_getExecuteTaskQueue];
__block id
delegate = (id
)self.delegate;
dispatch_async(q.queue, ^{
if ([self canceled]) { [[ADManager shareInstance] ad_finishTask:q]; return; }
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 ctx = UIGraphicsGetCurrentContext();
if (opaque && ctx) {
CGContextSaveGState(ctx);
if (!backgroundColor || CGColorGetAlpha(backgroundColor) < 1) {
CGContextSetFillColorWithColor(ctx, [UIColor whiteColor].CGColor);
CGContextAddRect(ctx, CGRectMake(0, 0, size.width*scale, size.height*scale));
CGContextFillPath(ctx);
}
if (backgroundColor) {
CGContextSetFillColorWithColor(ctx, backgroundColor);
CGContextAddRect(ctx, CGRectMake(0, 0, size.width*scale, size.height*scale));
CGContextFillPath(ctx);
}
CGContextRestoreGState(ctx);
CGColorRelease(backgroundColor);
} else {
CGColorRelease(backgroundColor);
}
[delegate asyncDrawLayer:self inContext:ctx canceled:[self canceled]];
if ([self canceled]) { [[ADManager shareInstance] ad_finishTask:q]; UIGraphicsEndImageContext(); return; }
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); });
});
}10. Detailed Design – Async Image Download & Caching
- (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;
}
NSString *imageKey = url.absoluteString;
NSData *imageData = self.imageDataDict[imageKey];
if (imageData) {
UIImage *image = [UIImage imageWithData:imageData];
if (completedBlock) completedBlock(image, nil);
} else {
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 {
ADOperation *operation = [self ad_downloadImageWithURL:url toPath:imagePath completed:completedBlock];
[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:^{
NSData *newImageData = self.imageDataDict[imageKey];
if (!newImageData) return;
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
UIImage *newImage = [UIImage imageWithData:newImageData];
[blockOperation.targetSet enumerateObjectsUsingBlock:^(id obj, BOOL *stop) {
if ([obj isKindOfClass:[UIImageView class]]) {
((UIImageView *)obj).image = newImage;
}
}];
[blockOperation removeAllTargets];
}];
[newImageData writeToFile:imagePath atomically:YES];
[self.operationDict removeObjectForKey:imageKey];
}];
[self.operationQueue addOperation:operation];
[self.operationDict setValue:operation forKey:imageKey];
}
return operation;
}11. Usage Example – Async Text Rendering
@implementation ADLabel
- (void)setText:(NSString *)text {
_text = text;
[[ADManager shareInstance] addTaskWith:self selector:@selector(asyncDraw)];
}
+ (Class)layerClass { return ADLayer.class; }
- (void)asyncDraw { [self.layer setNeedsDisplay]; }
- (void)layerWillDraw:(CALayer *)layer { }
- (void)asyncDrawLayer:(ADLayer *)layer inContext:(CGContextRef)ctx canceled:(BOOL)canceled {
if (canceled) { NSLog(@"异步绘制取消~"); return; }
UIColor *bg = _backgroundColor;
NSString *txt = _text;
UIFont *font = _font;
UIColor *txtColor = _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 *attrs = @{NSFontAttributeName:font, NSForegroundColorAttributeName:txtColor, NSBackgroundColorAttributeName:bg, NSParagraphStyleAttributeName:self.paragraphStyle ?: [NSParagraphStyle new]};
NSMutableAttributedString *attrStr = [[NSMutableAttributedString alloc] initWithString:txt attributes:attrs];
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attrStr);
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attrStr.length), path, NULL);
CFRelease(framesetter);
CGPathRelease(path);
CTFrameDraw(frame, ctx);
CFRelease(frame);
}
@end12. Usage Example – Async Image Rendering
@implementation ADImageView
- (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;
}];
}
@end13. Effect Evidence
Performance tests were conducted on an iPhone 11 (iOS 13.5.1) loading 1,000 rows. With async drawing disabled, the frame rate dropped to as low as 1 fps during fast scrolling, despite modest CPU and memory usage. Enabling async drawing kept the frame rate stable at 60 fps; CPU peaked around 90 % and memory rose to ~200 MB, which is acceptable on a 4 GB device.
Additional tests with only text rendering (kOnlyShowText) showed a modest improvement from 40‑50 fps to smoother scrolling, confirming that both async text drawing and async image download contribute to overall performance.
14. Core Code Location
Demo repository: https://github.com/stkusegithub/AsyncDraw
\---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.mSohu Tech Products
A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.