Sunday, October 27, 2013

Easy UITableView optimizations

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: 1) loading NSURLConnection data while UITableViews scroll, 2) buffering UITableViews so the content loads before it is shown on screen and 3) 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...


  1. NSURLConnection on scrolling run loop source
  2. UITableView buffering
  3. 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 NSURLConnections are created and run on the main thread, the problem is that UIScrollViews (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 NSURLConnections. The effect is that no downloads complete while the view is scrolling.

There are 3 simple solutions, each totally viable so select the one that best fits your use case.

  1. 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.
  2. 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.
  3. 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 NSURLConnections 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:


// UIImage+ASyncRendering.h

#import <UIKit/UIKit.h>

typedef void(^UIImageASyncRenderingCompletionBlock)(UIImage*);

typedef NS_ENUM(NSInteger, NSPUIImageType)
{
    NSPUIImageType_JPEG,
    NSPUIImageType_PNG
};

@interface UIImage (ASyncRendering)

/**
    Renders the provided image data asynchronously so as to not block the main thread.  Very useful when desiring to 
    keep the UI responsive while still generating UI from downloaded and compressed images.
    @param imageData the data to render as a UIImage.
    @param imageType the image type decode the \a imageData as.  Can be \c NSPUIImageType_JPEG or \c NSPUIImageType_PNG.
    @param block the completion block to be called once the \a imageData is rendered as a \c UIImage.
 */
+ (void) imageByRenderingData:(NSData*)imageData
                  ofImageType:(NSPUIImageType)imageType
                   completion:(UIImageASyncRenderingCompletionBlock)block;

@end


/********************************************************/

// UIImage+ASyncRendering.m

#import "UIImage+ASyncRendering.h"

@implementation UIImage (ASyncRendering)

+ (void) imageByRenderingData:(NSData*)imageData
                  ofImageType:(NSPUIImageType)imageType
                   completion:(UIImageASyncRenderingCompletionBlock)block
{
    // NOTE: though I create a dispatch queue specifically for serializing image rendering, this is just my choice. You can easily just use the dispatch_get_global_queue.
    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:

// Custom cell implementation
- (void) loadImagery
{
   // ... download logic
}

- (void) completeLoadImagery:(NSData*)imageData
{
   // _imageView.image = [UIImage imageWithData:imageData];  <-- don't do this anymore

   [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 demo project to show what's happening.  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 usefull code.

8 comments:

  1. If there's any interest in a before and after project to show how these optimizations benefit an app using UITableViews, let me know and I'll jump on it in my spare time.

    ReplyDelete
  2. Hi, yes please make a before and after project. Especially the something that demonstrates 2. UITableView buffering. Thanks.

    ReplyDelete
    Replies
    1. Demo added:
      https://github.com/NSProgrammer/NSProgrammer/tree/master/code/Examples/TableViewOptimizations

      Delete
  3. Nolan, can you elaborate on the buffering? I'm looking at your example and trying to apply it to my solution, but I'm not getting the expected results. Check out my question on SO. http://stackoverflow.com/questions/27003566/uitableview-buffering. Thanks.

    ReplyDelete
  4. Hey there, I like the solution for the UITable bottom row buffer, tanks for that. It is simple and it works. BUT, I tried the same technique for the top row, and just couldn't get it working. As soon as the next row hits a negative origin.y in the tableview, the row above it gets reused at the bottom. Do you know a work-around for this? Thanks again!

    ReplyDelete
  5. Dear lord, I turned on that gooey thingy inside the upper chamber and got it working. In my case I positioned the tableview higher and inset the top content with the same height.

    ReplyDelete
  6. Pokerstars Casino: Live casino, mobile app | JTM Hub
    Pokerstars Casino offers the 영주 출장샵 best in Live Casino, a cutting edge gaming 부천 출장샵 app, plus access to exclusive 과천 출장안마 online and mobile 수원 출장안마 casino 충주 출장샵 games like Blackjack, Roulette and

    ReplyDelete