39

I'm trying to load UIImages in a background thread and then display them on the iPad. However, there's a stutter when I set the imageViews' view property to the image. I soon figured out that image loading is lazy on iOS, and found a partial solution in this question:

CGImage/UIImage lazily loading on UI thread causes stutter

This actually forces the image to be loaded in the thread, but there's still a stutter when displaying the image.

You can find my sample project here: http://www.jasamer.com/files/SwapTest.zip (edit: fixed version), check the SwapTestViewController. Try dragging the picture to see the stutter.

The test-code I created that stutters is this (the forceLoad method is the one taken from the stack overflow question I posted above):

NSArray* imagePaths = [NSArray arrayWithObjects:
                       [[NSBundle mainBundle] pathForResource: @"a.png" ofType: nil], 
                       [[NSBundle mainBundle] pathForResource: @"b.png" ofType: nil], nil];

NSOperationQueue* queue = [[NSOperationQueue alloc] init];

[queue addOperationWithBlock: ^(void) {
    int imageIndex = 0;
    while (true) {
        UIImage* image = [[UIImage alloc] initWithContentsOfFile: [imagePaths objectAtIndex: imageIndex]];
        imageIndex = (imageIndex+1)%2;
        [image forceLoad];

        //What's missing here?

        [self performSelectorOnMainThread: @selector(setImage:) withObject: image waitUntilDone: YES];
        [image release];
    }
}];

There are two reasons why I know the stuttering can be avoided:

(1) Apple is able to load images without stuttering in the Photos app

(2) This code does not cause stutter after placeholder1 and placeholder2 have been displayed once in this modified version of the above code:

    UIImage* placeholder1 = [[UIImage alloc] initWithContentsOfFile:[[NSBundle mainBundle] pathForResource: @"a.png" ofType: nil]];
[placeholder1 forceLoad];
UIImage* placeholder2 = [[UIImage alloc] initWithContentsOfFile:[[NSBundle mainBundle] pathForResource: @"b.png" ofType: nil]];
[placeholder2 forceLoad];

NSArray* imagePaths = [NSArray arrayWithObjects:
                       [[NSBundle mainBundle] pathForResource: @"a.png" ofType: nil], 
                       [[NSBundle mainBundle] pathForResource: @"b.png" ofType: nil], nil];
NSOperationQueue* queue = [[NSOperationQueue alloc] init];
[queue addOperationWithBlock: ^(void) {
    int imageIndex = 0;
    while (true) {
        //The image is not actually used here - just to prove that the background thread isn't causing the stutter
        UIImage* image = [[UIImage alloc] initWithContentsOfFile: [imagePaths objectAtIndex: imageIndex]];
        imageIndex = (imageIndex+1)%2;
        [image forceLoad];

        if (self.imageView.image==placeholder1) {
            [self performSelectorOnMainThread: @selector(setImage:) withObject: placeholder2 waitUntilDone: YES];
        } else {
            [self performSelectorOnMainThread: @selector(setImage:) withObject: placeholder1 waitUntilDone: YES];
        }                

        [image release];
    }
}];

However, I can't keep all my images in memory.

This implies that forceLoad doesn't do the complete job - there's something else going on before the images are actually displayed. Does anyone know what that is, and how I can put that into the background thread?

Thanks, Julian

Update

Used a few of Tommys tips. What I figured out is that it's CGSConvertBGRA8888toRGBA8888 that's taking so much time, so it seems it's a color conversion that's causing the lag. Here's the (inverted) call stack of that method.

Running        Symbol Name
6609.0ms        CGSConvertBGRA8888toRGBA8888
6609.0ms         ripl_Mark
6609.0ms          ripl_BltImage
6609.0ms           RIPLayerBltImage
6609.0ms            ripc_RenderImage
6609.0ms             ripc_DrawImage
6609.0ms              CGContextDelegateDrawImage
6609.0ms               CGContextDrawImage
6609.0ms                CA::Render::create_image_by_rendering(CGImage*, CGColorSpace*, bool)
6609.0ms                 CA::Render::create_image(CGImage*, CGColorSpace*, bool)
6609.0ms                  CA::Render::copy_image(CGImage*, CGColorSpace*, bool)
6609.0ms                   CA::Render::prepare_image(CGImage*, CGColorSpace*, bool)
6609.0ms                    CALayerPrepareCommit_(CALayer*, CA::Transaction*)
6609.0ms                     CALayerPrepareCommit_(CALayer*, CA::Transaction*)
6609.0ms                      CALayerPrepareCommit_(CALayer*, CA::Transaction*)
6609.0ms                       CALayerPrepareCommit_(CALayer*, CA::Transaction*)
6609.0ms                        CALayerPrepareCommit
6609.0ms                         CA::Context::commit_transaction(CA::Transaction*)
6609.0ms                          CA::Transaction::commit()
6609.0ms                           CA::Transaction::observer_callback(__CFRunLoopObserver*, unsigned long, void*)
6609.0ms                            __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__
6609.0ms                             __CFRunLoopDoObservers
6609.0ms                              __CFRunLoopRun
6609.0ms                               CFRunLoopRunSpecific
6609.0ms                                CFRunLoopRunInMode
6609.0ms                                 GSEventRunModal
6609.0ms                                  GSEventRun
6609.0ms                                   -[UIApplication _run]
6609.0ms                                    UIApplicationMain
6609.0ms                                     main         

The last bit-mask changes he proposed didn't change anything, sadly.

Community
  • 1
  • 1
jasamer
  • 1,089
  • 1
  • 10
  • 18

3 Answers3

40

UIKit may be used on the main thread only. Your code is therefore technically invalid, since you use UIImage from a thread other than the main thread. You should use CoreGraphics alone to load (and non-lazily decode) graphics on a background thread, post the CGImageRef to the main thread and turn it into a UIImage there. It may appear to work (albeit with the stutter you don't want) in your current implementation, but it isn't guaranteed to. There seems to be a lot of superstition and bad practice advocated around this area, so it's not surprising you've managed to find some bad advice...

Recommended to run on a background thread:

// get a data provider referencing the relevant file
CGDataProviderRef dataProvider = CGDataProviderCreateWithFilename(filename);

// use the data provider to get a CGImage; release the data provider
CGImageRef image = CGImageCreateWithPNGDataProvider(dataProvider, NULL, NO, 
                                                    kCGRenderingIntentDefault);
CGDataProviderRelease(dataProvider);

// make a bitmap context of a suitable size to draw to, forcing decode
size_t width = CGImageGetWidth(image);
size_t height = CGImageGetHeight(image);
unsigned char *imageBuffer = (unsigned char *)malloc(width*height*4);

CGColorSpaceRef colourSpace = CGColorSpaceCreateDeviceRGB();

CGContextRef imageContext =
    CGBitmapContextCreate(imageBuffer, width, height, 8, width*4, colourSpace,
                  kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Little);

CGColorSpaceRelease(colourSpace);

// draw the image to the context, release it
CGContextDrawImage(imageContext, CGRectMake(0, 0, width, height), image);
CGImageRelease(image);

// now get an image ref from the context
CGImageRef outputImage = CGBitmapContextCreateImage(imageContext);

// post that off to the main thread, where you might do something like
// [UIImage imageWithCGImage:outputImage]
[self performSelectorOnMainThread:@selector(haveThisImage:) 
         withObject:[NSValue valueWithPointer:outputImage] waitUntilDone:YES];

// clean up
CGImageRelease(outputImage);
CGContextRelease(imageContext);
free(imageBuffer);

There's no need to do the malloc/free if you're on iOS 4 or later, you can just pass NULL as the relevant parameter of CGBitmapContextCreate, and let CoreGraphics sort out its own storage.

This differs from the solution you post to because it:

  1. creates a CGImage from a PNG data source — lazy loading applies, so this isn't necessarily a fully loaded and decompressed image
  2. creates a bitmap context of the same size as the PNG
  3. draws the CGImage from the PNG data source onto the bitmap context — this should force full loading and decompression since the actual colour values have to be put somewhere we could access them from a C array. This step is as far as the forceLoad you link to goes.
  4. converts the bitmap context into an image
  5. posts that image off to the main thread, presumably to become a UIImage

So there's no continuity of object between the thing loaded and the thing displayed; pixel data goes through a C array (so, no opportunity for hidden shenanigans) and only if it was put into the array correctly is it possible to make the final image.

Tommy
  • 99,986
  • 12
  • 185
  • 204
  • 1
    Thanks for that information; UIImage is just so convenient ;-) I replaced my method with your code, but the stutter ist still there. – jasamer Mar 10 '11 at 22:29
  • Tried to set the image via imageView.layer.contents = theCGImage;, seems like it improves stuff a little bit, but not good enough. Is it possible that the stutter is caused by the time it takes to copy the image data to the graphics memory or because it has to change something about the images representation first? – jasamer Mar 10 '11 at 22:36
  • Well it's definitely not the cost of image loading/decoding — it's impossible that any loading/decoding is left to do with the above code. Have you tried adding a few NSTimeInterval time = [NSDate timeIntervalSinceReferenceDate]s around and logging the differences? That'll tell you how long things are actually taking, so you can figure out what the true culprit is. – Tommy Mar 10 '11 at 22:36
  • Sorry; criss-crossed comments. I guess it could be a quick colour space conversion and/or upload. I can't find a definitive reference on whether CALayers are thread safe; it might be worth setting the CGImageRef wherever NSOperationQueue lands you and just doing a quick setNeedsDisplay on the main thread? – Tommy Mar 10 '11 at 22:41
  • No, it's not the cost of decoding/loading, I agree. I just used that sweet new time profiler, the culprit seems to be CGSConvertBGRX8888toRGBA8888 that's being called from a CALayer method that's being called from the main run loop. It really seems to be a color conversion problem! – jasamer Mar 10 '11 at 23:03
  • Oh, that'll be busy premultiplying the alpha — which also exposes another flaw in my code; that it doesn't preserve the alpha channel from a supplied PNG! Hang on, I'll try to fix it. – Tommy Mar 10 '11 at 23:09
  • @Julian, try now; I changed the flags passed to CGBitmapContextCreate to set what I believe is the same format as used internally. – Tommy Mar 10 '11 at 23:12
  • @Tommy: Hehe, you discovered the exact same flags as I did, sadly it didn't change anything - not a lot of google results for BGRA8888, eh? I added the call stack from the profiler to my question because it wouldn't fit in the comments field ;-), perhaps it helps - is it possible that CGColorSpaceCreateDeviceRGB returns a "wrong" color space? – jasamer Mar 10 '11 at 23:33
  • Oh, no, I pulled those out of an old project. If it's doing a BGRA8888 to RGBA8888 for some reason, it's obviously no longer using BGRA internally. Correct thing to do is change kCGBitmapByteOrder32Big to kCGBitmapByteOrder32Little. – Tommy Mar 10 '11 at 23:38
  • Based on your other response, it sounds like they still use BGRA but with an endianness swap somewhere for some reason. Obviously the correct method would be to find out what byte layout a CALayer wants — that's something to research... – Tommy Mar 11 '11 at 00:30
  • I've just one little problem left: setting the layer contents from the other thread is what works best, but it won't update what's displayed. A setNeedsDisplay doesn't work here, as that erases the contents of the layer. Changing a style attribute works, but that's ugly. Any idea on how to do this? – jasamer Mar 11 '11 at 00:44
  • Assuming you're using a UIView, you might be better off creating a CALayer directly and giving it a delegate that doesn't do anything? Then you can setNeedsDisplay to your hearts content. It feels like it may be a bodge around a lack of thread safety though — I wouldn't be completely comfortable. Alternatively, try setting up a worker thread with a run loop to do your background loading on, since CALayers do a lot of work by scheduling things on the runloop. – Tommy Mar 11 '11 at 01:10
  • Thanks for the hints - there should be a NSRunloop for every thread that needs one, though (according to the NSThread docs). I didn't try the delegate, I found the reason for the little remaining lag - seems I was very picky. It was because doing a drag update + a content update + another drag update was too much - the reason why setting the content from the other thread was faster was only because it **didn't** update the layer. So if there's some dragging in progress now, I wait for the next event and both apply the new translation and contents - works like a charm. – jasamer Mar 11 '11 at 01:59
  • Btw, if you change that one bitmask item, I'll give you credit for the answer - woldn't have figured that one out without your help! – jasamer Mar 11 '11 at 02:10
  • 2
    By the way, UIImage +imageWithContentsOfFile: is thread-safe after iOS4. You can use this method in background threads. [link](https://devforums.apple.com/message/360659#360659) – Kazuki Sakamoto Mar 11 '11 at 02:28
  • 1
    @Kazuki: that makes sense; UIImage doesn't know anything about user interaction so you'd expect it to be one of the easiest bits of UIKit to strip back thread restrictions on. @Julian: answer edited. – Tommy Mar 11 '11 at 02:30
  • I'd freakin' upvote this whole thread ten thousand times if I could. I'm blending up to five layers of fullscreen tiled iPad retina images, and have been searching high and low for a solution to the stuttering. This trick finally took it from 'acceptable' to 'perfect.' – jankins Sep 17 '12 at 23:18
  • Thank you, this worked fine for me. A lot of people are using this piece of code: https://gist.github.com/259357 But thats not quite thread safe because of the use of UIImage right? – Tieme Oct 04 '12 at 13:12
  • @Tieme no, that's probably not thread safe. Or, rather, there's no documented reason to believe that it is thread safe. – Tommy Oct 04 '12 at 23:43
  • Okay, yeah it will probably just work fine but if i want to avoid it, what would be the best solution? The UIImage return an autoreleased object. I'm using GCD to return the CGImageRef to the main thread where a create an UIImage from it and then release the CGImageRef which is probably fine but not very clear code. – Tieme Oct 05 '12 at 07:59
  • +1 for mentioning about UIImage is also one of UIKit which should be used only in main-thread. – eonil Oct 10 '12 at 19:09
14

Ok, figured it out - with a lot of help by Tommy. Thank you!

If you create your context with

        CGContextRef imageContext =
        CGBitmapContextCreate(imageBuffer, width, height, 8, width*4, colourSpace,
                              kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Little);

the main run loop won't cause any conversions on the main thread any more. Displaying is now buttery smooth. (The flags are a bit counterintuitive, does anyone know why you have to choose kCGImageAlphaPremultipliedFirst?)

Edit:

Uploaded the fixed sample project: http://www.jasamer.com/files/SwapTest-Fixed.zip. If you have problems with image performance, this is a great starting point!

jasamer
  • 1,089
  • 1
  • 10
  • 18
  • if i download big image from the internet,how to do?your method is - (UIImage*) initImmediateLoadWithContentsOfFile:(NSString*)path; is load from local – pengwang Apr 20 '13 at 11:50
10

How about UIImage+ImmediateLoad.m?

It decodes images immediately. Besides UIImage -initWithContentsOfFile:, -imageWithCGImage: and CG* are thread-safe after iOS4.

Kazuki Sakamoto
  • 13,929
  • 2
  • 34
  • 96
  • Good to know that these methods are thread-safe now. With an UIImage category like that the code is nicer to look at, I'll update my sample project. It needed the tweaked bitmask options to get rid of the stuttering, though. – jasamer Mar 11 '11 at 03:53
  • I got enough of a benefit from just loading the images once using your code that I actually just load the images and abandon them, then go upon my business as usual. I changed the method slight to be `imageImmediateLoadWithName` thus `[UIImage imageImmediateLoadWithName:@"myImage.png"];` This is enough to make subsequent references to the image much faster. – Ben Flynn Aug 18 '12 at 21:23
  • Have you got a reference for ' Besides UIImage -initWithContentsOfFile:, -imageWithCGImage: and CG* are thread-safe after iOS4.'? There's nothing to that effect the UIImage documentation or the iOS change logs (https://developer.apple.com/library/ios/#releasenotes/General/WhatsNewIniPhoneOS/Articles/iPhoneOS4.html#//apple_ref/doc/uid/TP40009559-SW1 ) – Tommy Oct 04 '12 at 23:45
  • I had this problem on an UICollectionView which had a lot of UIImageViews. The scrolling was horrible on iPhone 5. I've implemented this as a category on UIImage and offloads the loading to a different dispatch queue. Now it scrolls smoothly. Thanks! – Trenskow Dec 16 '13 at 11:40