Now this may be the most simplistic blog post I write, but I think it's something that every iOS developer should utilize.
UITableView
is an incredibly fundamental view in iOS and is used in many many apps. However, I can't help but notice over and over so many amazing apps out there that use table views but have the jarring effect of not loading the data for the table view's cells' content until the cells are visible. I want to take this blog entry to address 3 missing pieces for improved table view experience:
Loading NSURLConnection
data while UITableView
s scroll,
- buffering
UITableView
s so the content loads before it is shown on screen, and - rendering a network graphic on a background thread so that the
UITableView
doesn't stutter (with a blocked main thread)
when the network graphic is decompressed and rendered.
Smooth Tables Views is easy as 1, 2, 3...
- NSURLConnection on scrolling run loop source
- UITableView buffering
- Background Image Rendering
1. NSURLConnection on scrolling run loop source
By default NSURLConnection
runs on the thread it was created on and is scheduled to execute in the NSDefaultRunLoopMode
of that threads main
run loop. Now, a very common case is that NSURLConnection
s are created and run on the main thread, the problem is that UIScrollView
s (and subclasses
such as UITableView
) run their scrolling on a different run loop source that ends up preventing sources on the default run loop mode from executing,
including our NSURLConnection
s. The effect is that no downloads complete while the view is scrolling.
There are three simple solutions, each totally viable so select the one that best fits your use case.
- Run the
NSURLConnection
on a background thread. Note: NSURLConnection
cannot be run from a background thread on iOS 4 due to a bug, so this solution is iOS 5 and above only. - Run the
NSURLConnection
with the NSRunLoopCommonModes
run loop mode. You'll likely want to create your connection without it automatically starting using initWithRequest:delegate:startImmediately:
with startImmediately set to NO
. Then, before starting the connection, use scheduleInRunLoop:forMode:
using NSRunLoopCommonModes
. You can often use [NSRunLoop currentRunLoop]
for the run loop. - Lastly, you can use the amazing AFNetworking open source library by none other than Mattt Thompson who authors the NSHipster blog. This library permits you to configure the run loop modes per
AFURLConnectionOperation
, and even defaults it's run loop modes to NSRunLoopCommonModes
. Though I've never used this library in production, I have on side projects and must say it is the gold standard for iOS networking. Use it and never look back.
Now with NSURLConnection
s loading as the scroll view scrolls, you will no longer have the annoying behavior having to have the table view stop scrolling before your content shows up in your cells. An important performance consideration though: adding executable code to run along side scrolling (like our
NSURLConnection
) is processor intensive so you'll likely notice stuttering if you use options 2 or 3 on older devices. For this reason, I require
that the device be an iPad 2 or later, an iPhone 4 or later, and an iPod 4th generation or later in order to enable this - otherwise the choppiness is not worth it.
2. UITableView buffering
This may be one of the simplest changes you can make to improve perceived performance in any iOS application. Simply enough, if you want to preload data of you table view
beyond the visible bounds of the table view, you need only extend the table view itself to achieve the desired buffer. For example, you have an iPhone app that has a full
screen table view (320x480 or 320x568). If you want the table view to buffer an extra table view height of data, you just make your table view twice as tall (320x960 or 320x1136)
and then counter the height extension using UIScrollView
's contentInset
and scrollIndicatorInsets
properties.
It really is that easy. With this simple change to your table views, you now have the ability to avoid showing table view cells with blank content as the content
is downloaded and then have the content jarringly show up.
3. Background Image Rendering
Now definitely the least obvious and most difficult optimization for making your table views behave whizzbang smooth is the rendering
of images on the background. Now let me just back up a second to dissect what the problem is here before we try and solve it.
When a network graphic (PNG or JPG) is downloaded it has to do 2 things in order to be displayed on screen as an image. First, the most expensive part, the graphic must
be decompressed into a bitmap. Second, that bitmap must be rendered to the graphics context in order to be ready for display on the devices screen.
If you don't have background image rendering and start scrolling a table view with a lot of network downloaded graphics (particularly if the graphics are retina
and your device is on the slower end), you'll notice choppiness. Further investigation using XCode's instruments will show you that the slowdown can almost entirely
be attributed to decompressing those network graphics.
The solution is to add a layer of indirection when we download a network graphic. Instead of directly converting the NSData
returned
from the network response into a UIImage
and then putting that on your view hierarchy's UIImageView
, you'll want to asynchronously
convert that data into a pre-rendered UIImage
and then throw that back to the main thread for setting onto your UIImageView
.
Remember, UIImage
is an opaque object and masks numerous data representations of images that can change based on the needs of the UIImage
,
that's why we want to ensure the image is in the final state we want it to be in (decompressed and rendered) before using it on the main thread.
Here's a code snippet that does as we just discussed:
#import <UIKit/UIKit.h>
typedef void(^UIImageASyncRenderingCompletionBlock)(UIImage*);
typedef NS_ENUM(NSInteger, NSPUIImageType)
{
NSPUIImageType_JPEG,
NSPUIImageType_PNG
};
@interface UIImage (ASyncRendering)
+ (void) imageByRenderingData:(NSData*)imageData
ofImageType:(NSPUIImageType)imageType
completion:(UIImageASyncRenderingCompletionBlock)block;
@end
#import "UIImage+ASyncRendering.h"
@implementation UIImage (ASyncRendering)
+ (void) imageByRenderingData:(NSData*)imageData
ofImageType:(NSPUIImageType)imageType
completion:(UIImageASyncRenderingCompletionBlock)block
{
static dispatch_queue_t s_imageRenderQ;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
s_imageRenderQ =
dispatch_queue_create("UIImage+ASyncRendering_Queue",
DISPATCH_QUEUE_SERIAL);
});
dispatch_async(s_imageRenderQ, ^() {
UIImage* imageObj = nil;
if (imageData)
{
CGDataProviderRef dataProvider =
CGDataProviderCreateWithCFData((__bridge CFDataRef)imageData);
if (dataProvider)
{
CGImageRef image = NULL;
if (NSPUIImageType_PNG == imageType)
{
image =
CGImageCreateWithPNGDataProvider(dataProvider,
NULL,
NO,
kCGRenderingIntentDefault);
}
else
{
image =
CGImageCreateWithJPEGDataProvider(dataProvider,
NULL,
NO,
kCGRenderingIntentDefault);
}
if (image)
{
size_t width = CGImageGetWidth(image);
size_t height = CGImageGetHeight(image);
unsigned char* imageBuffer =
(unsigned char*)malloc(width*height*4);
CGColorSpaceRef colorSpace =
CGColorSpaceCreateDeviceRGB();
CGContextRef imageContext =
CGBitmapContextCreate(imageBuffer,
width,
height,
8,
width*4,
colorSpace,
(kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Little));
if (imageContext)
{
CGContextDrawImage(imageContext,
CGRectMake(0, 0, width, height),
image);
CGImageRef outputImage =
CGBitmapContextCreateImage(imageContext);
if (outputImage)
{
imageObj =
[UIImage imageWithCGImage:outputImage
scale:[UIScreen mainScreen].scale
orientation:UIImageOrientationUp];
CGImageRelease(outputImage);
}
CGContextRelease(imageContext);
}
CGColorSpaceRelease(colorSpace);
free(imageBuffer);
CGImageRelease(image);
}
CGDataProviderRelease(dataProvider);
}
}
dispatch_async(dispatch_get_main_queue(), ^() {
block(imageObj);
});
});
}
@end
And with this category added to your project you can do something like this:
- (void) loadImagery
{
}
- (void) completeLoadImagery:(NSData*)imageData
{
[UIImage imageByRenderingData:imageData
ofImageType:NSPUIImageType_JPEG
completion:^(UIImage* image) {
_imageView.image = image;
}];
}
Alternatively, if you use AFNetworking, you can take advantage of the UIImageView(AFNetworking)
category.
[EDIT] I've updated my github
code to support NSPUIImageType_Auto which will auto detect the image type from the NSData provided.
[EDIT] I've added a github
demo project to show what's happening (NSPDTableViewOptimizations). Honestly, the best way to see the optimization is to run the demo against an iPhone 4 with iOS 6 (or 5). This will be the slowest possible device with a retina screen and really can show the jarring experience and how it improves.
Conclusion
See! Just as easy as 1, 2, 3! You can download the asynchronous image rendering code from my github
project as well as other useful code.